Compare commits
51 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8721f2a1b | ||
|
|
fb39c6679a | ||
|
|
f1bed131bf | ||
|
|
257cf6ed02 | ||
|
|
908daafaa1 | ||
|
|
d15437face | ||
|
|
30281f879d | ||
|
|
4ef6edfb10 | ||
|
|
918419e371 | ||
|
|
9265b8f117 | ||
|
|
943fa854e1 | ||
|
|
a2f8ba72a2 | ||
|
|
1d546dde3d | ||
|
|
63877e61ec | ||
|
|
8896f1d451 | ||
|
|
f523099854 | ||
|
|
15f38f096f | ||
|
|
cdd2ddec6c | ||
|
|
292d3739df | ||
|
|
1136c1948e | ||
|
|
8406042db1 | ||
|
|
f07cbbe0eb | ||
|
|
768da9f955 | ||
|
|
f58e9d8930 | ||
|
|
133242eff5 | ||
|
|
aebd2bea4a | ||
|
|
60238fbe78 | ||
|
|
5336236e79 | ||
|
|
f9719f11b5 | ||
|
|
e4083ded1a | ||
|
|
65a89f102a | ||
|
|
5b34ec2980 | ||
|
|
e579979829 | ||
|
|
c7edc71a97 | ||
|
|
5fd8f8fb40 | ||
|
|
694419d442 | ||
|
|
e82fb2a5c7 | ||
|
|
39234dcf52 | ||
|
|
36e8a62284 | ||
|
|
d622b91b81 | ||
|
|
5a546b9a8b | ||
|
|
b8c1a094b6 | ||
|
|
c2c56cfca5 | ||
|
|
449a62e31f | ||
|
|
7d6fd5cc51 | ||
|
|
75ed6460ac | ||
|
|
a3096c596f | ||
|
|
6105c7527c | ||
|
|
92eee0d470 | ||
|
|
f14fda09d5 | ||
|
|
31d12d8cc8 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.97
|
||||
AVERAGE_THRESHOLD=9.85
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.88
|
||||
|
||||
66
.github/workflows/release-stable.yml
vendored
66
.github/workflows/release-stable.yml
vendored
@@ -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
|
||||
|
||||
47
.github/workflows/release.yml
vendored
47
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)\n‹token› → 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 nodes → Markdown 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
40
docs/adr/0082-markdown-durable-math-notes.md
Normal 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.
|
||||
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal 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.
|
||||
35
docs/adr/0084-app-localization-foundation.md
Normal file
35
docs/adr/0084-app-localization-foundation.md
Normal 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.
|
||||
39
docs/adr/0085-non-git-vault-support.md
Normal file
39
docs/adr/0085-non-git-vault-support.md
Normal 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.
|
||||
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
32
docs/adr/0086-in-app-image-file-preview.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
scripts/playwright-smoke-server.mjs
Normal file
32
scripts/playwright-smoke-server.mjs
Normal 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)
|
||||
})
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
514
src-tauri/src/window_state.rs
Normal file
514
src-tauri/src/window_state.rs
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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'"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
175
src/App.tsx
175
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
295
src/components/AppLoadingSkeleton.tsx
Normal file
295
src/components/AppLoadingSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">×</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')}>×</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 · {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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
67
src/components/FilePreview.test.tsx
Normal file
67
src/components/FilePreview.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
221
src/components/FilePreview.tsx
Normal file
221
src/components/FilePreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'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's entry again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
45
src/components/blockNoteCursorTarget.ts
Normal file
45
src/components/blockNoteCursorTarget.ts
Normal 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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }}>←</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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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\n\nParagraph tail'
|
||||
const mediaBlocks: MockBlock[] = [
|
||||
{ id: 'title', markdown: '# Title', content: [] },
|
||||
{ id: 'image', markdown: '' },
|
||||
{ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
71
src/components/inlineWikilinkDropText.ts
Normal file
71
src/components/inlineWikilinkDropText.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user