Compare commits

...

51 Commits

Author SHA1 Message Date
lucaronin
f8721f2a1b feat: add note width modes 2026-04-27 05:34:11 +02:00
lucaronin
fb39c6679a fix: handle invalid Windows save paths 2026-04-27 04:04:04 +02:00
lucaronin
f1bed131bf fix: refresh type state after type changes 2026-04-27 03:25:24 +02:00
lucaronin
257cf6ed02 fix: keep theme tooltip inside window 2026-04-27 02:20:28 +02:00
lucaronin
908daafaa1 feat: add basic file actions 2026-04-27 01:45:16 +02:00
lucaronin
d15437face test: cover consecutive IME composer input 2026-04-27 01:05:34 +02:00
lucaronin
30281f879d chore: ratchet codescene thresholds 2026-04-27 00:49:39 +02:00
lucaronin
4ef6edfb10 feat: preview vault images in app 2026-04-27 00:36:24 +02:00
lucaronin
918419e371 fix: respect macos control text bindings 2026-04-26 23:22:53 +02:00
lucaronin
9265b8f117 fix: stabilize AI composer text editing 2026-04-26 22:11:54 +02:00
lucaronin
943fa854e1 fix: satisfy localization build 2026-04-26 19:39:56 +02:00
lucaronin
a2f8ba72a2 fix: complete app localization pass 2026-04-26 19:37:21 +02:00
lucaronin
1d546dde3d fix: allow note drops to insert wikilinks 2026-04-26 18:15:22 +02:00
lucaronin
63877e61ec fix: store window state in logical points 2026-04-26 17:54:41 +02:00
lucaronin
8896f1d451 fix: restore saved window size after launch 2026-04-26 17:54:41 +02:00
lucaronin
f523099854 feat: add vault loading skeleton 2026-04-26 17:35:09 +02:00
lucaronin
15f38f096f feat: support non-git vaults 2026-04-26 17:08:14 +02:00
lucaronin
cdd2ddec6c feat: persist main window placement 2026-04-26 15:45:34 +02:00
lucaronin
292d3739df fix: prevent editor handling note wikilink drops 2026-04-26 14:37:11 +02:00
lucaronin
1136c1948e fix: clarify macos release artifact names 2026-04-26 14:29:47 +02:00
lucaronin
8406042db1 fix: support native folder drops in AI inputs 2026-04-26 13:59:39 +02:00
lucaronin
f07cbbe0eb test: reflect cloned getting started vault in smoke 2026-04-26 12:28:01 +02:00
lucaronin
768da9f955 fix: prevent bottom bar wrap at narrow widths 2026-04-26 12:12:05 +02:00
lucaronin
f58e9d8930 fix: allow editor runtime styles under CSP 2026-04-26 11:31:41 +02:00
lucaronin
133242eff5 fix: keep compact status bar on one row 2026-04-26 10:33:16 +02:00
lucaronin
aebd2bea4a fix: refresh language settings draft 2026-04-26 09:46:11 +02:00
lucaronin
60238fbe78 fix: persist native language settings 2026-04-26 09:17:28 +02:00
lucaronin
5336236e79 test: cover native command wrappers 2026-04-26 08:39:56 +02:00
lucaronin
f9719f11b5 feat: add app localization foundation 2026-04-26 08:18:47 +02:00
lucaronin
e4083ded1a fix: detect claude cli on windows 2026-04-26 07:34:27 +02:00
lucaronin
65a89f102a fix: detect codex from macos shell paths 2026-04-26 06:48:35 +02:00
lucaronin
5b34ec2980 feat: publish intel mac builds 2026-04-26 06:07:33 +02:00
lucaronin
e579979829 fix: preserve composed ai input 2026-04-26 05:24:05 +02:00
lucaronin
c7edc71a97 feat: add note layout preference 2026-04-26 04:41:18 +02:00
lucaronin
5fd8f8fb40 feat: add markdown math support 2026-04-26 03:52:10 +02:00
lucaronin
694419d442 fix: disable native text suggestions 2026-04-26 03:01:10 +02:00
lucaronin
e82fb2a5c7 test: cover table cell paste regression 2026-04-26 02:58:46 +02:00
lucaronin
39234dcf52 fix: block unsafe editor links 2026-04-26 02:16:20 +02:00
lucaronin
36e8a62284 fix: isolate blocknote side-menu clicks 2026-04-26 01:20:11 +02:00
lucaronin
d622b91b81 docs: ratchet codescene thresholds 2026-04-26 00:31:55 +02:00
lucaronin
5a546b9a8b feat: add generic MCP config support 2026-04-26 00:15:22 +02:00
lucaronin
b8c1a094b6 fix: resolve ime selection typing 2026-04-25 23:50:04 +02:00
lucaronin
c2c56cfca5 fix: harden inline wikilink ime handling 2026-04-25 23:46:31 +02:00
lucaronin
449a62e31f fix: address unicode slug type check 2026-04-25 23:11:50 +02:00
lucaronin
7d6fd5cc51 fix: preserve unicode note title slugs 2026-04-25 23:08:30 +02:00
lucaronin
75ed6460ac feat: drop notes into editors as wikilinks 2026-04-25 22:31:57 +02:00
lucaronin
a3096c596f fix: reflow status bar at narrow widths 2026-04-25 21:59:10 +02:00
lucaronin
6105c7527c refactor: fix hook dependency lint warnings 2026-04-25 21:26:25 +02:00
lucaronin
92eee0d470 fix: keep editor selection stable around media blocks 2026-04-25 21:21:41 +02:00
lucaronin
f14fda09d5 fix: accept finder folder drops in ai composer 2026-04-25 20:41:56 +02:00
lucaronin
31d12d8cc8 test: stabilize smoke web server 2026-04-25 19:51:41 +02:00
221 changed files with 11829 additions and 1810 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.97
AVERAGE_THRESHOLD=9.85
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.88

View File

@@ -62,6 +62,8 @@ jobs:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
@@ -497,6 +499,54 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Normalize macOS release artifact names
run: |
normalize_macos_artifacts() {
local arch="$1"
local normalized_updater="$2"
local normalized_dmg="$3"
local updater_dir="updater-${arch}"
local updater_file
updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
if [ -z "$updater_file" ]; then
echo "::error::Missing macOS updater artifact in ${updater_dir}" >&2
return 1
fi
local sig_file="${updater_file}.sig"
if [ ! -f "$sig_file" ]; then
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
return 1
fi
local normalized_sig="${normalized_updater}.sig"
if [ "$updater_file" != "$normalized_updater" ]; then
mv "$updater_file" "$normalized_updater"
fi
if [ "$sig_file" != "$normalized_sig" ]; then
mv "$sig_file" "$normalized_sig"
fi
local dmg_dir="dmg-${arch}"
local dmg_file
dmg_file=$(find "$dmg_dir" -maxdepth 1 -name "*.dmg" -print -quit)
if [ -z "$dmg_file" ]; then
echo "::error::Missing macOS DMG artifact in ${dmg_dir}" >&2
return 1
fi
if [ "$dmg_file" != "$normalized_dmg" ]; then
mv "$dmg_file" "$normalized_dmg"
fi
}
normalize_macos_artifacts aarch64 \
"updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \
"dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg"
normalize_macos_artifacts x86_64 \
"updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \
"dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg"
- 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 "")
@@ -513,7 +563,7 @@ jobs:
echo "---"
echo "**Stable release — manually promoted from \`main\`**"
echo ""
echo "**Includes macOS (Apple Silicon), Windows x64, and Linux x64 bundles**"
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
@@ -545,6 +595,12 @@ jobs:
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
INTEL_TARBALL=$(basename "$INTEL_UPDATER_FILE")
INTEL_DMG=$(basename "$(find_required "dmg-x86_64/*.dmg")")
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
@@ -568,6 +624,11 @@ jobs:
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
},
"darwin-x86_64": {
"signature": "${INTEL_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_DMG}"
},
"linux-x86_64": {
"signature": "${LINUX_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
@@ -595,6 +656,9 @@ jobs:
dmg-aarch64/*.dmg
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
dmg-x86_64/*.dmg
updater-x86_64/*.app.tar.gz
updater-x86_64/*.app.tar.gz.sig
linux-x86_64-bundles/*.deb
linux-x86_64-bundles/*.deb.sig
linux-x86_64-bundles/*.AppImage

View File

@@ -121,6 +121,8 @@ jobs:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
@@ -551,6 +553,37 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Normalize macOS updater artifact names
run: |
normalize_updater() {
local arch="$1"
local normalized_updater="$2"
local artifact_dir="updater-${arch}"
local updater_file
updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
if [ -z "$updater_file" ]; then
echo "::error::Missing macOS updater artifact in ${artifact_dir}" >&2
return 1
fi
local sig_file="${updater_file}.sig"
if [ ! -f "$sig_file" ]; then
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
return 1
fi
local normalized_sig="${normalized_updater}.sig"
if [ "$updater_file" != "$normalized_updater" ]; then
mv "$updater_file" "$normalized_updater"
fi
if [ "$sig_file" != "$normalized_sig" ]; then
mv "$sig_file" "$normalized_sig"
fi
}
normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz"
normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz"
- name: Generate release notes
run: |
PREV_TAG=$(python3 <<'PY'
@@ -587,7 +620,7 @@ jobs:
echo "---"
echo "**Alpha build — updated on every push to \`main\`**"
echo ""
echo "**Includes macOS (Apple Silicon), Linux x64, and Windows x64 bundles**"
echo "**Includes macOS (Apple Silicon and Intel), Linux x64, and Windows x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
@@ -616,6 +649,11 @@ jobs:
ARM_SIG=$(cat "$ARM_SIG_FILE")
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
INTEL_UPDATER=$(basename "$INTEL_UPDATER_FILE")
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
@@ -639,6 +677,11 @@ jobs:
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
},
"darwin-x86_64": {
"signature": "${INTEL_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}"
},
"linux-x86_64": {
"signature": "${LINUX_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
@@ -665,6 +708,8 @@ jobs:
files: |
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
updater-x86_64/*.app.tar.gz
updater-x86_64/*.app.tar.gz.sig
linux-x86_64-bundles/*.deb
linux-x86_64-bundles/*.deb.sig
linux-x86_64-bundles/*.AppImage

View File

@@ -47,6 +47,7 @@ _icon: shapes # icon assigned to a type
_color: blue # color assigned to a type
_order: 10 # sort order in the sidebar
_sidebar_label: Projects # override label in sidebar
_width: wide # per-note reading width: normal or wide
```
**This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text.
@@ -57,6 +58,17 @@ The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must f
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
### Vault Git Capability
Git is a per-vault capability, not a prerequisite for the document model. A vault can be:
| State | Meaning | UI behavior |
|---|---|---|
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
### VaultEntry
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
@@ -81,6 +93,7 @@ classDiagram
+Boolean archived
+Boolean trashed ⚠ legacy
+Number? trashedAt ⚠ legacy
+String? noteWidth
+Record~string,string~ properties
}
@@ -131,10 +144,26 @@ interface VaultEntry {
archived: boolean // Archived flag
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
noteWidth?: 'normal' | 'wide' | null // Per-note `_width` reading preference
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
}
```
Per-note reading width uses the system property `_width: normal | wide`. It is hidden from the Properties panel by the underscore convention, parsed into `VaultEntry.noteWidth`, and updated through the breadcrumb width toggle or the command palette. When absent or invalid, the renderer falls back to the app-level `settings.note_width_mode` default and then to `normal`.
### File kinds and binary previews
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
| `fileKind` | Source files | UI behavior |
|---|---|---|
| `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 open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### 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.
@@ -240,7 +269,9 @@ Tolaria separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **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`.
- **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.
### Title Surface (UI)
@@ -269,7 +300,7 @@ type SidebarSelection =
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
@@ -314,6 +345,8 @@ A `vault_health_check` command detects stray files in non-protected subfolders a
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
@@ -472,6 +505,15 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts``typescript`.
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
### Markdown Math
Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
### Formatting Surface Policy
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
@@ -481,6 +523,9 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- 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.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. 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.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
### Markdown-to-BlockNote Pipeline
@@ -488,22 +533,23 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessWikilinks(body)\n[[target]] → token"]
C --> D["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
D --> E["injectWikilinks(blocks)\ntoken → WikiLink node"]
E --> F["editor.replaceBlocks()\n→ rendered editor"]
C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"]
F --> G["editor.replaceBlocks()\n→ rendered editor"]
style A fill:#f8f9fa,stroke:#6c757d,color:#000
style F fill:#d4edda,stroke:#28a745,color:#000
style G fill:#d4edda,stroke:#28a745,color:#000
```
> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math placeholder tokens use ASCII sentinels with URI-encoded LaTeX payloads.
### BlockNote-to-Markdown Pipeline (Save)
```mermaid
flowchart LR
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
B --> C["postProcessWikilinks()\nWikiLink node → [[target]]"]
B --> C["restoreWikilinks + serializeMathAwareBlocks()\nschema nodesMarkdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]
@@ -541,6 +587,17 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
## Localization
App UI strings are centralized in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)):
- `AppLocale`: currently `'en' | 'zh-Hans'`
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
## Inspector Abstraction
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
@@ -595,7 +652,7 @@ No indexing step required — search runs directly against the filesystem.
Per-vault settings stored locally and scoped by vault path:
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files
@@ -623,6 +680,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- Only shows after vault onboarding has already resolved to a ready state
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -650,11 +708,13 @@ interface Settings {
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: 'en' | 'zh-Hans' | null
note_width_mode: 'normal' | 'wide' | null
default_ai_agent: 'claude_code' | 'codex' | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. 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. `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. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. 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
@@ -694,6 +754,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -31,7 +31,10 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
- ✅ Vault: `_width: wide` in a note (this note should open wide on every device)
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language)
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without `_width`)
### No hardcoded exceptions
@@ -99,6 +102,7 @@ flowchart LR
| Frontmatter parsing | gray_matter | 0.2 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
| Search | Keyword (walkdir-based file scan) | - |
| Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
@@ -176,15 +180,17 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, 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 document in `type/`.
- **Note List / Pulse View** (200-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. 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, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `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.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, 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 document in `type/`.
- **Note List / Pulse View** (200-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 binaries get an image indicator 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 note-width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, 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 normal/wide reading width across rich and raw modes. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning 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).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
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 sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. 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 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.
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 macOS uses for native menu clicks.
When Tolaria is launched from a Linux AppImage, `run()` also injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable. This keeps the workaround scoped to bundled WebKitGTK launches that are prone to Fedora/Wayland DMA-BUF crashes without changing native package installs.
@@ -217,7 +223,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
Claude Code availability intentionally does not depend only on the desktop app's inherited `PATH`. The detector checks the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, and Homebrew paths so first-run onboarding works on fresh macOS installs.
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
#### Agent Event Flow
@@ -315,6 +321,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
Tolaria can register itself as an MCP server in:
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
@@ -335,7 +342,7 @@ flowchart TD
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json"]
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
```
### WebSocket Bridge
@@ -362,8 +369,8 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs on explicit user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code and Cursor configs |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code, Cursor, and generic MCP configs on explicit user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
@@ -413,6 +420,12 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
## Localization
Tolaria's app chrome uses an app-owned localization layer in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)). English is the canonical fallback, and Simplified Chinese (`zh-Hans`) is the first additional locale. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Missing translation keys fall back to English so partially translated locales do not render broken placeholders.
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
## Vault Management
### Vault List
@@ -444,10 +457,12 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
- **Create a new vault** → creates an empty git repo in a folder the user chooses
- **Open an existing folder** → system file picker
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
@@ -571,6 +586,8 @@ flowchart TD
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
@@ -697,9 +714,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor config |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -751,6 +768,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
@@ -761,7 +780,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, default note width, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
@@ -781,7 +800,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:
@@ -810,12 +829,13 @@ push to main
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
upload signed .app.tar.gz + .sig updater artifacts
pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate alpha-latest.json
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
→ pages job:
→ build static HTML release history page
@@ -832,7 +852,8 @@ push stable-vYYYY.M.D tag
→ version job: validate YYYY.M.D from the tag
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
upload signed .app.tar.gz + .sig and .dmg artifacts
pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-linux job:
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
→ upload .deb, .AppImage, and signed Linux updater bundles
@@ -840,7 +861,7 @@ push stable-vYYYY.M.D tag
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate stable-latest.json with macOS, Linux, and Windows updater URLs plus platform-specific manual download URLs
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
→ publish GitHub release Tolaria YYYY.M.D
→ pages job:
→ publish stable/latest.json

View File

@@ -157,6 +157,7 @@ tolaria/
│ ├── lib/
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
│ │ ├── i18n.ts # App-owned localization dictionary and locale resolution
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
│ │ └── utils.ts # Tailwind merge + cn() helper
│ │
@@ -294,12 +295,12 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, auto-sync interval, default AI agent). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, 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). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. |
| `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/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
## Architecture Patterns

View File

@@ -0,0 +1,40 @@
---
type: ADR
id: "0082"
title: "Markdown-durable math in notes"
status: active
date: 2026-04-26
---
## Context
Tolaria notes are durable Markdown files, while the main editor uses BlockNote and raw mode uses CodeMirror. Users coming from technical note-taking tools expect inline math such as `$E=mc^2$` and display math such as `$$ ... $$` to render inside notes without turning the note into an app-only document format.
BlockNote does not currently ship a first-party math block in the local editor package. Tiptap now offers an official Mathematics extension that renders KaTeX nodes, but Tolaria's save path still depends on BlockNote's Markdown parser and `blocksToMarkdownLossy()` serializer. Adding opaque ProseMirror math nodes without an explicit Tolaria serializer would risk losing or rewriting the original Markdown source.
## Decision
**Tolaria will support note math through a Markdown placeholder round-trip owned by the editor pipeline.**
The initial implementation:
- Treats `$...$` as inline math and line-owned `$$...$$` / multiline `$$` blocks as display math.
- Converts math source to temporary placeholders before BlockNote parses Markdown.
- Replaces placeholders with Tolaria schema nodes that render via the existing `katex` dependency.
- Serializes those schema nodes back to the original Markdown delimiters before saving or entering raw mode.
- Uses KaTeX with `throwOnError: false` and `trust: false` so malformed or untrusted formulas remain visible rather than breaking the note.
## Options considered
- **Tolaria-owned placeholder round-trip with KaTeX rendering** (chosen): matches the existing wikilink architecture, preserves plain-text source, and avoids depending on BlockNote support for non-default ProseMirror math nodes.
- **Tiptap Mathematics extension directly in BlockNote**: attractive because it is official Tiptap and KaTeX-backed, but it does not by itself solve Tolaria's BlockNote Markdown serializer contract.
- **Raw-mode-only math support**: preserves source but fails the rich editor reading experience users expect.
- **Store formulas as custom JSON/frontmatter metadata**: richer structured editing later, but violates the Markdown-first durability requirement.
## Consequences
- `src/utils/mathMarkdown.ts` is the canonical parser/serializer bridge for note math.
- The rich editor renders math as schema nodes; raw mode remains the most direct way to edit exact math source.
- CodeMirror raw editing keeps the literal Markdown delimiters, so imported Obsidian-style notes remain understandable outside Tolaria.
- Future equation editing helpers can be added on top of the same Markdown source contract instead of changing the storage model.
- Re-evaluate direct Tiptap Mathematics integration only if it can be proven to preserve Tolaria's Markdown save path without custom lossy behavior.

View File

@@ -0,0 +1,38 @@
---
type: ADR
id: "0083"
title: "Dual-architecture macOS release artifacts"
status: active
date: 2026-04-26
supersedes: "0080"
---
## Context
ADR-0080 made Tolaria's desktop release pipeline cross-platform, but the macOS leg still shipped only Apple Silicon artifacts. That left Intel Mac users without a compatible build in both the alpha feed and stable releases, even though Tauri and Rust can produce `x86_64-apple-darwin` bundles from the same release workflow.
The updater manifest also needs to distinguish macOS CPU architectures. A generic `darwin` or macOS-only entry would make it too easy for an Intel installation to see an Apple Silicon updater bundle, and browser user agents cannot reliably tell Apple Silicon Macs apart from Intel Macs.
## Decision
**Tolaria publishes macOS release artifacts for both Apple Silicon (`darwin-aarch64`) and Intel (`darwin-x86_64`) in every alpha and stable release.**
- Alpha and stable workflows build the macOS matrix for `aarch64-apple-darwin` and `x86_64-apple-darwin`.
- Alpha manifests include signed updater tarballs for `darwin-aarch64` and `darwin-x86_64`.
- Stable manifests include both macOS updater tarballs and both manual DMG downloads, alongside the existing Windows x64 and Linux x86_64 entries.
- Release jobs normalize macOS artifact filenames with the architecture suffix before publishing so GitHub release assets stay unambiguous.
- The stable download page exposes separate Apple Silicon and Intel Mac links. When both Mac links exist, a generic macOS browser is not auto-redirected because user-agent architecture detection is unreliable.
- The cross-platform filename portability decisions from ADR-0080 remain in force.
## Options considered
- **Publish separate Apple Silicon and Intel Mac artifacts** (chosen): gives each updater client an architecture-specific manifest key and gives users explicit manual download links. Cons: doubles the macOS release matrix and signing/notarization surface.
- **Publish a universal macOS binary**: gives users one download, but requires lipo/re-sign/notarize coordination and reintroduces the artifact-combining complexity the release pipeline intentionally avoided.
- **Keep Apple Silicon-only macOS releases**: keeps CI cheaper, but leaves Intel Mac users unsupported and makes the release artifacts inconsistent with Tolaria's desktop support goals.
## Consequences
- macOS release jobs now run one matrix entry per CPU architecture.
- Release manifest consumers must treat `darwin-aarch64` and `darwin-x86_64` as distinct platform keys.
- Stable manual downloads show two Mac choices instead of pretending browser detection can select the right CPU architecture.
- Future macOS release changes must validate both updater and manual-download artifacts for both architectures.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0084"
title: "App-owned localization foundation"
status: active
date: 2026-04-26
---
## Context
Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure.
## Decision
Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`.
- English is the canonical fallback locale.
- Simplified Chinese (`zh-Hans`) is the first additional locale.
- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English".
- Missing translation keys fall back to English.
- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context.
- Language switching is exposed in Settings and through command-palette actions.
## Alternatives considered
- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow.
- **Store language in the vault**: rejected because UI language is an installation preference, not content structure.
- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive.
## Consequences
- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale.
- Partially translated locales remain usable because English is the fallback for missing keys.
- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault.
- Larger feature surfaces can migrate to the shared localization module incrementally.

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0085"
title: "Non-git vaults open with explicit later Git initialization"
status: active
date: 2026-04-26
supersedes: "0034"
---
## Context
ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory.
Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration.
## Decision
**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action.
While a vault is not Git-backed:
- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled.
- Background auto-sync and AutoGit checkpoints do not run.
- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally.
`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit.
## Options considered
- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow.
- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows.
- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata.
## Consequences
- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior.
- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant.
- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA.
- Future Git-dependent features must check the current vault's Git state before registering commands or running background work.

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0086"
title: "In-app image previews for binary vault files"
status: active
date: 2026-04-26
supersedes: "0041"
---
## Context
ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model.
That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault.
## Decision
**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.**
- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type.
- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read.
- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk.
- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically.
- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface.
- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model.
## Consequences
- The existing `VaultEntry` model and cache version do not need to change.
- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes.
- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them.
- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations.

View File

@@ -89,7 +89,7 @@ proposed → active → superseded
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) |
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
@@ -135,5 +135,8 @@ proposed → active → superseded
| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active |
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |
| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active |
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | active |
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | superseded → [0083](0083-dual-architecture-macos-release-artifacts.md) |
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |

View File

@@ -30,7 +30,7 @@ export default defineConfig({
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --host 127.0.0.1 --port ${port} --strictPort`,
command: `node scripts/playwright-smoke-server.mjs ${port}`,
url: baseURL,
reuseExistingServer,
timeout: 30_000,

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
const port = process.argv[2] ?? process.env.PORT ?? '41741'
const child = spawn(
'pnpm',
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
{
cwd: process.cwd(),
env: process.env,
stdio: ['pipe', 'inherit', 'inherit'],
},
)
function forwardSignal(signal) {
if (child.killed) return
child.kill(signal)
}
process.on('SIGINT', () => forwardSignal('SIGINT'))
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -122,7 +122,11 @@ fn version_for_binary(binary: &PathBuf) -> Option<String> {
}
fn find_codex_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_codex_binary_on_path()? {
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
@@ -133,27 +137,76 @@ fn find_codex_binary() -> Result<PathBuf, String> {
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}
fn find_codex_binary_on_path() -> Result<Option<PathBuf>, String> {
let output = Command::new("which")
fn find_codex_binary_on_path() -> Option<PathBuf> {
Command::new("which")
.arg("codex")
.output()
.map_err(|error| format!("Failed to run `which codex`: {error}"))?;
.ok()
.and_then(|output| path_from_successful_output(&output))
}
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(Some(PathBuf::from(path)));
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "codex"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
Ok(None)
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn codex_binary_candidates() -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
dirs::home_dir()
.map(|home| codex_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".npm/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
@@ -419,6 +472,65 @@ mod tests {
}
}
#[test]
fn codex_binary_candidates_include_supported_macos_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-codex");
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
assert_eq!(first_existing_path(&stdout), Some(codex));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_codex_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
codex.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();

View File

@@ -63,7 +63,7 @@ pub struct AgentStreamRequest {
// ---------------------------------------------------------------------------
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_claude_binary_on_path()? {
if let Some(binary) = find_claude_binary_on_path() {
return Ok(binary);
}
@@ -78,13 +78,20 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
}
fn find_claude_binary_on_path() -> Result<Option<PathBuf>, String> {
let output = Command::new("which")
fn find_claude_binary_on_path() -> Option<PathBuf> {
Command::new(claude_path_lookup_command())
.arg("claude")
.output()
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
.ok()
.and_then(|output| path_from_successful_output(&output))
}
Ok(path_from_successful_output(&output))
fn claude_path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_claude_binary_in_user_shell() -> Option<PathBuf> {
@@ -143,11 +150,24 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/claude"),
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude"),
home.join(".claude/local/claude.exe"),
home.join(".local/share/mise/shims/claude"),
home.join(".local/share/mise/shims/claude.exe"),
home.join(".asdf/shims/claude"),
home.join(".asdf/shims/claude.exe"),
home.join(".npm-global/bin/claude"),
home.join(".npm-global/bin/claude.cmd"),
home.join(".npm-global/bin/claude.exe"),
home.join(".npm/bin/claude"),
home.join(".npm/bin/claude.cmd"),
home.join(".npm/bin/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
home.join("AppData/Roaming/npm/claude.exe"),
home.join("AppData/Local/pnpm/claude.cmd"),
home.join("AppData/Local/pnpm/claude.exe"),
home.join("scoop/shims/claude.exe"),
PathBuf::from("/opt/homebrew/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
]
@@ -1099,6 +1119,45 @@ mod tests {
}
}
#[test]
fn claude_binary_candidates_include_windows_exe_installs() {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn claude_path_lookup_command_matches_current_platform() {
let expected = if cfg!(windows) { "where" } else { "which" };
assert_eq!(claude_path_lookup_command(), expected);
}
#[test]
fn find_existing_binary_finds_windows_exe_candidate() {
let dir = tempfile::tempdir().unwrap();
let claude = dir.path().join(".local/bin/claude.exe");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "").unwrap();
assert_eq!(
find_existing_binary(claude_binary_candidates_for_home(dir.path())),
Some(claude)
);
}
#[test]
fn find_claude_binary_returns_result() {
let result = find_claude_binary();

View File

@@ -5,11 +5,22 @@ use crate::git::{
use super::expand_tilde;
type VaultPathArg = String;
type NotePathArg = String;
type CommitHashArg = String;
type CommitMessageArg = String;
type ConflictStrategyArg = String;
type RemoteUrlArg = String;
type LocalPathArg = String;
// ── Git commands (desktop) ──────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
pub fn get_file_history(
vault_path: VaultPathArg,
path: NotePathArg,
) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
crate::git::get_file_history(&vault_path, &path)
@@ -17,14 +28,14 @@ pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommi
#[cfg(desktop)]
#[tauri::command]
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
pub fn get_modified_files(vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::get_modified_files(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
pub fn get_file_diff(vault_path: VaultPathArg, path: NotePathArg) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
crate::git::get_file_diff(&vault_path, &path)
@@ -33,9 +44,9 @@ pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String>
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_diff_at_commit(
vault_path: String,
path: String,
commit_hash: String,
vault_path: VaultPathArg,
path: NotePathArg,
commit_hash: CommitHashArg,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
@@ -45,7 +56,7 @@ pub fn get_file_diff_at_commit(
#[cfg(desktop)]
#[tauri::command]
pub fn get_vault_pulse(
vault_path: String,
vault_path: VaultPathArg,
limit: Option<usize>,
skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
@@ -57,21 +68,21 @@ pub fn get_vault_pulse(
#[cfg(desktop)]
#[tauri::command]
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
pub fn git_commit(vault_path: VaultPathArg, message: CommitMessageArg) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_commit(&vault_path, &message)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
pub fn get_last_commit_info(vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::get_last_commit_info(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
pub async fn git_pull(vault_path: VaultPathArg) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::git::git_pull(&vault_path))
.await
@@ -80,14 +91,14 @@ pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
#[cfg(desktop)]
#[tauri::command]
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
pub fn get_conflict_files(vault_path: VaultPathArg) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::get_conflict_files(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_conflict_mode(vault_path: String) -> String {
pub fn get_conflict_mode(vault_path: VaultPathArg) -> String {
let vault_path = expand_tilde(&vault_path);
crate::git::get_conflict_mode(&vault_path)
}
@@ -95,9 +106,9 @@ pub fn get_conflict_mode(vault_path: String) -> String {
#[cfg(desktop)]
#[tauri::command]
pub fn git_resolve_conflict(
vault_path: String,
file: String,
strategy: String,
vault_path: VaultPathArg,
file: NotePathArg,
strategy: ConflictStrategyArg,
) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_resolve_conflict(&vault_path, &file, &strategy)
@@ -105,14 +116,14 @@ pub fn git_resolve_conflict(
#[cfg(desktop)]
#[tauri::command]
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
pub fn git_commit_conflict_resolution(vault_path: VaultPathArg) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_commit_conflict_resolution(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
pub async fn git_push(vault_path: VaultPathArg) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::git::git_push(&vault_path))
.await
@@ -121,7 +132,7 @@ pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
#[cfg(desktop)]
#[tauri::command]
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::git::git_remote_status(&vault_path))
.await
@@ -130,14 +141,17 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
#[cfg(desktop)]
#[tauri::command]
pub fn git_discard_file(vault_path: String, relative_path: String) -> Result<(), String> {
pub fn git_discard_file(
vault_path: VaultPathArg,
relative_path: NotePathArg,
) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::discard_file_changes(&vault_path, &relative_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn is_git_repo(vault_path: String) -> bool {
pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
let vault_path = expand_tilde(&vault_path);
std::path::Path::new(vault_path.as_ref())
.join(".git")
@@ -146,14 +160,14 @@ pub fn is_git_repo(vault_path: String) -> bool {
#[cfg(desktop)]
#[tauri::command]
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::init_repo(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
pub fn clone_repo(url: RemoteUrlArg, local_path: LocalPathArg) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
crate::git::clone_repo(&url, &local_path)
}
@@ -162,28 +176,31 @@ pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_history(_vault_path: String, _path: String) -> Result<Vec<GitCommit>, String> {
pub fn get_file_history(
_vault_path: VaultPathArg,
_path: NotePathArg,
) -> Result<Vec<GitCommit>, String> {
Err("Git history is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
pub fn get_modified_files(_vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
pub fn get_file_diff(_vault_path: VaultPathArg, _path: NotePathArg) -> Result<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff_at_commit(
_vault_path: String,
_path: String,
_commit_hash: String,
_vault_path: VaultPathArg,
_path: NotePathArg,
_commit_hash: CommitHashArg,
) -> Result<String, String> {
Err("Git diff is not available on mobile".into())
}
@@ -191,7 +208,7 @@ pub fn get_file_diff_at_commit(
#[cfg(mobile)]
#[tauri::command]
pub fn get_vault_pulse(
_vault_path: String,
_vault_path: VaultPathArg,
_limit: Option<usize>,
_skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
@@ -200,59 +217,59 @@ pub fn get_vault_pulse(
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
pub fn git_commit(_vault_path: VaultPathArg, _message: CommitMessageArg) -> Result<String, String> {
Err("Git commit is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
pub fn get_last_commit_info(_vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
pub async fn git_pull(_vault_path: VaultPathArg) -> Result<GitPullResult, String> {
Err("Git pull is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, String> {
pub fn get_conflict_files(_vault_path: VaultPathArg) -> Result<Vec<String>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_mode(_vault_path: String) -> String {
pub fn get_conflict_mode(_vault_path: VaultPathArg) -> String {
"none".to_string()
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_resolve_conflict(
_vault_path: String,
_file: String,
_strategy: String,
_vault_path: VaultPathArg,
_file: NotePathArg,
_strategy: ConflictStrategyArg,
) -> Result<(), String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit_conflict_resolution(_vault_path: String) -> Result<String, String> {
pub fn git_commit_conflict_resolution(_vault_path: VaultPathArg) -> Result<String, String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
pub async fn git_push(_vault_path: VaultPathArg) -> Result<GitPushResult, String> {
Err("Git push is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
Ok(GitRemoteStatus {
branch: String::new(),
has_remote: false,
@@ -263,24 +280,112 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
#[cfg(mobile)]
#[tauri::command]
pub fn git_discard_file(_vault_path: String, _relative_path: String) -> Result<(), String> {
pub fn git_discard_file(
_vault_path: VaultPathArg,
_relative_path: NotePathArg,
) -> Result<(), String> {
Err("Git discard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn is_git_repo(_vault_path: String) -> bool {
pub fn is_git_repo(_vault_path: VaultPathArg) -> bool {
false
}
#[cfg(mobile)]
#[tauri::command]
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
pub fn init_git_repo(_vault_path: VaultPathArg) -> Result<(), String> {
Err("Git init is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
pub fn clone_repo(_url: RemoteUrlArg, _local_path: LocalPathArg) -> Result<String, String> {
Err("Git clone is not available on mobile".into())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_path(dir: &TempDir) -> String {
dir.path().to_string_lossy().into_owned()
}
fn note_path(dir: &TempDir, name: &str) -> String {
dir.path().join(name).to_string_lossy().into_owned()
}
fn create_initialized_vault() -> (TempDir, String) {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
let vault = vault_path(&dir);
init_git_repo(vault.clone()).unwrap();
(dir, vault)
}
#[test]
fn desktop_git_commands_route_to_git_backend() {
let (dir, vault) = create_initialized_vault();
let note = note_path(&dir, "note.md");
assert!(is_git_repo(vault.clone()));
fs::write(dir.path().join("note.md"), "# Updated\n").unwrap();
let modified = get_modified_files(vault.clone()).unwrap();
assert!(modified.iter().any(|file| file.relative_path == "note.md"));
let diff = get_file_diff(vault.clone(), note.clone()).unwrap();
assert!(diff.contains("# Updated"));
git_commit(vault.clone(), "Update note".to_string()).unwrap();
let history = get_file_history(vault.clone(), note.clone()).unwrap();
assert!(history.iter().any(|commit| commit.message == "Update note"));
let last_commit = get_last_commit_info(vault.clone()).unwrap().unwrap();
assert!(!last_commit.short_hash.is_empty());
let commit_diff = get_file_diff_at_commit(
vault.clone(),
note.clone(),
history.first().unwrap().hash.clone(),
)
.unwrap();
assert!(commit_diff.contains("# Updated"));
let pulse = get_vault_pulse(vault.clone(), Some(5), Some(0)).unwrap();
assert!(!pulse.is_empty());
fs::write(dir.path().join("note.md"), "# Discard me\n").unwrap();
git_discard_file(vault.clone(), "note.md".to_string()).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join("note.md")).unwrap(),
"# Updated\n"
);
assert!(get_conflict_files(vault.clone()).unwrap().is_empty());
assert_eq!(get_conflict_mode(vault.clone()), "none");
assert!(
git_resolve_conflict(vault.clone(), "note.md".to_string(), "invalid".to_string(),)
.is_err()
);
}
#[tokio::test]
async fn desktop_remote_commands_report_no_remote() {
let (_dir, vault) = create_initialized_vault();
let pull = git_pull(vault.clone()).await.unwrap();
assert_eq!(pull.status, "no_remote");
let push = git_push(vault.clone()).await.unwrap();
assert_eq!(push.status, "error");
let status = git_remote_status(vault.clone()).await.unwrap();
assert!(!status.has_remote);
assert_eq!((status.ahead, status.behind), (0, 0));
}
}

View File

@@ -214,3 +214,113 @@ pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_root(dir: &TempDir) -> PathBuf {
dir.path().to_path_buf()
}
fn note_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(name)
}
#[test]
fn note_content_commands_roundtrip_with_requested_vault() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = note_path(&dir, "notes/command-note.md");
create_note_content(
note.clone(),
"# Command Note\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note.clone(), Some(root.clone())).unwrap(),
"# Command Note\n"
);
save_note_content(
note.clone(),
"---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap());
save_note_content(
note.clone(),
"# Updated Command Note\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap());
assert!(get_note_content(note, Some(root))
.unwrap()
.contains("title: Command Note"));
}
#[test]
fn note_content_commands_accept_windows_sensitive_valid_segments() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = root
.join("@raflymln")
.join("notes with spaces")
.join("résumé note.md");
save_note_content(
note.clone(),
"# Windows-Sensitive Path\n\nBody\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note, Some(root)).unwrap(),
"# Windows-Sensitive Path\n\nBody\n"
);
}
#[test]
fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
assert_eq!(
create_vault_folder(root.clone(), PathBuf::from("Projects")).unwrap(),
"Projects"
);
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
let entries = list_vault(root.clone()).unwrap();
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
let folders = list_vault_folders(root).unwrap();
assert!(folders.iter().any(|folder| folder.name == "Projects"));
}
#[test]
fn commands_reject_paths_outside_requested_vault() {
let vault = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_note = outside.path().join("outside.md");
fs::write(&outside_note, "# Outside\n").unwrap();
let error = get_note_content(outside_note, Some(vault.path().to_path_buf())).unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
let folder_error =
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"))
.unwrap_err();
assert!(folder_error.contains("Path must stay inside the active vault"));
}
}

View File

@@ -113,3 +113,118 @@ pub fn update_wikilinks_for_renames(
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &renames)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_path(dir: &TempDir) -> String {
dir.path().to_string_lossy().into_owned()
}
fn write_note(dir: &TempDir, relative_path: &str, content: &str) -> String {
let path = dir.path().join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn rename_note_command_updates_title_file_and_links() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let old_path = write_note(
&dir,
"old-title.md",
"---\ntitle: Old Title\n---\n# Old Title\n",
);
let linked_path = write_note(&dir, "linked.md", "See [[Old Title]].\n");
let result = rename_note(
vault.clone(),
old_path.clone(),
"New Title".to_string(),
None,
)
.unwrap();
assert!(result.new_path.ends_with("new-title.md"));
assert!(!Path::new(&old_path).exists());
assert!(Path::new(&result.new_path).exists());
assert!(fs::read_to_string(linked_path)
.unwrap()
.contains("[[new-title]]"));
assert_eq!(result.failed_updates, 0);
}
#[test]
fn filename_and_folder_commands_preserve_note_content() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let old_path = write_note(
&dir,
"draft.md",
"---\ntitle: Draft Title\n---\n# Draft Title\n",
);
let renamed =
rename_note_filename(vault.clone(), old_path, "custom-name".to_string()).unwrap();
assert!(renamed.new_path.ends_with("custom-name.md"));
fs::create_dir(dir.path().join("Projects")).unwrap();
let moved = move_note_to_folder(
vault.clone(),
renamed.new_path.clone(),
"Projects".to_string(),
)
.unwrap();
assert!(moved.new_path.ends_with("Projects/custom-name.md"));
assert!(fs::read_to_string(moved.new_path)
.unwrap()
.contains("Draft Title"));
}
#[test]
fn auto_rename_and_detected_rename_commands_route_through_vault() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
let auto = auto_rename_untitled(vault.clone(), untitled)
.unwrap()
.unwrap();
assert!(auto.new_path.ends_with("project-plan.md"));
crate::git::init_repo(&vault).unwrap();
let old_path = dir.path().join("project-plan.md");
let new_path = dir.path().join("plans.md");
fs::rename(&old_path, &new_path).unwrap();
crate::hidden_command("git")
.args(["add", "-A"])
.current_dir(dir.path())
.output()
.unwrap();
let renames = detect_renames(vault.clone()).unwrap();
assert_eq!(renames.len(), 1);
assert_eq!(renames[0].old_path, "project-plan.md");
assert_eq!(renames[0].new_path, "plans.md");
assert_eq!(update_wikilinks_for_renames(vault, renames).unwrap(), 0);
}
#[test]
fn move_note_to_folder_rejects_empty_folder() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let note = write_note(&dir, "note.md", "# Note\n");
let error = move_note_to_folder(vault, note, " ".to_string()).unwrap_err();
assert!(error.contains("Folder path cannot be empty"));
}
}

View File

@@ -12,6 +12,8 @@ pub mod settings;
pub mod telemetry;
pub mod vault;
pub mod vault_list;
#[cfg(desktop)]
mod window_state;
use std::ffi::OsStr;
use std::process::Command;
@@ -154,6 +156,7 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
#[cfg(not(target_os = "linux"))]
menu::setup_menu(app)?;
setup_linux_window_chrome(app)?;
window_state::restore_main_window_state(app);
Ok(())
}
@@ -365,6 +368,8 @@ fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<ta
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
use tauri::Manager;
window_state::handle_run_event(app_handle, event);
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
@@ -383,7 +388,8 @@ pub fn run() {
#[cfg(desktop)]
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())));
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
.manage(window_state::MainWindowFrameState::default());
with_invoke_handler(builder)
.setup(setup_app)

View File

@@ -134,6 +134,7 @@ fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
}
@@ -197,7 +198,7 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
status.to_string()
}
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
@@ -310,7 +311,7 @@ pub fn remove_mcp() -> String {
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the Tolaria entry exists for the active vault in
/// Claude Code or Cursor config and the referenced index.js file is present.
/// an external AI tool config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
@@ -556,6 +557,7 @@ mod tests {
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
register_mcp_to_configs(
@@ -564,12 +566,14 @@ mod tests {
claude_user_cfg.clone(),
claude_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
);
assert!(claude_user_cfg.exists());
assert!(claude_cfg.exists());
assert!(cursor_cfg.exists());
assert!(generic_cfg.exists());
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
@@ -580,7 +584,7 @@ mod tests {
}
#[test]
fn mcp_config_paths_for_home_includes_claude_root_and_legacy_paths() {
fn mcp_config_paths_for_home_includes_all_supported_config_paths() {
let home = Path::new("/Users/tester");
let paths = mcp_config_paths_for_home(home);
@@ -590,6 +594,7 @@ mod tests {
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
);
}

View File

@@ -18,8 +18,10 @@ pub struct Settings {
pub anonymous_id: Option<String>,
pub release_channel: Option<String>,
pub theme_mode: Option<String>,
pub ui_language: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub note_width_mode: Option<String>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
@@ -61,6 +63,41 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
}
}
pub fn normalize_note_width_mode(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
_ => None,
}
}
fn canonical_language_code(value: &str) -> Option<String> {
let code = value.trim().replace('_', "-").to_ascii_lowercase();
if code.is_empty() {
None
} else {
Some(code)
}
}
fn is_english_language(code: &str) -> bool {
code == "en" || code.starts_with("en-")
}
fn is_simplified_chinese_language(code: &str) -> bool {
matches!(code, "zh" | "zh-cn" | "zh-hans" | "zh-sg")
}
pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
let language = canonical_language_code(value?)?;
if is_english_language(&language) {
return Some("en".to_string());
}
if is_simplified_chinese_language(&language) {
return Some("zh-Hans".to_string());
}
None
}
fn normalize_settings(settings: Settings) -> Settings {
Settings {
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
@@ -78,8 +115,10 @@ fn normalize_settings(settings: Settings) -> Settings {
anonymous_id: normalize_optional_string(settings.anonymous_id),
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
}
}
@@ -87,7 +126,7 @@ fn app_config_dir() -> Result<PathBuf, String> {
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
}
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
pub(crate) fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
}
@@ -219,8 +258,10 @@ mod tests {
anonymous_id: Some("abc-123-uuid".to_string()),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
note_width_mode: Some("wide".to_string()),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
@@ -245,8 +286,10 @@ mod tests {
auto_advance_inbox_after_organize: Some(true),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
note_width_mode: Some("wide".to_string()),
..Default::default()
});
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
@@ -256,8 +299,10 @@ mod tests {
assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
}
#[test]
@@ -266,13 +311,17 @@ mod tests {
anonymous_id: Some(" test-uuid ".to_string()),
release_channel: Some(" alpha ".to_string()),
theme_mode: Some(" dark ".to_string()),
ui_language: Some(" zh-cn ".to_string()),
default_ai_agent: Some(" codex ".to_string()),
note_width_mode: Some(" wide ".to_string()),
..Default::default()
});
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
}
#[test]
@@ -322,6 +371,33 @@ mod tests {
assert!(loaded.theme_mode.is_none());
}
#[test]
fn test_invalid_note_width_mode_is_filtered() {
let loaded = save_and_reload(Settings {
note_width_mode: Some("full".to_string()),
..Default::default()
});
assert!(loaded.note_width_mode.is_none());
}
#[test]
fn test_invalid_ui_language_is_filtered() {
let loaded = save_and_reload(Settings {
ui_language: Some("fr-FR".to_string()),
..Default::default()
});
assert!(loaded.ui_language.is_none());
}
#[test]
fn test_ui_language_aliases_are_canonicalized() {
assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en"));
assert_eq!(
normalize_ui_language(Some("zh_CN")).as_deref(),
Some("zh-Hans")
);
}
#[test]
fn test_get_settings_normalizes_legacy_beta_channel() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -54,6 +54,9 @@ pub struct VaultEntry {
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Per-note content width override stored in `_width` frontmatter.
#[serde(rename = "noteWidth")]
pub note_width: Option<String>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Whether this note has been explicitly organized (removed from Inbox).

View File

@@ -1,5 +1,5 @@
use std::fs;
use std::io::{ErrorKind, Write};
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -25,6 +25,49 @@ fn invalid_utf8_text_error(path: &Path) -> String {
format!("File is not valid UTF-8 text: {}", path.display())
}
fn is_invalid_platform_path_error(error: &Error) -> bool {
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
}
#[derive(Clone, Copy)]
enum NoteIoOperation {
Save,
Create,
}
#[derive(Clone, Copy)]
struct NotePathDisplay<'a> {
value: &'a str,
}
impl<'a> NotePathDisplay<'a> {
fn new(value: &'a str) -> Self {
Self { value }
}
}
impl NoteIoOperation {
fn verb(self) -> &'static str {
match self {
Self::Save => "save",
Self::Create => "create",
}
}
}
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
let verb = operation.verb();
if is_invalid_platform_path_error(error) {
let path = path.value;
format!(
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
)
} else {
let path = path.value;
format!("Failed to {verb} {path}: {error}")
}
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.exists() {
@@ -62,12 +105,14 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
fs::write(file_path, content)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
/// Create a new note file without overwriting any existing file.
@@ -75,8 +120,9 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
@@ -86,8 +132,27 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => format!("Failed to create {}: {}", path, e),
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to save {}: {}", path, e))
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
let message = note_io_error(
NoteIoOperation::Save,
NotePathDisplay::new(path),
&Error::from_raw_os_error(123),
);
assert!(message.contains("path is invalid on this platform"));
assert!(message.contains("Rename the note or move it to a valid folder"));
assert!(!message.contains("os error 123"));
}
}

View File

@@ -40,6 +40,8 @@ pub(crate) struct Frontmatter {
pub sort: Option<StringOrList>,
#[serde(default)]
pub view: Option<StringOrList>,
#[serde(rename = "_width", default)]
pub width: Option<StringOrList>,
#[serde(default)]
pub visible: Option<bool>,
#[serde(
@@ -203,6 +205,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
"_sort",
"sort",
"view",
"_width",
"visible",
"notion_id",
"Status",

View File

@@ -60,6 +60,14 @@ fn preferred_relationship_refs(
.unwrap_or_default()
}
fn normalize_note_width(value: Option<String>) -> Option<String> {
match value?.trim().to_ascii_lowercase().as_str() {
"normal" => Some("normal".to_string()),
"wide" => Some("wide".to_string()),
_ => None,
}
}
pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
@@ -149,6 +157,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
template: frontmatter.template.and_then(|v| v.into_scalar()),
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
view: frontmatter.view.and_then(|v| v.into_scalar()),
note_width: normalize_note_width(frontmatter.width.and_then(|v| v.into_scalar())),
visible: frontmatter.visible,
organized: frontmatter.organized.unwrap_or(false),
favorite: frontmatter.favorite.unwrap_or(false),

View File

@@ -55,17 +55,22 @@ struct WikilinkUpdateSummary {
failed_updates: usize,
}
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
/// Convert a title to a filename slug (lowercase, hyphens, Unicode letters/digits preserved).
pub(super) fn title_to_slug(title: &str) -> String {
title
let slug = title
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
.join("-");
if slug.is_empty() {
"untitled".to_string()
} else {
slug
}
}
/// Build a regex that matches wiki links referencing any of the provided targets.
@@ -634,6 +639,32 @@ mod tests {
assert_eq!(result.unwrap_err(), expected_error.as_ref());
}
fn assert_slug_case(input: &str, expected: &str) {
assert_eq!(title_to_slug(input), expected);
}
fn assert_unicode_rename_path(result: &RenameResult) {
assert!(
result.new_path.ends_with("你好.md"),
"got {}",
result.new_path
);
}
fn assert_unicode_rename_filesystem(vault: &Path, old_path: &Path, result: &RenameResult) {
assert!(Path::new(&result.new_path).exists());
assert!(!old_path.exists());
assert!(
!vault.join(".md").exists(),
"must not produce a stem-less .md file"
);
}
fn assert_unicode_rename_frontmatter(result: &RenameResult) {
let new_content = fs::read_to_string(&result.new_path).unwrap();
assert!(new_content.contains("title: 你好"));
}
#[test]
fn test_title_to_slug() {
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
@@ -641,6 +672,44 @@ mod tests {
assert_eq!(title_to_slug("Hello World"), "hello-world");
}
#[test]
fn test_title_to_slug_preserves_unicode_letters() {
assert_slug_case("你好", "你好");
assert_slug_case("こんにちは", "こんにちは");
assert_slug_case("My Note 你好", "my-note-你好");
assert_slug_case("项目-2025 ✦ Q1", "项目-2025-q1");
}
#[test]
fn test_title_to_slug_falls_back_to_untitled_for_symbol_only_titles() {
assert_eq!(title_to_slug(""), "untitled");
assert_eq!(title_to_slug("---"), "untitled");
}
#[test]
fn test_rename_note_with_cjk_title_writes_unicode_filename() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"untitled-note-1700000000.md",
"---\ntype: Note\n---\n# Untitled Note\n",
);
let old_path = vault.join("untitled-note-1700000000.md");
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "你好",
old_title_hint: None,
})
.unwrap();
assert_unicode_rename_path(&result);
assert_unicode_rename_filesystem(vault, &old_path, &result);
assert_unicode_rename_frontmatter(&result);
}
#[test]
fn test_rename_note_basic() {
let dir = TempDir::new().unwrap();

View File

@@ -33,6 +33,31 @@ fn parses_canonical_system_metadata_keys() {
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
}
#[test]
fn parses_note_width_without_property_leak() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_width: wide\n---\n# Note\n",
);
assert_eq!(entry.note_width.as_deref(), Some("wide"));
assert!(!entry.properties.contains_key("_width"));
}
#[test]
fn parses_bare_width_as_custom_property() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "note.md", "---\nwidth: 320\n---\n# Note\n");
assert!(entry.note_width.is_none());
assert_eq!(
entry.properties.get("width").and_then(|v| v.as_i64()),
Some(320)
);
}
#[test]
fn parses_legacy_system_metadata_keys_without_property_leaks() {
let dir = TempDir::new().unwrap();

View File

@@ -0,0 +1,514 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{
App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow,
WindowEvent,
};
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;
#[derive(Debug, Default)]
pub(crate) struct MainWindowFrameState(Mutex<Option<WindowFrame>>);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
struct WindowFrame {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
struct ScreenArea {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedWindowState {
main: Option<WindowFrame>,
#[serde(default)]
coordinate_space: WindowFrameCoordinateSpace,
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WindowFrameCoordinateSpace {
#[default]
Physical,
Logical,
}
pub(crate) fn restore_main_window_state(app: &mut App) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app.handle(), &window, "during setup");
}
pub(crate) fn handle_run_event(app_handle: &AppHandle, event: &RunEvent) {
match event {
event if restores_window_frame_after_runtime_ready(event) => {
restore_main_window_state_from_handle(app_handle)
}
RunEvent::WindowEvent {
label,
event:
WindowEvent::Moved(_) | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. },
..
} if label == MAIN_WINDOW_LABEL => cache_current_normal_frame(app_handle),
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed,
..
} if label == MAIN_WINDOW_LABEL => save_main_window_frame(app_handle),
RunEvent::Exit => save_main_window_frame(app_handle),
_ => {}
}
}
fn restores_window_frame_after_runtime_ready(event: &RunEvent) -> bool {
matches!(event, RunEvent::Ready)
}
fn restore_main_window_state_from_handle(app_handle: &AppHandle) {
let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app_handle, &window, "after runtime ready");
}
fn restore_main_window_frame(app_handle: &AppHandle, window: &WebviewWindow, phase: &str) {
let Some(frame) = read_main_window_frame(window_scale_factor(window)) else {
return;
};
let areas = current_screen_areas(window);
let Some(restored_frame) = fit_frame_to_screens(frame, &areas) else {
return;
};
if let Err(err) = apply_window_frame(window, restored_frame) {
log::warn!("Failed to restore main window state {phase}: {err}");
return;
}
cache_frame(app_handle, restored_frame);
}
fn cache_current_normal_frame(app_handle: &AppHandle) {
if let Some(frame) = current_normal_main_window_frame(app_handle) {
cache_frame(app_handle, frame);
}
}
fn save_main_window_frame(app_handle: &AppHandle) {
let frame = current_normal_main_window_frame(app_handle).or_else(|| cached_frame(app_handle));
if let Some(frame) = frame {
if let Err(err) = write_main_window_frame(frame) {
log::warn!("Failed to save main window state: {err}");
}
}
}
fn current_normal_main_window_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL)?;
if !is_normal_window(&window) {
return None;
}
read_window_frame(&window).filter(is_valid_saved_frame)
}
fn is_normal_window(window: &WebviewWindow) -> bool {
let is_fullscreen = window.is_fullscreen().unwrap_or(false);
let is_maximized = window.is_maximized().unwrap_or(false);
let is_minimized = window.is_minimized().unwrap_or(false);
!is_fullscreen && !is_maximized && !is_minimized
}
fn read_window_frame(window: &WebviewWindow) -> Option<WindowFrame> {
let scale_factor = window_scale_factor(window);
let position = window.outer_position().ok()?;
let size = window.inner_size().ok()?;
Some(WindowFrame::from_logical_geometry(
position.to_logical::<f64>(scale_factor),
size.to_logical::<f64>(scale_factor),
))
}
fn apply_window_frame(window: &WebviewWindow, frame: WindowFrame) -> tauri::Result<()> {
window.set_size(Size::Logical(LogicalSize::new(
frame.width as f64,
frame.height as f64,
)))?;
window.set_position(Position::Logical(LogicalPosition::new(
frame.x as f64,
frame.y as f64,
)))
}
fn current_screen_areas(window: &WebviewWindow) -> Vec<ScreenArea> {
let scale_factor = window_scale_factor(window);
window
.available_monitors()
.unwrap_or_default()
.into_iter()
.map(|monitor| {
let area = monitor.work_area();
let position = area.position.to_logical::<f64>(scale_factor);
let size = area.size.to_logical::<f64>(scale_factor);
ScreenArea {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
})
.filter(ScreenArea::has_area)
.collect()
}
fn window_scale_factor(window: &WebviewWindow) -> f64 {
window.scale_factor().unwrap_or(1.0).max(1.0)
}
fn fit_frame_to_screens(frame: WindowFrame, screens: &[ScreenArea]) -> Option<WindowFrame> {
if frame_is_visible(frame, screens) {
return Some(frame);
}
let screen = best_screen_for_frame(frame, screens)?;
let width = clamp_dimension(frame.width, MIN_WINDOW_WIDTH, screen.width);
let height = clamp_dimension(frame.height, MIN_WINDOW_HEIGHT, screen.height);
Some(WindowFrame {
x: clamp_axis(frame.x, width, screen.x, screen.width),
y: clamp_axis(frame.y, height, screen.y, screen.height),
width,
height,
})
}
fn frame_is_visible(frame: WindowFrame, screens: &[ScreenArea]) -> bool {
frame_corners(frame)
.into_iter()
.all(|point| screens.iter().any(|screen| screen.contains(point)))
}
fn frame_corners(frame: WindowFrame) -> [(i32, i32); 4] {
let right = frame.right() - 1;
let bottom = frame.bottom() - 1;
[
(frame.x, frame.y),
(right, frame.y),
(frame.x, bottom),
(right, bottom),
]
}
fn best_screen_for_frame(frame: WindowFrame, screens: &[ScreenArea]) -> Option<ScreenArea> {
screens
.iter()
.copied()
.filter(ScreenArea::has_area)
.max_by_key(|screen| intersection_area(frame, *screen))
}
fn intersection_area(frame: WindowFrame, screen: ScreenArea) -> u64 {
let left = frame.x.max(screen.x);
let top = frame.y.max(screen.y);
let right = frame.right().min(screen.right());
let bottom = frame.bottom().min(screen.bottom());
if right <= left || bottom <= top {
return 0;
}
(right - left) as u64 * (bottom - top) as u64
}
fn clamp_dimension(value: u32, min: u32, max: u32) -> u32 {
if max < min {
max
} else {
value.clamp(min, max)
}
}
fn clamp_axis(value: i32, size: u32, area_start: i32, area_size: u32) -> i32 {
let max_start = area_start + area_size as i32 - size as i32;
if max_start < area_start {
return area_start;
}
value.clamp(area_start, max_start)
}
fn cache_frame(app_handle: &AppHandle, frame: WindowFrame) {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
if let Ok(mut cached_frame) = state.0.lock() {
*cached_frame = Some(frame);
};
}
fn cached_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
state.0.lock().ok().and_then(|cached_frame| *cached_frame)
}
fn window_state_path() -> Result<PathBuf, String> {
crate::settings::preferred_app_config_path(WINDOW_STATE_FILE)
}
fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
let content = fs::read_to_string(window_state_path().ok()?).ok()?;
let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?;
persisted
.main
.map(|frame| {
persisted
.coordinate_space
.to_logical_frame(frame, scale_factor)
})
.filter(is_valid_saved_frame)
}
fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> {
let path = window_state_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create window state directory: {e}"))?;
}
let persisted = PersistedWindowState {
main: Some(frame),
coordinate_space: WindowFrameCoordinateSpace::Logical,
};
let json = serde_json::to_string_pretty(&persisted)
.map_err(|e| format!("Failed to serialize window state: {e}"))?;
fs::write(path, json).map_err(|e| format!("Failed to write window state: {e}"))
}
fn is_valid_saved_frame(frame: &WindowFrame) -> bool {
frame.width >= MIN_WINDOW_WIDTH && frame.height >= MIN_WINDOW_HEIGHT
}
fn rounded_i32(value: f64) -> i32 {
value.round() as i32
}
fn rounded_u32(value: f64) -> u32 {
value.round().max(0.0) as u32
}
impl WindowFrame {
fn from_logical_geometry(position: LogicalPosition<f64>, size: LogicalSize<f64>) -> Self {
Self {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
}
fn to_logical(self, scale_factor: f64) -> Self {
let scale_factor = scale_factor.max(1.0);
Self {
x: rounded_i32(self.x as f64 / scale_factor),
y: rounded_i32(self.y as f64 / scale_factor),
width: rounded_u32(self.width as f64 / scale_factor),
height: rounded_u32(self.height as f64 / scale_factor),
}
}
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
}
impl WindowFrameCoordinateSpace {
fn to_logical_frame(self, frame: WindowFrame, scale_factor: f64) -> WindowFrame {
match self {
Self::Logical => frame,
Self::Physical => frame.to_logical(scale_factor),
}
}
}
impl ScreenArea {
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
fn has_area(&self) -> bool {
self.width > 0 && self.height > 0
}
fn contains(&self, point: (i32, i32)) -> bool {
let (x, y) = point;
x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(x: i32, y: i32, width: u32, height: u32) -> WindowFrame {
WindowFrame {
x,
y,
width,
height,
}
}
fn screen(x: i32, y: i32, width: u32, height: u32) -> ScreenArea {
ScreenArea {
x,
y,
width,
height,
}
}
#[test]
fn records_logical_window_geometry_for_persistence() {
let saved = WindowFrame::from_logical_geometry(
LogicalPosition::new(80.0, 120.0),
LogicalSize::new(1100.0, 700.0),
);
assert_eq!(saved, frame(80, 120, 1100, 700));
}
#[test]
fn migrates_legacy_physical_frames_to_logical_points() {
let saved = frame(160, 240, 2200, 1400);
assert_eq!(
WindowFrameCoordinateSpace::Physical.to_logical_frame(saved, 2.0),
frame(80, 120, 1100, 700)
);
}
#[test]
fn keeps_explicit_logical_frames_unscaled() {
let saved = frame(80, 120, 1100, 700);
assert_eq!(
WindowFrameCoordinateSpace::Logical.to_logical_frame(saved, 2.0),
saved
);
}
#[test]
fn keeps_valid_frame_unchanged() {
let saved = frame(120, 80, 1400, 900);
let screens = [screen(0, 0, 1920, 1080)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn clamps_oversized_frame_to_current_work_area() {
let saved = frame(-100, -80, 2600, 1800);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 1440, 900))
);
}
#[test]
fn moves_offscreen_frame_back_to_a_visible_screen() {
let saved = frame(3200, 1800, 900, 700);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(540, 200, 900, 700))
);
}
#[test]
fn picks_the_screen_with_the_largest_visible_overlap() {
let saved = frame(1700, 100, 900, 700);
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn ignores_empty_screen_areas_when_restoring() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 0, 900), screen(0, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn returns_none_when_no_usable_screens_exist() {
let saved = frame(100, 100, 800, 600);
assert_eq!(fit_frame_to_screens(saved, &[]), None);
assert_eq!(fit_frame_to_screens(saved, &[screen(0, 0, 0, 0)]), None);
}
#[test]
fn fits_to_tiny_work_area_when_it_is_smaller_than_minimum_size() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 320, 240)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 320, 240))
);
}
#[test]
fn reports_visibility_across_adjacent_screens() {
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert!(frame_is_visible(frame(1700, 100, 900, 700), &screens));
assert!(!frame_is_visible(frame(1700, 850, 900, 300), &screens));
}
#[test]
fn computes_frame_and_screen_edges_for_overlap_checks() {
let saved = frame(10, 20, 800, 600);
let area = screen(0, 0, 500, 400);
assert_eq!(saved.right(), 810);
assert_eq!(saved.bottom(), 620);
assert_eq!(area.right(), 500);
assert_eq!(area.bottom(), 400);
assert_eq!(intersection_area(saved, area), 490 * 380);
assert_eq!(intersection_area(saved, screen(900, 900, 200, 200)), 0);
}
#[test]
fn rejects_corrupted_tiny_saved_frames() {
assert!(!is_valid_saved_frame(&frame(100, 100, 1, 900)));
assert!(!is_valid_saved_frame(&frame(100, 100, 1400, 1)));
}
#[test]
fn restores_again_after_runtime_ready() {
assert!(restores_window_frame_after_runtime_ready(&RunEvent::Ready));
assert!(!restores_window_frame_after_runtime_ready(
&RunEvent::Resumed
));
}
}

View File

@@ -23,7 +23,7 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"backgroundColor": "#F7F6F3",
"dragDropEnabled": false
"dragDropEnabled": true
}
],
"security": {
@@ -33,6 +33,8 @@
"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:",
"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:",
"object-src": "'none'"

View File

@@ -147,6 +147,8 @@ const mockCommandResults: Record<string, unknown> = {
sync_vault_asset_scope_for_window: null,
get_file_history: [],
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
is_git_repo: true,
init_git_repo: null,
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
@@ -288,6 +290,8 @@ function resetMockCommandResults() {
anonymous_id: null,
release_channel: null,
},
is_git_repo: true,
init_git_repo: null,
save_settings: null,
check_vault_exists: true,
get_default_vault_path: expectedDefaultVaultPath,
@@ -354,6 +358,7 @@ vi.mock('@blocknote/core/extensions', () => ({
}))
vi.mock('@blocknote/react', () => ({
createReactBlockSpec: () => () => ({}),
createReactInlineContentSpec: () => ({ render: () => null }),
BlockNoteViewRaw: ({ children }: { children?: ReactNode }) => (
<div data-testid="blocknote-view">
@@ -461,6 +466,31 @@ describe('App', () => {
})
})
it('shows the app shell skeleton while the vault note scan is pending', async () => {
let resolveListVault: ((value: typeof mockEntries) => void) | null = null
const listVaultPromise = new Promise<typeof mockEntries>((resolve) => {
resolveListVault = resolve
})
mockCommandResults.list_vault = () => listVaultPromise
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
})
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
await act(async () => {
resolveListVault?.(mockEntries)
await Promise.resolve()
})
await waitFor(() => {
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
})
})
it('shows empty state in editor when no note is selected', async () => {
render(<App />)
await waitFor(() => {
@@ -668,7 +698,7 @@ describe('App', () => {
await Promise.resolve()
})
expect(screen.getByText('Loading…')).toBeInTheDocument()
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
expect(screen.queryByText('Vault not found')).not.toBeInTheDocument()
await act(async () => {
@@ -985,6 +1015,32 @@ describe('App', () => {
})
})
it('clears the Git setup dialog when switching to a Git-enabled vault', async () => {
mockCommandResults.load_vault_list = {
vaults: [
{ label: 'Missing Git', path: '/work' },
{ label: 'Git Vault', path: '/vault-2' },
],
active_vault: '/work',
hidden_defaults: [],
}
mockCommandResults.is_git_repo = ({ vaultPath }: { vaultPath?: string } = {}) => vaultPath === '/vault-2'
render(<App />)
expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('status-vault-trigger'))
fireEvent.click(screen.getByTestId('vault-menu-item-Git Vault'))
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Git Vault')
})
await waitFor(() => {
expect(screen.queryByText('Enable Git for this vault?')).not.toBeInTheDocument()
})
})
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -16,6 +16,7 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { AppLoadingSkeleton } from './components/AppLoadingSkeleton'
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
@@ -64,6 +65,7 @@ import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useFileActions } from './hooks/useFileActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
@@ -76,19 +78,26 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types'
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition, NoteWidthMode } from './types'
import type { NoteListItem } from './utils/ai-context'
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry'
import {
SYSTEM_UI_LANGUAGE,
getBrowserLanguagePreferences,
resolveEffectiveLocale,
serializeUiLanguagePreference,
type UiLanguagePreference,
} from './lib/i18n'
import { normalizeReleaseChannel } from './lib/releaseChannel'
import {
buildVaultAiGuidanceRefreshKey,
@@ -105,6 +114,7 @@ import {
shouldProcessNeighborhoodEscape,
} from './utils/neighborhoodHistory'
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
import { resolveNoteWidthMode, toggleNoteWidthMode } from './utils/noteWidth'
import {
INBOX_SELECTION,
isExplicitOrganizationEnabled,
@@ -311,24 +321,54 @@ function App() {
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
// Git repo check: 'checking' | 'missing' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'missing' | 'ready'>('checking')
const [showGitSetupDialog, setShowGitSetupDialog] = useState(false)
const dismissedGitSetupPathRef = useRef<string | null>(null)
useEffect(() => {
if (!resolvedPath) return
setGitRepoState('checking')
const check = isTauri()
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
: Promise.resolve(true) // browser mock: assume git
: mockInvoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
check
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
.then(isGit => setGitRepoState(isGit ? 'ready' : 'missing'))
.catch(() => setGitRepoState('ready')) // fail open
}, [resolvedPath])
const handleInitGitRepo = useCallback(async () => {
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
setGitRepoState('ready')
useEffect(() => {
if (noteWindowParams || gitRepoState !== 'missing' || !resolvedPath) return
if (dismissedGitSetupPathRef.current === resolvedPath) return
setShowGitSetupDialog(true)
}, [gitRepoState, noteWindowParams, resolvedPath])
useEffect(() => {
if (gitRepoState === 'missing') return
setShowGitSetupDialog(false)
}, [gitRepoState])
const openGitSetupDialog = useCallback(() => {
if (gitRepoState !== 'missing') return
setShowGitSetupDialog(true)
}, [gitRepoState])
const dismissGitSetupDialog = useCallback(() => {
dismissedGitSetupPathRef.current = resolvedPath
setShowGitSetupDialog(false)
}, [resolvedPath])
const handleInitGitRepo = useCallback(async () => {
if (isTauri()) {
await invoke('init_git_repo', { vaultPath: resolvedPath })
} else {
await mockInvoke('init_git_repo', { vaultPath: resolvedPath })
}
setGitRepoState('ready')
dismissedGitSetupPathRef.current = null
setShowGitSetupDialog(false)
setToastMessage('Git initialized for this vault')
}, [resolvedPath, setToastMessage])
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
const {
status: vaultAiGuidanceStatus,
@@ -374,12 +414,27 @@ function App() {
})
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
const systemLocale = useMemo(
() => resolveEffectiveLocale(SYSTEM_UI_LANGUAGE, getBrowserLanguagePreferences()),
[],
)
const appLocale = useMemo(
() => resolveEffectiveLocale(settings.ui_language, [systemLocale]),
[settings.ui_language, systemLocale],
)
const selectedUiLanguage = settings.ui_language ?? SYSTEM_UI_LANGUAGE
useEffect(() => {
document.documentElement.lang = appLocale
}, [appLocale])
useThemeMode(settings.theme_mode, settingsLoaded)
const documentThemeMode = useDocumentThemeMode()
const handleToggleThemeMode = useCallback(() => {
const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark'
void saveSettings({ ...settings, theme_mode })
}, [documentThemeMode, saveSettings, settings])
const handleSetUiLanguage = useCallback((uiLanguage: UiLanguagePreference) => {
void saveSettings({ ...settings, ui_language: serializeUiLanguagePreference(uiLanguage) })
}, [saveSettings, settings])
const aiAgentPreferences = useAiAgentPreferences({
settings,
saveSettings,
@@ -397,6 +452,14 @@ function App() {
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
useEffect(() => {
if (gitRepoState !== 'ready') return
void loadVaultModifiedFiles()
void refreshGitRemoteStatus()
}, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus])
const openMcpSetupDialog = useCallback(() => {
setShowMcpSetupDialog(true)
@@ -489,6 +552,7 @@ function App() {
unsavedPaths: vault.unsavedPaths,
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
onNewNotePersisted: vault.loadModifiedFiles,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onFrontmatterPersisted: vault.loadModifiedFiles,
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
@@ -526,6 +590,7 @@ function App() {
vault.unsavedPaths,
])
const autoSync = useAutoSync({
enabled: gitRepoState === 'ready',
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
@@ -761,6 +826,11 @@ function App() {
reloadFolders: vault.reloadFolders,
setToastMessage,
})
const fileActions = useFileActions({
selection: effectiveSelection,
setToastMessage,
vaultPath: resolvedPath,
})
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
@@ -835,7 +905,8 @@ function App() {
vaultPath: resolvedPath,
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const isGitVault = !vault.modifiedFilesError
const isGitVault = gitRepoState !== 'missing'
const shouldShowGitSetupDialog = !noteWindowParams && gitRepoState === 'missing' && showGitSetupDialog
const modifiedFilesSignature = useMemo(
() => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'),
[vault.modifiedFiles],
@@ -1272,6 +1343,18 @@ function App() {
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
[activeDeletedFile, handleDiscardFile],
)
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const noteWidth = resolveNoteWidthMode(activeTab?.entry.noteWidth, settings.note_width_mode)
const handleSetDefaultNoteWidth = useCallback((width: NoteWidthMode) => {
void saveSettings({ ...settings, note_width_mode: width })
}, [saveSettings, settings])
const handleSetActiveNoteWidth = useCallback((width: NoteWidthMode) => {
if (!notes.activeTabPath) return
void notes.handleUpdateFrontmatter(notes.activeTabPath, '_width', width)
}, [notes])
const handleToggleNoteWidth = useCallback(() => {
handleSetActiveNoteWidth(toggleNoteWidthMode(noteWidth))
}, [handleSetActiveNoteWidth, noteWidth])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -1291,17 +1374,24 @@ function App() {
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: handleCommitPush,
isGitVault,
onInitializeGit: openGitSetupDialog,
onPull: autoSync.triggerSync,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
noteWidth,
onSetNoteWidth: !activeDeletedFile && notes.activeTabPath ? handleSetActiveNoteWidth : undefined,
onSetDefaultNoteWidth: handleSetDefaultNoteWidth,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
onRenameFolder: folderActions.renameSelectedFolder,
onDeleteFolder: folderActions.deleteSelectedFolder,
onRevealSelectedFolder: fileActions.revealSelectedFolder,
onCopySelectedFolderPath: fileActions.copySelectedFolderPath,
showInbox: explicitOrganizationEnabled,
onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
@@ -1316,6 +1406,10 @@ function App() {
onRestoreGettingStarted: cloneGettingStartedVault,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
locale: appLocale,
systemLocale,
selectedUiLanguage,
onSetUiLanguage: handleSetUiLanguage,
mcpStatus,
onInstallMcp: openMcpSetupDialog,
onOpenAiAgents: dialogs.openSettings,
@@ -1337,6 +1431,9 @@ function App() {
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onRevealActiveFile: fileActions.revealFile,
onCopyActiveFilePath: fileActions.copyFilePath,
onOpenActiveFileExternal: fileActions.openExternalFile,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: toggleOrganizedCommand,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
@@ -1346,8 +1443,6 @@ function App() {
canRestoreDeletedNote: !!activeDeletedFile,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
const aiNoteList = useMemo<NoteListItem[]>(() => {
@@ -1374,9 +1469,9 @@ function App() {
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
// Show loading skeleton while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <LoadingView />
return <AppLoadingSkeleton />
}
// Show telemetry consent dialog on first launch (skip for note windows).
@@ -1420,23 +1515,18 @@ function App() {
)
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required' && !showMcpSetupDialog) {
// Show loading skeleton while checking git status or scanning vault notes.
if (!noteWindowParams && onboarding.state.status === 'ready' && (gitRepoState === 'checking' || vault.isLoading)) {
return (
<div className="app-shell">
<GitRequiredModal
onCreateRepo={handleInitGitRepo}
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
/>
</div>
<AppLoadingSkeleton
noteListWidth={layout.noteListWidth}
showNoteList={noteListVisible}
showSidebar={sidebarVisible}
sidebarWidth={layout.sidebarWidth}
/>
)
}
// Show loading spinner while checking git status
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
return <LoadingView />
}
return (
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
<div className="app-shell">
@@ -1444,7 +1534,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} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
<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} 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} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1453,9 +1543,9 @@ function App() {
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} locale={appLocale} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1496,12 +1586,17 @@ function App() {
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand}
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onOpenExternalFile={fileActions.openExternalFile}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
noteWidth={noteWidth}
onToggleNoteWidth={activeDeletedFile ? undefined : handleToggleNoteWidth}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
@@ -1516,12 +1611,14 @@ function App() {
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
locale={appLocale}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1531,6 +1628,7 @@ function App() {
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
locale={appLocale}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
@@ -1564,7 +1662,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
@@ -1631,15 +1729,4 @@ function AiAgentsOnboardingView({
)
}
/** Loading spinner view - extracted from main App component */
function LoadingView() {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
export default App

View File

@@ -12,6 +12,7 @@ import {
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS } from '../utils/propertyTypes'
import { translate, type AppLocale } from '../lib/i18n'
function parseDateValue(value: string): Date | undefined {
const iso = toISODate(value)
@@ -39,7 +40,7 @@ function canSubmitProperty({ key, value, displayMode }: { key: string; value: st
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
<button
@@ -47,12 +48,12 @@ function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: str
onClick={() => onChange(boolVal ? 'false' : 'true')}
data-testid="add-property-boolean-toggle"
>
{boolVal ? '\u2713 Yes' : '\u2717 No'}
{boolVal ? `\u2713 ${translate(locale, 'inspector.properties.yes')}` : `\u2717 ${translate(locale, 'inspector.properties.no')}`}
</button>
)
}
function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function AddDateInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const selectedDate = value ? parseDateValue(value) : undefined
const formatted = value ? formatDateValue(value) : ''
return (
@@ -64,7 +65,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
>
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ' text-foreground'}`}>
{formatted || 'Pick a date\u2026'}
{formatted || translate(locale, 'inspector.properties.pickDate')}
</span>
</button>
</PopoverTrigger>
@@ -122,15 +123,16 @@ function AddNumberInput({ value, onChange, onKeyDown }: {
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses, locale }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
locale: AppLocale
}) {
switch (displayMode) {
case 'number':
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'boolean': return <AddBooleanInput value={value} locale={locale} onChange={onChange} />
case 'date': return <AddDateInput value={value} locale={locale} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
@@ -138,16 +140,17 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
/>
)
default: return (
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder={translate(locale, 'inspector.properties.valuePlaceholder')} value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
}
}
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses, locale = 'en' }: {
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
vaultStatuses: string[]
locale?: AppLocale
}) {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
@@ -169,7 +172,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<Input
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
type="text" placeholder={translate(locale, 'inspector.properties.propertyName')} value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
/>
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
@@ -193,15 +196,15 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
})}
</SelectContent>
</Select>
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} locale={locale} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!canSubmit} title="Add property"
disabled={!canSubmit} title={translate(locale, 'inspector.properties.addProperty')}
data-testid="add-property-confirm"
>
<Check className="size-3.5" />
</Button>
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
<Button size="icon-xs" variant="outline" onClick={onCancel} title={translate(locale, 'common.cancel')} data-testid="add-property-cancel">
<X className="size-3.5" />
</Button>
</div>

View File

@@ -136,19 +136,31 @@ describe('AiPanel', () => {
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
})
it('shows contextual placeholder when active entry exists', () => {
it('shows active agent placeholder when active entry exists', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
const input = screen.getByTestId('agent-input')
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
})
it('shows generic placeholder when no active entry', () => {
it('shows active agent placeholder when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
const input = screen.getByTestId('agent-input')
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
})
it('uses the selected AI agent in the placeholder', () => {
render(
<AiPanel
onClose={vi.fn()}
vaultPath="/tmp/vault"
defaultAiAgent="codex"
defaultAiAgentReady
/>,
)
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
it('auto-focuses input on mount', async () => {

View File

@@ -121,11 +121,9 @@ export function AiPanelView({
entries={entries ?? []}
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
hasContext={hasContext}
input={input}
inputRef={inputRef}
isActive={isActive}
legacyCopy={useLegacyAiExperience}
onChange={setInput}
onSend={handleSend}
onUnsupportedAiPaste={onUnsupportedAiPaste}

View File

@@ -35,11 +35,9 @@ interface AiPanelComposerProps {
entries: VaultEntry[]
agentLabel: string
agentReady: boolean
hasContext: boolean
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
legacyCopy: boolean
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedAiPaste?: (message: string) => void
@@ -48,18 +46,12 @@ interface AiPanelComposerProps {
function getComposerPlaceholder(
agentLabel: string,
agentReady: boolean,
legacyCopy: boolean,
hasContext: boolean,
): string {
if (!agentReady) {
return `${agentLabel} is not installed. Open AI Agents in Settings.`
}
if (legacyCopy) {
return hasContext ? 'Ask about this note...' : 'Ask the AI agent...'
}
return hasContext ? `Ask ${agentLabel} about this note...` : `Ask ${agentLabel}...`
return `Ask ${agentLabel}`
}
function AiPanelEmptyState({
@@ -209,18 +201,16 @@ export function AiPanelComposer({
entries,
agentLabel,
agentReady,
hasContext,
input,
inputRef,
isActive,
legacyCopy,
onChange,
onSend,
onUnsupportedAiPaste,
}: AiPanelComposerProps) {
const composerDisabled = isActive || !agentReady
const canSend = !composerDisabled && input.trim().length > 0
const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext)
const placeholder = getComposerPlaceholder(agentLabel, agentReady)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',

View File

@@ -0,0 +1,295 @@
import type { CSSProperties } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
AlignJustify,
Archive,
CheckCircle2,
ChevronDown,
Code2,
FileText,
GitBranch,
Inbox,
Plus,
Search,
Settings,
SlidersHorizontal,
Sparkles,
Star,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const SIDEBAR_GROUPS = [
{ rows: ['62%', '48%', '58%', '52%'], count: '24px' },
{ rows: ['72%'], count: '30px' },
{ rows: ['34%', '42%', '38%', '56%', '50%', '44%', '39%', '46%'], count: '26px' },
{ rows: ['38%', '52%', '32%'] },
]
const NOTE_ROWS = [
{ selected: false, title: '68%', snippet: '84%', chips: ['56px'] },
{ selected: true, title: '62%', snippet: '78%', chips: ['64px', '54px', '72px'] },
{ selected: false, title: '48%', snippet: '44%', chips: [] },
{ selected: false, title: '82%', snippet: '74%', chips: ['68px'] },
{ selected: false, title: '70%', snippet: '88%', chips: ['76px', '92px'] },
{ selected: false, title: '58%', snippet: '66%', chips: ['64px'] },
{ selected: false, title: '76%', snippet: '72%', chips: ['72px'] },
]
const EDITOR_ACTIONS = [Star, CheckCircle2, GitBranch, Code2, AlignJustify, Sparkles, Archive, Trash2, SlidersHorizontal]
const STATUS_LEFT = ['70px', '104px', '88px', '116px', '82px']
const STATUS_RIGHT = ['44px', '78px', '18px', '18px']
type AppLoadingSkeletonProps = {
noteListWidth?: number
showNoteList?: boolean
showSidebar?: boolean
sidebarWidth?: number
}
function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
return (
<div
className={cn('animate-pulse rounded bg-muted', className)}
style={style}
/>
)
}
function SkeletonIcon({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
return (
<Icon
size={16}
strokeWidth={1.9}
className={cn(active ? 'text-primary' : 'text-muted-foreground', 'shrink-0 opacity-70')}
/>
)
}
function SidebarRow({ icon, width, active = false, countWidth }: {
icon: LucideIcon
width: string
active?: boolean
countWidth?: string
}) {
return (
<div
className={cn('flex items-center gap-2 rounded px-4 py-1.5', active && 'bg-primary/10')}
style={active ? { boxShadow: 'inset 3px 0 0 var(--primary)' } : undefined}
>
<SkeletonIcon icon={icon} active={active} />
<SkeletonBar className="h-3" style={{ width }} />
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
</div>
)
}
function SidebarSkeletonGlyph() {
return <SkeletonBar className="h-4 w-4 shrink-0 rounded-[4px]" />
}
function SidebarStubRow({ width, countWidth }: {
width: string
countWidth?: string
}) {
return (
<div className="flex items-center gap-2 rounded px-4 py-1.5">
<SidebarSkeletonGlyph />
<SkeletonBar className="h-3" style={{ width }} />
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
</div>
)
}
function SidebarGroupSkeleton({ rows, count }: { rows: string[]; count?: string }) {
return (
<div className="border-b border-border px-1.5 pb-2">
<div className="flex items-center gap-1 px-4 py-2">
<ChevronDown size={12} className="text-muted-foreground opacity-60" />
<SkeletonBar className="h-2.5" style={{ width: '54px' }} />
{count && <SkeletonBar className="ml-auto h-4 rounded-full" style={{ width: count }} />}
</div>
<div className="flex flex-col gap-1">
{rows.map((width, index) => (
<SidebarStubRow
key={`${width}-${index}`}
width={width}
countWidth={index > 3 ? '30px' : undefined}
/>
))}
</div>
</div>
)
}
function SidebarSkeleton() {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<div className="h-[52px] shrink-0 border-b border-border" />
<nav className="flex-1 overflow-hidden py-1">
<div className="border-b border-border px-1.5 pb-1">
<SidebarRow icon={Inbox} width="48%" active countWidth="30px" />
<SidebarRow icon={FileText} width="54%" countWidth="38px" />
<SidebarRow icon={Archive} width="42%" countWidth="32px" />
</div>
{SIDEBAR_GROUPS.map((group, index) => (
<SidebarGroupSkeleton key={index} rows={group.rows} count={group.count} />
))}
</nav>
</aside>
)
}
function NoteRowSkeleton({ selected, title, snippet, chips }: {
selected: boolean
title: string
snippet: string
chips: string[]
}) {
return (
<div
className={cn('relative border-b border-border px-5 py-4', selected && 'bg-primary/5')}
style={selected ? { boxShadow: 'inset 3px 0 0 var(--accent-green)' } : undefined}
>
<SkeletonBar className="absolute right-4 top-4 h-3.5 w-3.5 rounded-sm" />
<SkeletonBar className="mb-3 h-3.5" style={{ width: title }} />
<div className="space-y-1.5">
<SkeletonBar className="h-2.5" style={{ width: snippet }} />
<SkeletonBar className="h-2.5" style={{ width: '58%' }} />
</div>
{chips.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{chips.map((width) => (
<SkeletonBar key={width} className="h-5 rounded" style={{ width }} />
))}
</div>
)}
<div className="mt-3 grid grid-cols-[1fr_auto] gap-3">
<SkeletonBar className="h-2.5" style={{ width: '38px' }} />
<SkeletonBar className="h-2.5" style={{ width: '72px' }} />
</div>
</div>
)
}
function NoteListSkeleton() {
return (
<section className="flex h-full flex-col overflow-hidden border-r border-border bg-card text-foreground">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4">
<SkeletonBar className="h-4" style={{ width: '78px' }} />
<div className="flex items-center gap-3 text-muted-foreground">
<SkeletonBar className="h-2.5" style={{ width: '58px' }} />
<Search size={16} />
<SlidersHorizontal size={16} />
<Plus size={16} />
</div>
</header>
<div className="flex-1 overflow-hidden">
{NOTE_ROWS.map((row, index) => (
<NoteRowSkeleton key={index} {...row} />
))}
</div>
</section>
)
}
function EditorSkeleton() {
return (
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-5">
<div className="flex items-center gap-2">
<SkeletonBar className="h-3" style={{ width: '44px' }} />
<SkeletonBar className="h-3" style={{ width: '150px' }} />
</div>
<div className="flex items-center gap-5 text-muted-foreground">
{EDITOR_ACTIONS.map((Icon, index) => (
<Icon key={index} size={16} strokeWidth={1.8} className="opacity-65" />
))}
</div>
</header>
<article className="mx-auto flex w-full max-w-[760px] flex-1 flex-col px-10 py-16">
<SkeletonBar className="mb-7 h-9" style={{ width: '58%' }} />
<SkeletonBar className="mb-6 h-px w-full rounded-none" />
<div className="space-y-4">
<SkeletonBar className="h-4" style={{ width: '52%' }} />
<SkeletonBar className="h-4" style={{ width: '92%' }} />
<SkeletonBar className="h-4" style={{ width: '82%' }} />
<SkeletonBar className="h-4" style={{ width: '88%' }} />
<SkeletonBar className="h-4" style={{ width: '74%' }} />
</div>
<SkeletonBar className="my-10 h-px w-full rounded-none" />
<div className="space-y-4">
<SkeletonBar className="h-4" style={{ width: '22%' }} />
{[0, 1, 2].map((index) => (
<div key={index} className="flex items-center gap-4 pl-4">
<SkeletonBar className="h-2.5 w-2.5 rounded-full bg-primary/70" />
<SkeletonBar className="h-4" style={{ width: index === 1 ? '54%' : '44%' }} />
</div>
))}
</div>
</article>
</main>
)
}
function StatusSkeleton() {
return (
<footer
className="flex shrink-0 items-center justify-between border-t border-border bg-sidebar px-2 text-muted-foreground"
style={{ height: 30 }}
>
<div className="flex items-center gap-4">
{STATUS_LEFT.map((width, index) => (
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
))}
</div>
<div className="flex items-center gap-4">
{STATUS_RIGHT.map((width, index) => (
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
))}
<Settings size={14} className="opacity-60" />
</div>
</footer>
)
}
export function AppLoadingSkeleton({
noteListWidth = 350,
showNoteList = true,
showSidebar = true,
sidebarWidth = 250,
}: AppLoadingSkeletonProps) {
return (
<div
className="app-shell"
data-testid="vault-loading-skeleton"
role="status"
aria-busy="true"
aria-live="polite"
>
<span className="sr-only">Loading vault</span>
<div className="app" aria-hidden="true">
{showSidebar && (
<>
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<SidebarSkeleton />
</div>
<div className="w-px shrink-0 bg-border" />
</>
)}
{showNoteList && (
<>
<div className="app__note-list" style={{ width: noteListWidth }}>
<NoteListSkeleton />
</div>
<div className="w-px shrink-0 bg-border" />
</>
)}
<div className="app__editor">
<EditorSkeleton />
</div>
</div>
<StatusSkeleton />
</div>
)
}

View File

@@ -1,10 +1,12 @@
import { Archive, ArrowUUpLeft } from '@phosphor-icons/react'
import { translate, type AppLocale } from '../lib/i18n'
interface ArchivedNoteBannerProps {
onUnarchive: () => void
locale?: AppLocale
}
export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteBannerProps) {
return (
<div
data-testid="archived-note-banner"
@@ -21,7 +23,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
}}
>
<Archive size={13} weight="bold" />
<span>Archived</span>
<span>{translate(locale, 'editor.banner.archived')}</span>
<button
data-testid="unarchive-btn"
onClick={onUnarchive}
@@ -38,10 +40,10 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
title="Unarchive"
title={translate(locale, 'editor.banner.unarchive')}
>
<ArrowUUpLeft size={12} />
Unarchive
{translate(locale, 'editor.banner.unarchive')}
</button>
</div>
)

View File

@@ -135,6 +135,26 @@ describe('BreadcrumbBar — archive/unarchive', () => {
})
})
describe('BreadcrumbBar — file actions', () => {
it('reveals the current file from the breadcrumb toolbar', () => {
const onRevealFile = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRevealFile={onRevealFile} />)
fireEvent.click(screen.getByRole('button', { name: 'Reveal in Finder' }))
expect(onRevealFile).toHaveBeenCalledWith('/vault/note/test.md')
})
it('copies the current file path from the breadcrumb toolbar', () => {
const onCopyFilePath = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onCopyFilePath={onCopyFilePath} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy file path' }))
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/note/test.md')
})
})
describe('BreadcrumbBar — organized shortcut hint', () => {
it('shows Cmd+E on the organized toggle tooltip', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
@@ -306,3 +326,26 @@ describe('BreadcrumbBar — raw editor toggle', () => {
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
})
})
describe('BreadcrumbBar — note width toggle', () => {
it('shows the wide width action while normal', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Switch this note to wide width' })).toBeInTheDocument()
})
it('shows the normal width action while wide', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="wide" onToggleNoteWidth={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Switch this note to normal width' })).toBeInTheDocument()
})
it('calls onToggleNoteWidth when the width button is clicked', () => {
const onToggleNoteWidth = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={onToggleNoteWidth} />)
fireEvent.click(screen.getByRole('button', { name: 'Switch this note to wide width' }))
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
})
})

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { VaultEntry } from '../types'
import type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -14,9 +15,13 @@ import {
Trash,
Archive,
ArrowUUpLeft,
ClipboardText,
FolderOpen,
Star,
CheckCircle,
ArrowsClockwise,
TextAlignCenter,
TextAlignLeft,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
@@ -39,12 +44,17 @@ interface BreadcrumbBarProps {
onToggleInspector?: () => void
onToggleFavorite?: () => void
onToggleOrganized?: () => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onDelete?: () => void
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
locale?: AppLocale
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -142,6 +152,12 @@ interface ToggleIconActionProps {
shortcut: string
}
interface TranslatedToggleIconActionProps extends Omit<ToggleIconActionProps, 'activeLabel' | 'inactiveLabel'> {
activeLabelKey: Parameters<typeof translate>[1]
inactiveLabelKey: Parameters<typeof translate>[1]
locale?: AppLocale
}
function ToggleIconAction({
active,
activeClassName,
@@ -166,75 +182,141 @@ function ToggleIconAction({
)
}
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
function TranslatedToggleIconAction({
activeLabelKey,
inactiveLabelKey,
locale = 'en',
...props
}: TranslatedToggleIconActionProps) {
return (
<ToggleIconAction
active={!!rawMode}
activeClassName="text-foreground"
activeLabel="Return to the editor"
inactiveLabel="Open the raw editor"
onClick={onToggleRaw}
shortcut={formatShortcutDisplay({ display: '⌘\\' })}
>
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
{...props}
activeLabel={translate(locale, activeLabelKey)}
inactiveLabel={translate(locale, inactiveLabelKey)}
/>
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
const TOGGLE_ACTION_CONFIGS = {
raw: {
activeClassName: 'text-foreground',
activeLabelKey: 'editor.toolbar.rawReturn',
inactiveLabelKey: 'editor.toolbar.rawOpen',
shortcut: '⌘\\',
renderIcon: () => <Code size={16} className={BREADCRUMB_ICON_CLASS} />,
},
favorite: {
activeClassName: 'text-[var(--accent-yellow)]',
activeLabelKey: 'editor.toolbar.removeFavorite',
inactiveLabelKey: 'editor.toolbar.addFavorite',
shortcut: '⌘D',
renderIcon: (active: boolean) => <Star size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
},
organized: {
activeClassName: 'text-[var(--accent-green)]',
activeLabelKey: 'editor.toolbar.markUnorganized',
inactiveLabelKey: 'editor.toolbar.markOrganized',
shortcut: '⌘E',
renderIcon: (active: boolean) => <CheckCircle size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
},
} satisfies Record<string, {
activeClassName: string
activeLabelKey: Parameters<typeof translate>[1]
inactiveLabelKey: Parameters<typeof translate>[1]
shortcut: string
renderIcon: (active: boolean) => ReactNode
}>
function ConfiguredToggleAction({
active,
config,
locale = 'en',
onClick,
}: {
active: boolean
config: (typeof TOGGLE_ACTION_CONFIGS)[keyof typeof TOGGLE_ACTION_CONFIGS]
locale?: AppLocale
onClick?: () => void
}) {
return (
<ToggleIconAction
active={favorite}
activeClassName="text-[var(--accent-yellow)]"
activeLabel="Remove from favorites"
inactiveLabel="Add to favorites"
onClick={onToggleFavorite}
shortcut={formatShortcutDisplay({ display: '⌘D' })}
<TranslatedToggleIconAction
active={active}
activeClassName={config.activeClassName}
activeLabelKey={config.activeLabelKey}
inactiveLabelKey={config.inactiveLabelKey}
locale={locale}
onClick={onClick}
shortcut={formatShortcutDisplay({ display: config.shortcut })}
>
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
{config.renderIcon(active)}
</TranslatedToggleIconAction>
)
}
function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: boolean; locale?: AppLocale; onToggleRaw?: () => void }) {
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
}
function NoteWidthAction({
noteWidth = 'normal',
locale = 'en',
onToggleNoteWidth,
}: {
noteWidth?: NoteWidthMode
locale?: AppLocale
onToggleNoteWidth?: () => void
}) {
if (!onToggleNoteWidth) return null
const isWide = noteWidth === 'wide'
return (
<IconActionButton
copy={{ label: translate(locale, isWide ? 'editor.toolbar.normalWidth' : 'editor.toolbar.wideWidth') }}
onClick={onToggleNoteWidth}
className={cn(isWide ? 'text-foreground' : 'hover:text-foreground')}
>
{isWide
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
</IconActionButton>
)
}
function FavoriteAction({ favorite, locale = 'en', onToggleFavorite }: { favorite: boolean; locale?: AppLocale; onToggleFavorite?: () => void }) {
return <ConfiguredToggleAction active={favorite} config={TOGGLE_ACTION_CONFIGS.favorite} locale={locale} onClick={onToggleFavorite} />
}
function OrganizedAction({
organized,
locale = 'en',
onToggleOrganized,
}: {
organized: boolean
locale?: AppLocale
onToggleOrganized?: () => void
}) {
if (!onToggleOrganized) return null
return (
<ToggleIconAction
active={organized}
activeClassName="text-[var(--accent-green)]"
activeLabel="Set note as not organized"
inactiveLabel="Set note as organized"
onClick={onToggleOrganized}
shortcut={formatShortcutDisplay({ display: '⌘E' })}
>
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
return <ConfiguredToggleAction active={organized} config={TOGGLE_ACTION_CONFIGS.organized} locale={locale} onClick={onToggleOrganized} />
}
function DiffAction({
showDiffToggle,
diffMode,
diffLoading,
locale = 'en',
onToggleDiff,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'locale' | 'onToggleDiff'>) {
if (!showDiffToggle) {
return (
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.noDiff') }} style={DISABLED_ICON_STYLE}>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
const copy: ActionTooltipCopy = diffLoading
? { label: 'Loading the diff' }
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
? { label: translate(locale, 'editor.toolbar.loadingDiff') }
: { label: translate(locale, diffMode ? 'editor.toolbar.rawReturn' : 'editor.toolbar.showDiff') }
return (
<IconActionButton
copy={copy}
@@ -246,13 +328,13 @@ function DiffAction({
)
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'locale' | 'onToggleAIChat'>) {
return (
<ToggleIconAction
active={!!showAIChat}
activeClassName="text-primary"
activeLabel="Close the AI panel"
inactiveLabel="Open the AI panel"
activeLabel={translate(locale, 'editor.toolbar.closeAi')}
inactiveLabel={translate(locale, 'editor.toolbar.openAi')}
onClick={onToggleAIChat}
shortcut={formatShortcutDisplay({ display: '⌘⇧L' })}
>
@@ -263,29 +345,30 @@ function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, '
function ArchiveAction({
archived,
locale = 'en',
onArchive,
onUnarchive,
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'locale' | 'onArchive' | 'onUnarchive'>) {
if (archived) {
return (
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.restoreArchived') }} onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
return (
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.archive') }} onClick={onArchive} className="hover:text-foreground">
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
function DeleteAction({ locale = 'en', onDelete }: Pick<BreadcrumbBarProps, 'locale' | 'onDelete'>) {
return (
<IconActionButton
copy={{
label: 'Delete this note',
label: translate(locale, 'editor.toolbar.delete'),
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
}}
onClick={onDelete}
@@ -296,15 +379,48 @@ function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
)
}
function FilePathActions({
entry,
locale = 'en',
onRevealFile,
onCopyFilePath,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRevealFile' | 'onCopyFilePath'>) {
return (
<>
{onRevealFile && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.revealFile') }}
onClick={() => onRevealFile(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-reveal-file"
>
<FolderOpen size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
{onCopyFilePath && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.copyFilePath') }}
onClick={() => onCopyFilePath(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-copy-file-path"
>
<ClipboardText size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
</>
)
}
function InspectorAction({
inspectorCollapsed,
locale = 'en',
onToggleInspector,
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'locale' | 'onToggleInspector'>) {
if (!inspectorCollapsed) return null
return (
<IconActionButton
copy={{
label: 'Open the properties panel',
label: translate(locale, 'editor.toolbar.openProperties'),
shortcut: formatShortcutDisplay({ display: '⌘⇧I' }),
}}
onClick={onToggleInspector}
@@ -331,12 +447,14 @@ function deriveSyncStem(entry: VaultEntry): string | null {
function FilenameInput({
inputRef,
draftStem,
locale = 'en',
onDraftStemChange,
onBlur,
onKeyDown,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
draftStem: string
locale?: AppLocale
onDraftStemChange: (nextValue: string) => void
onBlur: () => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
@@ -350,7 +468,7 @@ function FilenameInput({
onKeyDown={onKeyDown}
className="h-7 w-[180px] text-sm"
data-testid="breadcrumb-filename-input"
aria-label="Rename filename"
aria-label={translate(locale, 'editor.filename.rename')}
/>
)
}
@@ -358,10 +476,12 @@ function FilenameInput({
function FilenameTrigger({
entry,
filenameStem,
locale = 'en',
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
locale?: AppLocale
onStartEditing: () => void
}) {
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
@@ -379,7 +499,7 @@ function FilenameTrigger({
onDoubleClick={onStartEditing}
onKeyDown={handleKeyDown}
data-testid="breadcrumb-filename-trigger"
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
aria-label={translate(locale, 'editor.filename.trigger', { filename: filenameStem })}
>
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
@@ -390,15 +510,17 @@ function FilenameTrigger({
function SyncFilenameButton({
entryPath,
syncStem,
locale = 'en',
onRenameFilename,
}: {
entryPath: string
syncStem: string | null
locale?: AppLocale
onRenameFilename?: (path: string, newFilenameStem: string) => void
}) {
if (!syncStem || !onRenameFilename) return null
return (
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
<ActionTooltip copy={{ label: translate(locale, 'editor.filename.renameToTitle') }} side="bottom">
<Button
type="button"
variant="ghost"
@@ -406,7 +528,7 @@ function SyncFilenameButton({
className="text-muted-foreground hover:text-foreground"
onClick={() => onRenameFilename(entryPath, syncStem)}
data-testid="breadcrumb-sync-button"
aria-label="Rename the file to match the title"
aria-label={translate(locale, 'editor.filename.renameToTitle')}
>
<ArrowsClockwise size={14} />
</Button>
@@ -418,24 +540,26 @@ function FilenameDisplay({
entry,
filenameStem,
syncStem,
locale,
onRenameFilename,
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
syncStem: string | null
locale?: AppLocale
onRenameFilename?: (path: string, newFilenameStem: string) => void
onStartEditing: () => void
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
<FilenameTrigger entry={entry} filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
</div>
)
}
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
const [isEditing, setIsEditing] = useState(false)
@@ -471,6 +595,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
<FilenameInput
inputRef={inputRef}
draftStem={draftStem}
locale={locale}
onDraftStemChange={setDraftStem}
onBlur={submitRename}
onKeyDown={handleInputKeyDown}
@@ -483,6 +608,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
entry={entry}
filenameStem={filenameStem}
syncStem={syncStem}
locale={locale}
onRenameFilename={onRenameFilename}
onStartEditing={startEditing}
/>
@@ -498,46 +624,55 @@ function BreadcrumbActions({
rawMode,
onToggleRaw,
forceRawMode,
noteWidth,
onToggleNoteWidth,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
onToggleInspector,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onDelete,
onArchive,
onUnarchive,
locale = 'en',
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
return (
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
<FavoriteAction favorite={entry.favorite} locale={locale} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} locale={locale} onToggleOrganized={onToggleOrganized} />
<DiffAction
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
locale={locale}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction locale={locale} onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />
</div>
)
}
function BreadcrumbTitle({
entry,
locale,
onRenameFilename,
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<div className="flex min-w-0 items-center gap-1 truncate">
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
<FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
</div>
</div>
)
@@ -546,6 +681,7 @@ function BreadcrumbTitle({
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
locale = 'en',
onRenameFilename,
...actionProps
}: BreadcrumbBarProps) {
@@ -567,14 +703,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}}
>
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
<BreadcrumbTitle entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
</div>
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
<BreadcrumbActions entry={entry} locale={locale} {...actionProps} />
</div>
</TooltipProvider>
)

View File

@@ -1,14 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import { readSelectionRange } from './inlineWikilinkDom'
import { CommandPalette } from './CommandPalette'
import type { CommandAction } from '../hooks/useCommandRegistry'
type NativeDropPayload = {
type: string
paths: string[]
position: { x: number; y: number }
}
type NativeDropHandler = (event: { payload: NativeDropPayload }) => void
const nativeDropState = vi.hoisted(() => ({
tauriMode: false,
handlers: {} as Record<string, NativeDropHandler[] | undefined>,
}))
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
vi.mock('../mock-tauri', () => ({
isTauri: () => nativeDropState.tauriMode,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
onDragDropEvent: vi.fn((handler: NativeDropHandler) => {
nativeDropState.handlers['native-drag-drop'] = [
...(nativeDropState.handlers['native-drag-drop'] ?? []),
handler,
]
return Promise.resolve(() => {
const handlers = nativeDropState.handlers['native-drag-drop']?.filter((candidate) => candidate !== handler) ?? []
if (handlers.length > 0) nativeDropState.handlers['native-drag-drop'] = handlers
else delete nativeDropState.handlers['native-drag-drop']
})
}),
}),
}))
vi.mock('../utils/aiPromptBridge', () => ({
queueAiPrompt: vi.fn(),
requestOpenAiChat: vi.fn(),
@@ -86,13 +117,60 @@ function updateAiInput(text: string) {
return editor
}
function resetNativeDropState() {
nativeDropState.tauriMode = false
for (const eventName of Object.keys(nativeDropState.handlers)) {
delete nativeDropState.handlers[eventName]
}
}
function mockElementRect(element: HTMLElement) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 48,
width: 400,
height: 48,
toJSON: () => ({}),
}),
})
}
function emitNativePathDrop(paths: string[]) {
const handlers = nativeDropState.handlers['native-drag-drop']
if (!handlers || handlers.length === 0) throw new Error('No native drop handler registered')
for (const handler of handlers) {
handler({
payload: {
type: 'drop',
paths,
position: { x: 20, y: 20 },
},
})
}
}
async function waitForNativePathDropListener() {
await waitFor(() => {
expect(nativeDropState.handlers['native-drag-drop']?.length).toBeGreaterThan(0)
})
}
describe('CommandPalette', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
resetNativeDropState()
})
afterEach(resetNativeDropState)
it('renders nothing when closed', () => {
const { container } = render(
<CommandPalette open={false} commands={commands} onClose={onClose} />,
@@ -165,6 +243,15 @@ describe('CommandPalette', () => {
expect(screen.getByText('No matching commands')).toBeInTheDocument()
})
it('localizes command palette chrome', () => {
render(<CommandPalette open={true} commands={commands} locale="zh-Hans" onClose={onClose} />)
const input = screen.getByPlaceholderText('输入命令...')
fireEvent.change(input, { target: { value: 'zzzzzzz' } })
expect(screen.getByText('没有匹配的命令')).toBeInTheDocument()
expect(screen.getByText('↑↓ 导航')).toBeInTheDocument()
})
it('calls onClose when pressing Escape', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
fireEvent.keyDown(window, { key: 'Escape' })
@@ -275,6 +362,24 @@ describe('CommandPalette', () => {
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
})
it('inserts Tauri native folder drops into the command query input', async () => {
nativeDropState.tauriMode = true
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
mockElementRect(input)
input.focus()
await waitForNativePathDropListener()
act(() => {
emitNativePathDrop(['/Users/test/Projects'])
})
await waitFor(() => {
expect(input).toHaveValue('/Users/test/Projects')
})
})
it('focuses the AI editor immediately when the leading space triggers AI mode', () => {
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)

View File

@@ -6,9 +6,13 @@ import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import type { NoteReference } from '../utils/ai-context'
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { localizeCommandGroup } from '../hooks/commands/localizeCommands'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { formatDroppedPathList } from './inlineWikilinkDropText'
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
import { Input } from './ui/input'
import { useNativePathDrop } from './useNativePathDrop'
interface CommandPaletteProps {
open: boolean
@@ -17,6 +21,7 @@ interface CommandPaletteProps {
claudeCodeReady?: boolean
aiAgentReady?: boolean
aiAgentLabel?: string
locale?: AppLocale
onClose: () => void
}
@@ -115,21 +120,53 @@ function rememberCommandOpener(
rememberFeedbackDialogOpener(target instanceof HTMLElement ? target : null)
}
function inputSelectionRange(input: HTMLInputElement, fallbackIndex: number) {
const start = input.selectionStart ?? fallbackIndex
const end = input.selectionEnd ?? start
return {
start: Math.max(0, start),
end: Math.max(start, end),
}
}
function CommandPaletteInput({
inputRef,
query,
onChange,
placeholder,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
query: string
onChange: (value: string) => void
placeholder: string
}) {
const insertNativePathDrop = (paths: string[]) => {
const droppedPathText = formatDroppedPathList(paths)
const input = inputRef.current
if (!droppedPathText || !input) return
const { start, end } = inputSelectionRange(input, query.length)
const nextValue = `${query.slice(0, start)}${droppedPathText}${query.slice(end)}`
const nextCursor = start + droppedPathText.length
onChange(nextValue)
window.requestAnimationFrame(() => {
input.focus()
input.setSelectionRange(nextCursor, nextCursor)
})
}
useNativePathDrop({
targetRef: inputRef,
onPathDrop: insertNativePathDrop,
})
return (
<Input
ref={inputRef}
className="h-auto rounded-none border-x-0 border-t-0 border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground shadow-none transition-none outline-none placeholder:text-muted-foreground focus-visible:border-border focus-visible:ring-0 md:text-[15px]"
type="text"
placeholder="Type a command..."
placeholder={placeholder}
value={query}
spellCheck={false}
autoCorrect="off"
@@ -144,12 +181,16 @@ function CommandPaletteResults({
groups,
selectedIndex,
listRef,
emptyText,
locale,
onHover,
onSelect,
}: {
groups: { group: CommandGroup; items: CommandAction[] }[]
selectedIndex: number
listRef: React.RefObject<HTMLDivElement | null>
emptyText: string
locale: AppLocale
onHover: (index: number) => void
onSelect: (command: CommandAction) => void
}) {
@@ -159,7 +200,7 @@ function CommandPaletteResults({
return (
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
No matching commands
{emptyText}
</div>
</div>
)
@@ -183,7 +224,7 @@ function CommandPaletteResults({
return (
<div key={group}>
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{group}
{localizeCommandGroup(group, locale)}
</div>
{items.map((command, index) => {
const globalIndex = startIndex + index
@@ -207,15 +248,23 @@ function CommandPaletteResults({
function CommandPaletteFooter({
aiMode,
aiAgentLabel = 'Claude Code',
footerText,
}: {
aiMode: boolean
aiAgentLabel?: string
footerText: {
aiMode: string
navigate: string
select: string
send: string
close: string
}
}) {
return (
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span>{aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'}</span>
<span>{aiMode ? '↵ send' : '↵ select'}</span>
<span>esc close</span>
<span>{aiMode ? footerText.aiMode.replace('{agent}', aiAgentLabel) : footerText.navigate}</span>
<span>{aiMode ? footerText.send : footerText.select}</span>
<span>{footerText.close}</span>
</div>
)
}
@@ -231,6 +280,7 @@ function OpenCommandPalette({
claudeCodeReady = true,
aiAgentReady,
aiAgentLabel = 'Claude Code',
locale = 'en',
onClose,
}: Omit<CommandPaletteProps, 'open'>) {
const [query, setQuery] = useState('')
@@ -242,6 +292,14 @@ function OpenCommandPalette({
const aiMode = aiValue.startsWith(' ')
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
const { groups, flatList } = usePaletteResults(commands, query)
const t = createTranslator(locale)
const footerText = {
aiMode: t('command.aiMode', { agent: '{agent}' }),
navigate: t('command.footerNavigate'),
select: t('command.footerSelect'),
send: t('command.footerSend'),
close: t('command.footerClose'),
}
useLayoutEffect(() => {
const target = aiMode ? aiInputRef.current : inputRef.current
@@ -364,15 +422,22 @@ function OpenCommandPalette({
/>
) : (
<>
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
<CommandPaletteInput
inputRef={inputRef}
query={query}
placeholder={t('command.palettePlaceholder')}
onChange={handleQueryChange}
/>
<CommandPaletteResults
groups={groups}
selectedIndex={selectedIndex}
listRef={listRef}
emptyText={t('command.noMatches')}
locale={locale}
onHover={setSelectedIndex}
onSelect={handleSelectCommand}
/>
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} />
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} footerText={footerText} />
</>
)}
</div>

View File

@@ -1,11 +1,13 @@
import { AlertTriangle } from 'lucide-react'
import { translate, type AppLocale } from '../lib/i18n'
interface ConflictNoteBannerProps {
onKeepMine: () => void
onKeepTheirs: () => void
locale?: AppLocale
}
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs, locale = 'en' }: ConflictNoteBannerProps) {
return (
<div
data-testid="conflict-note-banner"
@@ -22,7 +24,7 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
}}
>
<AlertTriangle size={13} />
<span>This note has a merge conflict</span>
<span>{translate(locale, 'editor.banner.conflict')}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
data-testid="conflict-keep-mine-btn"
@@ -39,9 +41,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep my local version"
title={translate(locale, 'editor.banner.keepMineTooltip')}
>
Keep mine
{translate(locale, 'editor.banner.keepMine')}
</button>
<button
data-testid="conflict-keep-theirs-btn"
@@ -58,9 +60,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep the remote version"
title={translate(locale, 'editor.banner.keepTheirsTooltip')}
>
Keep theirs
{translate(locale, 'editor.banner.keepTheirs')}
</button>
</div>
</div>

View File

@@ -205,6 +205,19 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('some-team')).toBeInTheDocument()
})
it('localizes UI actions without translating stored property names', () => {
renderPanel({
frontmatter: { Status: 'Active', 'Belongs to': 'some-team' },
onAddProperty,
locale: 'zh-Hans',
})
expect(screen.getByText('Type')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '添加属性' })).toBeInTheDocument()
})
it('hides custom field with wikilink value from Properties', () => {
renderPanel({ frontmatter: { Mentor: '[[person/luca]]' } })
// Mentor contains a wikilink → shown in Relationships, not Properties

View File

@@ -21,6 +21,7 @@ import {
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
import { humanizePropertyKey } from '../utils/propertyLabels'
import { translate, type AppLocale } from '../lib/i18n'
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
@@ -32,7 +33,7 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, locale, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]; vaultTags: string[]
@@ -40,6 +41,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) {
@@ -57,7 +59,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title={translate(locale, 'inspector.properties.deleteProperty')}>&times;</button>
)}
</span>
<div className="min-w-0">
@@ -67,7 +69,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)
}
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
function AddPropertyButton({ locale, onClick, disabled }: { locale: AppLocale; onClick: () => void; disabled: boolean }) {
return (
<Button
type="button"
@@ -86,7 +88,7 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
>
<Plus className="size-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 truncate">Add property</span>
<span className="min-w-0 truncate">{translate(locale, 'inspector.properties.addProperty')}</span>
</span>
<span aria-hidden="true" className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME} />
</Button>
@@ -230,6 +232,7 @@ function useSuggestedPropertyActions({
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
locale = 'en',
}: {
entry: VaultEntry
content?: string | null
@@ -240,6 +243,7 @@ export function DynamicPropertiesPanel({
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
locale?: AppLocale
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
@@ -279,6 +283,7 @@ export function DynamicPropertiesPanel({
onNavigate={onNavigate}
missingTypeName={missingTypeName}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
@@ -290,6 +295,7 @@ export function DynamicPropertiesPanel({
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
))}
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
@@ -308,6 +314,7 @@ export function DynamicPropertiesPanel({
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
)}
{missingSuggested.map(({ key, label }) => (
@@ -320,6 +327,7 @@ export function DynamicPropertiesPanel({
))}
{!showAddDialog && (
<AddPropertyButton
locale={locale}
onClick={() => setShowAddDialog(true)}
disabled={!onAddProperty}
/>
@@ -330,6 +338,7 @@ export function DynamicPropertiesPanel({
onAdd={handleAdd}
onCancel={() => setShowAddDialog(false)}
vaultStatuses={vaultStatuses}
locale={locale}
/>
)}
</div>

View File

@@ -204,6 +204,21 @@
width: 100%;
}
.editor-content-width--wide .editor-content-wrapper {
max-width: min(100%, 1280px);
padding-left: clamp(24px, 4vw, 72px);
padding-right: clamp(24px, 4vw, 72px);
}
.editor-content-wrapper--raw {
min-height: 0;
}
.raw-editor-codemirror {
width: 100%;
margin: 0;
}
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;

View File

@@ -1,7 +1,8 @@
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -17,6 +18,11 @@ Object.defineProperty(window, 'matchMedia', {
})),
})
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
invoke: vi.fn(),
}))
// Hoisted mock editor — available before vi.mock factory runs.
// Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering.
const mockEditor = vi.hoisted(() => ({
@@ -34,6 +40,9 @@ const mockEditor = vi.hoisted(() => ({
focus: vi.fn(),
setTextCursorPosition: vi.fn(),
}))
const blockNoteCreation = vi.hoisted(() => ({
options: [] as unknown[],
}))
// Mock BlockNote components
vi.mock('@blocknote/core', () => ({
@@ -59,8 +68,12 @@ const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
vi.mock('@blocknote/react', () => ({
createReactBlockSpec: () => () => ({}),
createReactInlineContentSpec: () => ({ render: () => null }),
useCreateBlockNote: () => mockEditor,
useCreateBlockNote: (options: unknown) => {
blockNoteCreation.options.push(options)
return mockEditor
},
FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
LinkToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
getFormattingToolbarItems: () => [],
@@ -70,6 +83,11 @@ vi.mock('@blocknote/react', () => ({
},
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div
contentEditable={editable !== false}
data-testid="blocknote-editable"
suppressContentEditableWarning
/>
{children}
</div>
),
@@ -204,6 +222,10 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
}
describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
})
it('shows empty state when no tabs are open', () => {
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
@@ -238,6 +260,124 @@ describe('Editor', () => {
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
})
it('renders an in-app image preview for binary image tabs', () => {
const imageEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: imageEntry, content: '' }],
activeTabPath: imageEntry.path,
entries: [imageEntry],
})
const preview = screen.getByTestId('file-preview')
expect(preview).toHaveAttribute('tabindex', '0')
expect(screen.getByRole('img', { name: 'photo.png' })).toHaveAttribute(
'src',
'asset://localhost/%2Fvault%2Fassets%2Fphoto.png',
)
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
})
it('shows a graceful fallback when an image preview fails to render', () => {
const imageEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/broken.png',
filename: 'broken.png',
title: 'broken.png',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: imageEntry, content: '' }],
activeTabPath: imageEntry.path,
entries: [imageEntry],
})
fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Image preview failed')
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
})
it('shows an explicit unsupported-file fallback for non-image binary tabs', () => {
const binaryEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/archive.zip',
filename: 'archive.zip',
title: 'archive.zip',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: binaryEntry, content: '' }],
activeTabPath: binaryEntry.path,
entries: [binaryEntry],
})
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Preview unavailable')
expect(screen.getByText('ZIP file')).toBeInTheDocument()
})
it('moves focus back to the note list when Escape is pressed on the file preview', () => {
const imageEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
}
render(
<>
<div data-testid="note-list-container" tabIndex={0} />
<Editor
{...defaultProps}
tabs={[{ entry: imageEntry, content: '' }]}
activeTabPath={imageEntry.path}
entries={[imageEntry]}
/>
</>,
)
const preview = screen.getByTestId('file-preview')
preview.focus()
fireEvent.keyDown(preview, { key: 'Escape' })
expect(screen.getByTestId('note-list-container')).toHaveFocus()
})
it('passes the runtime CSP style nonce into BlockNote and TipTap', () => {
renderEditor({
tabs: [mockTab],
activeTabPath: mockEntry.path,
})
expect(blockNoteCreation.options.at(-1)).toMatchObject({
_tiptapOptions: {
injectNonce: RUNTIME_STYLE_NONCE,
},
})
})
it('disables native text assistance on the rich editor editable surface', () => {
renderEditor({
tabs: [mockTab],
activeTabPath: mockEntry.path,
})
const editable = screen.getByTestId('blocknote-editable')
expect(editable).toHaveAttribute('spellcheck', 'false')
expect(editable).toHaveAttribute('autocorrect', 'off')
expect(editable).toHaveAttribute('autocomplete', 'off')
expect(editable).toHaveAttribute('autocapitalize', 'off')
})
it('renders breadcrumb bar with action buttons', () => {
renderEditor({
tabs: [mockTab],
@@ -566,6 +706,53 @@ describe('raw-mode sync content guards', () => {
expect(rawLatestContentRef.current).toBe(result)
})
it('serializes rich math nodes back to Markdown source when entering raw mode', () => {
const rawLatestContentRef = { current: null as string | null }
const originalDocument = mockEditor.document
const originalSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
try {
mockEditor.document = [
{
id: 'math-inline',
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline ', styles: {} },
{ type: 'mathInline', props: { latex: 'E=mc^2' } },
],
props: {},
children: [],
},
{
id: 'math-block',
type: 'mathBlock',
props: { latex: '\\int_0^1 x\\,dx' },
children: [],
},
]
mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
(blocks as Array<{ content?: Array<{ text?: string }> }>)
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
.join('\n\n')
))
const result = syncActiveTabIntoRawBuffer({
editor: mockEditor as never,
activeTabPath: mockEntry.path,
activeTabContent: mockContent,
rawLatestContentRef,
})
expect(result).toBe(
'---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\nInline $E=mc^2$\n\n$$\n\\int_0^1 x\\,dx\n$$\n',
)
expect(rawLatestContentRef.current).toBe(result)
} finally {
mockEditor.document = originalDocument
mockEditor.blocksToMarkdownLossy.mockImplementation(originalSerializer)
}
})
it('does not emit a content change when leaving raw mode without user edits', () => {
const onContentChange = vi.fn()
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'

View File

@@ -2,9 +2,12 @@ import { useRef, useEffect, useCallback, memo } from 'react'
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 { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { VaultEntry, GitCommit, NoteStatus, NoteWidthMode } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
@@ -14,6 +17,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { FilePreview } from './FilePreview'
import { schema } from './editorSchema'
import {
applyPendingRawExitContent,
@@ -65,6 +69,9 @@ interface EditorProps {
noteListFilter?: { type: string | null; query: string }
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
@@ -72,6 +79,8 @@ interface EditorProps {
onSave?: () => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
@@ -92,6 +101,7 @@ interface EditorProps {
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
locale?: AppLocale
}
function useEditorModeExclusion({
@@ -125,7 +135,7 @@ function useEditorModeExclusion({
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
function EditorEmptyState() {
function EditorEmptyState({ locale = 'en' }: { locale?: AppLocale }) {
const breadcrumbBarHeight = 52
const { onMouseDown } = useDragRegion()
const quickOpenShortcut = formatShortcutDisplay({ display: '⌘P / ⌘O' })
@@ -142,8 +152,8 @@ function EditorEmptyState() {
style={{ height: breadcrumbBarHeight }}
/>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="text-xs text-muted-foreground">{quickOpenShortcut} to search &middot; {newNoteShortcut} to create</span>
<p className="m-0 text-[15px]">{translate(locale, 'editor.empty.selectNote')}</p>
<span className="text-xs text-muted-foreground">{translate(locale, 'editor.empty.shortcuts', { quickOpen: quickOpenShortcut, newNote: newNoteShortcut })}</span>
</div>
</div>
)
@@ -174,6 +184,7 @@ function useEditorSetup({
const editor = useCreateBlockNote({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
extensions: [createArrowLigaturesExtension()],
})
useFilenameAutolinkGuard(editor)
@@ -291,6 +302,9 @@ function EditorLayout({
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onOpenExternalFile,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
@@ -298,6 +312,8 @@ function EditorLayout({
rawModeContent,
rawLatestContentRef,
onRenameFilename,
noteWidth,
onToggleNoteWidth,
isConflicted,
onKeepMine,
onKeepTheirs,
@@ -321,6 +337,7 @@ function EditorLayout({
onFileModified,
onVaultChanged,
onUnsupportedAiPaste,
locale,
}: {
tabs: Tab[]
activeTab: Tab | null
@@ -345,6 +362,9 @@ function EditorLayout({
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
@@ -352,6 +372,8 @@ function EditorLayout({
rawModeContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
@@ -375,13 +397,25 @@ function EditorLayout({
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
onUnsupportedAiPaste?: (message: string) => void
locale?: AppLocale
}) {
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
{tabs.length === 0
? <EditorEmptyState />
: <EditorContent
? <EditorEmptyState locale={locale} />
: activeBinaryTab
? (
<FilePreview
entry={activeBinaryTab.entry}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onRevealFile={onRevealFile}
/>
)
: <EditorContent
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
@@ -404,6 +438,8 @@ function EditorLayout({
onEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
@@ -411,9 +447,12 @@ function EditorLayout({
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
locale={locale}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
@@ -446,6 +485,7 @@ function EditorLayout({
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
locale={locale}
/>
</div>
</div>
@@ -464,11 +504,14 @@ export const Editor = memo(function Editor(props: EditorProps) {
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
noteWidth, onToggleNoteWidth,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
locale,
} = props
const {
@@ -519,6 +562,9 @@ export const Editor = memo(function Editor(props: EditorProps) {
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
@@ -526,6 +572,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
@@ -549,6 +597,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
locale={locale}
/>
)
})

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
@@ -36,6 +37,7 @@ interface EditorRightPanelProps {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
locale?: AppLocale
}
export function EditorRightPanel({
@@ -47,6 +49,7 @@ export function EditorRightPanel({
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
}: EditorRightPanelProps) {
const aiPanelController = useAiPanelController({
vaultPath,
@@ -117,6 +120,7 @@ export function EditorRightPanel({
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}
locale={locale}
/>
</div>
)

View File

@@ -240,6 +240,34 @@
cursor: text;
}
.editor__blocknote-container .math {
color: var(--colors-text);
cursor: text;
}
.editor__blocknote-container .math--inline {
display: inline-flex;
max-width: 100%;
vertical-align: -0.15em;
}
.editor__blocknote-container .math-block-shell {
width: 100%;
overflow-x: auto;
padding: 6px 0;
}
.editor__blocknote-container .math--block {
display: block;
min-width: max-content;
text-align: center;
}
.editor__blocknote-container .math .katex-error {
color: var(--destructive) !important;
white-space: pre-wrap;
}
.editor__blocknote-container[data-follow-links] .bn-editor a,
.editor__blocknote-container[data-follow-links] .wikilink {
cursor: pointer;

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FilePreview } from './FilePreview'
import type { VaultEntry } from '../types'
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: (path: string) => `asset://${path}`,
}))
const imageEntry: VaultEntry = {
path: '/vault/Attachments/photo.png',
filename: 'photo.png',
title: 'photo.png',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
fileKind: 'binary',
}
describe('FilePreview', () => {
it('routes header file actions to the active file path', () => {
const onRevealFile = vi.fn()
const onCopyFilePath = vi.fn()
const onOpenExternalFile = vi.fn()
render(
<FilePreview
entry={imageEntry}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Reveal' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy path' }))
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
expect(onRevealFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
})
})

View File

@@ -0,0 +1,221 @@
import { useCallback, useMemo, useState, type KeyboardEvent } from 'react'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ArrowSquareOut, ClipboardText, FileDashed, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types'
import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview'
import { focusNoteListContainer } from '../utils/neighborhoodHistory'
import { openLocalFile } from '../utils/url'
import { Button } from './ui/button'
interface FilePreviewProps {
entry: VaultEntry
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onRevealFile?: (path: string) => void
}
interface FilePreviewFallbackProps {
icon: 'warning' | 'file'
title: string
description: string
onOpenExternal: () => void
}
function FilePreviewFallback({ icon, title, description, onOpenExternal }: FilePreviewFallbackProps) {
const Icon = icon === 'warning' ? WarningCircle : FileDashed
return (
<div
className="flex h-full min-h-[260px] flex-col items-center justify-center gap-4 px-8 text-center"
data-testid="file-preview-fallback"
>
<Icon size={34} className="text-muted-foreground" aria-hidden="true" />
<div className="space-y-1">
<h2 className="m-0 text-[15px] font-semibold text-foreground">{title}</h2>
<p className="m-0 max-w-md text-[13px] leading-6 text-muted-foreground">{description}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onOpenExternal}>
<ArrowSquareOut size={15} />
Open in default app
</Button>
</div>
)
}
function FilePreviewHeader({
entry,
isImage,
fileTypeLabel,
onOpenExternal,
onRevealFile,
onCopyFilePath,
}: {
entry: VaultEntry
isImage: boolean
fileTypeLabel: string
onOpenExternal: () => void
onRevealFile?: () => void
onCopyFilePath?: () => void
}) {
const HeaderIcon = isImage ? ImageSquare : FileDashed
return (
<div
className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4"
data-tauri-drag-region
>
<div className="flex min-w-0 items-center gap-2">
<HeaderIcon size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0">
<h1 className="m-0 truncate text-[14px] font-semibold text-foreground">{entry.title}</h1>
<p className="m-0 text-[11px] text-muted-foreground">{fileTypeLabel}</p>
</div>
</div>
<div className="flex items-center gap-1">
{onRevealFile && (
<Button type="button" variant="ghost" size="sm" onClick={onRevealFile}>
<FolderOpen size={15} />
Reveal
</Button>
)}
{onCopyFilePath && (
<Button type="button" variant="ghost" size="sm" onClick={onCopyFilePath}>
<ClipboardText size={15} />
Copy path
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={onOpenExternal}>
<ArrowSquareOut size={15} />
Open
</Button>
</div>
</div>
)
}
function FilePreviewImage({
entry,
imageSrc,
onImageError,
}: {
entry: VaultEntry
imageSrc: string
onImageError: () => void
}) {
return (
<div className="flex h-full min-h-[260px] items-center justify-center p-6">
<img
src={imageSrc}
alt={entry.title}
className="max-h-full max-w-full object-contain"
data-testid="image-file-preview"
onError={onImageError}
/>
</div>
)
}
function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string {
return isImage && imageSrc !== null && !imageFailed
}
function FilePreviewBody({
entry,
isImage,
imageSrc,
imageFailed,
onImageError,
onOpenExternal,
}: {
entry: VaultEntry
isImage: boolean
imageSrc: string | null
imageFailed: boolean
onImageError: () => void
onOpenExternal: () => void
}) {
if (shouldRenderImagePreview(isImage, imageSrc, imageFailed)) {
return <FilePreviewImage entry={entry} imageSrc={imageSrc} onImageError={onImageError} />
}
return (
<FilePreviewFallback
icon={isImage ? 'warning' : 'file'}
title={isImage ? 'Image preview failed' : 'Preview unavailable'}
description={
isImage
? 'Tolaria could not render this image file in the preview.'
: 'Tolaria does not have an in-app preview for this file type.'
}
onOpenExternal={onOpenExternal}
/>
)
}
export function FilePreview({
entry,
onCopyFilePath,
onOpenExternalFile,
onRevealFile,
}: FilePreviewProps) {
const [imageFailed, setImageFailed] = useState(false)
const isImage = isImagePreviewEntry(entry)
const imageSrc = useMemo(() => (isImage ? convertFileSrc(entry.path) : null), [entry.path, isImage])
const fileTypeLabel = previewFileTypeLabel(entry)
const handleImageError = useCallback(() => setImageFailed(true), [])
const handleOpenExternal = useCallback(() => {
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])
const handleRevealFile = useCallback(() => {
onRevealFile?.(entry.path)
}, [entry.path, onRevealFile])
const handleCopyFilePath = useCallback(() => {
onCopyFilePath?.(entry.path)
}, [entry.path, onCopyFilePath])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape') return
event.preventDefault()
focusNoteListContainer(document)
}, [])
return (
<section
className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"
data-testid="file-preview"
tabIndex={0}
role="group"
aria-label={`Preview ${entry.title}`}
onKeyDown={handleKeyDown}
>
<FilePreviewHeader
entry={entry}
isImage={isImage}
fileTypeLabel={fileTypeLabel}
onOpenExternal={handleOpenExternal}
onRevealFile={onRevealFile ? handleRevealFile : undefined}
onCopyFilePath={onCopyFilePath ? handleCopyFilePath : undefined}
/>
<div className="min-h-0 flex-1 overflow-auto bg-background">
<FilePreviewBody
entry={entry}
isImage={isImage}
imageSrc={imageSrc}
imageFailed={imageFailed}
onImageError={handleImageError}
onOpenExternal={handleOpenExternal}
/>
</div>
</section>
)
}

View File

@@ -223,4 +223,29 @@ describe('FolderTree', () => {
fireEvent.click(screen.getByTestId('delete-folder-menu-item'))
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
it('opens folder file actions from the context menu', () => {
const onRevealFolder = vi.fn()
const onCopyFolderPath = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
folderFileActions={{
copyFolderPath: onCopyFolderPath,
revealFolder: onRevealFolder,
}}
onStartRenameFolder={vi.fn()}
/>,
)
fireEvent.contextMenu(screen.getByText('projects'))
fireEvent.click(screen.getByTestId('reveal-folder-menu-item'))
expect(onRevealFolder).toHaveBeenCalledWith('projects')
fireEvent.contextMenu(screen.getByText('projects'))
fireEvent.click(screen.getByTestId('copy-folder-path-menu-item'))
expect(onCopyFolderPath).toHaveBeenCalledWith('projects')
})
})

View File

@@ -13,6 +13,8 @@ import { FolderTreeRow } from './folder-tree/FolderTreeRow'
import { useFolderContextMenu } from './folder-tree/useFolderContextMenu'
import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure'
import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader'
import { translate, type AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
interface FolderTreeProps {
folders: FolderNode[]
@@ -21,10 +23,12 @@ interface FolderTreeProps {
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
folderFileActions?: FolderFileActions
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
collapsed?: boolean
locale?: AppLocale
onToggle?: () => void
}
@@ -35,10 +39,12 @@ export const FolderTree = memo(function FolderTree({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
collapsed: externalCollapsed,
locale = 'en',
onToggle,
}: FolderTreeProps) {
const {
@@ -58,12 +64,15 @@ export const FolderTree = memo(function FolderTree({
const {
closeContextMenu,
contextMenu,
handleCopyPathFromMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRevealFromMenu,
handleRenameFromMenu,
menuRef,
} = useFolderContextMenu({
onDeleteFolder,
folderFileActions,
onStartRenameFolder,
})
@@ -79,28 +88,18 @@ export const FolderTree = memo(function FolderTree({
return created
}, [closeCreateForm, onCreateFolder])
const handleCreateFolderClick = useCallback(() => {
closeContextMenu()
openCreateForm()
}, [closeContextMenu, openCreateForm])
if (folders.length === 0 && !isCreating) return null
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="FOLDERS" collapsed={sectionCollapsed} onToggle={handleToggleSection}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.folders')} collapsed={sectionCollapsed} onToggle={handleToggleSection}>
{onCreateFolder && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title="Create folder"
aria-label="Create folder"
onClick={(event) => {
event.stopPropagation()
closeContextMenu()
openCreateForm()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
<CreateFolderButton locale={locale} onCreate={handleCreateFolderClick} />
)}
</SidebarGroupHeader>
{!sectionCollapsed && (
@@ -118,6 +117,7 @@ export const FolderTree = memo(function FolderTree({
onStartRenameFolder={onStartRenameFolder}
onToggle={toggleFolder}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
@@ -125,9 +125,9 @@ export const FolderTree = memo(function FolderTree({
{isCreating && (
<div style={{ paddingLeft: 8 }}>
<FolderNameInput
ariaLabel="New folder name"
ariaLabel={translate(locale, 'sidebar.folder.newName')}
initialValue=""
placeholder="Folder name"
placeholder={translate(locale, 'sidebar.folder.name')}
submitOnBlur={true}
testId="new-folder-input"
onCancel={closeCreateForm}
@@ -141,8 +141,37 @@ export const FolderTree = memo(function FolderTree({
menu={contextMenu}
menuRef={menuRef}
onDelete={handleDeleteFromMenu}
onReveal={handleRevealFromMenu}
onCopyPath={handleCopyPathFromMenu}
onRename={handleRenameFromMenu}
locale={locale}
/>
</div>
)
})
function CreateFolderButton({
locale,
onCreate,
}: {
locale: AppLocale
onCreate: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title={translate(locale, 'sidebar.action.createFolder')}
aria-label={translate(locale, 'sidebar.action.createFolder')}
onClick={(event) => {
event.stopPropagation()
onCreate()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)
}

View File

@@ -1,67 +1,62 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { GitRequiredModal } from './GitRequiredModal'
import { GitSetupDialog } from './GitRequiredModal'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('GitRequiredModal', () => {
describe('GitSetupDialog', () => {
it('renders title and explanation', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
expect(screen.getByText('Git repository required')).toBeInTheDocument()
expect(screen.getByText(/track changes/)).toBeInTheDocument()
render(<GitSetupDialog open onInitGit={vi.fn()} onDismiss={vi.fn()} />)
expect(screen.getByText('Enable Git for this vault?')).toBeInTheDocument()
expect(screen.getByText(/You can keep using this vault without Git/)).toBeInTheDocument()
})
it('renders both action buttons', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
expect(screen.getByText('Create repository')).toBeInTheDocument()
expect(screen.getByText('Choose another vault')).toBeInTheDocument()
render(<GitSetupDialog open onInitGit={vi.fn()} onDismiss={vi.fn()} />)
expect(screen.getByText('Initialize Git')).toBeInTheDocument()
expect(screen.getByText('Not now')).toBeInTheDocument()
})
it('calls onCreateRepo when primary button clicked', async () => {
const onCreateRepo = vi.fn().mockResolvedValue(undefined)
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
fireEvent.click(screen.getByText('Create repository'))
expect(onCreateRepo).toHaveBeenCalledOnce()
it('calls onInitGit when primary button clicked', async () => {
const onInitGit = vi.fn().mockResolvedValue(undefined)
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={vi.fn()} />)
fireEvent.click(screen.getByText('Initialize Git'))
expect(onInitGit).toHaveBeenCalledOnce()
})
it('calls onChooseVault when secondary button clicked', () => {
const onChooseVault = vi.fn()
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={onChooseVault} />)
fireEvent.click(screen.getByText('Choose another vault'))
expect(onChooseVault).toHaveBeenCalledOnce()
it('calls onDismiss when secondary button clicked', () => {
const onDismiss = vi.fn()
render(<GitSetupDialog open onInitGit={vi.fn()} onDismiss={onDismiss} />)
fireEvent.click(screen.getByText('Not now'))
expect(onDismiss).toHaveBeenCalledOnce()
})
it('disables buttons and shows spinner while creating', async () => {
let resolve: () => void
const onCreateRepo = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r }))
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
fireEvent.click(screen.getByText('Create repository'))
const onInitGit = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r }))
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={vi.fn()} />)
fireEvent.click(screen.getByText('Initialize Git'))
await waitFor(() => {
expect(screen.getByText('Creating…')).toBeInTheDocument()
expect(screen.getByText('Initializing…')).toBeInTheDocument()
})
resolve!()
})
it('shows error message when creation fails', async () => {
const onCreateRepo = vi.fn().mockRejectedValue(new Error('Permission denied'))
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
fireEvent.click(screen.getByText('Create repository'))
const onInitGit = vi.fn().mockRejectedValue(new Error('Permission denied'))
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={vi.fn()} />)
fireEvent.click(screen.getByText('Initialize Git'))
await waitFor(() => {
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
})
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
it('closes on Escape without initializing Git', () => {
const onDismiss = vi.fn()
const onInitGit = vi.fn()
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={onDismiss} />)
const shell = screen.getByTestId('git-required-shell')
fireEvent.mouseDown(shell)
fireEvent.keyDown(document, { key: 'Escape' })
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
expect(onDismiss).toHaveBeenCalledOnce()
expect(onInitGit).not.toHaveBeenCalled()
})
})

View File

@@ -1,13 +1,22 @@
import { useState } from 'react'
import { GitBranch } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
import { GitBranch } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface GitRequiredModalProps {
onCreateRepo: () => Promise<void>
onChooseVault: () => void
interface GitSetupDialogProps {
open: boolean
onInitGit: () => Promise<void>
onDismiss: () => void
}
export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) {
export function GitSetupDialog({ open, onInitGit, onDismiss }: GitSetupDialogProps) {
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -15,7 +24,7 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
setCreating(true)
setError(null)
try {
await onCreateRepo()
await onInitGit()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setCreating(false)
@@ -23,40 +32,33 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
}
return (
<OnboardingShell
style={{ background: 'var(--sidebar)' }}
contentClassName="w-full max-w-sm"
testId="git-required-shell"
>
<div className="flex flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<GitBranch size={36} className="text-muted-foreground" />
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
Tolaria uses a git repository to track changes, detect moved files, and keep your vault safe.
We'll create a local repo — no remote needed.
</p>
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen && !creating) onDismiss()
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-md bg-muted text-muted-foreground">
<GitBranch size={18} />
</div>
<DialogTitle>Enable Git for this vault?</DialogTitle>
<DialogDescription>
You can keep using this vault without Git. History, sync, commits, and change views stay disabled until you initialize Git.
</DialogDescription>
</DialogHeader>
{error && (
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 text-center text-[12px] text-destructive">
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 text-[12px] text-destructive">
{error}
</p>
)}
<div className="flex w-full flex-col gap-2">
<button
className="w-full cursor-pointer rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleCreate}
disabled={creating}
>
{creating ? 'Creating' : 'Create repository'}
</button>
<button
className="w-full cursor-pointer rounded-md border border-border bg-transparent px-4 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
onClick={onChooseVault}
disabled={creating}
>
Choose another vault
</button>
</div>
</div>
</OnboardingShell>
<DialogFooter>
<Button variant="outline" onClick={onDismiss} disabled={creating}>
Not now
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? 'Initializing…' : 'Initialize Git'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,6 @@
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -10,13 +12,19 @@ import { buildTypeEntryMap } from '../utils/typeColors'
import {
deleteInlineSelection,
replaceInlineSelection,
selectedInlineText,
} from './inlineWikilinkEdits'
import {
buildInlineWikilinkSegments,
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
} from './inlineWikilinkText'
import { serializeInlineNode } from './inlineWikilinkDom'
import { extractDroppedPathText, formatDroppedPathList } from './inlineWikilinkDropText'
import {
readSelectionRange,
serializeInlineNode,
type InlineSelectionRange,
} from './inlineWikilinkDom'
import {
buildPendingPasteState,
type PendingPasteState,
@@ -31,7 +39,11 @@ import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
import { isInsertBeforeInput } from './inlineWikilinkBeforeInput'
import {
isInsertBeforeInput,
isPlainTextBeforeInput,
} from './inlineWikilinkBeforeInput'
import { useNativePathDrop } from './useNativePathDrop'
interface InlineWikilinkInputProps {
entries: VaultEntry[]
@@ -59,6 +71,17 @@ function collapseSelectionRange(nextSelectionIndex: number) {
}
}
function fullSelectionRange(value: string) {
return {
start: 0,
end: value.length,
}
}
function isSelectAllShortcut(event: React.KeyboardEvent<HTMLDivElement>) {
return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey)
}
export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.'
function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) {
@@ -141,6 +164,7 @@ export function InlineWikilinkInput({
paletteFooter,
}: InlineWikilinkInputProps) {
const [renderVersion, forceRender] = useState(0)
const isComposingRef = useRef(false)
const segments = useMemo(
() => buildInlineWikilinkSegments(value, entries),
[entries, value],
@@ -159,10 +183,18 @@ export function InlineWikilinkInput({
value,
onChange,
inputRef,
isComposingRef,
})
const pendingPasteRef = useRef<PendingPasteState | null>(null)
const isComposingRef = useRef(false)
const pendingCompositionInputRef = useRef(false)
const handledFileDropRef = useRef(false)
const pendingFocusAfterRemountRef = useRef<InlineSelectionRange | null>(null)
useLayoutEffect(() => {
const target = pendingFocusAfterRemountRef.current
if (!target) return
pendingFocusAfterRemountRef.current = null
focusSelectionRange(target)
}, [focusSelectionRange, renderVersion])
const activeQuery = useMemo(
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
@@ -186,12 +218,34 @@ export function InlineWikilinkInput({
onSelectionIndexChange: (nextSelectionIndex) => setSelectionRange(collapseSelectionRange(nextSelectionIndex)),
focusSelectionAt: (nextSelectionIndex) => focusSelectionRange(collapseSelectionRange(nextSelectionIndex)),
})
const insertText = (text: string) => {
const nextState = replaceInlineSelection(value, selectionRange, text)
const insertTransferText = useCallback((text: string, focusAfterInsert = false) => {
const editor = editorRef.current
const currentSelectionRange = editor && !focusAfterInsert
? readSelectionRange(editor)
: selectionRange
const nextState = replaceInlineSelection(value, currentSelectionRange, text)
const shouldRestoreFocus = focusAfterInsert || document.activeElement === editor
onChange(nextState.value)
setSelectionRange(nextState.selection)
pendingFocusAfterRemountRef.current = shouldRestoreFocus ? nextState.selection : null
forceRender((current) => current + 1)
}, [editorRef, onChange, selectionRange, setSelectionRange, value])
const insertNativePathDrop = (paths: string[]) => {
const droppedPathText = formatDroppedPathList(paths)
if (!droppedPathText) return
insertTransferText(droppedPathText, true)
}
const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE)
useNativePathDrop({
targetRef: editorRef,
disabled,
onPathDrop: insertNativePathDrop,
})
const notifyUnsupportedPaste = useCallback(
() => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE),
[onUnsupportedPaste],
)
const recoverUnsupportedMutation = () => {
pendingCompositionInputRef.current = false
pendingPasteRef.current = null
@@ -205,17 +259,83 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handleBeforeInput = (event: React.FormEvent<HTMLDivElement>) => {
const selectAllContent = () => {
const nextSelection = fullSelectionRange(value)
setSelectionRange(nextSelection)
focusSelectionRange(nextSelection)
}
const cutSelectedContent = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
const editor = editorRef.current
const currentSelectionRange = editor ? readSelectionRange(editor) : selectionRange
const selectedText = selectedInlineText(value, currentSelectionRange)
if (!selectedText) return
event.preventDefault()
event.clipboardData.setData('text/plain', normalizeInlineWikilinkValue(selectedText))
const nextState = deleteInlineSelection(value, currentSelectionRange, segments, 'backward')
if (!nextState) return
onChange(nextState.value)
setSelectionRange(nextState.selection)
pendingFocusAfterRemountRef.current = nextState.selection
forceRender((current) => current + 1)
}
const handleBeforeInput = useCallback((nativeEvent: InputEvent) => {
if (disabled) return
const nativeEvent = event.nativeEvent as InputEvent
if (!isInsertBeforeInput(nativeEvent)) return
if (isPlainTextBeforeInput(nativeEvent)) {
nativeEvent.preventDefault()
insertTransferText(nativeEvent.data)
return
}
const dataTransfer = nativeEvent.dataTransfer
if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return
event.preventDefault()
if (nativeEvent.inputType === 'insertFromDrop' && handledFileDropRef.current) {
handledFileDropRef.current = false
nativeEvent.preventDefault()
return
}
if (nativeEvent.inputType === 'insertFromDrop') {
const droppedPathText = extractDroppedPathText(dataTransfer)
if (droppedPathText) {
nativeEvent.preventDefault()
insertTransferText(droppedPathText)
return
}
}
nativeEvent.preventDefault()
notifyUnsupportedPaste()
}, [disabled, insertTransferText, notifyUnsupportedPaste])
useLayoutEffect(() => {
const editor = editorRef.current
if (!editor) return
editor.addEventListener('beforeinput', handleBeforeInput as EventListener)
return () => editor.removeEventListener('beforeinput', handleBeforeInput as EventListener)
}, [editorRef, handleBeforeInput, renderVersion])
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (disabled) return
if (!hasUnsupportedClipboardPayload(event.dataTransfer)) return
handledFileDropRef.current = true
const droppedPathText = extractDroppedPathText(event.dataTransfer)
event.preventDefault()
if (!droppedPathText) {
notifyUnsupportedPaste()
return
}
insertTransferText(droppedPathText)
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
@@ -261,7 +381,27 @@ export function InlineWikilinkInput({
const flushPendingCompositionInput = () => {
if (isComposingRef.current || !pendingCompositionInputRef.current) return
pendingCompositionInputRef.current = false
syncValueFromEditor()
const editor = editorRef.current
if (!editor) return
if (containsUnsupportedInlineContent(editor)) {
recoverUnsupportedMutation()
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 handleCompositionStart = () => {
isComposingRef.current = true
@@ -281,7 +421,13 @@ export function InlineWikilinkInput({
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isSelectAllShortcut(event)) {
event.preventDefault()
selectAllContent()
return
}
handleInlineWikilinkKeyDown({
event,
disabled,
@@ -290,10 +436,10 @@ export function InlineWikilinkInput({
onCycleSuggestions: cycleSuggestions,
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
onDeleteContent: deleteContent,
onInsertText: insertText,
canSubmit: onSubmit !== undefined,
onSubmit: submitValue,
})
}
const editor = (
<InlineWikilinkEditorField
key={renderVersion}
@@ -303,11 +449,12 @@ export function InlineWikilinkInput({
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onBeforeInput={handleBeforeInput}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onInput={handleInput}
onKeyDown={handleKeyDown}
onCut={cutSelectedContent}
onDrop={handleDrop}
onPaste={handlePaste}
onSelectionChange={syncSelectionRange}
segments={segments}

View File

@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { Fragment, createElement } from 'react'
import type { VaultEntry } from '../types'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { NoteTitleIcon } from './NoteTitleIcon'
@@ -158,11 +158,12 @@ export function InlineWikilinkEditorField({
inputRef,
dataTestId,
editorClassName,
onBeforeInput,
onCompositionEnd,
onCompositionStart,
onInput,
onKeyDown,
onCut,
onDrop,
onPaste,
onSelectionChange,
segments,
@@ -174,11 +175,12 @@ export function InlineWikilinkEditorField({
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onBeforeInput: (event: React.FormEvent<HTMLDivElement>) => void
onCompositionEnd: () => void
onCompositionStart: () => void
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onCut: (event: React.ClipboardEvent<HTMLDivElement>) => void
onDrop: (event: React.DragEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
@@ -210,11 +212,12 @@ export function InlineWikilinkEditorField({
disabled && 'cursor-not-allowed opacity-60',
editorClassName,
)}
onBeforeInput={onBeforeInput}
onCompositionEnd={onCompositionEnd}
onCompositionStart={onCompositionStart}
onInput={onInput}
onKeyDown={onKeyDown}
onCut={onCut}
onDrop={onDrop}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
@@ -223,7 +226,7 @@ export function InlineWikilinkEditorField({
>
{segments.map((segment, index) => (
segment.kind === 'text'
? <span key={`text-${index}`}>{segment.text}</span>
? <Fragment key={`text-${index}`}>{segment.text}</Fragment>
: (
<InlineWikilinkChipView
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}

View File

@@ -16,6 +16,7 @@ import type { ReferencedByItem } from './InspectorPanels'
import { EmptyInspector, InitializePropertiesPrompt, InspectorHeader, InvalidFrontmatterNotice } from './inspector/InspectorChrome'
import { useBacklinks, useReferencedBy } from './inspector/useInspectorData'
import { useInspectorPropertyActions } from './inspector/useInspectorPropertyActions'
import type { AppLocale } from '../lib/i18n'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -36,6 +37,7 @@ interface InspectorProps {
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
locale?: AppLocale
}
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
@@ -59,6 +61,7 @@ function ValidFrontmatterPanels({
onDeleteProperty,
onAddProperty,
onCreateMissingType,
locale,
}: {
entry: VaultEntry
entries: VaultEntry[]
@@ -72,6 +75,7 @@ function ValidFrontmatterPanels({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
locale: AppLocale
}) {
return (
<>
@@ -84,6 +88,7 @@ function ValidFrontmatterPanels({
onAddProperty={onAddProperty}
onNavigate={onNavigate}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
@@ -96,6 +101,7 @@ function ValidFrontmatterPanels({
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
@@ -119,6 +125,7 @@ function PrimaryInspectorPanel({
onDeleteProperty,
onAddProperty,
onCreateMissingType,
locale,
}: {
entry: VaultEntry
frontmatterState: ReturnType<typeof detectFrontmatterState>
@@ -135,6 +142,7 @@ function PrimaryInspectorPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
locale: AppLocale
}) {
if (frontmatterState === 'valid') {
return (
@@ -151,15 +159,16 @@ function PrimaryInspectorPanel({
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
)
}
if (frontmatterState === 'invalid') {
return onToggleRawEditor ? <InvalidFrontmatterNotice onFix={onToggleRawEditor} /> : null
return onToggleRawEditor ? <InvalidFrontmatterNotice locale={locale} onFix={onToggleRawEditor} /> : null
}
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
return onInitializeProperties ? <InitializePropertiesPrompt locale={locale} onClick={() => onInitializeProperties(entry.path)} /> : null
}
function InspectorBody({
@@ -177,6 +186,7 @@ function InspectorBody({
onCreateAndOpenNote,
onInitializeProperties,
onToggleRawEditor,
locale = 'en',
}: Omit<InspectorProps, 'collapsed' | 'onToggle'>) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
@@ -197,7 +207,7 @@ function InspectorBody({
})
if (!entry) {
return <EmptyInspector />
return <EmptyInspector locale={locale} />
}
return (
@@ -218,11 +228,12 @@ function InspectorBody({
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
locale={locale}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
<NoteInfoPanel entry={entry} content={content} locale={locale} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
@@ -232,7 +243,7 @@ function InspectorBody({
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
<InspectorHeader collapsed={collapsed} locale={bodyProps.locale} onToggle={onToggle} />
{!collapsed && (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
<InspectorBody {...bodyProps} />

View File

@@ -19,7 +19,8 @@ describe('McpSetupDialog', () => {
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getByText(/Claude Code CLI reads/i)).toBeInTheDocument()
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
expect(screen.getByText(/picked up by other MCP-compatible tools/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
})

View File

@@ -73,9 +73,10 @@ export function McpSetupDialog({
<div>~/.claude.json</div>
<div>~/.claude/mcp.json</div>
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, while Tolaria also refreshes the legacy Claude MCP file for compatibility with older setups. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
</div>

View File

@@ -19,11 +19,11 @@ describe('NoteItem', () => {
openExternalUrl.mockClear()
})
it('renders binary files as non-clickable muted rows', () => {
it('renders unsupported binary files as non-clickable muted rows', () => {
const binaryEntry = makeEntry({
path: '/vault/photo.png',
filename: 'photo.png',
title: 'photo.png',
path: '/vault/archive.zip',
filename: 'archive.zip',
title: 'archive.zip',
fileKind: 'binary',
})
const onClickNote = vi.fn()
@@ -38,6 +38,26 @@ describe('NoteItem', () => {
expect(onClickNote).not.toHaveBeenCalled()
})
it('renders image files as clickable rows with an image file indicator', () => {
const imageEntry = makeEntry({
path: '/vault/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
})
const onClickNote = vi.fn()
render(<NoteItem entry={imageEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
const item = screen.getByTestId('image-file-item')
expect(item.className).not.toContain('opacity-50')
expect(item).toHaveAttribute('title', 'Open image preview')
fireEvent.click(item)
expect(onClickNote).toHaveBeenCalledWith(imageEntry, expect.any(Object))
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'image')
})
it('renders text files as clickable rows', () => {
const textEntry = makeEntry({
path: '/vault/config.yml',

View File

@@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
File, FileDashed,
File, FileDashed, ImageSquare,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isImagePreviewEntry } from '../utils/filePreview'
import { NoteTitleIcon } from './NoteTitleIcon'
import { PropertyChips } from './note-item/PropertyChips'
import { ChangeNoteContent } from './note-item/ChangeNoteContent'
@@ -61,7 +62,7 @@ function StateBadge({ archived }: { archived: boolean }) {
}
type NoteItemVisualState = {
isBinary: boolean
isUnavailableBinary: boolean
isSelected: boolean
isMultiSelected: boolean
isHighlighted: boolean
@@ -89,8 +90,8 @@ const NOTE_ITEM_ROW_CLASS_NAMES: Record<NoteItemRowState, string> = {
default: 'cursor-pointer hover:bg-muted',
}
function resolveNoteItemRowState({ isBinary, isSelected, isMultiSelected, isHighlighted }: NoteItemVisualState): NoteItemRowState {
if (isBinary) return 'binary'
function resolveNoteItemRowState({ isUnavailableBinary, isSelected, isMultiSelected, isHighlighted }: NoteItemVisualState): NoteItemRowState {
if (isUnavailableBinary) return 'binary'
if (isMultiSelected) return 'multiSelected'
if (isSelected) return 'selected'
if (isHighlighted) return 'highlighted'
@@ -104,11 +105,22 @@ function noteItemClassName(state: NoteItemVisualState) {
function NoteTypeIndicator({
TypeIcon,
typeColor,
filePreviewKind,
}: {
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
typeColor: string
filePreviewKind?: 'image'
}) {
return <TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
return (
<TypeIcon
width={14}
height={14}
className="absolute right-3 top-2.5"
style={{ color: typeColor }}
data-testid="type-icon"
data-file-preview-kind={filePreviewKind}
/>
)
}
function NoteSnippet({ snippet }: { snippet?: string | null }) {
@@ -190,6 +202,7 @@ function InteractiveNoteDetails({
}
function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (isImagePreviewEntry(entry)) return ImageSquare
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, customIcon)
}
@@ -197,6 +210,7 @@ function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): Com
function StandardNoteContent({
entry,
isBinary,
isUnavailableBinary,
noteStatus,
isSelected,
typeColor,
@@ -207,6 +221,7 @@ function StandardNoteContent({
}: {
entry: VaultEntry
isBinary: boolean
isUnavailableBinary: boolean
noteStatus: NoteStatus
isSelected: boolean
typeColor: string
@@ -217,15 +232,16 @@ function StandardNoteContent({
}) {
const te = typeEntryMap[entry.isA ?? '']
const TypeIcon = resolveNoteTypeIcon(entry, te?.icon)
const filePreviewKind = isImagePreviewEntry(entry) ? 'image' : undefined
return (
<>
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} />
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={filePreviewKind} />
<div className="space-y-2" data-testid="note-content-stack">
{isBinary ? (
<NoteTitleRow
entry={entry}
isBinary={true}
isBinary={isUnavailableBinary}
isSelected={isSelected}
noteStatus={noteStatus}
/>
@@ -316,10 +332,10 @@ type NoteItemProps = {
function createNoteItemClickHandler(
entry: VaultEntry,
isBinary: boolean,
isUnavailableBinary: boolean,
onClickNote: NoteItemProps['onClickNote'],
) {
if (isBinary) {
if (isUnavailableBinary) {
return (event: ReactMouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -328,9 +344,46 @@ function createNoteItemClickHandler(
return (event: ReactMouseEvent) => onClickNote(entry, event)
}
function resolveNoteItemSurfaceStyle({
isUnavailableBinary,
isSelected,
isMultiSelected,
typeColor,
typeLightColor,
}: Pick<NoteItemVisualState, 'isUnavailableBinary' | 'isSelected' | 'isMultiSelected'> & {
typeColor: string
typeLightColor: string
}) {
if (isUnavailableBinary) return BINARY_NOTE_STYLE
return noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)
}
function resolveNoteItemTestId({
isMultiSelected,
isImagePreview,
isUnavailableBinary,
}: Pick<NoteItemVisualState, 'isMultiSelected' | 'isUnavailableBinary'> & {
isImagePreview: boolean
}) {
if (isMultiSelected) return 'multi-selected-item'
if (isImagePreview) return 'image-file-item'
return isUnavailableBinary ? 'binary-file-item' : undefined
}
function resolveNoteItemTitle({
isImagePreview,
isUnavailableBinary,
}: Pick<NoteItemVisualState, 'isUnavailableBinary'> & {
isImagePreview: boolean
}) {
if (isImagePreview) return 'Open image preview'
return isUnavailableBinary ? 'Cannot open this file type' : undefined
}
function resolveNoteItemSurfaceProps({
entry,
isBinary,
isUnavailableBinary,
isImagePreview,
isSelected,
isMultiSelected,
isHighlighted,
@@ -341,6 +394,7 @@ function resolveNoteItemSurfaceProps({
typeLightColor,
}: NoteItemVisualState & {
entry: VaultEntry
isImagePreview: boolean
onClickNote: NoteItemProps['onClickNote']
onPrefetch?: NoteItemProps['onPrefetch']
onContextMenu?: NoteItemProps['onContextMenu']
@@ -348,13 +402,13 @@ function resolveNoteItemSurfaceProps({
typeLightColor: string
}): NoteItemSurfaceProps {
return {
className: noteItemClassName({ isBinary, isSelected, isMultiSelected, isHighlighted }),
style: isBinary ? BINARY_NOTE_STYLE : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor),
onClick: createNoteItemClickHandler(entry, isBinary, onClickNote),
className: noteItemClassName({ isUnavailableBinary, isSelected, isMultiSelected, isHighlighted }),
style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }),
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
onMouseEnter: !isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined,
testId: isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined,
title: isBinary ? 'Cannot open this file type' : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
testId: resolveNoteItemTestId({ isMultiSelected, isImagePreview, isUnavailableBinary }),
title: resolveNoteItemTitle({ isImagePreview, isUnavailableBinary }),
}
}
@@ -392,6 +446,7 @@ function NoteItemRow({
function NoteItemContent({
entry,
isBinary,
isUnavailableBinary,
isSelected,
noteStatus,
changeStatus,
@@ -403,6 +458,7 @@ function NoteItemContent({
}: {
entry: VaultEntry
isBinary: boolean
isUnavailableBinary: boolean
isSelected: boolean
noteStatus: NoteStatus
changeStatus?: NoteItemProps['changeStatus']
@@ -427,6 +483,7 @@ function NoteItemContent({
<StandardNoteContent
entry={entry}
isBinary={isBinary}
isUnavailableBinary={isUnavailableBinary}
noteStatus={noteStatus}
isSelected={isSelected}
typeColor={typeColor}
@@ -440,13 +497,16 @@ function NoteItemContent({
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: NoteItemProps) {
const isBinary = entry.fileKind === 'binary'
const isImagePreview = isImagePreviewEntry(entry)
const isUnavailableBinary = isBinary && !isImagePreview
const te = typeEntryMap[entry.isA ?? '']
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeColor = isImagePreview ? 'var(--accent-blue)' : isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const surfaceProps = resolveNoteItemSurfaceProps({
entry,
isBinary,
isUnavailableBinary,
isImagePreview,
isSelected,
isMultiSelected,
isHighlighted,
@@ -467,6 +527,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
<NoteItemContent
entry={entry}
isBinary={isBinary}
isUnavailableBinary={isUnavailableBinary}
isSelected={isSelected}
noteStatus={noteStatus}
changeStatus={changeStatus}

View File

@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
import { useDragRegion } from '../hooks/useDragRegion'
import type { PulseCommit, PulseFile } from '../types'
import { relativeDate } from '../utils/noteListHelpers'
import { translate, type AppLocale } from '../lib/i18n'
import {
Plus, Minus, PencilSimple, GitCommit, ArrowSquareOut,
FileText, CaretDown, CaretRight, Pulse,
@@ -19,13 +20,20 @@ interface PulseViewProps {
onOpenNote?: (relativePath: string, commitHash?: string) => void
sidebarCollapsed?: boolean
onExpandSidebar?: () => void
locale?: AppLocale
}
function formatDateKey(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
const groups = new Map<string, PulseCommit[]>()
for (const commit of commits) {
const date = new Date(commit.date * 1000)
const key = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const key = formatDateKey(new Date(commit.date * 1000))
const existing = groups.get(key)
if (existing) {
existing.push(commit)
@@ -37,20 +45,20 @@ function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
}
function isToday(dateKey: string): boolean {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === today
return dateKey === formatDateKey(new Date())
}
function isYesterday(dateKey: string): boolean {
const yesterday = new Date(Date.now() - 86400000)
.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === yesterday
return dateKey === formatDateKey(new Date(Date.now() - 86400000))
}
function formatDayLabel(dateKey: string): string {
if (isToday(dateKey)) return 'Today'
if (isYesterday(dateKey)) return 'Yesterday'
return dateKey
function formatDayLabel(dateKey: string, locale: AppLocale): string {
if (isToday(dateKey)) return translate(locale, 'pulse.today')
if (isYesterday(dateKey)) return translate(locale, 'pulse.yesterday')
const date = new Date(`${dateKey}T00:00:00`)
const dateLocale = locale === 'zh-Hans' ? 'zh-CN' : 'en-US'
return date.toLocaleDateString(dateLocale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
}
const STATUS_ICON = {
@@ -124,9 +132,11 @@ function FileItem({
function CommitCard({
commit,
locale,
onOpenNote,
}: {
commit: PulseCommit
locale: AppLocale
onOpenNote?: (path: string, commitHash?: string) => void
}) {
const [expanded, setExpanded] = useState(false)
@@ -173,7 +183,7 @@ function CommitCard({
window.open(commit.githubUrl!, '_blank')
}
}}
title="Open on GitHub"
title={translate(locale, 'pulse.openOnGitHub')}
>
{commit.shortHash}
<ArrowSquareOut size={10} />
@@ -192,7 +202,7 @@ function CommitCard({
event.stopPropagation()
toggleExpanded()
}}
aria-label={expanded ? 'Collapse files' : 'Expand files'}
aria-label={translate(locale, expanded ? 'pulse.collapseFiles' : 'pulse.expandFiles')}
>
<Chevron size={12} />
</button>
@@ -208,9 +218,10 @@ function CommitCard({
)
}
function DayGroup({ label, commits, onOpenNote }: {
function DayGroup({ label, commits, locale, onOpenNote }: {
label: string
commits: PulseCommit[]
locale: AppLocale
onOpenNote?: (path: string, commitHash?: string) => void
}) {
const [collapsed, setCollapsed] = useState(false)
@@ -236,11 +247,14 @@ function DayGroup({ label, commits, onOpenNote }: {
{label}
</span>
<span className="text-[11px] text-muted-foreground">
({commits.length} {commits.length === 1 ? 'commit' : 'commits'})
({translate(locale, 'pulse.commitCount', {
count: commits.length,
label: translate(locale, commits.length === 1 ? 'pulse.commitSingular' : 'pulse.commitPlural'),
})})
</span>
</div>
{!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} onOpenNote={onOpenNote} />
<CommitCard key={commit.hash} commit={commit} locale={locale} onOpenNote={onOpenNote} />
))}
</div>
)
@@ -249,7 +263,8 @@ function DayGroup({ label, commits, onOpenNote }: {
function PulseHeader({
sidebarCollapsed,
onExpandSidebar,
}: Pick<PulseViewProps, 'sidebarCollapsed' | 'onExpandSidebar'>) {
locale = 'en',
}: Pick<PulseViewProps, 'sidebarCollapsed' | 'onExpandSidebar' | 'locale'>) {
const { onMouseDown } = useDragRegion()
return (
@@ -266,31 +281,31 @@ function PulseHeader({
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onExpandSidebar}
aria-label="Expand sidebar"
aria-label={translate(locale, 'sidebar.action.expand')}
>
<CaretRight size={14} weight="bold" />
</button>
)}
<Pulse size={16} className="text-primary" />
<span className="text-[14px] font-semibold text-foreground">History</span>
<span className="text-[14px] font-semibold text-foreground">{translate(locale, 'pulse.title')}</span>
</div>
</div>
)
}
function EmptyState() {
function EmptyState({ locale = 'en' }: { locale?: AppLocale }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<Pulse size={32} style={{ marginBottom: 8, opacity: 0.5 }} />
<p className="text-[13px]">No activity yet</p>
<p className="text-[13px]">{translate(locale, 'pulse.noActivity')}</p>
<p className="text-[12px]" style={{ marginTop: 4 }}>
Commit changes to see your vault's history
{translate(locale, 'pulse.emptyDescription')}
</p>
</div>
)
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
function ErrorState({ message, locale = 'en', onRetry }: { message: string; locale?: AppLocale; onRetry: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p>
@@ -298,7 +313,7 @@ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void
className="mt-2 cursor-pointer rounded border border-border bg-transparent px-3 py-1 text-[12px] text-foreground transition-colors hover:bg-accent"
onClick={onRetry}
>
Retry
{translate(locale, 'pulse.retry')}
</button>
</div>
)
@@ -310,6 +325,7 @@ function PulseFeed({
loading,
loadingMore,
error,
locale,
onOpenNote,
onRetry,
sentinelRef,
@@ -319,6 +335,7 @@ function PulseFeed({
loading: boolean
loadingMore: boolean
error: string | null
locale: AppLocale
onOpenNote?: (path: string, commitHash?: string) => void
onRetry: () => void
sentinelRef: React.RefObject<HTMLDivElement | null>
@@ -326,17 +343,17 @@ function PulseFeed({
if (loading) {
return (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity…</span>
<span className="text-[13px] text-muted-foreground">{translate(locale, 'pulse.loadingActivity')}</span>
</div>
)
}
if (error) {
return <ErrorState message={error} onRetry={onRetry} />
return <ErrorState message={error} locale={locale} onRetry={onRetry} />
}
if (commits.length === 0) {
return <EmptyState />
return <EmptyState locale={locale} />
}
return (
@@ -344,15 +361,16 @@ function PulseFeed({
{Array.from(dayGroups.entries()).map(([day, dayCommits]) => (
<DayGroup
key={day}
label={formatDayLabel(day)}
label={formatDayLabel(day, locale)}
commits={dayCommits}
locale={locale}
onOpenNote={onOpenNote}
/>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{loadingMore && (
<div className="flex items-center justify-center" style={{ padding: 12 }}>
<span className="text-[12px] text-muted-foreground">Loading…</span>
<span className="text-[12px] text-muted-foreground">{translate(locale, 'pulse.loading')}</span>
</div>
)}
</>
@@ -361,7 +379,7 @@ function PulseFeed({
const PAGE_SIZE = 20
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar, locale = 'en' }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
@@ -383,12 +401,12 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
setHasMore(result.length >= PAGE_SIZE)
setSkip(result.length)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
const msg = typeof err === 'string' ? err : translate(locale, 'pulse.loadError')
setError(msg)
} finally {
setLoading(false)
}
}, [vaultPath])
}, [locale, vaultPath])
// Append next page
const loadMore = useCallback(async () => {
@@ -424,7 +442,7 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
return (
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
<PulseHeader sidebarCollapsed={sidebarCollapsed} onExpandSidebar={onExpandSidebar} />
<PulseHeader sidebarCollapsed={sidebarCollapsed} locale={locale} onExpandSidebar={onExpandSidebar} />
<div className="flex-1 overflow-y-auto">
<PulseFeed
@@ -433,6 +451,7 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
loading={loading}
loadingMore={loadingMore}
error={error}
locale={locale}
onOpenNote={onOpenNote}
onRetry={loadInitial}
sentinelRef={sentinelRef}

View File

@@ -9,6 +9,7 @@ const {
detectYamlErrorMock,
extractWikilinkQueryMock,
getRawEditorDropdownPositionMock,
insertWikilinkAtCursorMock,
noteSearchListState,
replaceActiveWikilinkQueryMock,
trackEventMock,
@@ -21,6 +22,7 @@ const {
detectYamlErrorMock: vi.fn(),
extractWikilinkQueryMock: vi.fn(),
getRawEditorDropdownPositionMock: vi.fn(),
insertWikilinkAtCursorMock: vi.fn(),
noteSearchListState: { lastProps: null as null | Record<string, unknown> },
replaceActiveWikilinkQueryMock: vi.fn(),
trackEventMock: vi.fn(),
@@ -41,6 +43,10 @@ vi.mock('../utils/rawEditorUtils', () => ({
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
}))
vi.mock('../utils/rawEditorInsertions', () => ({
insertWikilinkAtCursor: insertWikilinkAtCursorMock,
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: buildTypeEntryMapMock,
}))
@@ -118,6 +124,30 @@ function createMockView(docText = '[[Target') {
}
}
function createMockDataTransfer(seedData: Record<string, string>): DataTransfer {
const data = new Map(Object.entries(seedData))
const types = Array.from(data.keys())
return {
dropEffect: 'none',
effectAllowed: 'move',
setData(type: string, value: string) {
data.set(type, value)
if (!types.includes(type)) types.push(type)
},
getData(type: string) {
return data.get(type) ?? ''
},
clearData() {
data.clear()
types.splice(0, types.length)
},
get types() {
return types
},
} as DataTransfer
}
describe('RawEditorView behavior coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -139,6 +169,10 @@ describe('RawEditorView behavior coverage', () => {
text: '[[Inserted]]',
cursor: 12,
})
insertWikilinkAtCursorMock.mockReturnValue({
text: 'Before [[Projects/Alpha]]',
cursor: 'Before [[Projects/Alpha]]'.length,
})
})
afterEach(() => {
@@ -312,4 +346,37 @@ describe('RawEditorView behavior coverage', () => {
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
expect(callbacks.onEscape()).toBe(true)
})
it('inserts a canonical wikilink when a note is dropped onto the raw editor', () => {
const onContentChange = vi.fn()
const mockView = createMockView('Before ')
viewRefState.current = mockView
render(
<RawEditorView
content="Before "
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={onContentChange}
onSave={vi.fn()}
vaultPath="/vault"
/>,
)
fireEvent.drop(screen.getByTestId('raw-editor-codemirror'), {
dataTransfer: createMockDataTransfer({
'application/x-laputa-note-path': '/vault/Projects/Alpha.md',
'text/plain': '/vault/Projects/Alpha.md',
}),
})
expect(insertWikilinkAtCursorMock).toHaveBeenCalledWith('Before ', 'Before '.length, 'Projects/Alpha')
expect(mockView.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 'Before '.length, insert: 'Before [[Projects/Alpha]]' },
selection: { anchor: 'Before [[Projects/Alpha]]'.length },
})
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', 'Before [[Projects/Alpha]]')
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
expect(mockView.focus).toHaveBeenCalledTimes(1)
})
})

View File

@@ -43,6 +43,17 @@ describe('RawEditorView', () => {
expect(content?.textContent).toContain('title: My Note')
})
it('disables native text assistance on the editable CodeMirror surface', () => {
render(<RawEditorView {...defaultProps} />)
const container = screen.getByTestId('raw-editor-codemirror')
const content = container.querySelector('.cm-content')
expect(content).toHaveAttribute('spellcheck', 'false')
expect(content).toHaveAttribute('autocorrect', 'off')
expect(content).toHaveAttribute('autocomplete', 'off')
expect(content).toHaveAttribute('autocapitalize', 'off')
})
it('calls onContentChange when editor content changes (debounced)', async () => {
vi.useFakeTimers()
const onContentChange = vi.fn()

View File

@@ -1,6 +1,8 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
import { trackEvent } from '../lib/telemetry'
import type { EditorView } from '@codemirror/view'
import { useNoteWikilinkDrop } from '../hooks/useNoteWikilinkDrop'
import { insertWikilinkAtCursor } from '../utils/rawEditorInsertions'
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { buildTypeEntryMap } from '../utils/typeColors'
import { NoteSearchList } from './NoteSearchList'
@@ -15,6 +17,7 @@ import {
} from '../utils/rawEditorUtils'
import { useCodeMirror } from '../hooks/useCodeMirror'
import type { VaultEntry } from '../types'
import { translate, type AppLocale } from '../lib/i18n'
export interface RawEditorViewProps {
content: string
@@ -26,6 +29,7 @@ export interface RawEditorViewProps {
/** Mutable ref updated on every keystroke with the latest doc string.
* Allows the parent to flush debounced content before unmount. */
latestContentRef?: React.MutableRefObject<string | null>
locale?: AppLocale
}
const DEBOUNCE_MS = 500
@@ -297,33 +301,52 @@ function useRawEditorWikilinkInsertion({
setAutocomplete: RawEditorSetAutocomplete
viewRef: React.MutableRefObject<EditorView | null>
}) {
const insertWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
const cursor = view.state.selection.main.head
const applyWikilinkChange = useCallback((view: EditorView, next: { text: string; cursor: number }) => {
const doc = view.state.doc.toString()
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
if (!replacement) return
view.dispatch({
changes: { from: 0, to: doc.length, insert: replacement.text },
selection: { anchor: replacement.cursor },
changes: { from: 0, to: doc.length, insert: next.text },
selection: { anchor: next.cursor },
})
trackEvent('wikilink_inserted')
setAutocomplete(null)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = null
latestDocRef.current = replacement.text
onContentChangeRef.current(pathRef.current, replacement.text)
latestDocRef.current = next.text
onContentChangeRef.current(pathRef.current, next.text)
view.focus()
}, [debounceRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete, viewRef])
}, [debounceRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete])
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilinkRef, insertWikilink])
const insertAutocompleteWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
const cursor = view.state.selection.main.head
const doc = view.state.doc.toString()
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
if (!replacement) return
applyWikilinkChange(view, replacement)
}, [applyWikilinkChange, viewRef])
const insertDroppedWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
applyWikilinkChange(
view,
insertWikilinkAtCursor(view.state.doc.toString(), view.state.selection.main.head, target),
)
}, [applyWikilinkChange, viewRef])
useEffect(() => { insertWikilinkRef.current = insertAutocompleteWikilink }, [insertAutocompleteWikilink, insertWikilinkRef])
return { insertDroppedWikilink }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en' }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path })
const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath })
@@ -334,7 +357,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
onEscape: autocompleteController.handleEscape,
})
useRawEditorWikilinkInsertion({
const { insertDroppedWikilink } = useRawEditorWikilinkInsertion({
debounceRef: pendingChanges.debounceRef,
insertWikilinkRef: autocompleteController.insertWikilinkRef,
latestDocRef: pendingChanges.latestDocRef,
@@ -343,6 +366,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
setAutocomplete: autocompleteController.setAutocomplete,
viewRef,
})
useNoteWikilinkDrop({ containerRef, onInsertTarget: insertDroppedWikilink, vaultPath })
const dropdownPosition = getRawEditorDropdownPosition(autocompleteController.autocomplete, DROPDOWN_MAX_HEIGHT, window)
@@ -351,9 +375,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<div
ref={containerRef}
className="flex flex-1 min-h-0"
className="raw-editor-codemirror flex flex-1 min-h-0"
data-testid="raw-editor-codemirror"
aria-label="Raw editor"
aria-label={translate(locale, 'editor.raw.label')}
/>
<RawEditorAutocompleteDropdown
autocomplete={autocompleteController.autocomplete}

View File

@@ -16,6 +16,7 @@ const emptySettings: Settings = {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: null,
}
function installPointerCapturePolyfill() {
@@ -69,6 +70,24 @@ describe('SettingsPanel', () => {
expect(screen.getByText('Sync & Updates')).toBeInTheDocument()
})
it('updates the draft language when stored settings finish loading', () => {
const { rerender } = render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
rerender(
<SettingsPanel
open={true}
settings={{ ...emptySettings, ui_language: 'zh-Hans' }}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByText('设置')).toBeInTheDocument()
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
})
it('calls onSave with stable defaults on save', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
@@ -97,6 +116,51 @@ describe('SettingsPanel', () => {
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false')
})
it('defaults the language selector to system language', () => {
render(
<SettingsPanel
open={true}
settings={emptySettings}
locale="en"
systemLocale="zh-Hans"
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByTestId('settings-ui-language')).toHaveAttribute('data-value', 'system')
expect(screen.getByText('系统(简体中文)')).toBeInTheDocument()
})
it('keeps the language selector keyboard accessible', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const trigger = screen.getByTestId('settings-ui-language')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown', code: 'ArrowDown' })
expect(screen.getByRole('option', { name: 'Simplified Chinese' })).toBeInTheDocument()
})
it('saves the selected UI language and updates visible settings text', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.pointerDown(screen.getByTestId('settings-ui-language'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: 'Simplified Chinese' }))
expect(screen.getByText('设置')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
ui_language: 'zh-Hans',
}))
})
it('uses the stored color mode mirror when settings have no saved mode', () => {
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark')

View File

@@ -17,6 +17,15 @@ import {
} from 'react'
import { Moon, Sun, X } from '@phosphor-icons/react'
import type { Settings } from '../types'
import {
SYSTEM_UI_LANGUAGE,
createTranslator,
localeDisplayName,
resolveEffectiveLocale,
serializeUiLanguagePreference,
type AppLocale,
type UiLanguagePreference,
} from '../lib/i18n'
import {
DEFAULT_THEME_MODE,
readStoredThemeMode,
@@ -40,6 +49,8 @@ interface SettingsPanelProps {
open: boolean
settings: Settings
aiAgentsStatus?: AiAgentsStatus
locale?: AppLocale
systemLocale?: AppLocale
onSave: (settings: Settings) => void
isGitVault?: boolean
explicitOrganizationEnabled?: boolean
@@ -56,6 +67,7 @@ interface SettingsDraft {
defaultAiAgent: AiAgentId
releaseChannel: ReleaseChannel
themeMode: ThemeMode
uiLanguage: UiLanguagePreference
initialH1AutoRename: boolean
crashReporting: boolean
analytics: boolean
@@ -63,6 +75,7 @@ interface SettingsDraft {
}
interface SettingsBodyProps {
t: Translate
pullInterval: number
setPullInterval: (value: number) => void
isGitVault: boolean
@@ -81,6 +94,10 @@ interface SettingsBodyProps {
setReleaseChannel: (value: ReleaseChannel) => void
themeMode: ThemeMode
setThemeMode: (value: ThemeMode) => void
uiLanguage: UiLanguagePreference
setUiLanguage: (value: UiLanguagePreference) => void
locale: AppLocale
systemLocale: AppLocale
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
explicitOrganization: boolean
@@ -94,6 +111,7 @@ interface SettingsBodyProps {
const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const
const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90
const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30
type Translate = ReturnType<typeof createTranslator>
function isSaveShortcut(event: ReactKeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
@@ -118,6 +136,7 @@ function createSettingsDraft(
defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent),
releaseChannel: normalizeReleaseChannel(settings.release_channel),
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
@@ -157,6 +176,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
anonymous_id: resolveAnonymousId(settings, draft),
release_channel: serializeReleaseChannel(draft.releaseChannel),
theme_mode: draft.themeMode,
ui_language: serializeUiLanguagePreference(draft.uiLanguage),
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
}
@@ -180,6 +200,8 @@ export function SettingsPanel({
open,
settings,
aiAgentsStatus = createMissingAiAgentsStatus(),
locale = 'en',
systemLocale = locale,
onSave,
isGitVault = true,
explicitOrganizationEnabled = true,
@@ -192,6 +214,8 @@ export function SettingsPanel({
<SettingsPanelInner
settings={settings}
aiAgentsStatus={aiAgentsStatus}
locale={locale}
systemLocale={systemLocale}
onSave={onSave}
isGitVault={isGitVault}
explicitOrganizationEnabled={explicitOrganizationEnabled}
@@ -203,6 +227,8 @@ export function SettingsPanel({
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus' | 'isGitVault'> & {
aiAgentsStatus: AiAgentsStatus
locale: AppLocale
systemLocale: AppLocale
isGitVault: boolean
explicitOrganizationEnabled: boolean
}
@@ -210,6 +236,7 @@ type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrgani
function SettingsPanelInner({
settings,
aiAgentsStatus,
systemLocale,
onSave,
isGitVault,
explicitOrganizationEnabled,
@@ -218,6 +245,12 @@ function SettingsPanelInner({
}: SettingsPanelInnerProps) {
const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled))
const panelRef = useRef<HTMLDivElement>(null)
const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale])
const t = createTranslator(draftLocale)
useEffect(() => {
setDraft(createSettingsDraft(settings, explicitOrganizationEnabled))
}, [explicitOrganizationEnabled, settings])
useEffect(() => {
const timer = setTimeout(() => {
@@ -277,8 +310,11 @@ function SettingsPanelInner({
className="rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>
<SettingsHeader onClose={onClose} />
<SettingsHeader onClose={onClose} t={t} />
<SettingsBody
t={t}
locale={draftLocale}
systemLocale={systemLocale}
pullInterval={draft.pullInterval}
setPullInterval={(value) => updateDraft('pullInterval', value)}
isGitVault={isGitVault}
@@ -297,6 +333,8 @@ function SettingsPanelInner({
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
themeMode={draft.themeMode}
setThemeMode={(value) => updateDraft('themeMode', value)}
uiLanguage={draft.uiLanguage}
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
explicitOrganization={draft.explicitOrganization}
@@ -306,25 +344,25 @@ function SettingsPanelInner({
analytics={draft.analytics}
setAnalytics={(value) => updateDraft('analytics', value)}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
<SettingsFooter onClose={onClose} onSave={handleSave} t={t} />
</div>
</div>
)
}
function SettingsHeader({ onClose }: { onClose: () => void }) {
function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) {
return (
<div
className="flex items-center justify-between shrink-0"
style={{ height: 56, padding: '0 24px', borderBottom: '1px solid var(--border)' }}
>
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--foreground)' }}>Settings</span>
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--foreground)' }}>{t('settings.title')}</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
title="Close settings"
aria-label="Close settings"
title={t('settings.close')}
aria-label={t('settings.close')}
>
<X size={16} />
</Button>
@@ -333,6 +371,9 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
}
function SettingsBody({
t,
locale,
systemLocale,
pullInterval,
setPullInterval,
isGitVault,
@@ -351,6 +392,8 @@ function SettingsBody({
setReleaseChannel,
themeMode,
setThemeMode,
uiLanguage,
setUiLanguage,
initialH1AutoRename,
setInitialH1AutoRename,
explicitOrganization,
@@ -364,6 +407,7 @@ function SettingsBody({
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 0, overflow: 'auto' }}>
<SettingsSection showDivider={false}>
<SyncAndUpdatesSection
t={t}
pullInterval={pullInterval}
setPullInterval={setPullInterval}
releaseChannel={releaseChannel}
@@ -373,6 +417,7 @@ function SettingsBody({
<SettingsSection>
<AutoGitSettingsSection
t={t}
isGitVault={isGitVault}
autoGitEnabled={autoGitEnabled}
setAutoGitEnabled={setAutoGitEnabled}
@@ -385,13 +430,25 @@ function SettingsBody({
<SettingsSection>
<AppearanceSettingsSection
t={t}
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
</SettingsSection>
<SettingsSection>
<LanguageSettingsSection
t={t}
locale={locale}
systemLocale={systemLocale}
uiLanguage={uiLanguage}
setUiLanguage={setUiLanguage}
/>
</SettingsSection>
<SettingsSection>
<TitleSettingsSection
t={t}
initialH1AutoRename={initialH1AutoRename}
setInitialH1AutoRename={setInitialH1AutoRename}
/>
@@ -399,6 +456,7 @@ function SettingsBody({
<SettingsSection>
<AiAgentSettingsSection
t={t}
aiAgentsStatus={aiAgentsStatus}
defaultAiAgent={defaultAiAgent}
setDefaultAiAgent={setDefaultAiAgent}
@@ -407,6 +465,7 @@ function SettingsBody({
<SettingsSection>
<OrganizationWorkflowSection
t={t}
checked={explicitOrganization}
onChange={setExplicitOrganization}
autoAdvanceInboxAfterOrganize={autoAdvanceInboxAfterOrganize}
@@ -416,6 +475,7 @@ function SettingsBody({
<SettingsSection>
<PrivacySettingsSection
t={t}
crashReporting={crashReporting}
setCrashReporting={setCrashReporting}
analytics={analytics}
@@ -427,20 +487,21 @@ function SettingsBody({
}
function SyncAndUpdatesSection({
t,
pullInterval,
setPullInterval,
releaseChannel,
setReleaseChannel,
}: Pick<SettingsBodyProps, 'pullInterval' | 'setPullInterval' | 'releaseChannel' | 'setReleaseChannel'>) {
}: Pick<SettingsBodyProps, 't' | 'pullInterval' | 'setPullInterval' | 'releaseChannel' | 'setReleaseChannel'>) {
return (
<>
<SectionHeading
title="Sync & Updates"
description="Configure background pulling and which update feed Tolaria follows. Stable only receives manually promoted releases, while Alpha follows every push to main."
title={t('settings.sync.title')}
description={t('settings.sync.description')}
/>
<LabeledSelect
label="Pull interval (minutes)"
label={t('settings.pullInterval')}
value={`${pullInterval}`}
onValueChange={(value) => setPullInterval(Number(value))}
options={PULL_INTERVAL_OPTIONS.map((value) => ({
@@ -452,12 +513,12 @@ function SyncAndUpdatesSection({
/>
<LabeledSelect
label="Release channel"
label={t('settings.releaseChannel')}
value={releaseChannel}
onValueChange={(value) => setReleaseChannel(value as ReleaseChannel)}
options={[
{ value: 'stable', label: 'Stable' },
{ value: 'alpha', label: 'Alpha' },
{ value: 'stable', label: t('settings.releaseStable') },
{ value: 'alpha', label: t('settings.releaseAlpha') },
]}
testId="settings-release-channel"
/>
@@ -466,17 +527,18 @@ function SyncAndUpdatesSection({
}
function AppearanceSettingsSection({
t,
themeMode,
setThemeMode,
}: Pick<SettingsBodyProps, 'themeMode' | 'setThemeMode'>) {
}: Pick<SettingsBodyProps, 't' | 'themeMode' | 'setThemeMode'>) {
return (
<>
<SectionHeading
title="Appearance"
description="Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs."
title={t('settings.appearance.title')}
description={t('settings.appearance.description')}
/>
<ThemeModeControl value={themeMode} onChange={setThemeMode} />
<ThemeModeControl value={themeMode} onChange={setThemeMode} t={t} />
</>
)
}
@@ -484,21 +546,23 @@ function AppearanceSettingsSection({
function ThemeModeControl({
value,
onChange,
t,
}: {
value: ThemeMode
onChange: (value: ThemeMode) => void
t: Translate
}) {
return (
<div
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
role="radiogroup"
aria-label="Theme"
aria-label={t('settings.theme.label')}
data-testid="settings-theme-mode"
>
<ThemeModeButton label="Light" selected={value === 'light'} value="light" onSelect={onChange}>
<ThemeModeButton label={t('settings.theme.light')} selected={value === 'light'} value="light" onSelect={onChange}>
<Sun size={14} />
</ThemeModeButton>
<ThemeModeButton label="Dark" selected={value === 'dark'} value="dark" onSelect={onChange}>
<ThemeModeButton label={t('settings.theme.dark')} selected={value === 'dark'} value="dark" onSelect={onChange}>
<Moon size={14} />
</ThemeModeButton>
</div>
@@ -540,13 +604,14 @@ function ThemeModeButton({
)
}
function autoGitSectionDescription(isGitVault: boolean): string {
function autoGitSectionDescription(isGitVault: boolean, t: Translate): string {
return isGitVault
? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.'
: 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.'
? t('settings.autogit.description.enabled')
: t('settings.autogit.description.disabled')
}
function AutoGitSettingsSection({
t,
isGitVault,
autoGitEnabled,
setAutoGitEnabled,
@@ -556,6 +621,7 @@ function AutoGitSettingsSection({
setAutoGitInactiveThresholdSeconds,
}: Pick<
SettingsBodyProps,
| 't'
| 'isGitVault'
| 'autoGitEnabled'
| 'setAutoGitEnabled'
@@ -567,13 +633,13 @@ function AutoGitSettingsSection({
return (
<>
<SectionHeading
title="AutoGit"
description={autoGitSectionDescription(isGitVault)}
title={t('settings.autogit.title')}
description={autoGitSectionDescription(isGitVault, t)}
/>
<SettingsSwitchRow
label="Enable AutoGit"
description="When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive."
label={t('settings.autogit.enable')}
description={t('settings.autogit.enableDescription')}
checked={autoGitEnabled}
onChange={setAutoGitEnabled}
disabled={!isGitVault}
@@ -581,7 +647,7 @@ function AutoGitSettingsSection({
/>
<LabeledNumberInput
label="Idle threshold (seconds)"
label={t('settings.autogit.idleThreshold')}
value={autoGitIdleThresholdSeconds}
onValueChange={setAutoGitIdleThresholdSeconds}
testId="settings-autogit-idle-threshold"
@@ -589,7 +655,7 @@ function AutoGitSettingsSection({
/>
<LabeledNumberInput
label="Inactive-app grace period (seconds)"
label={t('settings.autogit.inactiveThreshold')}
value={autoGitInactiveThresholdSeconds}
onValueChange={setAutoGitInactiveThresholdSeconds}
testId="settings-autogit-inactive-threshold"
@@ -599,20 +665,63 @@ function AutoGitSettingsSection({
)
}
function TitleSettingsSection({
initialH1AutoRename,
setInitialH1AutoRename,
}: Pick<SettingsBodyProps, 'initialH1AutoRename' | 'setInitialH1AutoRename'>) {
function buildLanguageOptions(t: Translate, locale: AppLocale, systemLocale: AppLocale) {
return [
{
value: SYSTEM_UI_LANGUAGE,
label: t('settings.language.system', {
language: localeDisplayName(systemLocale, locale),
}),
},
{ value: 'en', label: t('settings.language.en') },
{ value: 'zh-Hans', label: t('settings.language.zhHans') },
]
}
function LanguageSettingsSection({
t,
locale,
systemLocale,
uiLanguage,
setUiLanguage,
}: Pick<SettingsBodyProps, 't' | 'locale' | 'systemLocale' | 'uiLanguage' | 'setUiLanguage'>) {
return (
<>
<SectionHeading
title="Titles & Filenames"
description="Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title."
title={t('settings.language.title')}
description={t('settings.language.description')}
/>
<LabeledSelect
label={t('settings.language.label')}
value={uiLanguage}
onValueChange={(value) => setUiLanguage(value as UiLanguagePreference)}
options={buildLanguageOptions(t, locale, systemLocale)}
testId="settings-ui-language"
/>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
{t('settings.language.summary')}
</div>
</>
)
}
function TitleSettingsSection({
t,
initialH1AutoRename,
setInitialH1AutoRename,
}: Pick<SettingsBodyProps, 't' | 'initialH1AutoRename' | 'setInitialH1AutoRename'>) {
return (
<>
<SectionHeading
title={t('settings.titles.title')}
description={t('settings.titles.description')}
/>
<SettingsSwitchRow
label="Auto-rename untitled notes from first H1"
description="When enabled, Tolaria renames untitled-note files as soon as the first H1 becomes a real title. Turn this off to keep the filename unchanged until you rename it manually from the breadcrumb bar."
label={t('settings.titles.autoRename')}
description={t('settings.titles.autoRenameDescription')}
checked={initialH1AutoRename}
onChange={setInitialH1AutoRename}
testId="settings-initial-h1-auto-rename"
@@ -621,12 +730,12 @@ function TitleSettingsSection({
)
}
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ value: string; label: string }> {
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> {
return AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]
const suffix = status.status === 'installed'
? ` (installed${status.version ? ` ${status.version}` : ''})`
: ' (missing)'
? ` (${t('settings.aiAgents.installed')}${status.version ? ` ${status.version}` : ''})`
: ` (${t('settings.aiAgents.missing')})`
return {
value: definition.id,
label: `${definition.label}${suffix}`,
@@ -635,55 +744,57 @@ function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ val
}
function AiAgentSettingsSection({
t,
aiAgentsStatus,
defaultAiAgent,
setDefaultAiAgent,
}: Pick<SettingsBodyProps, 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
}: Pick<SettingsBodyProps, 't' | 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
return (
<>
<SectionHeading
title="AI Agents"
description="Choose which CLI AI agent Tolaria uses in the AI panel and command palette."
title={t('settings.aiAgents.title')}
description={t('settings.aiAgents.description')}
/>
<LabeledSelect
label="Default AI agent"
label={t('settings.aiAgents.default')}
value={defaultAiAgent}
onValueChange={(value) => setDefaultAiAgent(value as AiAgentId)}
options={buildDefaultAiAgentOptions(aiAgentsStatus)}
options={buildDefaultAiAgentOptions(aiAgentsStatus, t)}
testId="settings-default-ai-agent"
/>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus, t)}
</div>
</>
)
}
function PrivacySettingsSection({
t,
crashReporting,
setCrashReporting,
analytics,
setAnalytics,
}: Pick<SettingsBodyProps, 'crashReporting' | 'setCrashReporting' | 'analytics' | 'setAnalytics'>) {
}: Pick<SettingsBodyProps, 't' | 'crashReporting' | 'setCrashReporting' | 'analytics' | 'setAnalytics'>) {
return (
<>
<SectionHeading
title="Privacy & Telemetry"
description="Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent."
title={t('settings.privacy.title')}
description={t('settings.privacy.description')}
/>
<TelemetryToggle
label="Crash reporting"
description="Send anonymous error reports"
label={t('settings.privacy.crashReporting')}
description={t('settings.privacy.crashReportingDescription')}
checked={crashReporting}
onChange={setCrashReporting}
testId="settings-crash-reporting"
/>
<TelemetryToggle
label="Usage analytics"
description="Share anonymous usage patterns"
label={t('settings.privacy.analytics')}
description={t('settings.privacy.analyticsDescription')}
checked={analytics}
onChange={setAnalytics}
testId="settings-analytics"
@@ -738,13 +849,16 @@ function Divider() {
return <div style={{ height: 1, background: 'color-mix(in srgb, var(--border) 82%, transparent)' }} />
}
function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus): string {
function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus, t: Translate): string {
const definition = getAiAgentDefinition(defaultAiAgent)
const status = aiAgentsStatus[defaultAiAgent]
if (status.status === 'installed') {
return `${definition.label}${status.version ? ` ${status.version}` : ''} is ready to use.`
return t('settings.aiAgents.ready', {
agent: definition.label,
version: status.version ? ` ${status.version}` : '',
})
}
return `${definition.label} is not installed yet. You can still select it now and install it later.`
return t('settings.aiAgents.notInstalled', { agent: definition.label })
}
function LabeledSelect({
@@ -818,11 +932,13 @@ function LabeledNumberInput({
}
function OrganizationWorkflowSection({
t,
checked,
onChange,
autoAdvanceInboxAfterOrganize,
onChangeAutoAdvanceInboxAfterOrganize,
}: {
t: Translate
checked: boolean
onChange: (value: boolean) => void
autoAdvanceInboxAfterOrganize: boolean
@@ -831,21 +947,21 @@ function OrganizationWorkflowSection({
return (
<>
<SectionHeading
title="Workflow"
description="Choose whether Tolaria shows the Inbox workflow, plus how it moves through items while you triage them."
title={t('settings.workflow.title')}
description={t('settings.workflow.description')}
/>
<SettingsSwitchRow
label="Organize notes explicitly"
description="When enabled, an Inbox section shows unorganized notes, and a toggle lets you mark notes as organized."
label={t('settings.workflow.explicit')}
description={t('settings.workflow.explicitDescription')}
checked={checked}
onChange={onChange}
testId="settings-explicit-organization"
/>
<SettingsSwitchRow
label="Auto-advance to next Inbox item"
description="When enabled, marking an Inbox note as organized immediately opens the next visible Inbox note."
label={t('settings.workflow.autoAdvance')}
description={t('settings.workflow.autoAdvanceDescription')}
checked={autoAdvanceInboxAfterOrganize}
onChange={onChangeAutoAdvanceInboxAfterOrganize}
testId="settings-auto-advance-inbox-after-organize"
@@ -908,19 +1024,19 @@ function TelemetryToggle({
)
}
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
function SettingsFooter({ onClose, onSave, t }: { onClose: () => void; onSave: () => void; t: Translate }) {
return (
<div
className="flex items-center justify-between shrink-0"
style={{ height: 56, padding: '0 24px', borderTop: '1px solid var(--border)' }}
>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{'\u2318'}, to open settings</span>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{t('settings.footerShortcut')}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
{t('settings.cancel')}
</Button>
<Button onClick={onSave} data-testid="settings-save">
Save
{t('settings.save')}
</Button>
</div>
</div>

View File

@@ -22,6 +22,8 @@ import {
ViewsSection,
} from './sidebar/SidebarSections'
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
interface SidebarProps {
entries: VaultEntry[]
@@ -45,14 +47,176 @@ interface SidebarProps {
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
folderFileActions?: FolderFileActions
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
showInbox?: boolean
inboxCount?: number
locale?: AppLocale
onCollapse?: () => void
}
interface SidebarNavigationProps extends Pick<
SidebarProps,
| 'entries'
| 'selection'
| 'onSelect'
| 'onSelectFavorite'
| 'onReorderFavorites'
| 'views'
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'folders'
| 'onCreateFolder'
| 'onRenameFolder'
| 'onDeleteFolder'
| 'folderFileActions'
| 'renamingFolderPath'
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
| 'showInbox'
| 'inboxCount'
| 'onCreateNewType'
| 'locale'
> {
activeCount: number
archivedCount: number
groupCollapsed: ReturnType<typeof useSidebarCollapsed>['collapsed']
toggleGroup: ReturnType<typeof useSidebarCollapsed>['toggle']
visibleSections: ReturnType<typeof useSidebarSections>['visibleSections']
allSectionGroups: ReturnType<typeof useSidebarSections>['allSectionGroups']
sectionIds: string[]
sensors: ReturnType<typeof useSensors>
handleDragEnd: (event: DragEndEvent) => void
sectionProps: SidebarSectionProps
typeInteractions: ReturnType<typeof useSidebarTypeInteractions>
isSectionVisible: (type: string) => boolean
toggleVisibility: (type: string) => void
}
function SidebarNavigation({
entries,
selection,
onSelect,
onSelectFavorite,
onReorderFavorites,
views = [],
onCreateView,
onEditView,
onDeleteView,
folders = [],
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
locale = 'en',
onCreateNewType,
activeCount,
archivedCount,
groupCollapsed,
toggleGroup,
visibleSections,
allSectionGroups,
sectionIds,
sensors,
handleDragEnd,
sectionProps,
typeInteractions,
isSectionVisible,
toggleVisibility,
}: SidebarNavigationProps) {
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
const hasViews = views.length > 0 || !!onCreateView
return (
<nav className="flex-1 overflow-y-auto">
<SidebarTopNav
selection={selection}
onSelect={onSelect}
showInbox={showInbox}
inboxCount={inboxCount}
activeCount={activeCount}
archivedCount={archivedCount}
locale={locale}
/>
{hasFavorites && (
<div className="border-b border-border">
<FavoritesSection
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectNote={onSelectFavorite}
onReorder={onReorderFavorites}
collapsed={groupCollapsed.favorites}
locale={locale}
onToggle={() => toggleGroup('favorites')}
/>
</div>
)}
{hasViews && (
<ViewsSection
views={views}
selection={selection}
onSelect={onSelect}
collapsed={groupCollapsed.views}
onToggle={() => toggleGroup('views')}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
)}
<TypesSection
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
locale={locale}
/>
<FolderTree
folders={folders}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
folderFileActions={folderFileActions}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
locale={locale}
onToggle={() => toggleGroup('folders')}
/>
</nav>
)
}
function useSidebarDndSensors() {
return useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
}
export const Sidebar = memo(function Sidebar({
entries,
selection,
@@ -72,11 +236,13 @@ export const Sidebar = memo(function Sidebar({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
locale = 'en',
onCollapse,
onCreateNewType,
}: SidebarProps) {
@@ -94,10 +260,7 @@ export const Sidebar = memo(function Sidebar({
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const sensors = useSidebarDndSensors()
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
@@ -109,6 +272,7 @@ export const Sidebar = memo(function Sidebar({
const sectionProps: SidebarSectionProps = {
entries,
selection,
locale,
onSelect,
onContextMenu: typeInteractions.handleContextMenu,
renamingType: typeInteractions.renamingType,
@@ -117,83 +281,52 @@ export const Sidebar = memo(function Sidebar({
onRenameCancel: typeInteractions.cancelRename,
}
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
const hasViews = views.length > 0 || !!onCreateView
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar onCollapse={onCollapse} />
<nav className="flex-1 overflow-y-auto">
<SidebarTopNav
selection={selection}
onSelect={onSelect}
showInbox={showInbox}
inboxCount={inboxCount}
activeCount={activeCount}
archivedCount={archivedCount}
/>
{hasFavorites && (
<div className="border-b border-border">
<FavoritesSection
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectNote={onSelectFavorite}
onReorder={onReorderFavorites}
collapsed={groupCollapsed.favorites}
onToggle={() => toggleGroup('favorites')}
/>
</div>
)}
{hasViews && (
<ViewsSection
views={views}
selection={selection}
onSelect={onSelect}
collapsed={groupCollapsed.views}
onToggle={() => toggleGroup('views')}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
/>
)}
<TypesSection
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
/>
<FolderTree
folders={folders}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
onToggle={() => toggleGroup('folders')}
/>
</nav>
<SidebarTitleBar locale={locale} onCollapse={onCollapse} />
<SidebarNavigation
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
folderFileActions={folderFileActions}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
showInbox={showInbox}
inboxCount={inboxCount}
locale={locale}
onCreateNewType={onCreateNewType}
activeCount={activeCount}
archivedCount={archivedCount}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
typeInteractions={typeInteractions}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
/>
<ContextMenuOverlay
pos={typeInteractions.contextMenuPos}
type={typeInteractions.contextMenuType}
innerRef={typeInteractions.contextMenuRef}
onOpenCustomize={typeInteractions.openCustomizeTarget}
onStartRename={typeInteractions.handleStartRename}
locale={locale}
/>
<CustomizeOverlay
target={typeInteractions.customizeTarget}

View File

@@ -5,6 +5,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
import { Button } from './ui/button'
import { translate, type AppLocale } from '../lib/i18n'
const SIDEBAR_COUNT_PILL_STYLE = {
borderRadius: 9999,
@@ -275,12 +276,13 @@ export interface SectionContentProps {
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
locale?: AppLocale
}
export function SectionContent({
group, itemCount, selection, onSelect,
onContextMenu, dragHandleProps,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel, locale,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const { sectionColor, sectionLightColor } = resolveSectionColors(type, customColor)
@@ -299,14 +301,16 @@ export function SectionContent({
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
locale={locale}
/>
)
}
function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
function InlineRenameInput({ initialValue, onSubmit, onCancel, locale }: {
initialValue: string
onSubmit: (value: string) => void
onCancel: () => void
locale?: AppLocale
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
@@ -326,7 +330,7 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(value.trim())}
onClick={(e) => e.stopPropagation()}
aria-label="Section name"
aria-label={translate(locale ?? 'en', 'sidebar.section.name')}
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
style={{ padding: '1px 4px' }}
/>
@@ -382,6 +386,7 @@ function SectionHeaderLabel({
renameInitialValue,
onRenameSubmit,
onRenameCancel,
locale,
}: {
type: string
label: string
@@ -391,6 +396,7 @@ function SectionHeaderLabel({
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
locale?: AppLocale
}) {
const inlineRenameHandlers = resolveInlineRenameHandlers({
isRenaming,
@@ -405,6 +411,7 @@ function SectionHeaderLabel({
initialValue={renameInitialValue ?? label}
onSubmit={inlineRenameHandlers.onRenameSubmit}
onCancel={inlineRenameHandlers.onRenameCancel}
locale={locale}
/>
)
}
@@ -431,13 +438,14 @@ function SectionHeaderCountPill({
)
}
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel, locale }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; sectionLightColor: string; itemCount: number; isActive: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean; renameInitialValue?: string
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
locale?: AppLocale
}) {
return (
<div
@@ -458,6 +466,7 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
locale={locale}
/>
</div>
<SectionHeaderCountPill itemCount={itemCount} isActive={isActive} sectionColor={sectionColor} />
@@ -469,10 +478,12 @@ function VisibilityPopoverItem({
group,
isVisible,
onToggle,
locale = 'en',
}: {
group: SectionGroup
isVisible: boolean
onToggle: (type: string) => void
locale?: AppLocale
}) {
const { label, type, Icon, customColor } = group
const { sectionColor } = resolveSectionColors(type, customColor)
@@ -485,7 +496,7 @@ function VisibilityPopoverItem({
className="h-auto w-full justify-start rounded-none px-3 py-1.5"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => onToggle(type)}
aria-label={`Toggle ${label}`}
aria-label={translate(locale, 'sidebar.section.toggle', { label })}
>
<Icon size={14} style={{ color: sectionColor }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
@@ -496,23 +507,25 @@ function VisibilityPopoverItem({
// --- Visibility Popover ---
export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
export function VisibilityPopover({ sections, isSectionVisible, onToggle, locale = 'en' }: {
sections: SectionGroup[]
isSectionVisible: (type: string) => boolean
onToggle: (type: string) => void
locale?: AppLocale
}) {
return (
<div
className="border border-border bg-popover text-popover-foreground"
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px var(--shadow-dialog)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>{translate(locale, 'sidebar.section.showInSidebar')}</div>
{sections.map((group) => (
<VisibilityPopoverItem
key={group.type}
group={group}
isVisible={isSectionVisible(group.type)}
onToggle={onToggle}
locale={locale}
/>
))}
</div>

View File

@@ -2,6 +2,9 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
@@ -9,6 +12,7 @@ const state = vi.hoisted(() => ({
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
capturedBlockNoteOnChange: null as null | (() => void),
capturedMantineGetStyleNonce: null as null | (() => string),
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
@@ -108,7 +112,16 @@ vi.mock('@mantine/core', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
MantineContext: React.createContext(null),
MantineProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
MantineProvider: ({
children,
getStyleNonce,
}: {
children?: ReactNode
getStyleNonce?: () => string
}) => {
state.capturedMantineGetStyleNonce = getStyleNonce ?? null
return <>{children}</>
},
}
})
@@ -243,6 +256,30 @@ function createEditor() {
}
}
function createMockDataTransfer(seedData: Record<string, string>): DataTransfer {
const data = new Map(Object.entries(seedData))
const types = Array.from(data.keys())
return {
dropEffect: 'none',
effectAllowed: 'move',
setData(type: string, value: string) {
data.set(type, value)
if (!types.includes(type)) types.push(type)
},
getData(type: string) {
return data.get(type) ?? ''
},
clearData() {
data.clear()
types.splice(0, types.length)
},
get types() {
return types
},
} as DataTransfer
}
describe('SingleEditorView', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -251,6 +288,7 @@ describe('SingleEditorView', () => {
state.capturedSuggestionProps = {}
state.capturedImageDropArgs = null
state.capturedBlockNoteOnChange = null
state.capturedMantineGetStyleNonce = null
state.imageDropState.isDragOver = false
state.wikilinkEntriesRef.current = []
mockOpenExternalUrl.mockClear()
@@ -321,6 +359,54 @@ describe('SingleEditorView', () => {
)
})
it('inserts a canonical wikilink when a note is dropped onto the editor surface', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
const noteDropData = createMockDataTransfer({
[NOTE_DRAG_MIME]: '/vault/Projects/Alpha.md',
'text/plain': '/vault/Projects/Alpha.md',
})
fireEvent.drop(screen.getByTestId('blocknote-view').parentElement as HTMLElement, {
dataTransfer: noteDropData,
})
expect(editor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Projects/Alpha' } },
' ',
], { updateSelection: true })
})
it('ignores dropped plain text that is not a markdown note path', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
fireEvent.drop(screen.getByTestId('blocknote-view').parentElement as HTMLElement, {
dataTransfer: createMockDataTransfer({
'text/plain': 'Just some dragged text',
}),
})
expect(editor.insertInlineContent).not.toHaveBeenCalled()
})
it('wires the toolbar mouse guard and suggestion item click handlers', () => {
const editor = createEditor()
render(
@@ -395,6 +481,18 @@ describe('SingleEditorView', () => {
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-mantine-color-scheme', 'dark')
})
it('passes the runtime CSP style nonce to Mantine fallback style tags', () => {
render(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
expect(state.capturedMantineGetStyleNonce?.()).toBe(RUNTIME_STYLE_NONCE)
})
it('defers rich-editor change propagation until IME composition ends', async () => {
const editor = createEditor()
const onChange = vi.fn()
@@ -492,6 +590,62 @@ describe('SingleEditorView', () => {
expect(editor.focus).not.toHaveBeenCalled()
})
it('ignores editor-container click handling for BlockNote side-menu actions', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
const action = document.createElement('button')
action.type = 'button'
action.textContent = 'Add block'
sideMenu.appendChild(action)
container?.appendChild(sideMenu)
fireEvent.click(action)
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
expect(editor.focus).not.toHaveBeenCalled()
})
it('falls back to the nearest editable block when the trailing block has no inline content', () => {
const editor = createEditor()
editor.document = [
{ id: 'paragraph-block', type: 'paragraph', content: [], children: [] },
{ id: 'image-block', type: 'image', children: [] },
]
editor.setTextCursorPosition = vi.fn((blockId: string) => {
if (blockId === 'image-block') {
throw new Error('Attempting to set selection anchor in block without content (id image-block)')
}
})
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
expect(() => fireEvent.click(container!)).not.toThrow()
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('paragraph-block', 'end')
expect(editor.focus).toHaveBeenCalled()
})
it('routes the custom link-toolbar open action through openExternalUrl', () => {
render(
<SingleEditorView

View File

@@ -20,11 +20,14 @@ import { ExternalLink } from 'lucide-react'
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
import { useEditorTheme } from '../hooks/useTheme'
import { useImageDrop } from '../hooks/useImageDrop'
import { useNoteWikilinkDrop } from '../hooks/useNoteWikilinkDrop'
import { buildTypeEntryMap } from '../utils/typeColors'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
import { openExternalUrl } from '../utils/url'
import { observeNativeTextAssistanceDisabled } from '../lib/nativeTextAssistance'
import { getRuntimeStyleNonce } from '../lib/runtimeStyleNonce'
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
@@ -36,6 +39,7 @@ import {
} from './tolariaEditorFormatting'
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
import { useEditorLinkActivation } from './useEditorLinkActivation'
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| --- | --- | --- |
@@ -46,6 +50,7 @@ const CONTAINER_CLICK_IGNORE_SELECTOR = [
'[contenteditable="true"]',
'.bn-formatting-toolbar',
'.bn-link-toolbar',
'.bn-side-menu',
'.bn-form-popover',
'[role="menu"]',
'[role="dialog"]',
@@ -87,6 +92,7 @@ function SharedContextBlockNoteView(props: React.ComponentProps<typeof BlockNote
<MantineProvider
// BlockNote scopes Mantine defaults under `.bn-mantine` instead of `:root`.
withCssVariables={false}
getStyleNonce={getRuntimeStyleNonce}
getRootElement={() => undefined}
>
{view}
@@ -241,7 +247,11 @@ function queueTitleHeadingCursorRepair(
const firstBlock = editor.document[0]
if (firstBlock?.type !== 'heading') return
editor.setTextCursorPosition(firstBlock.id, 'end')
try {
editor.setTextCursorPosition(firstBlock.id, 'end')
} catch {
return
}
editor.focus()
})
@@ -261,7 +271,14 @@ function useEditorContainerClickHandler(options: {
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
const targetBlock = findNearestTextCursorBlock(blocks, blocks.length - 1)
if (targetBlock) {
try {
editor.setTextCursorPosition(targetBlock.id, 'end')
} catch {
// Ignore transient BlockNote selection errors and at least restore focus.
}
}
}
editor.focus()
}, [editor, editable])
@@ -420,11 +437,18 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
_wikilinkEntriesRef.current = entries
}, [entries])
useEffect(() => {
const container = containerRef.current
if (!container) return
return observeNativeTextAssistanceDisabled(container)
}, [])
useSeedBlockNoteTableBridge(editor)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
useNoteWikilinkDrop({ containerRef, onInsertTarget: insertWikilink, vaultPath })
const {
getWikilinkItems,
getPersonMentionItems,

View File

@@ -1,19 +1,32 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS } from '../utils/noteListHelpers'
interface SortItem {
value: SortOption
label: string
}
const SORT_LABEL_KEYS = {
modified: 'noteList.sort.modified',
created: 'noteList.sort.created',
title: 'noteList.sort.title',
status: 'noteList.sort.status',
} satisfies Record<string, TranslationKey>
type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
function buildSortItems(customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label }))
function getLocalizedSortOptionLabel(option: SortOption, locale: AppLocale): string {
if (option.startsWith('property:')) return option.slice('property:'.length)
return translate(locale, SORT_LABEL_KEYS[option as keyof typeof SORT_LABEL_KEYS])
}
function buildSortItems(locale: AppLocale, customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value }) => ({ value, label: getLocalizedSortOptionLabel(value, locale) }))
const customItems = (customProperties ?? []).map((key) => ({
value: `property:${key}` as SortOption,
label: key,
@@ -148,6 +161,7 @@ function SortDropdownTrigger({
current,
groupLabel,
direction,
locale,
onToggle,
}: {
triggerRef: React.RefObject<HTMLButtonElement | null>
@@ -155,9 +169,11 @@ function SortDropdownTrigger({
current: SortOption
groupLabel: string
direction: SortDirection
locale: AppLocale
onToggle: () => void
}) {
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
const currentLabel = getLocalizedSortOptionLabel(current, locale)
return (
<button
@@ -168,13 +184,13 @@ function SortDropdownTrigger({
event.stopPropagation()
onToggle()
}}
title={`Sort by ${getSortOptionLabel(current)}`}
title={translate(locale, 'noteList.sort.by', { label: currentLabel })}
aria-haspopup="menu"
aria-expanded={open}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
<span className="text-[10px] font-medium">{currentLabel}</span>
</button>
)
}
@@ -186,6 +202,7 @@ function SortDropdownMenu({
direction,
sortItems,
sortButtonRefs,
locale,
onKeyDown,
onSelect,
}: {
@@ -195,6 +212,7 @@ function SortDropdownMenu({
direction: SortDirection
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
locale: AppLocale
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
}) {
@@ -206,7 +224,7 @@ function SortDropdownMenu({
return (
<div
role="menu"
aria-label={`Sort ${groupLabel}`}
aria-label={translate(locale, 'noteList.sort.menu', { label: groupLabel })}
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover p-1 shadow-md"
style={{ width: 170, maxHeight: 280, overflowY: 'auto' }}
onKeyDown={onKeyDown}
@@ -225,6 +243,7 @@ function SortDropdownMenu({
sortButtonRefs.current[index] = node
}}
showSeparator={hasCustom && index === builtInOptionCount}
locale={locale}
onSelect={onSelect}
/>
))}
@@ -232,14 +251,15 @@ function SortDropdownMenu({
)
}
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
export function SortDropdown({ groupLabel, current, direction, customProperties, locale = 'en', onChange }: {
groupLabel: string
current: SortOption
direction: SortDirection
customProperties?: string[]
locale?: AppLocale
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties])
const sortItems = useMemo(() => buildSortItems(locale, customProperties), [customProperties, locale])
const {
open,
setOpen,
@@ -263,6 +283,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
current={current}
groupLabel={groupLabel}
direction={direction}
locale={locale}
onToggle={() => setOpen((value) => !value)}
/>
<SortDropdownMenu
@@ -272,6 +293,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
direction={direction}
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
locale={locale}
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}
/>
@@ -279,7 +301,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
)
}
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: {
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, locale, onSelect }: {
index: number
groupLabel: string
value: SortOption
@@ -288,6 +310,7 @@ function SortRow({ index, groupLabel, value, label, current, direction, buttonRe
direction: SortDirection
buttonRef: (node: HTMLButtonElement | null) => void
showSeparator: boolean
locale: AppLocale
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
@@ -329,6 +352,7 @@ function SortRow({ index, groupLabel, value, label, current, direction, buttonRe
onSelect={onSelect}
icon={<ArrowUp size={12} />}
itemData={itemData}
locale={locale}
/>
<SortDirectionButton
value={value}
@@ -338,6 +362,7 @@ function SortRow({ index, groupLabel, value, label, current, direction, buttonRe
onSelect={onSelect}
icon={<ArrowDown size={12} />}
itemData={itemData}
locale={locale}
/>
</span>
</div>
@@ -353,6 +378,7 @@ function SortDirectionButton({
onSelect,
icon,
itemData,
locale,
}: {
value: SortOption
direction: SortDirection
@@ -361,6 +387,7 @@ function SortDirectionButton({
onSelect: (opt: SortOption, dir: SortDirection) => void
icon: React.ReactNode
itemData: Record<string, string>
locale: AppLocale
}) {
return (
<button
@@ -371,7 +398,7 @@ function SortDirectionButton({
onSelect(value, direction)
}}
data-testid={`sort-dir-${direction}-${value}`}
title={direction === 'asc' ? 'Ascending' : 'Descending'}
title={translate(locale, direction === 'asc' ? 'noteList.sort.ascending' : 'noteList.sort.descending')}
{...itemData}
>
{icon}

View File

@@ -21,6 +21,36 @@ const installedAiAgentsStatus = {
codex: { status: 'installed' as const, version: '0.37.0' },
}
const DEFAULT_WINDOW_WIDTH = 1280
function setWindowWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
})
}
function renderDenseStatusBar() {
return render(
<StatusBar
noteCount={100}
modifiedCount={5}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
onCommitPush={vi.fn()}
onClickPulse={vi.fn()}
onOpenFeedback={vi.fn()}
buildNumber="b281"
onCheckForUpdates={vi.fn()}
mcpStatus="not_installed"
claudeCodeStatus="missing"
/>
)
}
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
act(() => {
fireEvent.focus(trigger)
@@ -37,6 +67,7 @@ async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
describe('StatusBar', () => {
beforeEach(() => {
vi.clearAllMocks()
setWindowWidth(DEFAULT_WINDOW_WIDTH)
})
it('does not display the bottom-bar note count readout', () => {
@@ -100,6 +131,27 @@ describe('StatusBar', () => {
expect(screen.queryByLabelText('Notifications are coming soon')).not.toBeInTheDocument()
})
it('end-aligns the theme tooltip to keep it inside the right window edge', async () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
themeMode="light"
onToggleThemeMode={vi.fn()}
onOpenSettings={vi.fn()}
/>,
)
act(() => {
fireEvent.focus(screen.getByTestId('status-theme-mode'))
})
const tooltip = await screen.findByTestId('status-theme-mode-tooltip')
expect(tooltip).toHaveAttribute('data-align', 'end')
expect(tooltip).toHaveTextContent('Switch to dark mode')
})
it('calls onToggleThemeMode from the bottom bar', () => {
const onToggleThemeMode = vi.fn()
render(
@@ -308,6 +360,61 @@ describe('StatusBar', () => {
expect(screen.getByText('3')).toBeInTheDocument()
})
it('keeps the bottom bar compact and unwrapped at medium widths', () => {
setWindowWidth(980)
renderDenseStatusBar()
expect(screen.getByTestId('status-bar')).toHaveStyle({
flexWrap: 'nowrap',
height: '30px',
})
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.queryByText('Commit')).not.toBeInTheDocument()
expect(screen.queryByText('History')).not.toBeInTheDocument()
expect(screen.queryByText('Contribute')).not.toBeInTheDocument()
})
it('collapses status labels to icon-first controls at very narrow widths', () => {
setWindowWidth(880)
renderDenseStatusBar()
expect(screen.getByTestId('status-bar')).toHaveStyle({
flexWrap: 'nowrap',
height: '30px',
})
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.getByTestId('status-build-number')).toBeInTheDocument()
expect(screen.getByTestId('status-claude-code')).toBeInTheDocument()
expect(screen.queryByText('Commit')).not.toBeInTheDocument()
expect(screen.queryByText('History')).not.toBeInTheDocument()
expect(screen.queryByText('Contribute')).not.toBeInTheDocument()
expect(screen.queryByText('No remote')).not.toBeInTheDocument()
expect(screen.queryByText('MCP')).not.toBeInTheDocument()
expect(screen.queryByText('b281')).not.toBeInTheDocument()
expect(screen.queryByText('Claude Code missing')).not.toBeInTheDocument()
})
it('hides the active AI agent label in compact status layout', () => {
setWindowWidth(880)
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
aiAgentsStatus={installedAiAgentsStatus}
defaultAiAgent="claude_code"
/>
)
expect(screen.getByTestId('status-ai-agents')).toBeInTheDocument()
expect(screen.queryByText('Claude')).not.toBeInTheDocument()
})
it('does not show Changes badge when modifiedCount is 0', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
@@ -471,11 +578,46 @@ describe('StatusBar', () => {
expect(onClickPulse).toHaveBeenCalledOnce()
})
it('disables History badge when isGitVault is false', () => {
const onClickPulse = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault={false} onClickPulse={onClickPulse} />)
fireEvent.click(screen.getByTestId('status-pulse'))
expect(onClickPulse).not.toHaveBeenCalled()
it('replaces git controls with a missing-Git warning when isGitVault is false', () => {
render(
<StatusBar
noteCount={100}
modifiedCount={5}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
isGitVault={false}
onClickPulse={vi.fn()}
onCommitPush={vi.fn()}
/>
)
expect(screen.getByTestId('status-missing-git')).toBeInTheDocument()
expect(screen.getByText('Git disabled')).toBeInTheDocument()
expect(screen.queryByTestId('status-pulse')).not.toBeInTheDocument()
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
})
it('opens Git setup from the missing-Git warning with mouse and keyboard', () => {
const onInitializeGit = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
isGitVault={false}
onInitializeGit={onInitializeGit}
/>
)
const warning = screen.getByTestId('status-missing-git')
fireEvent.click(warning)
expect(onInitializeGit).toHaveBeenCalledOnce()
warning.focus()
fireEvent.keyDown(warning, { key: 'Enter' })
expect(onInitializeGit).toHaveBeenCalledTimes(2)
})
it('shows Commit button in status bar', () => {

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../hooks/useMcpStatus'
import type { ThemeMode } from '../lib/themeMode'
import type { AppLocale } from '../lib/i18n'
import type { GitRemoteStatus, SyncStatus } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
@@ -14,6 +15,45 @@ import type { VaultOption } from './status-bar/types'
export type { VaultOption } from './status-bar/types'
const COMPACT_STATUS_BAR_MAX_WIDTH = 1000
function getWindowWidth() {
return typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth
}
function getStatusBarLayout(windowWidth: number) {
const compact = windowWidth <= COMPACT_STATUS_BAR_MAX_WIDTH
return {
compact,
stacked: false,
}
}
function useStatusBarTicker() {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((tick) => tick + 1), 30_000)
return () => clearInterval(id)
}, [])
}
function useStatusBarLayout() {
const [windowWidth, setWindowWidth] = useState(() => getWindowWidth())
useEffect(() => {
if (typeof window === 'undefined') return
const handleResize = () => setWindowWidth(getWindowWidth())
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return getStatusBarLayout(windowWidth)
}
interface StatusBarProps {
noteCount: number
modifiedCount?: number
@@ -28,6 +68,7 @@ interface StatusBarProps {
onClickPending?: () => void
onClickPulse?: () => void
onCommitPush?: () => void
onInitializeGit?: () => void
isOffline?: boolean
isGitVault?: boolean
syncStatus?: SyncStatus
@@ -54,15 +95,19 @@ interface StatusBarProps {
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
locale?: AppLocale
}
export function StatusBar({
noteCount,
interface StatusBarFooterProps extends StatusBarProps {
compact: boolean
stacked: boolean
}
function StatusBarPrimaryFromFooter({
modifiedCount = 0,
vaultPath,
vaults,
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
@@ -70,8 +115,9 @@ export function StatusBar({
onClickPending,
onClickPulse,
onCommitPush,
onInitializeGit,
isOffline = false,
isGitVault = false,
isGitVault = true,
syncStatus = 'idle',
lastSyncTime = null,
conflictCount = 0,
@@ -79,11 +125,6 @@ export function StatusBar({
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
zoomLevel = 100,
themeMode = 'light',
onZoomReset,
onToggleThemeMode,
onOpenFeedback,
buildNumber,
onCheckForUpdates,
onRemoveVault,
@@ -96,76 +137,118 @@ export function StatusBar({
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
}: StatusBarProps) {
const [, setTick] = useState(0)
locale = 'en',
compact,
stacked,
}: StatusBarFooterProps) {
return (
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
onInitializeGit={onInitializeGit}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
locale={locale}
stacked={stacked}
compact={compact}
/>
)
}
useEffect(() => {
const id = setInterval(() => setTick((tick) => tick + 1), 30_000)
return () => clearInterval(id)
}, [])
function StatusBarSecondaryFromFooter({
noteCount,
zoomLevel = 100,
themeMode = 'light',
onZoomReset,
onToggleThemeMode,
onOpenFeedback,
onOpenSettings,
locale = 'en',
compact,
stacked,
}: StatusBarFooterProps) {
return (
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
locale={locale}
stacked={stacked}
compact={compact}
/>
)
}
function StatusBarFooter(props: StatusBarFooterProps) {
const { compact, stacked } = props
return (
<footer
data-testid="status-bar"
style={{
minHeight: 30,
height: stacked ? 'auto' : 30,
flexShrink: 0,
display: 'flex',
flexWrap: stacked ? 'wrap' : 'nowrap',
alignItems: stacked ? 'flex-start' : 'center',
justifyContent: stacked ? 'flex-start' : 'space-between',
rowGap: stacked ? 4 : 0,
columnGap: compact ? 8 : 12,
background: 'var(--sidebar)',
borderTop: '1px solid var(--border)',
padding: stacked ? '4px 8px' : '0 8px',
fontSize: 11,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
}}
>
<StatusBarPrimaryFromFooter {...props} />
<StatusBarSecondaryFromFooter {...props} />
</footer>
)
}
export function StatusBar(props: StatusBarProps) {
useStatusBarTicker()
const { compact, stacked } = useStatusBarLayout()
return (
<TooltipProvider>
<footer
style={{
height: 30,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--sidebar)',
borderTop: '1px solid var(--border)',
padding: '0 8px',
fontSize: 11,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
}}
>
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
/>
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>
</footer>
<StatusBarFooter {...props} compact={compact} stacked={stacked} />
</TooltipProvider>
)
}

View File

@@ -15,11 +15,11 @@ import {
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
import { translate, type AppLocale } from '../lib/i18n'
const TYPE_NONE = '__none__'
const MIN_POPOVER_WIDTH = 220
const OPEN_COMBOBOX_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', ' '])
const MISSING_TYPE_TOOLTIP = 'Missing type'
interface TypeSelectorItemProps {
type: string
@@ -85,12 +85,14 @@ function TypeSelectorValue({
isA,
typeColorKeys,
typeIconKeys,
locale,
}: {
isA?: string | null
typeColorKeys: Record<string, string | null>
typeIconKeys: Record<string, string | null>
locale: AppLocale
}) {
if (!isA) return <span className="truncate text-muted-foreground">None</span>
if (!isA) return <span className="truncate text-muted-foreground">{translate(locale, 'inspector.properties.none')}</span>
return (
<span className="flex min-w-0 items-center gap-1">
@@ -115,9 +117,11 @@ function TypeRowLabel() {
function MissingTypeWarning({
missingTypeName,
locale,
onCreateMissingType,
}: {
missingTypeName: string
locale: AppLocale
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
const [dialogOpen, setDialogOpen] = useState(false)
@@ -136,13 +140,13 @@ function MissingTypeWarning({
!canCreateMissingType && 'cursor-default',
)}
data-testid="missing-type-warning"
aria-label={`Missing type ${missingTypeName}. Click to create this type.`}
aria-label={translate(locale, 'inspector.properties.missingTypeAria', { type: missingTypeName })}
onClick={canCreateMissingType ? () => setDialogOpen(true) : undefined}
>
<WarningCircle size={14} weight="fill" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{MISSING_TYPE_TOOLTIP}</TooltipContent>
<TooltipContent>{translate(locale, 'inspector.properties.missingType')}</TooltipContent>
</Tooltip>
{canCreateMissingType && (
<CreateTypeDialog
@@ -159,10 +163,12 @@ function MissingTypeWarning({
function TypeRowValue({
children,
missingTypeName,
locale,
onCreateMissingType,
}: {
children: ReactNode
missingTypeName?: string | null
locale: AppLocale
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
return (
@@ -171,6 +177,7 @@ function TypeRowValue({
{missingTypeName && (
<MissingTypeWarning
missingTypeName={missingTypeName}
locale={locale}
onCreateMissingType={onCreateMissingType}
/>
)}
@@ -183,12 +190,14 @@ function ReadOnlyType({
customColorKey,
onNavigate,
missingTypeName,
locale,
onCreateMissingType,
}: {
isA?: string | null
customColorKey?: string | null
onNavigate?: (target: string) => void
missingTypeName?: string | null
locale: AppLocale
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
if (!isA) return null
@@ -198,7 +207,7 @@ function ReadOnlyType({
style={PROPERTY_PANEL_ROW_STYLE}
>
<TypeRowLabel />
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
<TypeRowValue missingTypeName={missingTypeName} locale={locale} onCreateMissingType={onCreateMissingType}>
{onNavigate ? (
<button
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
@@ -229,6 +238,7 @@ interface TypeSelectorProps {
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
locale?: AppLocale
}
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
@@ -239,6 +249,7 @@ export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps)
customColorKey={props.customColorKey}
onNavigate={props.onNavigate}
missingTypeName={props.missingTypeName}
locale={props.locale ?? 'en'}
onCreateMissingType={props.onCreateMissingType}
/>
)
@@ -254,6 +265,7 @@ function EditableTypeSelector({
typeColorKeys,
typeIconKeys,
missingTypeName,
locale = 'en',
onCreateMissingType,
onUpdateProperty,
}: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
@@ -380,7 +392,7 @@ function EditableTypeSelector({
data-testid="type-selector"
>
<TypeRowLabel />
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
<TypeRowValue missingTypeName={missingTypeName} locale={locale} onCreateMissingType={onCreateMissingType}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div ref={rootRef} className="min-w-0">
@@ -405,7 +417,7 @@ function EditableTypeSelector({
onKeyDown={handleTriggerKeyDown}
>
<span className="flex min-w-0 items-center gap-1 truncate">
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} locale={locale} />
</span>
<CaretUpDown size={14} aria-hidden="true" />
</Button>
@@ -424,9 +436,9 @@ function EditableTypeSelector({
<Input
ref={inputRef}
value={query}
placeholder="Search types..."
placeholder={translate(locale, 'inspector.properties.searchTypes')}
autoComplete="off"
aria-label="Search types"
aria-label={translate(locale, 'inspector.properties.searchTypes')}
className="h-8 text-sm"
data-testid="type-selector-search-input"
onChange={(event) => handleSearchChange(event.target.value)}
@@ -436,7 +448,7 @@ function EditableTypeSelector({
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
{options.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No matching types
{translate(locale, 'inspector.properties.noMatchingTypes')}
</div>
) : (
<div id={listboxId} role="listbox">
@@ -460,7 +472,7 @@ function EditableTypeSelector({
onClick={() => selectType(type)}
>
{type === TYPE_NONE ? (
<span className="truncate text-muted-foreground">None</span>
<span className="truncate text-muted-foreground">{translate(locale, 'inspector.properties.none')}</span>
) : (
<span className="flex min-w-0 items-center gap-2 truncate">
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />

View File

@@ -50,8 +50,8 @@ function makeReadyStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'rea
}
}
function renderBanner(status: UpdateStatus, actions = makeActions()) {
const view = render(<UpdateBanner status={status} actions={actions} />)
function renderBanner(status: UpdateStatus, actions = makeActions(), locale: 'en' | 'zh-Hans' = 'en') {
const view = render(<UpdateBanner status={status} actions={actions} locale={locale} />)
return { ...view, actions }
}
@@ -83,6 +83,19 @@ describe('UpdateBanner', () => {
expect(screen.getByTestId('update-dismiss')).toBeTruthy()
})
it('localizes available update copy', () => {
renderBanner(makeAvailableStatus({
version: '2026.4.16-alpha.3',
displayVersion: 'Alpha 2026.4.16.3',
}), makeActions(), 'zh-Hans')
expect(screen.getByText(/Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
expect(screen.getByText(/可用/)).toBeTruthy()
expect(screen.getByTestId('update-release-notes')).toHaveTextContent('发行说明')
expect(screen.getByTestId('update-now-btn')).toHaveTextContent('立即更新')
expect(screen.getByTestId('update-dismiss')).toHaveAttribute('aria-label', '关闭')
})
it('"Update Now" calls startDownload', () => {
const { actions } = renderBanner(makeAvailableStatus())

View File

@@ -3,10 +3,12 @@ import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
import { restartApp } from '../hooks/useUpdater'
import { Button } from './ui/button'
import { translate, type AppLocale } from '../lib/i18n'
interface UpdateBannerProps {
status: UpdateStatus
actions: UpdateActions
locale?: AppLocale
}
type VisibleUpdateStatus = Exclude<UpdateStatus, { state: 'idle' } | { state: 'error' }>
@@ -62,12 +64,12 @@ const readyIconStyle = {
flexShrink: 0,
} satisfies CSSProperties
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions) {
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions, locale: AppLocale) {
return (
<>
<Download size={14} style={iconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> is available
<strong>Tolaria {status.displayVersion}</strong> {translate(locale, 'update.available')}
</span>
<Button
type="button"
@@ -77,7 +79,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
onClick={actions.openReleaseNotes}
style={{ color: 'var(--text-inverse)', padding: 0, height: 'auto' }}
>
Release Notes <ExternalLink size={11} />
{translate(locale, 'update.releaseNotes')} <ExternalLink size={11} />
</Button>
<Button
type="button"
@@ -86,7 +88,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
onClick={actions.startDownload}
style={primaryActionStyle}
>
Update Now
{translate(locale, 'update.updateNow')}
</Button>
<Button
type="button"
@@ -95,7 +97,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
data-testid="update-dismiss"
onClick={actions.dismiss}
style={dismissButtonStyle}
aria-label="Dismiss"
aria-label={translate(locale, 'update.dismiss')}
>
<X size={14} />
</Button>
@@ -103,11 +105,11 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
)
}
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>) {
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>, locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
<span>Downloading Tolaria {status.displayVersion}...</span>
<span>{translate(locale, 'update.downloading', { version: status.displayVersion })}</span>
<div style={progressTrackStyle}>
<div
data-testid="update-progress"
@@ -125,12 +127,12 @@ function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state:
)
}
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>) {
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>, locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={readyIconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> is ready - restart to apply
<strong>Tolaria {status.displayVersion}</strong> {translate(locale, 'update.readyRestart')}
</span>
<Button
type="button"
@@ -143,25 +145,25 @@ function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready
color: 'var(--text-inverse)',
}}
>
Restart Now
{translate(locale, 'update.restartNow')}
</Button>
</>
)
}
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions) {
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions, locale: AppLocale) {
switch (status.state) {
case 'available':
return renderAvailableContent(status, actions)
return renderAvailableContent(status, actions, locale)
case 'downloading':
return renderDownloadingContent(status)
return renderDownloadingContent(status, locale)
case 'ready':
return renderReadyContent(status)
return renderReadyContent(status, locale)
}
}
export function UpdateBanner({ status, actions }: UpdateBannerProps) {
export function UpdateBanner({ status, actions, locale = 'en' }: UpdateBannerProps) {
if (status.state === 'idle' || status.state === 'error') return null
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions)}</div>
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions, locale)}</div>
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
act,
createEvent,
fireEvent,
render,
@@ -8,12 +9,39 @@ import {
waitFor,
} from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractDroppedPathText } from './inlineWikilinkDropText'
import {
UNSUPPORTED_INLINE_PASTE_MESSAGE,
} from './InlineWikilinkInput'
import { isInsertBeforeInput } from './inlineWikilinkBeforeInput'
import type { VaultEntry } from '../types'
type NativeDropPayload = {
type: string
paths: string[]
position: { x: number; y: number }
}
type NativeDropHandler = (event: { payload: NativeDropPayload }) => void
const nativeDropState = vi.hoisted(() => ({
tauriMode: false,
handlers: {} as Record<string, NativeDropHandler | undefined>,
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => nativeDropState.tauriMode,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
onDragDropEvent: vi.fn((handler: NativeDropHandler) => {
nativeDropState.handlers['native-drag-drop'] = handler
return Promise.resolve(() => {
delete nativeDropState.handlers['native-drag-drop']
})
}),
}),
}))
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -95,6 +123,7 @@ function setSelection(editor: HTMLElement, offset: number) {
function updateEditorText(text: string) {
const editor = screen.getByTestId('agent-input')
editor.focus()
fireEvent.focus(editor)
editor.textContent = text
setSelection(editor, text.length)
@@ -122,7 +151,70 @@ function fireComposingKeyDown(editor: HTMLElement, key: string) {
fireEvent(editor, event)
}
function createFileLikeDataTransfer({
plainText = '',
uriList = '',
}: {
plainText?: string
uriList?: string
}) {
return {
getData: vi.fn((type: string) => {
if (type === 'text/plain') return plainText
if (type === 'text/uri-list') return uriList
return ''
}),
files: [new File(['folder'], 'Projects')],
items: [{ kind: 'file', type: '' }],
}
}
function resetNativeDropState() {
nativeDropState.tauriMode = false
for (const eventName of Object.keys(nativeDropState.handlers)) {
delete nativeDropState.handlers[eventName]
}
}
function mockElementRect(element: HTMLElement) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 48,
width: 400,
height: 48,
toJSON: () => ({}),
}),
})
}
function emitNativePathDrop(paths: string[], position = { x: 20, y: 20 }) {
const handler = nativeDropState.handlers['native-drag-drop']
if (!handler) throw new Error('No native drop handler registered')
handler({
payload: {
type: 'drop',
paths,
position,
},
})
}
async function waitForNativePathDropListener() {
await waitFor(() => {
expect(nativeDropState.handlers['native-drag-drop']).toBeDefined()
})
}
describe('WikilinkChatInput', () => {
beforeEach(resetNativeDropState)
afterEach(resetNativeDropState)
it('renders the placeholder overlay for an empty draft', () => {
render(<Controlled placeholder="Ask something..." />)
expect(screen.getByText('Ask something...')).toBeInTheDocument()
@@ -214,6 +306,126 @@ describe('WikilinkChatInput', () => {
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Alpha')
})
it('clears IME-injected stray text nodes after composition ends', async () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement
initialEditor.focus()
fireEvent.compositionStart(initialEditor)
initialEditor.appendChild(document.createTextNode('你'))
fireEvent.input(initialEditor)
fireEvent.compositionEnd(initialEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenCalledWith('你')
})
await waitFor(() => {
const editor = screen.getByTestId('agent-input') as HTMLDivElement
expect(editor.textContent).toBe('你')
})
})
it('does not duplicate syllables across consecutive IME compositions', async () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const firstEditor = screen.getByTestId('agent-input') as HTMLDivElement
firstEditor.focus()
fireEvent.compositionStart(firstEditor)
firstEditor.appendChild(document.createTextNode('안'))
fireEvent.input(firstEditor)
fireEvent.compositionEnd(firstEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenLastCalledWith('안')
})
const secondEditor = screen.getByTestId('agent-input') as HTMLDivElement
secondEditor.focus()
fireEvent.compositionStart(secondEditor)
secondEditor.appendChild(document.createTextNode('녕'))
fireEvent.input(secondEditor)
fireEvent.compositionEnd(secondEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenLastCalledWith('안녕')
})
expect(screen.getByTestId('agent-input').textContent).toBe('안녕')
})
it('does not steal focus back if it was moved elsewhere during composition end', async () => {
const onDraftChange = vi.fn()
render(
<>
<Controlled onDraftChange={onDraftChange} />
<button data-testid="other-target">Other</button>
</>,
)
const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement
const otherTarget = screen.getByTestId('other-target') as HTMLButtonElement
initialEditor.focus()
fireEvent.compositionStart(initialEditor)
initialEditor.appendChild(document.createTextNode('你'))
fireEvent.input(initialEditor)
otherTarget.focus()
fireEvent.compositionEnd(initialEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenCalledWith('你')
})
expect(document.activeElement).toBe(otherTarget)
})
it('does not reset the DOM selection while IME composition is active', () => {
const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges')
render(<Controlled />)
const editor = screen.getByTestId('agent-input') as HTMLDivElement
editor.focus()
fireEvent.compositionStart(editor)
editor.appendChild(document.createTextNode('ni'))
setSelection(editor, 2)
removeAllRanges.mockClear()
fireEvent.keyUp(editor)
fireEvent.click(editor)
fireEvent.mouseUp(editor)
expect(removeAllRanges).not.toHaveBeenCalled()
expect(editor.textContent).toContain('ni')
removeAllRanges.mockRestore()
})
it('lets committed composed characters reach the native input pipeline once', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const editor = screen.getByTestId('agent-input')
editor.focus()
const portugueseText = 'á é í ç ã õ'
const accentedKey = createEvent.keyDown(editor, { key: 'á' })
fireEvent(editor, accentedKey)
expect(accentedKey.defaultPrevented).toBe(false)
editor.textContent = portugueseText
setSelection(editor, portugueseText.length)
fireEvent.input(editor)
expect(onDraftChange).toHaveBeenLastCalledWith(portugueseText)
const cjkKey = createEvent.keyDown(editor, { key: '你' })
fireEvent(editor, cjkKey)
expect(cjkKey.defaultPrevented).toBe(false)
editor.textContent = `${portugueseText}`
setSelection(editor, portugueseText.length + 1)
fireEvent.input(editor)
expect(onDraftChange).toHaveBeenLastCalledWith(`${portugueseText}`)
})
it('deletes an inline chip with a single Backspace', () => {
render(<Controlled />)
updateEditorText('edit my [[alp')
@@ -274,6 +486,66 @@ describe('WikilinkChatInput', () => {
expect(editor.textContent).toContain('still works')
})
it('extracts dropped folder paths from text/plain payloads', () => {
expect(extractDroppedPathText(
createFileLikeDataTransfer({
plainText: '/Users/test/Projects',
}) as DataTransfer,
)).toBe('/Users/test/Projects')
})
it('falls back to file URLs exposed through uri lists', () => {
expect(extractDroppedPathText(
createFileLikeDataTransfer({
uriList: 'file:///Users/test/My%20Folder',
}) as DataTransfer,
)).toBe('"/Users/test/My Folder"')
})
it('inserts Tauri native folder drops into the AI composer', async () => {
nativeDropState.tauriMode = true
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const editor = screen.getByTestId('agent-input') as HTMLDivElement
mockElementRect(editor)
await waitForNativePathDropListener()
updateEditorText('Open ')
editor.focus()
onDraftChange.mockClear()
act(() => {
emitNativePathDrop(['/Users/test/My Folder'])
})
await waitFor(() => {
expect(onDraftChange).toHaveBeenCalledWith('Open "/Users/test/My Folder"')
})
await waitFor(() => {
expect(screen.getByTestId('agent-input').textContent).toContain('/Users/test/My Folder')
})
})
it('accepts Tauri native folder drops for the focused AI composer even when native coordinates miss', async () => {
nativeDropState.tauriMode = true
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const editor = screen.getByTestId('agent-input') as HTMLDivElement
mockElementRect(editor)
await waitForNativePathDropListener()
updateEditorText('Open ')
onDraftChange.mockClear()
act(() => {
emitNativePathDrop(['/Users/test/Projects'], { x: 900, y: 900 })
})
await waitFor(() => {
expect(onDraftChange).toHaveBeenCalledWith('Open /Users/test/Projects')
})
})
it('treats missing inputType as a non-insert beforeinput event', () => {
expect(() => isInsertBeforeInput({} as InputEvent)).not.toThrow()
expect(isInsertBeforeInput({} as InputEvent)).toBe(false)

View File

@@ -0,0 +1,45 @@
interface CursorTargetBlockLike {
id: string
content?: unknown
}
function blockSupportsTextCursor(block: CursorTargetBlockLike | undefined): block is CursorTargetBlockLike {
return Array.isArray(block?.content)
}
export function findNearestTextCursorBlock(
blocks: CursorTargetBlockLike[],
targetIndex: number,
): CursorTargetBlockLike | null {
if (blocks.length === 0) return null
const clampedTargetIndex = Math.min(Math.max(targetIndex, 0), blocks.length - 1)
const targetBlock = blocks[clampedTargetIndex]
if (blockSupportsTextCursor(targetBlock)) {
return targetBlock
}
for (let distance = 1; distance < blocks.length; distance += 1) {
const forwardBlock = blocks[clampedTargetIndex + distance]
if (blockSupportsTextCursor(forwardBlock)) {
return forwardBlock
}
const backwardBlock = blocks[clampedTargetIndex - distance]
if (blockSupportsTextCursor(backwardBlock)) {
return backwardBlock
}
}
return null
}
export function findNearestTextCursorBlockById(
blocks: CursorTargetBlockLike[],
targetBlockId: string,
): CursorTargetBlockLike | null {
const targetIndex = blocks.findIndex((block) => block.id === targetBlockId)
if (targetIndex === -1) return null
return findNearestTextCursorBlock(blocks, targetIndex)
}

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { EditorContentLayout } from './EditorContentLayout'
vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) => <div data-testid="breadcrumb-bar" data-note-width={noteWidth} />,
}))
vi.mock('../ArchivedNoteBanner', () => ({
@@ -63,6 +63,8 @@ function createModel(overrides: Record<string, unknown> = {}) {
onEditorChange: vi.fn(),
isDeletedPreview: false,
rawLatestContentRef: { current: null },
noteWidth: 'normal',
onToggleNoteWidth: vi.fn(),
forceRawMode: false,
showAIChat: false,
onToggleAIChat: vi.fn(),
@@ -99,4 +101,11 @@ describe('EditorContentLayout', () => {
expect(container.querySelector('.animate-pulse')).not.toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
})
it('marks the editor content root and breadcrumb with the note width preference', () => {
const { container } = render(<EditorContentLayout {...createModel({ noteWidth: 'wide' })} />)
expect(container.firstElementChild).toHaveClass('editor-content-width--wide')
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide')
})
})

View File

@@ -1,4 +1,6 @@
import type React from 'react'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../../lib/i18n'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
@@ -24,10 +26,14 @@ type BreadcrumbActions = Pick<
| 'showDiffToggle'
| 'onToggleFavorite'
| 'onToggleOrganized'
| 'onRevealFile'
| 'onCopyFilePath'
| 'onDeleteNote'
| 'onArchiveNote'
| 'onUnarchiveNote'
| 'onRenameFilename'
| 'noteWidth'
| 'onToggleNoteWidth'
>
function EditorLoadingSkeleton() {
@@ -42,16 +48,18 @@ function EditorLoadingSkeleton() {
)
}
function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | null; onToggleDiff: () => void }) {
function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffContent: string | null; locale?: AppLocale; onToggleDiff: () => void }) {
const label = translate(locale, 'editor.toolbar.rawReturn')
return (
<div className="flex-1 overflow-auto">
<button
className="flex items-center gap-1.5 px-4 py-2 text-xs text-primary bg-muted border-b border-border cursor-pointer hover:bg-accent transition-colors w-full border-t-0 border-l-0 border-r-0"
onClick={onToggleDiff}
title="Back to editor"
title={label}
>
<span style={{ fontSize: 14, lineHeight: 1 }}>&larr;</span>
Back to editor
{label}
</button>
<DiffView diff={diffContent ?? ''} />
</div>
@@ -67,25 +75,32 @@ function RawModeEditorSection({
onSave,
rawLatestContentRef,
vaultPath,
locale,
}: Pick<
EditorContentModel,
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
> & {
rawMode: boolean
locale?: AppLocale
}) {
if (!rawMode || !activeTab) return null
return (
<RawEditorView
key={activeTab.entry.path}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onRawContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
/>
<div className="editor-scroll-area">
<div className="editor-content-wrapper editor-content-wrapper--raw">
<RawEditorView
key={activeTab.entry.path}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onRawContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
</div>
</div>
)
}
@@ -99,12 +114,14 @@ function ActiveTabBreadcrumb({
wordCount,
path,
actions,
locale,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
wordCount: number
path: string
actions: BreadcrumbActions
locale?: AppLocale
}) {
return (
<BreadcrumbBar
@@ -124,10 +141,15 @@ function ActiveTabBreadcrumb({
onToggleInspector={actions.onToggleInspector}
onToggleFavorite={bindPath(actions.onToggleFavorite, path)}
onToggleOrganized={bindPath(actions.onToggleOrganized, path)}
onRevealFile={actions.onRevealFile}
onCopyFilePath={actions.onCopyFilePath}
onDelete={bindPath(actions.onDeleteNote, path)}
onArchive={bindPath(actions.onArchiveNote, path)}
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
onRenameFilename={actions.onRenameFilename}
noteWidth={actions.noteWidth}
onToggleNoteWidth={actions.onToggleNoteWidth}
locale={locale}
/>
)
}
@@ -142,22 +164,24 @@ function EditorChrome({
diffMode,
diffContent,
onToggleDiff,
locale,
}: Pick<
EditorContentModel,
'isArchived' | 'onUnarchiveNote' | 'path' | 'isConflicted' | 'onKeepMine' | 'onKeepTheirs' | 'diffMode' | 'diffContent' | 'onToggleDiff'
'isArchived' | 'onUnarchiveNote' | 'path' | 'isConflicted' | 'onKeepMine' | 'onKeepTheirs' | 'diffMode' | 'diffContent' | 'onToggleDiff' | 'locale'
>) {
return (
<>
{isArchived && onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} />
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} locale={locale} />
)}
{isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(path)}
onKeepTheirs={() => onKeepTheirs?.(path)}
locale={locale}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
{diffMode && <DiffModeView diffContent={diffContent} locale={locale} onToggleDiff={onToggleDiff} />}
</>
)
}
@@ -228,23 +252,30 @@ export function EditorContentLayout(model: EditorContentModel) {
isDeletedPreview,
rawLatestContentRef,
rawModeContent,
noteWidth,
locale,
} = model
const rootClassName = cn(
'flex flex-1 flex-col min-w-0 min-h-0',
noteWidth === 'wide' ? 'editor-content-width--wide' : 'editor-content-width--normal',
)
if (!activeTab) {
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
<div className={rootClassName}>
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
<div className={rootClassName}>
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
locale={locale}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
@@ -259,10 +290,14 @@ export function EditorContentLayout(model: EditorContentModel) {
showDiffToggle: model.showDiffToggle,
onToggleFavorite: model.onToggleFavorite,
onToggleOrganized: model.onToggleOrganized,
onRevealFile: model.onRevealFile,
onCopyFilePath: model.onCopyFilePath,
onDeleteNote: model.onDeleteNote,
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
onRenameFilename: model.onRenameFilename,
noteWidth: model.noteWidth,
onToggleNoteWidth: model.onToggleNoteWidth,
}}
/>
<EditorChrome
@@ -275,6 +310,7 @@ export function EditorContentLayout(model: EditorContentModel) {
diffMode={diffMode}
diffContent={diffContent}
onToggleDiff={onToggleDiff}
locale={locale}
/>
<RawModeEditorSection
activeTab={activeTab}
@@ -285,6 +321,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
<EditorCanvas
showEditor={showEditor}

View File

@@ -1,7 +1,8 @@
import type React from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { NoteStatus, VaultEntry } from '../../types'
import type { AppLocale } from '../../lib/i18n'
import type { NoteStatus, NoteWidthMode, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { deriveEditorContentState } from './editorContentState'
@@ -33,6 +34,8 @@ export interface EditorContentProps {
onEditorChange?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
@@ -40,9 +43,12 @@ export interface EditorContentProps {
rawModeContent?: string | null
rawLatestContentRef?: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
locale?: AppLocale
}
export function useEditorContentModel(props: EditorContentProps) {

View File

@@ -12,13 +12,14 @@ import {
interface MockBlock {
id: string
markdown: string
content?: unknown
}
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
const blocks: MockBlock[] = [
{ id: 'title', markdown: '# Title' },
{ id: 'details', markdown: 'Paragraph one' },
{ id: 'tail', markdown: '## Tail' },
{ id: 'title', markdown: '# Title', content: [] },
{ id: 'details', markdown: 'Paragraph one', content: [] },
{ id: 'tail', markdown: '## Tail', content: [] },
]
function makeEditor(blocks: MockBlock[]): BlockNotePositionEditor {
@@ -153,4 +154,36 @@ describe('editorModePosition', () => {
expect(editor.setSelection).toHaveBeenCalledWith('title', 'tail')
expect(editor.focus).toHaveBeenCalled()
})
it('falls back to the nearest content block when raw restore lands on media', () => {
const contentWithMedia = '---\ntitle: Demo\n---\n# Title\n\n![media](media.png)\n\nParagraph tail'
const mediaBlocks: MockBlock[] = [
{ id: 'title', markdown: '# Title', content: [] },
{ id: 'image', markdown: '![media](media.png)' },
{ id: 'tail', markdown: 'Paragraph tail', content: [] },
]
const editor = makeEditor(mediaBlocks)
const view: CodeMirrorViewLike = {
state: {
doc: { toString: () => contentWithMedia },
selection: {
main: {
anchor: contentWithMedia.indexOf('![media]') + 3,
head: contentWithMedia.indexOf('![media]') + 3,
},
},
},
scrollDOM: { scrollTop: 48 },
dispatch: vi.fn(),
focus: vi.fn(),
}
installRawView(view)
const snapshot = captureRawEditorPositionSnapshot(document)
const restored = restoreBlockNoteView(editor, snapshot!, document)
expect(restored).toBe(true)
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('tail', 'end')
expect(editor.focus).toHaveBeenCalled()
})
})

View File

@@ -1,8 +1,11 @@
import { compactMarkdown } from '../utils/compact-markdown'
import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks'
import { serializeMathAwareBlocks } from '../utils/mathMarkdown'
import { findNearestTextCursorBlockById } from './blockNoteCursorTarget'
interface BlockLike {
id: string
content?: unknown
}
interface BlockSelectionLike {
@@ -126,11 +129,11 @@ function getLineIndexFromRatio({ totalLines, ratio }: { totalLines: number; rati
}
function serializeBlock(editor: BlockNotePositionEditor, block: BlockLike): string {
return compactMarkdown(editor.blocksToMarkdownLossy(restoreWikilinksInBlocks([block])))
return compactMarkdown(serializeMathAwareBlocks(editor, restoreWikilinksInBlocks([block])))
}
function serializeEditorBody(editor: BlockNotePositionEditor): string {
return compactMarkdown(editor.blocksToMarkdownLossy(restoreWikilinksInBlocks(editor.document)))
return compactMarkdown(serializeMathAwareBlocks(editor, restoreWikilinksInBlocks(editor.document)))
}
function buildBlockLineRanges({
@@ -234,9 +237,19 @@ function buildBlockNoteRestoreState(
const headIndex = findNearestBlockIndex({ ranges, targetLine: headLine })
const startIndex = Math.min(anchorIndex, headIndex)
const endIndex = Math.max(anchorIndex, headIndex)
const startBlockId = findNearestTextCursorBlockById(
editor.document,
editor.document[startIndex].id,
)?.id
const endBlockId = findNearestTextCursorBlockById(
editor.document,
editor.document[endIndex].id,
)?.id
if (!startBlockId || !endBlockId) return null
return {
startBlockId: editor.document[startIndex].id,
endBlockId: editor.document[endIndex].id,
startBlockId,
endBlockId,
}
}
@@ -337,10 +350,14 @@ export function restoreBlockNoteView(
const state = buildBlockNoteRestoreState(editor, snapshot)
if (!state) return false
if (state.startBlockId === state.endBlockId) {
editor.setTextCursorPosition(state.endBlockId, 'end')
} else {
editor.setSelection(state.startBlockId, state.endBlockId)
try {
if (state.startBlockId === state.endBlockId) {
editor.setTextCursorPosition(state.endBlockId, 'end')
} else {
editor.setSelection(state.startBlockId, state.endBlockId)
}
} catch {
return false
}
editor.focus()
documentObject

View File

@@ -2,6 +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 { serializeMathAwareBlocks } from '../utils/mathMarkdown'
import { portableImageUrls } from '../utils/vaultImages'
interface Tab {
@@ -29,7 +30,7 @@ export function serializeEditorDocumentToMarkdown(
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const rawBodyMarkdown = compactMarkdown(serializeMathAwareBlocks(editor, restored))
const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`

View File

@@ -1,9 +1,10 @@
/* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */
import { createCodeBlockSpec, BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core'
import { codeBlockOptions } from '@blocknote/code-block'
import { createReactInlineContentSpec } from '@blocknote/react'
import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/react'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown'
import type { VaultEntry } from '../types'
import { NoteTitleIcon } from './NoteTitleIcon'
@@ -57,18 +58,67 @@ export const WikiLink = createReactInlineContentSpec(
}
)
function MathRender({ latex, displayMode }: { latex: string; displayMode: boolean }) {
const source = displayMode ? `$$\n${latex}\n$$` : `$${latex}$`
return (
<span
aria-label={`Math: ${latex}`}
className={displayMode ? 'math math--block' : 'math math--inline'}
data-latex={latex}
role="img"
title={source}
dangerouslySetInnerHTML={{ __html: renderMathToHtml({ latex, displayMode }) }}
/>
)
}
export const MathInline = createReactInlineContentSpec(
{
type: MATH_INLINE_TYPE,
propSchema: {
latex: { default: '' },
},
content: 'none',
},
{
render: (props) => (
<MathRender latex={props.inlineContent.props.latex} displayMode={false} />
),
},
)
const MathBlock = createReactBlockSpec(
{
type: MATH_BLOCK_TYPE,
propSchema: {
latex: { default: '' },
},
content: 'none',
},
{
render: (props) => (
<div className="math-block-shell">
<MathRender latex={props.block.props.latex} displayMode />
</div>
),
},
)
const codeBlock = createCodeBlockSpec({
...codeBlockOptions,
defaultLanguage: 'text',
})
const mathBlock = MathBlock()
export const schema = BlockNoteSchema.create({
inlineContentSpecs: {
...defaultInlineContentSpecs,
wikilink: WikiLink,
mathInline: MathInline,
},
}).extend({
blockSpecs: {
codeBlock,
mathBlock,
},
})

View File

@@ -1,6 +1,7 @@
import type { RefObject } from 'react'
import { PencilSimple, Trash } from '@phosphor-icons/react'
import { ClipboardText, FolderOpen, PencilSimple, Trash } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
export interface FolderContextMenuState {
path: string
@@ -12,14 +13,20 @@ interface FolderContextMenuProps {
menu: FolderContextMenuState | null
menuRef: RefObject<HTMLDivElement | null>
onDelete?: (folderPath: string) => void
onReveal?: (folderPath: string) => void
onCopyPath?: (folderPath: string) => void
onRename: (folderPath: string) => void
locale?: AppLocale
}
export function FolderContextMenu({
menu,
menuRef,
onDelete,
onReveal,
onCopyPath,
onRename,
locale = 'en',
}: FolderContextMenuProps) {
if (!menu) return null
@@ -30,6 +37,30 @@ export function FolderContextMenu({
style={{ left: menu.x, top: menu.y, minWidth: 180 }}
data-testid="folder-context-menu"
>
{onReveal && (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onReveal(menu.path)}
data-testid="reveal-folder-menu-item"
>
<FolderOpen size={14} />
{translate(locale, 'sidebar.action.revealFolderMenu')}
</Button>
)}
{onCopyPath && (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onCopyPath(menu.path)}
data-testid="copy-folder-path-menu-item"
>
<ClipboardText size={14} />
{translate(locale, 'sidebar.action.copyFolderPathMenu')}
</Button>
)}
<Button
type="button"
variant="ghost"
@@ -37,7 +68,7 @@ export function FolderContextMenu({
onClick={() => onRename(menu.path)}
>
<PencilSimple size={14} />
Rename folder
{translate(locale, 'sidebar.action.renameFolderMenu')}
</Button>
<Button
type="button"
@@ -47,7 +78,7 @@ export function FolderContextMenu({
data-testid="delete-folder-menu-item"
>
<Trash size={14} />
Delete folder
{translate(locale, 'sidebar.action.deleteFolderMenu')}
</Button>
</div>
)

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode } from '../../types'
import { useFolderRowInteractions } from './useFolderRowInteractions'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderItemRowProps {
contentInset: number
@@ -23,6 +24,7 @@ interface FolderItemRowProps {
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
locale?: AppLocale
}
export function FolderItemRow({
@@ -36,9 +38,10 @@ export function FolderItemRow({
onSelect,
onStartRenameFolder,
onToggle,
locale = 'en',
}: FolderItemRowProps) {
const hasChildren = node.children.length > 0
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
const expandLabel = translate(locale, isExpanded ? 'sidebar.folder.collapse' : 'sidebar.folder.expand', { name: node.name })
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
hasChildren,
@@ -81,9 +84,9 @@ export function FolderItemRow({
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onStartRenameFolder && (
<FolderActionButton
ariaLabel={`Rename ${node.name}`}
ariaLabel={translate(locale, 'sidebar.action.renameFolder')}
testId={`rename-folder-btn:${node.path}`}
title="Rename folder"
title={translate(locale, 'sidebar.action.renameFolder')}
onClick={() => {
onSelect()
onStartRenameFolder(node.path)
@@ -94,9 +97,9 @@ export function FolderItemRow({
)}
{onDeleteFolder && (
<FolderActionButton
ariaLabel={`Delete ${node.name}`}
ariaLabel={translate(locale, 'sidebar.action.deleteFolder')}
testId={`delete-folder-btn:${node.path}`}
title="Delete folder"
title={translate(locale, 'sidebar.action.deleteFolder')}
destructive
onClick={() => {
onSelect()

View File

@@ -4,6 +4,7 @@ import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
import { FolderNameInput } from './FolderNameInput'
import { FolderItemRow } from './FolderItemRow'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderTreeRowProps {
depth: number
@@ -16,6 +17,7 @@ interface FolderTreeRowProps {
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
onCancelRenameFolder?: () => void
locale?: AppLocale
renamingFolderPath?: string | null
selection: SidebarSelection
}
@@ -24,21 +26,23 @@ function FolderRenameRow({
contentInset,
depthIndent,
node,
locale,
onCancelRenameFolder,
onRenameFolder,
}: {
contentInset: number
depthIndent: number
node: FolderNode
locale: AppLocale
onCancelRenameFolder: () => void
onRenameFolder: (folderPath: string, nextName: string) => Promise<boolean> | boolean
}) {
return (
<div style={{ paddingLeft: depthIndent }}>
<FolderNameInput
ariaLabel="Folder name"
ariaLabel={translate(locale, 'sidebar.folder.name')}
initialValue={node.name}
placeholder="Folder name"
placeholder={translate(locale, 'sidebar.folder.name')}
leftInset={contentInset}
selectTextOnFocus={true}
testId="rename-folder-input"
@@ -60,6 +64,7 @@ function FolderChildren({
onStartRenameFolder,
onToggle,
onCancelRenameFolder,
locale,
renamingFolderPath,
selection,
}: FolderTreeRowProps) {
@@ -86,6 +91,7 @@ function FolderChildren({
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
@@ -105,6 +111,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onStartRenameFolder,
onToggle,
onCancelRenameFolder,
locale = 'en',
renamingFolderPath,
selection,
}: FolderTreeRowProps) {
@@ -129,6 +136,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onSelect={selectFolder}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
locale={locale}
/>
)
@@ -139,6 +147,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
contentInset={contentInset}
depthIndent={depthIndent}
node={node}
locale={locale}
onCancelRenameFolder={onCancelRenameFolder}
onRenameFolder={onRenameFolder}
/>
@@ -163,6 +172,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>

View File

@@ -1,21 +1,19 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type RefObject } from 'react'
import type { FolderNode } from '../../types'
import type { FolderFileActions } from '../../hooks/useFileActions'
import type { FolderContextMenuState } from './FolderContextMenu'
interface UseFolderContextMenuInput {
onDeleteFolder?: (folderPath: string) => void
folderFileActions?: FolderFileActions
onStartRenameFolder?: (folderPath: string) => void
}
export function useFolderContextMenu({
onDeleteFolder,
onStartRenameFolder,
}: UseFolderContextMenuInput) {
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
function useContextMenuDismiss(
contextMenu: FolderContextMenuState | null,
menuRef: RefObject<HTMLDivElement | null>,
closeContextMenu: () => void,
) {
useEffect(() => {
if (!contextMenu) return
@@ -32,7 +30,19 @@ export function useFolderContextMenu({
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleEscape)
}
}, [closeContextMenu, contextMenu])
}, [closeContextMenu, contextMenu, menuRef])
}
export function useFolderContextMenu({
onDeleteFolder,
folderFileActions,
onStartRenameFolder,
}: UseFolderContextMenuInput) {
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
useContextMenuDismiss(contextMenu, menuRef, closeContextMenu)
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault()
@@ -54,11 +64,23 @@ export function useFolderContextMenu({
onDeleteFolder?.(folderPath)
}, [closeContextMenu, onDeleteFolder])
const handleRevealFromMenu = useCallback((folderPath: string) => {
closeContextMenu()
folderFileActions?.revealFolder(folderPath)
}, [closeContextMenu, folderFileActions])
const handleCopyPathFromMenu = useCallback((folderPath: string) => {
closeContextMenu()
folderFileActions?.copyFolderPath(folderPath)
}, [closeContextMenu, folderFileActions])
return {
closeContextMenu,
contextMenu,
handleCopyPathFromMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRevealFromMenu,
handleRenameFromMenu,
menuRef,
}

View File

@@ -2,3 +2,12 @@ export function isInsertBeforeInput(nativeEvent: Pick<InputEvent, 'inputType'>)
return typeof nativeEvent.inputType === 'string'
&& nativeEvent.inputType.startsWith('insert')
}
export function isPlainTextBeforeInput(
nativeEvent: Pick<InputEvent, 'data' | 'inputType' | 'isComposing'>,
): nativeEvent is Pick<InputEvent, 'inputType' | 'isComposing'> & { data: string } {
return nativeEvent.inputType === 'insertText'
&& !nativeEvent.isComposing
&& typeof nativeEvent.data === 'string'
&& nativeEvent.data.length > 0
}

View File

@@ -0,0 +1,71 @@
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
function normalizeDroppedFileUrl(value: string): string {
if (!value.startsWith('file://')) return value
try {
const parsed = new URL(value)
if (parsed.protocol !== 'file:') return value
const decodedPath = decodeURIComponent(parsed.pathname)
if (parsed.hostname) return `//${parsed.hostname}${decodedPath}`
if (/^\/[A-Za-z]:/.test(decodedPath)) return decodedPath.slice(1)
return decodedPath
} catch {
return value
}
}
function formatDroppedPath(path: string): string {
return /\s/.test(path) ? JSON.stringify(path) : path
}
export function formatDroppedPathList(paths: string[]): string | null {
const cleanedPaths = paths
.map((path) => normalizeDroppedFileUrl(path.trim()))
.filter(Boolean)
if (cleanedPaths.length === 0) return null
return cleanedPaths.map(formatDroppedPath).join(' ')
}
function normalizeDroppedTransferText(rawText: string): string | null {
const trimmedText = normalizeInlineWikilinkValue(rawText).trim()
if (!trimmedText) return null
const lines = trimmedText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (lines.length > 0 && lines.every((line) => line.startsWith('file://'))) {
return formatDroppedPathList(lines)
}
return trimmedText
}
export function extractDroppedPathText(dataTransfer: DataTransfer): string | null {
const plainText = normalizeDroppedTransferText(dataTransfer.getData('text/plain'))
if (plainText) return plainText
const uriPaths = dataTransfer.getData('text/uri-list')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map(normalizeDroppedFileUrl)
const uriPathText = formatDroppedPathList(uriPaths)
if (uriPathText) return uriPathText
const filePaths = Array.from(dataTransfer.files)
.map((file) => (typeof (file as File & { path?: string }).path === 'string'
? (file as File & { path?: string }).path!.trim()
: ''))
.filter(Boolean)
const filePathText = formatDroppedPathList(filePaths)
if (filePathText) return filePathText
return null
}

View File

@@ -14,6 +14,14 @@ function collapseSelection(index: number): InlineSelectionRange {
return { start: index, end: index }
}
export function selectedInlineText(
value: string,
selection: InlineSelectionRange,
): string {
const normalizedSelection = normalizeSelectionRange(selection, value.length)
return value.slice(normalizedSelection.start, normalizedSelection.end)
}
export function replaceInlineSelection(
value: string,
selection: InlineSelectionRange,

View File

@@ -77,26 +77,6 @@ function handleDeleteKeys({
return false
}
interface HandleInsertTextArgs {
event: React.KeyboardEvent<HTMLDivElement>
isComposing: boolean
onInsertText: (text: string) => void
}
function handleInsertText({
event,
isComposing,
onInsertText,
}: HandleInsertTextArgs): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return false
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
if (event.key.length !== 1) return false
event.preventDefault()
onInsertText(event.key)
return true
}
interface HandleSubmitKeyArgs {
event: React.KeyboardEvent<HTMLDivElement>
isComposing: boolean
@@ -127,7 +107,6 @@ interface HandleInlineWikilinkKeyDownArgs {
onCycleSuggestions: (direction: 1 | -1) => void
onSelectSuggestion: () => void
onDeleteContent: (direction: 'backward' | 'forward') => void
onInsertText: (text: string) => void
canSubmit: boolean
onSubmit: () => void
}
@@ -140,7 +119,6 @@ export function handleInlineWikilinkKeyDown({
onCycleSuggestions,
onSelectSuggestion,
onDeleteContent,
onInsertText,
canSubmit,
onSubmit,
}: HandleInlineWikilinkKeyDownArgs) {
@@ -160,9 +138,5 @@ export function handleInlineWikilinkKeyDown({
return
}
if (handleInsertText({ event, isComposing, onInsertText })) {
return
}
handleSubmitKey({ event, isComposing, canSubmit, onSubmit })
}

View File

@@ -1,8 +1,10 @@
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { useDragRegion } from '../../hooks/useDragRegion'
import { translate, type AppLocale } from '../../lib/i18n'
export function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
export function InspectorHeader({ collapsed, locale = 'en', onToggle }: { collapsed: boolean; locale?: AppLocale; onToggle: () => void }) {
const { onMouseDown } = useDragRegion()
const propertiesTitle = translate(locale, 'inspector.title.properties')
return (
<div
@@ -14,18 +16,18 @@ export function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; o
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title="Properties (⌘⇧I)"
title={translate(locale, 'inspector.title.propertiesShortcut')}
>
<SlidersHorizontal size={16} />
</button>
) : (
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>Properties</span>
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title="Close Properties (⌘⇧I)"
title={translate(locale, 'inspector.title.closePropertiesShortcut')}
>
<X size={16} />
</button>
@@ -35,36 +37,36 @@ export function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; o
)
}
export function EmptyInspector() {
return <div><p className="m-0 text-[13px] text-muted-foreground">No note selected</p></div>
export function EmptyInspector({ locale = 'en' }: { locale?: AppLocale }) {
return <div><p className="m-0 text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.noNoteSelected')}</p></div>
}
export function InitializePropertiesPrompt({ onClick }: { onClick: () => void }) {
export function InitializePropertiesPrompt({ locale = 'en', onClick }: { locale?: AppLocale; onClick: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border px-4 py-6">
<Sparkle size={24} className="text-muted-foreground" />
<p className="m-0 text-center text-[13px] text-muted-foreground">This note has no properties yet</p>
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.noProperties')}</p>
<button
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onClick}
>
Initialize properties
{translate(locale, 'inspector.empty.initializeProperties')}
</button>
</div>
)
}
export function InvalidFrontmatterNotice({ onFix }: { onFix: () => void }) {
export function InvalidFrontmatterNotice({ locale = 'en', onFix }: { locale?: AppLocale; onFix: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6">
<WarningCircle size={24} className="text-destructive" />
<p className="m-0 text-center text-[13px] text-muted-foreground">Invalid properties</p>
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.invalidProperties')}</p>
<button
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onFix}
>
<PencilSimple size={14} />
Fix in editor
{translate(locale, 'inspector.empty.fixInEditor')}
</button>
</div>
)

View File

@@ -1,11 +1,16 @@
import type { VaultEntry } from '../../types'
import { Info } from '@phosphor-icons/react'
import { countWords } from '../../utils/wikilinks'
import { translate, type AppLocale } from '../../lib/i18n'
function formatDate(timestamp: number | null): string {
function dateLocale(locale: AppLocale): string {
return locale === 'zh-Hans' ? 'zh-CN' : 'en-US'
}
function formatDate(timestamp: number | null, locale: AppLocale): string {
if (!timestamp) return '\u2014'
const d = new Date(timestamp * 1000)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
return d.toLocaleDateString(dateLocale(locale), { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatFileSize(bytes: number): string {
@@ -25,19 +30,19 @@ function InfoRow({ label, value }: { label: string; value: string }) {
)
}
export function NoteInfoPanel({ entry, content }: { entry: VaultEntry; content: string | null }) {
export function NoteInfoPanel({ entry, content, locale = 'en' }: { entry: VaultEntry; content: string | null; locale?: AppLocale }) {
const wordCount = countWords(content ?? '')
return (
<div>
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<Info size={12} className="shrink-0" />
Info
{translate(locale, 'inspector.info.title')}
</h4>
<div className="flex flex-col gap-1.5">
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
<InfoRow label="Words" value={String(wordCount)} />
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
<InfoRow label={translate(locale, 'inspector.info.modified')} value={formatDate(entry.modifiedAt, locale)} />
<InfoRow label={translate(locale, 'inspector.info.created')} value={formatDate(entry.createdAt, locale)} />
<InfoRow label={translate(locale, 'inspector.info.words')} value={String(wordCount)} />
<InfoRow label={translate(locale, 'inspector.info.size')} value={formatFileSize(entry.fileSize)} />
</div>
</div>
)

View File

@@ -19,6 +19,7 @@ import {
PROPERTY_PANEL_LABEL_CLASS_NAME,
} from '../propertyPanelLayout'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { translate, type AppLocale } from '../../lib/i18n'
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'
@@ -68,6 +69,7 @@ function RelationshipSectionRow({ label, children, dataTestId }: {
label: string
children: ReactNode
dataTestId?: string
locale: AppLocale
}) {
return (
<div className={RELATIONSHIP_SECTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }} data-testid={dataTestId}>
@@ -212,9 +214,10 @@ function useCreateOption(
return { showCreate, createIndex: resultCount, totalItems: resultCount + (showCreate ? 1 : 0) }
}
function CreateAndOpenOption({ title, selected, onClick, onHover }: {
function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
title: string
selected: boolean
locale: AppLocale
onClick: () => void
onHover: () => void
}) {
@@ -228,18 +231,19 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: {
>
<Plus size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate text-foreground">
Create &amp; open <strong>{title}</strong>
{translate(locale, 'inspector.relationship.createAndOpen')} <strong>{title}</strong>
</span>
</div>
)
}
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
function SearchDropdownWithCreate({ search, onSelect, query, entries, locale, onCreateAndOpen }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (entry: VaultEntry) => void
query: string
entries: VaultEntry[]
onCreateAndOpen?: (title: string) => void
locale: AppLocale
}) {
const trimmed = query.trim()
const { showCreate, createIndex } = useCreateOption({
@@ -268,6 +272,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
<CreateAndOpenOption
title={trimmed}
selected={search.selectedIndex === createIndex}
locale={locale}
onClick={() => onCreateAndOpen(trimmed)}
onHover={() => search.setSelectedIndex(createIndex)}
/>
@@ -358,11 +363,12 @@ function useInlineAddNoteState(
}
}
function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
function InlineAddNote({ entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
const {
active,
@@ -386,7 +392,7 @@ function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
Add
{translate(locale, 'inspector.relationship.add')}
</button>
)
}
@@ -399,7 +405,7 @@ function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
autoFocus
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder="Note title"
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
@@ -419,22 +425,24 @@ function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
query={query}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
locale={locale}
/>
)}
</div>
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, locale, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void
onAddRef?: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
if (refs.length === 0) return null
return (
<RelationshipSectionRow label={label}>
<RelationshipSectionRow label={label} locale={locale}>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
@@ -454,6 +462,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNa
vaultPath={vaultPath}
onAdd={onAddRef}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
)}
</RelationshipSectionRow>
@@ -472,7 +481,7 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
function NoteTargetInput({ entries, value, locale, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
@@ -480,6 +489,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
onCancel?: () => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onSubmitWithCreate?: (title: string) => void
locale: AppLocale
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
@@ -527,7 +537,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Note title"
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setFocused(true)}
@@ -541,6 +551,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
query={value}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
locale={locale}
/>
)}
</div>
@@ -621,11 +632,12 @@ function useRelationshipPanelState({
}
}
function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: {
function AddRelationshipForm({ entries, vaultPath, locale, onAddProperty, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAddProperty: (key: string, value: FrontmatterValue) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
@@ -653,7 +665,7 @@ function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpe
if (!showForm) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Add relationship</button>
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>{translate(locale, 'inspector.relationship.addRelationship')}</button>
)
}
@@ -664,7 +676,7 @@ function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpe
autoFocus
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Relationship name"
placeholder={translate(locale, 'inspector.relationship.name')}
value={relKey}
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
@@ -677,10 +689,11 @@ function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpe
onCancel={resetForm}
onCreateAndOpenNote={onCreateAndOpenNote}
onSubmitWithCreate={handleCreateAndSubmit}
locale={locale}
/>
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">{translate(locale, 'inspector.relationship.add')}</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>{translate(locale, 'common.cancel')}</button>
</div>
</div>
)
@@ -698,38 +711,41 @@ function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterVal
return updated.length === 1 ? updated[0] : updated
}
function DisabledLinkButton() {
function DisabledLinkButton({ locale }: { locale: AppLocale }) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Add relationship</button>
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>{translate(locale, 'inspector.relationship.addRelationship')}</button>
)
}
function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
label: string
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
return (
<RelationshipSectionRow label={label} dataTestId="suggested-relationship">
<RelationshipSectionRow label={label} dataTestId="suggested-relationship" locale={locale}>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
onAdd={onAdd}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
</RelationshipSectionRow>
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
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
onDeleteProperty?: (key: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale?: AppLocale
}) {
const {
relationshipEntries,
@@ -755,6 +771,7 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
locale={locale}
/>
))}
{missingSuggestedRels.map(label => (
@@ -765,12 +782,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty!(label, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
))}
<RelationshipActionRow>
{onAddProperty
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
: <DisabledLinkButton />
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} locale={locale} />
: <DisabledLinkButton locale={locale} />
}
</RelationshipActionRow>
</div>

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
import type { NoteListFilter } from '../../utils/noteListHelpers'
interface FilterPillsProps {
@@ -6,16 +7,17 @@ interface FilterPillsProps {
counts: Record<NoteListFilter, number>
onChange: (filter: NoteListFilter) => void
position?: 'top' | 'bottom'
locale?: AppLocale
}
const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'archived', label: 'Archived' },
const PILLS: { value: NoteListFilter; labelKey: TranslationKey }[] = [
{ value: 'open', labelKey: 'noteList.filter.open' },
{ value: 'archived', labelKey: 'noteList.filter.archived' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) {
function FilterPillsInner({ active, counts, onChange, position = 'top', locale = 'en' }: FilterPillsProps) {
const isBottom = position === 'bottom'
return (
<div
@@ -25,7 +27,7 @@ function FilterPillsInner({ active, counts, onChange, position = 'top' }: Filter
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
data-testid="filter-pills"
>
{PILLS.map(({ value, label }) => (
{PILLS.map(({ value, labelKey }) => (
<button
key={value}
type="button"
@@ -39,7 +41,7 @@ function FilterPillsInner({ active, counts, onChange, position = 'top' }: Filter
onClick={() => onChange(value)}
data-testid={`filter-pill-${value}`}
>
{label}
{translate(locale, labelKey)}
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
{counts[value]}
</span>

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