Compare commits

...

53 Commits

Author SHA1 Message Date
lucaronin
f5cc5ffb4f fix: satisfy clippy and stabilize app startup test 2026-04-19 16:28:02 +02:00
lucaronin
374b4f6686 feat: keep getting started vaults local by default 2026-04-19 16:18:35 +02:00
lucaronin
36c20f34f4 fix: hide unavailable getting started vault 2026-04-19 15:21:59 +02:00
Luca Rossi
8551d819bd Deleted old unused font 2026-04-19 15:16:43 +02:00
lucaronin
d5497d7252 Match getting started subtitle copy 2026-04-19 14:30:07 +02:00
lucaronin
046afe0e67 Simplify Getting Started vault subtitle 2026-04-19 14:23:05 +02:00
lucaronin
cb1cc4b394 fix: include inverse neighborhood groups 2026-04-19 14:13:10 +02:00
lucaronin
00cb938766 fix: add cmd o quick-open alias 2026-04-19 13:47:02 +02:00
lucaronin
80c13272f3 fix: keep neighborhood reverse matches path-aware 2026-04-19 12:53:07 +02:00
lucaronin
9534449307 fix: hide absent neighborhood groups 2026-04-19 12:26:31 +02:00
lucaronin
2d5361b2e9 fix: keep keyboard note switching snappy 2026-04-19 11:49:48 +02:00
lucaronin
b22da65d44 fix: keep note switches instant 2026-04-19 11:29:04 +02:00
lucaronin
862af864d3 fix: preserve image selection while hovering toolbar 2026-04-19 10:48:45 +02:00
lucaronin
9ecefc90d5 feat: add stable landing page download link 2026-04-19 04:44:17 +02:00
lucaronin
9693fe24c9 fix: restore type filter icon colors 2026-04-19 04:15:12 +02:00
lucaronin
92ea32c0b8 feat: add neighborhood browsing mode 2026-04-19 03:37:33 +02:00
lucaronin
2e2bd2f3b3 fix: preserve native welcome vault creation 2026-04-19 02:37:29 +02:00
lucaronin
3556d934ee test: format vault command assertions 2026-04-19 01:55:39 +02:00
lucaronin
feef938174 fix: align fresh vault seed files 2026-04-19 01:33:53 +02:00
lucaronin
67a9930ea1 feat: add empty vault creation flow 2026-04-19 01:06:40 +02:00
lucaronin
aed4d05f1d docs: ratchet codescene thresholds 2026-04-19 00:25:35 +02:00
lucaronin
cac7f12c6d fix: stop duplicate native quick prompt paste 2026-04-19 00:18:42 +02:00
lucaronin
72b5b52140 feat: add view note-list controls 2026-04-18 20:31:25 +02:00
lucaronin
7deeb49751 feat: add number property type 2026-04-18 19:40:00 +02:00
lucaronin
8c0a2e7ec8 fix: share onboarding drag shell 2026-04-18 18:50:30 +02:00
lucaronin
c4e66dc74f fix: make onboarding window draggable 2026-04-18 18:09:37 +02:00
lucaronin
c0bb722db5 fix: reuse note content cache for rapid note switches 2026-04-18 17:30:49 +02:00
lucaronin
bc11c869aa fix: stabilize ai mode paste editing 2026-04-18 16:52:36 +02:00
lucaronin
502ecf0572 test: stabilize wikilink smoke coverage 2026-04-18 16:18:43 +02:00
lucaronin
8bbbba98b7 fix: correct vault path type guard 2026-04-18 16:02:19 +02:00
lucaronin
86adf860b5 fix: restore bottom bar vault switching 2026-04-18 15:59:34 +02:00
lucaronin
cec1627a81 fix: preserve image toolbar hover bridge 2026-04-18 15:12:39 +02:00
lucaronin
b00183419e fix: show Note metadata in autocomplete 2026-04-18 13:37:15 +02:00
lucaronin
ed9ceff1d2 fix: align Notes counts with explicit type 2026-04-18 13:07:17 +02:00
lucaronin
e18491bcd3 fix: preserve untitled rename session continuity 2026-04-18 12:17:24 +02:00
lucaronin
fd0cfc7fb6 fix: exclude attachments from all notes totals 2026-04-18 11:53:06 +02:00
lucaronin
6541ce5755 test: stabilize wikilink smoke timing 2026-04-18 11:19:41 +02:00
lucaronin
4456801526 fix: keep h1 typing responsive 2026-04-18 11:06:55 +02:00
lucaronin
db7aaf1385 fix: restore note type during vault repair 2026-04-18 10:38:50 +02:00
lucaronin
54b2616fa4 fix: restore manual commit dialog when autogit is off 2026-04-18 09:58:49 +02:00
lucaronin
6a90e9f770 fix: correct editor layout diff callback type 2026-04-18 09:28:18 +02:00
lucaronin
b79c78a5ba fix: save raw note changes before switching notes 2026-04-18 09:28:18 +02:00
Luca Rossi
48a8882970 Reorder CodeScene badges in README 2026-04-18 09:16:06 +02:00
Luca Rossi
98276702a2 Added codescene badges 2026-04-18 09:14:59 +02:00
lucaronin
857d55a7cd fix: stop cmd+s from renaming note titles 2026-04-18 08:25:43 +02:00
lucaronin
b78dfad384 fix: resume onboarding after telemetry on fresh start 2026-04-18 03:10:41 +02:00
lucaronin
9ac51bab9a test: pin app test active vault 2026-04-18 02:35:41 +02:00
lucaronin
1fee994863 test: cover mock vault config path 2026-04-18 02:21:48 +02:00
lucaronin
764bbf0e4c test: scope inbox sidebar assertion 2026-04-18 02:10:54 +02:00
lucaronin
ff05ba0430 chore: ratchet codescene thresholds 2026-04-18 01:56:12 +02:00
lucaronin
347df47c62 fix: stabilize smoke vault harness 2026-04-18 01:50:29 +02:00
lucaronin
7536169508 fix: resume onboarding after telemetry consent 2026-04-18 00:21:52 +02:00
lucaronin
34de384738 fix: preserve yml views in raw editor 2026-04-17 23:32:36 +02:00
152 changed files with 10115 additions and 1817 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.86
AVERAGE_THRESHOLD=9.68
HOTSPOT_THRESHOLD=9.88
AVERAGE_THRESHOLD=9.73

View File

@@ -208,6 +208,7 @@ jobs:
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
cat > stable-latest.json << EOF
{
@@ -217,7 +218,8 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
}
}
}
@@ -253,6 +255,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -263,6 +270,9 @@ jobs:
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json

View File

@@ -291,6 +291,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -301,6 +306,9 @@ jobs:
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json

Binary file not shown.

View File

@@ -1,3 +1,4 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
# Tolaria App
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.

View File

@@ -261,10 +261,24 @@ type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
| { kind: 'topic'; entry: VaultEntry } // topic selected
| { kind: 'folder'; path: string }
| { kind: 'entity'; entry: VaultEntry } // Neighborhood source note
| { kind: 'view'; filename: string }
```
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
- The selected `entry` is the neighborhood source note.
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
- Outgoing relationship groups render first using the note's `relationships` map.
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
- Empty groups stay visible with count `0`.
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
- Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
## File System Integration
### Vault Scanning (Rust)
@@ -345,6 +359,11 @@ interface GitRemoteStatus {
hasRemote: boolean
}
interface GitAddRemoteResult {
status: 'connected' | 'already_configured' | 'incompatible_history' | 'auth_error' | 'network_error' | 'error'
message: string
}
interface PulseCommit {
hash: string
shortHash: string
@@ -367,6 +386,7 @@ interface PulseCommit {
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe |
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes |
@@ -386,6 +406,11 @@ interface PulseCommit {
- Converts `hasRemote: false` into a local-only commit path
- Keeps the normal push path unchanged for vaults that do have a remote
`AddRemoteModal` is the explicit recovery path for those local-only vaults:
- Opens from the `No remote` status-bar chip and the command palette
- Calls `git_add_remote` with the current vault path and the pasted repository URL
- Shows auth, network, and incompatible-history failures inline without rewriting the local vault's history
`useAutoGit` is the checkpoint-time companion to both hooks:
- Consumes installation-local AutoGit settings (`autogit_enabled`, idle threshold, inactive threshold)
- Tracks the last meaningful editor activity plus app focus/visibility transitions
@@ -496,7 +521,8 @@ The app uses a single light theme — the vault-based theming system was removed
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
@@ -561,6 +587,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
`useOnboarding` hook detects first launch:
- If vault path doesn't exist → show `WelcomeScreen`
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning
- After the starter repo clone completes, Tolaria removes every remote so the new vault opens local-only by default
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
`useGettingStartedClone` encapsulates the non-onboarding Getting Started action:
@@ -578,6 +605,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
Tolaria delegates remote auth to the user's system git setup:
- `CloneVaultModal` captures a remote URL and local destination
- `clone_repo` shells out to system git for clone operations
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
- No provider-specific token or username is stored in app settings

View File

@@ -177,7 +177,7 @@ flowchart TD
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. 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 or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **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. 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).
@@ -442,7 +442,9 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
### Remote Clone & Auth Model
@@ -553,6 +555,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.
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.
#### Sync States
@@ -578,7 +582,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -587,7 +591,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|--------|---------|
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
@@ -615,7 +619,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
@@ -635,6 +639,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
| `git_remote_status` | Get branch name + ahead/behind counts |
| `git_add_remote` | Connect a local-only vault to a compatible remote and start tracking it |
| `git_resolve_conflict` | Resolve a merge conflict |
| `git_commit_conflict_resolution` | Commit conflict resolution |
| `get_file_history` | Last N commits for a file |
@@ -659,7 +664,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|---------|-------------|
| `get_vault_settings` | Read `.laputa/settings.json` |
| `save_vault_settings` | Write vault settings |
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore config |
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore root config/type defaults including `note.md` |
### AI & MCP
@@ -705,7 +710,7 @@ if (isTauri()) {
}
```
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits.
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits. It also tracks per-vault remote state so browser-mode Getting Started and empty-vault flows now behave like the desktop app: local-only until `git_add_remote` succeeds.
Browser smoke tests can also override `window.__mockHandlers` before the app boots. The AutoGit smoke bridge uses that path directly for seeded saves so the mocked git dirty-state stays synchronized even when the optional browser vault API is serving note content.
@@ -741,7 +746,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Shortcut | Action |
|----------|--------|
| Cmd+K | Open command palette |
| Cmd+P | Open quick open palette |
| Cmd+P / Cmd+O | Open quick open palette |
| Cmd+N | Create new note |
| Cmd+S | Save current note |
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
@@ -756,7 +761,7 @@ Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
@@ -798,10 +803,11 @@ push stable-vYYYY.M.D tag
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ upload signed .app.tar.gz + .sig and .dmg artifacts
→ release job:
→ generate stable-latest.json
→ generate stable-latest.json with both updater tarball and current stable DMG URLs
→ publish GitHub release Tolaria YYYY.M.D
→ pages job:
→ publish stable/latest.json
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG
→ preserve alpha/latest.json
→ deploy to gh-pages
```

View File

@@ -28,6 +28,10 @@ pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
pnpm playwright:regression # Full Playwright regression suite
```
## Starter Vaults And Remotes
`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow.
## Directory Structure
```
@@ -65,6 +69,7 @@ tolaria/
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── CreateNoteDialog.tsx # New note modal
@@ -154,7 +159,7 @@ tolaria/
│ │ ├── frontmatter/ # Frontmatter module
│ │ │ ├── mod.rs, yaml.rs, ops.rs
│ │ ├── git/ # Git module
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs, connect.rs
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ ├── telemetry.rs # Sentry init + path scrubber
│ │ ├── search.rs # Keyword search (walkdir-based)
@@ -211,6 +216,7 @@ tolaria/
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
| `src/components/AddRemoteModal.tsx` | Modal UI for connecting a local-only vault to a compatible remote. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
### Backend
@@ -220,7 +226,7 @@ tolaria/
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
@@ -292,8 +298,9 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string }
| { kind: 'folder'; path: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'topic'; entry: VaultEntry }
| { kind: 'view'; filename: string }
```
### Command Registry

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0069"
title: "Neighborhood mode for note-list relationship browsing"
status: active
date: 2026-04-19
---
## Context
Tolaria already had a relationship-browsing state behind `SidebarSelection.kind === 'entity'`, but the product language and interaction model were still fuzzy. The pinned source note rendered as a special card instead of a normal note row, grouped relationship results were deduplicated across sections, and Cmd-click behaved like a legacy "open separately" affordance rather than a clear graph-navigation action.
The new note-list flow needed an explicit product concept for browsing related notes around a source note, plus keyboard semantics that matched the mouse flow. The team also wanted the list to preserve graph truth instead of collapsing overlapping relationships away when a note legitimately belonged to multiple groups.
## Decision
**Tolaria formalizes `SidebarSelection.kind === 'entity'` as Neighborhood mode.** The note list now treats the selected note as the neighborhood source, pins it at the top using the standard active note-row styling, shows outgoing relationship groups first and inverse/backlink groups after, keeps empty groups visible with count `0`, and allows the same note to appear in multiple groups when multiple relationships are true.
**Neighborhood navigation is a distinct pivot action.** Plain click and plain `Enter` open the focused note without replacing the current neighborhood. Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
## Options considered
- **Reuse the existing `entity` selection as Neighborhood mode** (chosen): keeps the state model localized, avoids a second nearly-identical note-list mode, and lets sidebar navigation exit Neighborhood by selecting any other sidebar target. Cons: code still uses the historical `entity` name internally.
- **Add a new `neighborhood` selection variant**: clearer internal naming, but it duplicates the same source-note payload and would force wider selection-handling churn across the app for little product gain.
- **Keep the old implicit entity-browsing behavior**: lowest short-term engineering effort, but it leaves the product terminology inconsistent and preserves interaction mismatches like deduped groups and non-pivot Cmd-click behavior.
## Consequences
- Product, tests, and docs now refer to Neighborhood as a first-class note-list browsing mode.
- The note list preserves overlapping graph evidence: one note can appear in multiple groups when multiple relationships are true.
- Keyboard-only browsing now matches the pointer flow: arrow keys/open keep the current neighborhood, while Cmd/Ctrl-`Enter` pivots it.
- Sidebar navigation remains the exit path from Neighborhood because the app still models the mode through the existing selection union.
- Internal code still uses the `entity` discriminator, so future refactors should treat "entity selection" and "Neighborhood mode" as the same concept unless a broader navigation redesign justifies a new selection shape.

View File

@@ -124,3 +124,4 @@ proposed → active → superseded
| [0066](0066-calendar-semver-versioning-for-alpha-and-stable-releases.md) | Calendar-semver versioning for alpha and stable releases | active |
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |

View File

@@ -14,7 +14,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",

View File

@@ -0,0 +1,39 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
buildStableDownloadRedirectPage,
resolveStableDmgUrl,
} from '../src/utils/releaseDownloadPage'
function getArg(flag: string): string {
const index = process.argv.indexOf(flag)
const value = index >= 0 ? process.argv[index + 1] : null
if (!value) {
throw new Error(`Missing required argument: ${flag}`)
}
return value
}
function readLatestReleasePayload(filePath: string): unknown {
try {
return JSON.parse(readFileSync(filePath, 'utf8'))
} catch {
return {}
}
}
const latestJsonPath = resolve(getArg('--latest-json'))
const releasesJsonPath = resolve(getArg('--releases-json'))
const outputFilePath = resolve(getArg('--output-file'))
const latestPayload = readLatestReleasePayload(latestJsonPath)
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload)
const html = buildStableDownloadRedirectPage(dmgUrl)
mkdirSync(dirname(outputFilePath), { recursive: true })
writeFileSync(outputFilePath, html)
console.log(`Stable download page written to ${outputFilePath}`)

View File

@@ -0,0 +1,27 @@
use crate::git::GitAddRemoteResult;
use serde::Deserialize;
use super::expand_tilde;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitAddRemoteRequest {
vault_path: String,
remote_url: String,
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_add_remote(request: GitAddRemoteRequest) -> Result<GitAddRemoteResult, String> {
let vault_path = expand_tilde(&request.vault_path).into_owned();
let remote_url = request.remote_url;
tokio::task::spawn_blocking(move || crate::git::git_add_remote(&vault_path, &remote_url))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_add_remote(_request: GitAddRemoteRequest) -> Result<GitAddRemoteResult, String> {
Err("Adding git remotes is not available on mobile".into())
}

View File

@@ -1,6 +1,7 @@
mod ai;
mod delete;
mod git;
mod git_connect;
mod system;
mod vault;
mod version;
@@ -10,6 +11,7 @@ use std::borrow::Cow;
pub use ai::*;
pub use delete::*;
pub use git::*;
pub use git_connect::*;
pub use system::*;
pub use vault::*;
pub use version::*;

View File

@@ -55,6 +55,7 @@ pub struct MenuStateUpdate {
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
}
#[cfg(desktop)]
@@ -73,6 +74,9 @@ pub fn update_menu_state(
if let Some(v) = state.has_restorable_deleted_note {
menu::set_restore_deleted_item_enabled(&app_handle, v);
}
if let Some(v) = state.has_no_remote {
menu::set_git_no_remote_items_enabled(&app_handle, v);
}
Ok(())
}

View File

@@ -121,6 +121,7 @@ fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Re
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
@@ -129,6 +130,28 @@ fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Resu
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
@@ -291,6 +314,7 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
@@ -299,6 +323,35 @@ mod tests {
(dir, note)
}
fn assert_paths_exist(root: &Path, paths: &[&str]) {
for path in paths {
assert!(root.join(path).exists(), "{path} should exist");
}
}
fn assert_paths_absent(root: &Path, paths: &[&str]) {
for path in paths {
assert!(!root.join(path).exists(), "{path} should be absent");
}
}
fn assert_seeded_guidance_content(vault_path: &Path) {
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
let claude = std::fs::read_to_string(vault_path.join("CLAUDE.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
assert!(claude.starts_with("@AGENTS.md"));
assert!(claude.contains("# CLAUDE.md"));
}
fn assert_seeded_type_scaffolding(vault_path: &Path) {
let type_definition = std::fs::read_to_string(vault_path.join("type.md")).unwrap();
assert!(type_definition.contains("visible: false"));
assert!(type_definition.contains("# Type"));
}
#[test]
fn test_batch_archive_notes() {
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
@@ -427,24 +480,41 @@ mod tests {
let result = repair_vault(vault_path.to_str().unwrap().to_string());
assert!(result.is_ok());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("config.md").exists());
assert!(vault_path.join(".gitignore").exists());
assert_paths_exist(
vault_path,
&["AGENTS.md", "CLAUDE.md", "type.md", "note.md", ".gitignore"],
);
assert_paths_absent(vault_path, &["config.md"]);
}
#[test]
fn test_create_empty_vault_seeds_agents_and_config() {
fn test_create_empty_vault_seeds_agents_and_type_scaffolding() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("fresh-vault");
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
assert!(vault_path.join(".git").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("config.md").exists());
assert_paths_exist(
&vault_path,
&[".git", "AGENTS.md", "CLAUDE.md", "type.md", "note.md"],
);
assert_paths_absent(&vault_path, &["config.md"]);
assert_seeded_guidance_content(&vault_path);
assert_seeded_type_scaffolding(&vault_path);
}
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
#[test]
fn test_create_empty_vault_rejects_nonempty_target() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("existing-folder");
std::fs::create_dir_all(&vault_path).unwrap();
std::fs::write(vault_path.join("keep.txt"), "keep").unwrap();
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
let err = result.expect_err("expected non-empty folder to be rejected");
assert_eq!(err, "Choose an empty folder to create a new vault");
assert_paths_exist(&vault_path, &["keep.txt"]);
assert_paths_absent(&vault_path, &[".git", "AGENTS.md"]);
}
}

View File

@@ -0,0 +1,577 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::{Command, Output};
const DEFAULT_REMOTE_NAME: &str = "origin";
#[derive(Clone, Copy)]
enum ConnectStatus {
Connected,
AlreadyConfigured,
IncompatibleHistory,
AuthError,
NetworkError,
Error,
}
impl ConnectStatus {
fn as_str(self) -> &'static str {
match self {
Self::Connected => "connected",
Self::AlreadyConfigured => "already_configured",
Self::IncompatibleHistory => "incompatible_history",
Self::AuthError => "auth_error",
Self::NetworkError => "network_error",
Self::Error => "error",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitAddRemoteResult {
pub status: String, // "connected" | "already_configured" | "incompatible_history" | "auth_error" | "network_error" | "error"
pub message: String,
}
struct RemoteConnection {
branch: String,
remote_branch: String,
}
impl RemoteConnection {
fn new(branch: String) -> Self {
let remote_branch = format!("{DEFAULT_REMOTE_NAME}/{branch}");
Self {
branch,
remote_branch,
}
}
fn pushed_history_message(&self) -> String {
format!(
"Remote connected. Tolaria pushed your local commits and is now tracking {}.",
self.remote_branch
)
}
fn tracking_message(&self) -> String {
format!(
"Remote connected. This vault now tracks {}.",
self.remote_branch
)
}
}
pub fn disconnect_all_remotes(vault_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
for remote in list_remotes(vault)? {
run_git(vault, &["remote", "remove", &remote])?;
}
unset_upstream(vault);
Ok(())
}
pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemoteResult, String> {
let vault = Path::new(vault_path);
if remote_url.trim().is_empty() {
return Ok(connect_result(
ConnectStatus::Error,
"Enter a repository URL before connecting a remote.",
));
}
if !list_remotes(vault)?.is_empty() {
return Ok(connect_result(
ConnectStatus::AlreadyConfigured,
"This vault already has a remote configured.",
));
}
let branch = current_branch(vault)?;
if branch.is_empty() {
return Ok(connect_result(
ConnectStatus::Error,
"Tolaria could not determine the current branch for this vault.",
));
}
let connection = RemoteConnection::new(branch);
run_git(
vault,
&["remote", "add", DEFAULT_REMOTE_NAME, remote_url.trim()],
)?;
let result = finish_remote_connection(vault, &connection);
if result.status != "connected" {
let _ = disconnect_all_remotes(vault_path);
}
Ok(result)
}
fn finish_remote_connection(vault: &Path, connection: &RemoteConnection) -> GitAddRemoteResult {
if let Err(stderr) = fetch_remote(vault) {
return classify_connect_error(&stderr);
}
let remote_branches = match list_remote_branches(vault) {
Ok(branches) => branches,
Err(err) => return connect_result(ConnectStatus::Error, err),
};
if remote_branches.is_empty() {
return push_with_tracking(vault, connection, connection.pushed_history_message());
}
if !remote_branches
.iter()
.any(|candidate| candidate == &connection.remote_branch)
{
return connect_result(
ConnectStatus::IncompatibleHistory,
format!(
"This repository already has git branches, but not '{}'. Use an empty repository or one created from this vault.",
connection.branch
),
);
}
if !histories_share_base(vault, connection) {
return connect_result(
ConnectStatus::IncompatibleHistory,
"This repository has unrelated history. Use an empty repository or one created from this vault.",
);
}
let (_, behind) = match ahead_behind_counts(vault, connection) {
Ok(counts) => counts,
Err(err) => return connect_result(ConnectStatus::Error, err),
};
if behind > 0 {
return connect_result(
ConnectStatus::IncompatibleHistory,
format!(
"This repository already has commits on '{}' that are not in this vault. Tolaria will not connect it automatically.",
connection.branch
),
);
}
push_with_tracking(vault, connection, connection.tracking_message())
}
fn connect_result(status: ConnectStatus, message: impl Into<String>) -> GitAddRemoteResult {
GitAddRemoteResult {
status: status.as_str().to_string(),
message: message.into(),
}
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = git_output(vault, &["branch", "--show-current"])?;
if output.status.success() {
return Ok(stdout_text(&output));
}
Err(command_error("git branch --show-current", &output))
}
fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
let output = git_output(vault, &["remote"])?;
if !output.status.success() {
return Err(command_error("git remote", &output));
}
Ok(stdout_lines(&output))
}
fn unset_upstream(vault: &Path) {
let _ = Command::new("git")
.args(["branch", "--unset-upstream"])
.current_dir(vault)
.output();
}
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
.args(args)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git {}: {e}", args[0]))?;
if output.status.success() {
return Ok(());
}
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
fn fetch_remote(vault: &Path) -> Result<(), String> {
run_git(vault, &["fetch", DEFAULT_REMOTE_NAME, "--prune"])
}
fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
let output = git_output(
vault,
&[
"for-each-ref",
"--format=%(refname:short)",
"refs/remotes/origin",
],
)?;
if !output.status.success() {
return Err(command_error("git for-each-ref", &output));
}
Ok(stdout_lines(&output)
.into_iter()
.filter(|line| line != "origin/HEAD")
.collect())
}
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
Command::new("git")
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
.current_dir(vault)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> {
let revision_range = format!("HEAD...{}", connection.remote_branch);
let output = git_output(
vault,
&["rev-list", "--left-right", "--count", &revision_range],
)?;
if !output.status.success() {
return Err(command_error("git rev-list", &output));
}
let counts = stdout_text(&output);
let parts: Vec<&str> = counts.trim().split('\t').collect();
let ahead = parts
.first()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
let behind = parts
.get(1)
.and_then(|value| value.parse().ok())
.unwrap_or(0);
Ok((ahead, behind))
}
fn push_with_tracking(
vault: &Path,
connection: &RemoteConnection,
success_message: String,
) -> GitAddRemoteResult {
match run_git(
vault,
&[
"push",
"-u",
DEFAULT_REMOTE_NAME,
connection.branch.as_str(),
],
) {
Ok(()) => connect_result(ConnectStatus::Connected, success_message),
Err(stderr) => classify_connect_error(&stderr),
}
}
fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
let lower = stderr.to_lowercase();
if is_auth_error(&lower) {
return connect_result(
ConnectStatus::AuthError,
"Could not connect to that remote because git reported an authentication error. Check your credentials and try again.",
);
}
if is_network_error(&lower) {
return connect_result(
ConnectStatus::NetworkError,
"Could not reach that remote. Check your connection and repository URL, then try again.",
);
}
connect_result(
ConnectStatus::Error,
format!(
"Could not connect that remote: {}",
concise_git_detail(stderr)
),
)
}
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
Command::new("git")
.args(args)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git {}: {e}", args[0]))
}
fn command_error(command: &str, output: &Output) -> String {
format!("{command} failed: {}", stderr_text(output))
}
fn stderr_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn stdout_lines(output: &Output) -> Vec<String> {
stdout_text(output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn is_auth_error(lower: &str) -> bool {
[
"authentication failed",
"could not read username",
"permission denied",
"the requested url returned error: 403",
"invalid credentials",
"repository not found",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn is_network_error(lower: &str) -> bool {
[
"could not resolve host",
"unable to access",
"connection refused",
"network is unreachable",
"timed out",
"couldn't connect",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn concise_git_detail(stderr: &str) -> String {
stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("git reported an unknown error")
.trim_start_matches("fatal:")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::setup_git_repo;
use crate::git::{git_commit, git_remote_status};
use std::fs;
use std::process::Command as StdCommand;
use tempfile::TempDir;
fn init_bare_remote(path: &Path) {
StdCommand::new("git")
.args(["init", "--bare", "--initial-branch=main"])
.current_dir(path)
.output()
.unwrap();
}
fn configure_author(path: &Path, email: &str, name: &str) {
StdCommand::new("git")
.args(["config", "user.email", email])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", name])
.current_dir(path)
.output()
.unwrap();
}
fn seed_remote_history(bare_path: &Path) {
let working = TempDir::new().unwrap();
StdCommand::new("git")
.args(["clone", bare_path.to_str().unwrap(), "."])
.current_dir(working.path())
.output()
.unwrap();
configure_author(working.path(), "remote@test.com", "Remote User");
fs::write(working.path().join("remote.md"), "# Remote\n").unwrap();
StdCommand::new("git")
.args(["add", "."])
.current_dir(working.path())
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", "Seed remote"])
.current_dir(working.path())
.output()
.unwrap();
StdCommand::new("git")
.args(["push", "origin", "main"])
.current_dir(working.path())
.output()
.unwrap();
}
fn create_local_commit(path: &Path, filename: &str, title: &str, message: &str) {
fs::write(path.join(filename), format!("# {title}\n")).unwrap();
git_commit(path.to_str().unwrap(), message).unwrap();
}
#[test]
fn disconnect_all_remotes_removes_every_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vault_path = vault.to_str().unwrap();
StdCommand::new("git")
.args(["remote", "add", "origin", "https://example.com/one.git"])
.current_dir(vault)
.output()
.unwrap();
StdCommand::new("git")
.args(["remote", "add", "backup", "https://example.com/two.git"])
.current_dir(vault)
.output()
.unwrap();
disconnect_all_remotes(vault_path).unwrap();
assert!(list_remotes(vault).unwrap().is_empty());
}
#[test]
fn git_add_remote_connects_an_empty_remote_and_pushes_local_history() {
let local = setup_git_repo();
configure_author(local.path(), "local@test.com", "Local User");
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
assert!(result.message.contains("tracking"));
let status = git_remote_status(local.path().to_str().unwrap()).unwrap();
assert!(status.has_remote);
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[test]
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
let local = setup_git_repo();
configure_author(local.path(), "local@test.com", "Local User");
create_local_commit(local.path(), "note.md", "Base", "Base commit");
let bare = TempDir::new().unwrap();
StdCommand::new("git")
.args([
"clone",
"--bare",
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
])
.output()
.unwrap();
create_local_commit(local.path(), "next.md", "Next", "Local follow-up");
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
let status = git_remote_status(local.path().to_str().unwrap()).unwrap();
assert!(status.has_remote);
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[test]
fn git_add_remote_rejects_unrelated_remote_history_and_cleans_up() {
let local = setup_git_repo();
configure_author(local.path(), "local@test.com", "Local User");
create_local_commit(local.path(), "note.md", "Local", "Local commit");
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
seed_remote_history(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "incompatible_history");
assert!(result.message.contains("unrelated history"));
assert!(list_remotes(local.path()).unwrap().is_empty());
}
#[test]
fn git_add_remote_reports_when_the_vault_is_already_remote_backed() {
let local = setup_git_repo();
let vault = local.path();
StdCommand::new("git")
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()
.unwrap();
let result =
git_add_remote(vault.to_str().unwrap(), "https://example.com/other.git").unwrap();
assert_eq!(result.status, "already_configured");
}
#[test]
fn classify_connect_error_maps_auth_failures() {
let result = classify_connect_error(
"fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
);
assert_eq!(result.status, "auth_error");
}
#[test]
fn classify_connect_error_maps_network_failures() {
let result = classify_connect_error(
"fatal: unable to access 'https://github.com/org/repo.git/': Could not resolve host: github.com",
);
assert_eq!(result.status, "network_error");
}
}

View File

@@ -1,6 +1,7 @@
mod clone;
mod commit;
mod conflict;
mod connect;
mod dates;
mod history;
mod pulse;
@@ -16,6 +17,7 @@ pub use conflict::{
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
is_merge_in_progress, is_rebase_in_progress,
};
pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult};
pub use dates::{get_all_file_dates, GitDates};
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};

View File

@@ -46,7 +46,7 @@ fn run_startup_tasks() {
);
// Migrate legacy config/agents.md → root AGENTS.md (one-time, idempotent)
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and config.md at vault root if missing
// Seed AGENTS.md and starter type definitions at vault root if missing
vault::seed_config_files(vp_str);
// Register Tolaria MCP server in Claude Code and Cursor configs
@@ -96,6 +96,7 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
Ok(())
}
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
#[cfg(all(desktop, target_os = "macos"))]
@@ -110,6 +111,9 @@ fn setup_macos_webview_shortcut_prevention(
// WKWebView can swallow some browser-reserved chords before our shared
// renderer shortcut handler sees them. Keep this list narrow and verify
// every addition with native QA.
for key in MACOS_WEBVIEW_RESERVED_COMMAND_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey]));
}
for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
}
@@ -169,6 +173,7 @@ macro_rules! app_invoke_handler {
commands::git_pull,
commands::git_push,
commands::git_remote_status,
commands::git_add_remote,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,

View File

@@ -10,6 +10,7 @@ const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_NEW_TYPE: &str = "file-new-type";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
const FILE_SAVE: &str = "file-save";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
@@ -43,6 +44,7 @@ const NOTE_RESTORE_DELETED: &str = "note-restore-deleted";
const VAULT_OPEN: &str = "vault-open";
const VAULT_REMOVE: &str = "vault-remove";
const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
const VAULT_ADD_REMOTE: &str = "vault-add-remote";
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_PULL: &str = "vault-pull";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
@@ -57,6 +59,7 @@ const CUSTOM_IDS: &[&str] = &[
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_QUICK_OPEN,
FILE_QUICK_OPEN_ALIAS,
FILE_SAVE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_RAW_EDITOR,
@@ -85,6 +88,7 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_ADD_REMOTE,
VAULT_COMMIT_PUSH,
VAULT_PULL,
VAULT_RESOLVE_CONFLICTS,
@@ -115,6 +119,9 @@ const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH];
/// IDs of menu items that depend on having merge conflicts.
const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS];
/// IDs of menu items that depend on the active vault having no remote configured.
const GIT_NO_REMOTE_DEPENDENT_IDS: &[&str] = &[VAULT_ADD_REMOTE];
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
fn build_app_menu(app: &App) -> MenuResult {
@@ -155,6 +162,10 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
.build(app)?;
let quick_open_alias = MenuItemBuilder::new("Quick Open (Cmd+O)")
.id(FILE_QUICK_OPEN_ALIAS)
.accelerator("CmdOrCtrl+O")
.build(app)?;
let save = MenuItemBuilder::new("Save")
.id(FILE_SAVE)
.accelerator("CmdOrCtrl+S")
@@ -163,6 +174,7 @@ fn build_file_menu(app: &App) -> MenuResult {
.item(&new_note)
.item(&new_type)
.item(&quick_open)
.item(&quick_open_alias)
.separator()
.item(&save)
.build()?)
@@ -327,6 +339,10 @@ fn build_vault_menu(app: &App) -> MenuResult {
let restore_getting_started = MenuItemBuilder::new("Restore Getting Started")
.id(VAULT_RESTORE_GETTING_STARTED)
.build(app)?;
let add_remote = MenuItemBuilder::new("Add Remote…")
.id(VAULT_ADD_REMOTE)
.enabled(false)
.build(app)?;
let commit_push = MenuItemBuilder::new("Commit & Push")
.id(VAULT_COMMIT_PUSH)
.build(app)?;
@@ -355,6 +371,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&remove_vault)
.item(&restore_getting_started)
.separator()
.item(&add_remote)
.item(&commit_push)
.item(&pull)
.item(&resolve_conflicts)
@@ -410,9 +427,13 @@ pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), St
if !CUSTOM_IDS.contains(&id) {
return Err(format!("Unknown custom menu event: {id}"));
}
let emitted_id = match id {
FILE_QUICK_OPEN_ALIAS => FILE_QUICK_OPEN,
_ => id,
};
app_handle
.emit("menu-event", id)
.map_err(|err| format!("Failed to emit menu-event {id}: {err}"))
.emit("menu-event", emitted_id)
.map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}"))
}
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
@@ -441,6 +462,11 @@ pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on the active vault having no remote.
pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on a deleted note preview being active.
pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, RESTORE_DELETED_DEPENDENT_IDS, enabled);
@@ -483,6 +509,7 @@ mod tests {
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_ADD_REMOTE,
VAULT_COMMIT_PUSH,
VAULT_PULL,
VAULT_RESOLVE_CONFLICTS,
@@ -519,6 +546,12 @@ mod tests {
"git-conflict-dependent ID {id} not in CUSTOM_IDS"
);
}
for id in GIT_NO_REMOTE_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-no-remote-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]

View File

@@ -4,23 +4,47 @@ use std::path::{Path, PathBuf};
use super::getting_started::{agents_content_can_be_refreshed, AGENTS_MD};
/// Content for `config.md` — gives the Config type a sidebar icon and label.
const CONFIG_TYPE_DEFINITION: &str = "\
/// Content for `type.md` — describes the generic Type metamodel for the vault.
const TYPE_TYPE_DEFINITION: &str = "\
---
type: Type
icon: gear-six
color: gray
order: 90
sidebar label: Config
order: 0
visible: false
---
# Config
# Type
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
A Type defines shared metadata and defaults for a category of notes in this vault.
## Common properties
- **Icon**: Sidebar icon for this type
- **Color**: Accent color for notes of this type
- **Order**: Sidebar ordering
- **Sidebar label**: Override the default plural label
- **Template**: Default body for new notes of this type
- **View**: Preferred note-list view for this type
";
/// Content for `note.md` — restores the default Note type definition when missing.
const NOTE_TYPE_DEFINITION: &str = "\
---
type: Type
---
# Note
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
";
const LEGACY_CLAUDE_MD_SHIM: &str = "@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
# CLAUDE.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
@@ -69,7 +93,9 @@ fn root_agents_can_be_replaced(path: &Path) -> bool {
fn matches_claude_shim(content: &str) -> bool {
let trimmed = content.trim();
trimmed == "@AGENTS.md" || trimmed == CLAUDE_MD_SHIM.trim()
trimmed == "@AGENTS.md"
|| trimmed == LEGACY_CLAUDE_MD_SHIM.trim()
|| trimmed == CLAUDE_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
@@ -113,8 +139,8 @@ fn classify_guidance_file(
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
let vault = Path::new(vault_path);
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
let vault = vault_path;
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
}
@@ -137,7 +163,7 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
)
}
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
let (agents_path, claude_path) = guidance_paths(vault_path);
let agents_state = classify_agents_file(&agents_path);
let claude_state = classify_claude_file(&claude_path);
@@ -150,12 +176,12 @@ fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
}
}
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
@@ -203,34 +229,45 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
Ok(true)
}
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
pub fn get_ai_guidance_status(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
}
pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
sync_ai_guidance_files(vault_path)?;
get_ai_guidance_status(vault_path)
}
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
/// Also seeds `config.md` type definition for sidebar visibility.
pub fn seed_config_files(vault_path: &str) {
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
ensure_config_type_definition(vault_path);
ensure_root_type_definitions(vault_path);
}
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) {
let path = Path::new(vault_path).join("config.md");
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
let path = vault_path.join(file_name);
let _ = write_if_missing(&path, content);
}
/// Ensure the default root type definitions exist for opened/repaired vaults.
fn ensure_root_type_definitions(vault_path: &Path) {
ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION);
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
}
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
@@ -241,8 +278,8 @@ fn ensure_config_type_definition(vault_path: &str) {
/// - Cleans up empty `config/` directory after migration.
///
/// Always idempotent and silent.
pub fn migrate_agents_md(vault_path: &str) {
let vault = Path::new(vault_path);
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
@@ -259,22 +296,23 @@ pub fn migrate_agents_md(vault_path: &str) {
log::info!("Removed empty config/ directory");
}
let _ = sync_ai_guidance_files(vault_path);
let _ = sync_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
/// Migrates legacy `config/agents.md` to root if present.
/// Called by the "Repair Vault" command. Returns a status message.
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault_path)?;
sync_ai_guidance_files(vault)?;
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string())
}
@@ -417,15 +455,20 @@ mod tests {
}
#[test]
fn test_seed_config_files_creates_type_definition() {
fn test_seed_config_files_creates_type_definitions() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("config.md").exists());
let content = fs::read_to_string(vault.join("config.md")).unwrap();
assert!(content.contains("type: Type"));
assert!(content.contains("icon: gear-six"));
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
}
@@ -542,11 +585,18 @@ mod tests {
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("config.md").exists());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));
}
#[test]

View File

@@ -276,7 +276,7 @@ Keep edits compatible with this starter vault's current conventions. Prefer smal
- One Markdown note per file.
- The first H1 in the body is the preferred display title. Legacy `title:` frontmatter is still read as a fallback when a note has no H1, but do not add it to new notes.
- Store note type in the `type:` frontmatter field.
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `config.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `type.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
- Saved views live in `views/*.yml`.
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
@@ -486,6 +486,7 @@ fn create_getting_started_vault_from_repo(
crate::git::clone_repo(repo_url, &target_path_str)?;
let vault_path = canonical_vault_path(target_path)?;
crate::git::disconnect_all_remotes(path_to_utf8(&vault_path, "Vault path")?)?;
refresh_cloned_vault_config_files(&vault_path)?;
Ok(vault_path)
}
@@ -649,7 +650,8 @@ mod tests {
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
assert_eq!(content, AGENTS_MD);
assert!(dest.join("config.md").exists());
assert!(dest.join("type.md").exists());
assert!(dest.join("note.md").exists());
}
#[test]
@@ -686,7 +688,8 @@ mod tests {
fs::read_to_string(dest.join("AGENTS.md")).unwrap(),
AGENTS_MD
);
assert!(dest.join("config.md").exists());
assert!(dest.join("type.md").exists());
assert!(dest.join("note.md").exists());
}
#[test]
@@ -735,6 +738,18 @@ mod tests {
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
}
#[test]
fn test_create_getting_started_vault_removes_the_starter_remote() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
assert!(!crate::git::has_remote(dest.to_str().unwrap()).unwrap());
}
#[test]
fn test_create_getting_started_vault_replaces_legacy_agents_template() {
assert_getting_started_vault_replaces_template(LEGACY_AGENTS_MD);

View File

@@ -17,6 +17,12 @@ pub struct ViewDefinition {
pub color: Option<String>,
#[serde(default)]
pub sort: Option<String>,
#[serde(
default,
rename = "listPropertiesDisplay",
skip_serializing_if = "Vec::is_empty"
)]
pub list_properties_display: Vec<String>,
pub filters: FilterGroup,
}
@@ -195,6 +201,37 @@ pub fn migrate_views(vault_path: &Path) {
}
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
fn is_view_definition_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("yml")
}
fn read_view_file(path: &Path) -> Option<ViewFile> {
if !is_view_definition_file(path) {
return None;
}
let filename = path.file_name()?.to_string_lossy().to_string();
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) => {
log::warn!("Failed to read view file {}: {}", filename, error);
return None;
}
};
let definition = match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => definition,
Err(error) => {
log::warn!("Failed to parse view {}: {}", filename, error);
return None;
}
};
Some(ViewFile {
filename,
definition,
})
}
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
migrate_views(vault_path);
let views_dir = vault_path.join("views");
@@ -212,20 +249,8 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
continue;
}
let filename = entry.file_name().to_string_lossy().to_string();
match fs::read_to_string(&path) {
Ok(content) => match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => views.push(ViewFile {
filename,
definition,
}),
Err(e) => log::warn!("Failed to parse view {}: {}", filename, e),
},
Err(e) => log::warn!("Failed to read view file {}: {}", filename, e),
if let Some(view) = read_view_file(&entry.path()) {
views.push(view);
}
}
@@ -656,6 +681,22 @@ mod tests {
entry
}
fn make_project_view(name: &str) -> ViewDefinition {
ViewDefinition {
name: name.to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
}
}
#[test]
fn test_parse_simple_view() {
let yaml = r#"
@@ -670,6 +711,7 @@ filters:
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "Active Projects");
assert_eq!(def.icon.as_deref(), Some("rocket"));
assert!(def.list_properties_display.is_empty());
match &def.filters {
FilterGroup::All(nodes) => {
assert_eq!(nodes.len(), 1);
@@ -945,18 +987,10 @@ filters:
fn test_save_and_read_view() {
let dir = tempfile::TempDir::new().unwrap();
let def = ViewDefinition {
name: "Test View".to_string(),
icon: Some("star".to_string()),
color: None,
sort: Some("modified:desc".to_string()),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
};
let mut def = make_project_view("Test View");
def.icon = Some("star".to_string());
def.sort = Some("modified:desc".to_string());
def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()];
save_view(dir.path(), "test.yml", &def).unwrap();
@@ -964,6 +998,10 @@ filters:
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Test View");
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
assert_eq!(
views[0].definition.list_properties_display,
vec!["Priority".to_string(), "Owner".to_string()]
);
delete_view(dir.path(), "test.yml").unwrap();
let views = scan_views(dir.path());
@@ -974,18 +1012,8 @@ filters:
fn test_save_and_read_view_with_emoji_icon() {
let dir = tempfile::TempDir::new().unwrap();
let def = ViewDefinition {
name: "Monday".to_string(),
icon: Some("🗂️".to_string()),
color: None,
sort: None,
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
};
let mut def = make_project_view("Monday");
def.icon = Some("🗂️".to_string());
save_view(dir.path(), "monday.yml", &def).unwrap();

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -76,8 +76,14 @@ const mockAllContent: Record<string, string> = {
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
}
const mockVaultList = {
vaults: [{ label: 'Test Vault', path: '/vault' }],
active_vault: '/vault',
hidden_defaults: [],
}
const mockCommandResults: Record<string, unknown> = {
load_vault_list: { vaults: [], active_vault: null, hidden_defaults: [] },
load_vault_list: mockVaultList,
list_vault: mockEntries,
list_vault_folders: [],
list_views: [],
@@ -94,9 +100,42 @@ const mockCommandResults: Record<string, unknown> = {
get_vault_settings: { theme: null },
}
function resetMockCommandResults() {
Object.assign(mockCommandResults, {
load_vault_list: mockVaultList,
list_vault: mockEntries,
list_vault_folders: [],
list_views: [],
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
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,
},
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Getting Started',
list_themes: [],
get_vault_settings: { theme: null },
})
}
function resolveMockCommandResult(cmd: string, args?: unknown) {
const result = mockCommandResults[cmd]
return typeof result === 'function'
? (result as (input?: unknown) => unknown)(args)
: result ?? null
}
vi.mock('./mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
}))
@@ -156,16 +195,14 @@ const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dis
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockCommandResults()
localStorage.clear()
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
it('renders the four-panel layout', async () => {
render(<App />)
// Wait for vault to load
await waitFor(() => {
expect(screen.getByText('All Notes')).toBeInTheDocument()
})
expect(await screen.findByText('All Notes', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('loads and displays vault entries in sidebar', async () => {
@@ -187,7 +224,7 @@ describe('App', () => {
it('shows keyboard shortcut hints', async () => {
render(<App />)
await waitFor(() => {
expect(screen.getByText(/Cmd\+P to search/)).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
})
})
@@ -204,6 +241,68 @@ describe('App', () => {
})
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('telemetry-accept'))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it.each([
['telemetry-accept', 'Allow anonymous reporting'],
['telemetry-decline', 'No thanks'],
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = {
vaults: [],
active_vault: rememberedDefaultVaultPath,
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(buttonTestId))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('renders sidebar with correct default selection (All Notes)', async () => {
render(<App />)
await waitFor(() => {
@@ -214,6 +313,11 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',
hidden_defaults: [],
}
const disabledWorkflowConfig = JSON.stringify({
zoom: null,
view_mode: null,
@@ -223,13 +327,19 @@ describe('App', () => {
property_display_modes: null,
inbox: { noteListProperties: null, explicitOrganization: false },
})
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
const vaultPaths = [
'/Users/mock/Documents/Getting Started',
'/Users/mock/demo-vault-v2',
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
]
for (const path of vaultPaths) {
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
}
render(<App />)
await waitFor(() => {
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
expect(within(screen.getByTestId('sidebar-top-nav')).queryByText('Inbox')).not.toBeInTheDocument()
expect(screen.getByText('All Notes')).toBeInTheDocument()
})
})
@@ -245,6 +355,30 @@ describe('App', () => {
expect(appShell).toBeInTheDocument()
})
it('switches vaults from the bottom bar after onboarding is ready', async () => {
mockCommandResults.load_vault_list = {
vaults: [
{ label: 'Test Vault', path: '/work' },
{ label: 'Work Vault', path: '/vault-2' },
],
active_vault: '/work',
hidden_defaults: [],
}
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Test Vault')
})
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByTestId('vault-menu-item-Work Vault'))
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault')
})
})
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -35,9 +35,10 @@ import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { triggerCommitEntryAction } from './utils/commitEntryAction'
import { generateCommitMessage } from './utils/commitMessage'
import { useDialogs } from './hooks/useDialogs'
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useAutoSync } from './hooks/useAutoSync'
@@ -68,7 +69,7 @@ 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 } from './types'
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
@@ -104,6 +105,16 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
function shouldPreferOnboardingVaultPath(
onboardingState: { status: string; vaultPath?: string },
vaults: Array<{ path: string }>,
): onboardingState is { status: 'ready'; vaultPath: string } {
return onboardingState.status === 'ready'
&& typeof onboardingState.vaultPath === 'string'
&& onboardingState.vaultPath.length > 0
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
async function resolveNoteWindowEntry(
noteWindowParams: NoteWindowParams,
entries: VaultEntry[],
@@ -178,6 +189,9 @@ function App() {
setSelection(sel)
setNoteListFilter('open')
}, [])
const handleEnterNeighborhood = useCallback((entry: VaultEntry) => {
handleSetSelection({ kind: 'entity', entry })
}, [handleSetSelection])
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
const { setInspectorCollapsed } = layout
const visibleNotesRef = useRef<VaultEntry[]>([])
@@ -206,25 +220,53 @@ function App() {
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const { handleVaultCloned } = vaultSwitcher
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
if (!vaultPath) return
if (allVaults.some((vault) => vault.path === vaultPath)) {
switchVault(vaultPath)
return
}
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
handleVaultCloned(vaultPath, label)
}, [allVaults, handleVaultCloned, switchVault])
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
rememberOnboardingVaultChoice(vaultPath)
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
}, [handleVaultCloned])
}, [rememberOnboardingVaultChoice])
const cloneGettingStartedVault = useGettingStartedClone({
onError: (message) => setToastMessage(message),
onSuccess: handleGettingStartedVaultReady,
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
handleGettingStartedVaultReady(vaultPath)
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
// The active vault path can temporarily come from onboarding before the
// persisted vault switcher catches up to the newly cloned starter vault.
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
useEffect(() => {
const onboardingVaultPath = onboarding.userReadyVaultPath
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
rememberOnboardingVaultChoice(onboardingVaultPath)
}
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
// Onboarding can briefly own the vault path for a newly created/opened vault
// before the persisted switcher catches up, but once the path is already in
// the switcher list we should trust the explicit switcher state.
const resolvedPath = noteWindowParams?.vaultPath ?? (
shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults)
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
useEffect(() => {
@@ -336,12 +378,20 @@ function App() {
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const notes = useNoteActions({
addEntry: vault.addEntry,
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
flushBeforeNoteSwitch: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforePathRename: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -514,6 +564,11 @@ function App() {
}, [setInspectorCollapsed])
const handleCustomizeNoteListColumns = useCallback(() => {
if (effectiveSelection.kind === 'view') {
openNoteListPropertiesPicker('view')
return
}
if (effectiveSelection.kind !== 'filter') return
if (effectiveSelection.filter === 'all') {
openNoteListPropertiesPicker('all')
@@ -634,6 +689,7 @@ function App() {
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
})
const recordAutoGitActivity = autoGit.recordActivity
const openCommitDialog = commitFlow.openCommitDialog
const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint
const handleAppContentChange = appSave.handleContentChange
const handleAppSave = appSave.handleSave
@@ -644,9 +700,13 @@ function App() {
recordAutoGitActivity()
}, [modifiedFilesSignature, recordAutoGitActivity])
const handleQuickCommitPush = useCallback(() => {
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
}, [runAutomaticCheckpoint])
const handleCommitPush = useCallback(() => {
triggerCommitEntryAction({
autoGitEnabled: settings.autogit_enabled === true,
openCommitDialog,
runAutomaticCheckpoint,
})
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
const handleTrackedContentChange = useCallback((path: string, content: string) => {
recordAutoGitActivity()
@@ -748,21 +808,35 @@ function App() {
)
}, [notes, setToastMessage, vault.entries])
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
const existing = vault.views.find((view) => view.filename === filename)
if (!existing) return
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', {
vaultPath: resolvedPath,
filename,
definition: { ...existing.definition, ...patch },
})
await vault.reloadViews()
}, [resolvedPath, vault])
const handleEditView = useCallback((filename: string) => {
const view = vault.views.find((v) => v.filename === filename)
if (view) dialogs.openEditView(filename, view.definition)
@@ -899,6 +973,28 @@ function App() {
) ?? null
}, [notes.activeTabPath, vault.modifiedFiles])
const activeCommandEntry = useMemo(() => {
if (!notes.activeTabPath) return null
return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
?? null
}, [notes.activeTabPath, notes.tabs, vault.entries])
const canToggleRichEditor = !!activeCommandEntry
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const noteListColumnsLabel = useMemo(() => {
if (effectiveSelection.kind === 'view') {
const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename)
return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns'
}
return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -916,13 +1012,13 @@ function App() {
onOpenFeedback: openFeedback,
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onCommitPush: handleCommitPush,
onPull: autoSync.triggerSync,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -932,6 +1028,7 @@ function App() {
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: canGoBack, canGoForward: canGoForward,
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
@@ -963,8 +1060,12 @@ function App() {
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
canCustomizeNoteListColumns: effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')),
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
),
noteListColumnsLabel,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
canRestoreDeletedNote: !!activeDeletedFile,
})
@@ -987,16 +1088,45 @@ function App() {
return { type: null, query: '' }
}, [effectiveSelection])
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
}
const shouldResumeFreshStartOnboarding = useMemo(() => {
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
return remembersOnlyDefaultVault
&& vaultSwitcher.allVaults.length === 1
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows).
// After the user answers, the next render can continue into onboarding.
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
const id = crypto.randomUUID()
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
}}
onDecline={() => {
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
}}
/>
)
}
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = shouldResumeFreshStartOnboarding
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
: onboarding
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
return (
<>
@@ -1026,21 +1156,6 @@ function App() {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows)
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
const id = crypto.randomUUID()
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
}}
onDecline={() => {
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
}}
/>
)
}
return (
<div className="app-shell">
<div className="app">
@@ -1058,7 +1173,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<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={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} 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={notes.handleReplaceActiveTab} 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} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1117,12 +1232,13 @@ function App() {
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<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} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleQuickCommitPush} 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} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} 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} 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} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1186,7 +1302,7 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
defaultVaultPath={state.defaultPath}
onCreateVault={onboarding.handleCreateVault}
onRetryCreateVault={onboarding.retryCreateVault}
onCreateNewVault={onboarding.handleCreateNewVault}
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
onOpenFolder={onboarding.handleOpenFolder}
isOffline={isOffline}
creatingAction={onboarding.creatingAction}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Check, X } from 'lucide-react'
@@ -27,6 +28,17 @@ function dateToISO(day: Date): string {
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
function isValidNumberValue(value: string): boolean {
const trimmed = value.trim()
if (trimmed === '') return false
return Number.isFinite(Number(trimmed))
}
function canSubmitProperty({ key, value, displayMode }: { key: string; value: string; displayMode: PropertyDisplayMode }): boolean {
if (!key.trim()) return false
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
@@ -91,21 +103,42 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
)
}
function AddNumberInput({ value, onChange, onKeyDown }: {
value: string
onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void
}) {
return (
<Input
className={`${ADD_INPUT_CLASS} font-mono tabular-nums`}
type="text"
inputMode="decimal"
placeholder="0"
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
data-testid="add-property-number-input"
/>
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
}) {
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 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
default: return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
@@ -119,6 +152,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
const canSubmit = canSubmitProperty({ key: newKey, value: newValue, displayMode })
const handleModeChange = (mode: PropertyDisplayMode) => {
setDisplayMode(mode)
@@ -127,13 +161,13 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
if (e.key === 'Enter' && canSubmit) onAdd(newKey, newValue, displayMode)
else if (e.key === 'Escape') onCancel()
}
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<input
<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}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
@@ -162,7 +196,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!newKey.trim()} title="Add property"
disabled={!canSubmit} title="Add property"
data-testid="add-property-confirm"
>
<Check className="size-3.5" />

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AddRemoteModal } from './AddRemoteModal'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
describe('AddRemoteModal', () => {
const onClose = vi.fn()
const onRemoteConnected = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockResolvedValue({
status: 'connected',
message: 'Remote connected. This vault now tracks origin/main.',
})
})
it('renders the add-remote form when open', async () => {
render(
<AddRemoteModal
open={true}
vaultPath="/vault"
onClose={onClose}
onRemoteConnected={onRemoteConnected}
/>
)
expect(screen.getByText('Add Remote')).toBeInTheDocument()
const input = screen.getByTestId('add-remote-url')
await waitFor(() => expect(input).toHaveFocus())
})
it('submits the repository URL to git_add_remote', async () => {
render(
<AddRemoteModal
open={true}
vaultPath="/vault"
onClose={onClose}
onRemoteConnected={onRemoteConnected}
/>
)
fireEvent.change(screen.getByTestId('add-remote-url'), {
target: { value: 'git@github.com:user/my-vault.git' },
})
fireEvent.click(screen.getByTestId('add-remote-submit'))
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_add_remote', {
request: {
vaultPath: '/vault',
remoteUrl: 'git@github.com:user/my-vault.git',
},
})
})
expect(onRemoteConnected).toHaveBeenCalledWith('Remote connected. This vault now tracks origin/main.')
expect(onClose).toHaveBeenCalledOnce()
})
it('shows backend validation errors without closing the modal', async () => {
mockInvokeFn.mockResolvedValueOnce({
status: 'incompatible_history',
message: 'This repository has unrelated history.',
})
render(
<AddRemoteModal
open={true}
vaultPath="/vault"
onClose={onClose}
onRemoteConnected={onRemoteConnected}
/>
)
fireEvent.change(screen.getByTestId('add-remote-url'), {
target: { value: 'https://example.com/repo.git' },
})
fireEvent.click(screen.getByTestId('add-remote-submit'))
await waitFor(() => {
expect(screen.getByTestId('add-remote-error')).toHaveTextContent('This repository has unrelated history.')
})
expect(onRemoteConnected).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,171 @@
import { useCallback, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { invoke } from '@tauri-apps/api/core'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { GitAddRemoteResult } from '../types'
import { isTauri, mockInvoke } from '../mock-tauri'
type ConnectState = 'idle' | 'connecting'
interface AddRemoteModalProps {
open: boolean
vaultPath: string
onClose: () => void
onRemoteConnected: (message: string) => void | Promise<void>
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function shouldCloseAfterResult(result: GitAddRemoteResult): boolean {
return result.status === 'connected' || result.status === 'already_configured'
}
async function submitRemoteConnection(
vaultPath: string,
remoteUrl: string,
): Promise<GitAddRemoteResult> {
return tauriCall<GitAddRemoteResult>('git_add_remote', {
request: {
vaultPath,
remoteUrl,
},
})
}
async function getConnectErrorMessage({
vaultPath,
remoteUrl,
onRemoteConnected,
onClose,
}: {
vaultPath: string
remoteUrl: string
onRemoteConnected: (message: string) => void | Promise<void>
onClose: () => void
}): Promise<string | null> {
try {
const result = await submitRemoteConnection(vaultPath, remoteUrl)
if (shouldCloseAfterResult(result)) {
await onRemoteConnected(result.message)
onClose()
return null
}
return result.message
} catch (error) {
return `Could not connect that remote: ${String(error)}`
}
}
export function AddRemoteModal({
open,
vaultPath,
onClose,
onRemoteConnected,
}: AddRemoteModalProps) {
const [remoteUrl, setRemoteUrl] = useState('')
const [connectState, setConnectState] = useState<ConnectState>('idle')
const [connectError, setConnectError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const resetState = useCallback(() => {
setRemoteUrl('')
setConnectState('idle')
setConnectError(null)
}, [])
const handleClose = useCallback(() => {
resetState()
onClose()
}, [onClose, resetState])
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) {
handleClose()
}
}, [handleClose])
const handleRemoteUrlChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setRemoteUrl(event.target.value)
setConnectError(null)
}, [])
const handleSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmedUrl = remoteUrl.trim()
if (!trimmedUrl) return
setConnectState('connecting')
setConnectError(null)
const errorMessage = await getConnectErrorMessage({
vaultPath,
remoteUrl: trimmedUrl,
onRemoteConnected,
onClose: handleClose,
})
if (errorMessage) {
setConnectError(errorMessage)
}
setConnectState('idle')
}, [handleClose, onRemoteConnected, remoteUrl, vaultPath])
const connectDisabled = connectState === 'connecting' || !remoteUrl.trim()
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[520px]" data-testid="add-remote-modal">
<DialogHeader>
<DialogTitle>Add Remote</DialogTitle>
<DialogDescription>
Connect this local vault to a git remote. Your existing local commits stay intact; Tolaria
will only connect the vault when the remote history is safe to use.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4 py-2" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground" htmlFor="add-remote-url">Repository URL</label>
<Input
id="add-remote-url"
ref={inputRef}
autoFocus
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
value={remoteUrl}
onChange={handleRemoteUrlChange}
data-testid="add-remote-url"
/>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
Use an empty repository or one created from this vault. SSH keys, Git Credential Manager,
and other system git auth methods all work.
</p>
{connectError && (
<p className="text-xs text-destructive" data-testid="add-remote-error">{connectError}</p>
)}
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
<Button type="submit" disabled={connectDisabled} data-testid="add-remote-submit">
{connectState === 'connecting' ? 'Connecting...' : 'Connect Remote'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
const openExternalUrl = vi.fn()
const dragRegionMouseDown = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('AiAgentsOnboardingPrompt', () => {
beforeEach(() => {
@@ -65,4 +69,22 @@ describe('AiAgentsOnboardingPrompt', () => {
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli')
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
const screenContainer = screen.getByTestId('ai-agents-onboarding-screen')
fireEvent.mouseDown(screenContainer)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -7,6 +7,7 @@ import {
type AiAgentsStatus,
} from '../lib/aiAgents'
import { openExternalUrl } from '../utils/url'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
@@ -82,11 +83,12 @@ export function AiAgentsOnboardingPrompt({
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="ai-agents-onboarding-screen"
<OnboardingShell
className="bg-sidebar px-6 py-10"
contentClassName="w-full max-w-2xl"
testId="ai-agents-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<Card className="border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
@@ -141,6 +143,6 @@ export function AiAgentsOnboardingPrompt({
</div>
</CardFooter>
</Card>
</div>
</OnboardingShell>
)
}

View File

@@ -627,6 +627,14 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('smart property display — number', () => {
it('renders numeric properties with the number display affordance', () => {
renderEditablePanel({ estimate: -3.25 })
expect(screen.getByTestId('number-display')).toBeInTheDocument()
expect(screen.getByText('-3.25')).toBeInTheDocument()
})
})
describe('smart property display — status auto-detection', () => {
it('renders status badge for property named Status', () => {
renderEditablePanel({ Status: 'Active' })
@@ -741,6 +749,7 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByTestId('display-mode-trigger'))
expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-number')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-date')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument()
@@ -798,6 +807,13 @@ describe('DynamicPropertiesPanel', () => {
})
describe('type-aware add property form', () => {
it('shows number input when number type selected', () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
expect(screen.getByTestId('add-property-number-input')).toBeInTheDocument()
})
it('shows boolean toggle when boolean type selected', () => {
openAddPropertyForm()
// Switch type to boolean
@@ -829,6 +845,16 @@ describe('DynamicPropertiesPanel', () => {
expect(onAddProperty).toHaveBeenCalledWith('published', true)
})
it('stores trimmed decimal values as numbers when adding number properties', () => {
const { keyInput } = openAddPropertyForm()
fireEvent.change(keyInput, { target: { value: 'estimate' } })
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
fireEvent.change(screen.getByTestId('add-property-number-input'), { target: { value: ' -12.5 ' } })
fireEvent.click(screen.getByTestId('add-property-confirm'))
expect(onAddProperty).toHaveBeenCalledWith('estimate', -12.5)
})
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })

View File

@@ -181,7 +181,7 @@ describe('Editor', () => {
it('shows empty state when no tabs are open', () => {
renderEditor()
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P to search/)).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
})
it('renders an invisible drag region in the empty state', () => {
@@ -658,7 +658,7 @@ describe('wikilink autocomplete', () => {
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})
it('shows correct noteType and color for typed entries, neutral for untyped', async () => {
it('shows Note chips and icons for explicit Note entries while keeping untyped entries neutral', async () => {
const mixedEntries: VaultEntry[] = [
{ ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
{ ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
@@ -675,20 +675,24 @@ describe('wikilink autocomplete', () => {
/>
)
const items = await capturedGetItems!('Test')
// Typed entries should have noteType and color
// Typed entries should have noteType, color, and a left-side icon
const project = items.find((i: { title: string }) => i.title === 'Test Project')
expect(project).toBeDefined()
expect(project!.noteType).toBe('Project')
expect(project!.typeColor).toBeTruthy()
// Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral)
expect(project!.TypeIcon).toBeTruthy()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBe('Note')
expect(explicitNote!.typeColor).toBeTruthy()
expect(explicitNote!.TypeIcon).toBeTruthy()
// Untyped entries should remain neutral
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
expect(plainNote).toBeDefined()
expect(plainNote!.noteType).toBeUndefined()
expect(plainNote!.typeColor).toBeUndefined()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBeUndefined()
expect(explicitNote!.typeColor).toBeUndefined()
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})

View File

@@ -87,6 +87,8 @@ interface EditorProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
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>
}
function useEditorModeExclusion({
@@ -136,7 +138,7 @@ function EditorEmptyState() {
/>
<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">Cmd+P to search &middot; Cmd+N to create</span>
<span className="text-xs text-muted-foreground">Cmd+P or Cmd+O to search &middot; Cmd+N to create</span>
</div>
</div>
)
@@ -224,39 +226,147 @@ function useEditorSetup({
}
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useEffect(() => {
if (!flushPendingRawContentRef) return
flushPendingRawContentRef.current = flushPendingRawContent
return () => {
if (flushPendingRawContentRef.current === flushPendingRawContent) {
flushPendingRawContentRef.current = null
}
}
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function EditorLayout({
tabs,
activeTab,
isLoadingNewTab,
entries,
editor,
diffMode,
diffContent,
diffLoading,
handleToggleDiffExclusive,
rawMode,
handleToggleRawExclusive,
onContentChange,
onSave,
activeStatus,
showDiffToggle,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
vaultPath,
rawModeContent,
rawLatestContentRef,
onRenameFilename,
isConflicted,
onKeepMine,
onKeepTheirs,
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
gitHistory,
noteList,
noteListFilter,
handleViewCommitDiff,
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
onCreateAndOpenNote,
onInitializeProperties,
onFileCreated,
onFileModified,
onVaultChanged,
}: {
tabs: Tab[]
activeTab: Tab | null
isLoadingNewTab: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
diffContent: string | null
diffLoading: boolean
handleToggleDiffExclusive: () => void | Promise<void>
rawMode: boolean
handleToggleRawExclusive: () => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
gitHistory: GitCommit[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
handleViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
@@ -330,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
</div>
</div>
)
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
} = props
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
})
return (
<EditorLayout
tabs={tabs}
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
editor={editor}
diffMode={diffMode}
diffContent={diffContent}
diffLoading={diffLoading}
handleToggleDiffExclusive={handleToggleDiffExclusive}
rawMode={rawMode}
handleToggleRawExclusive={handleToggleRawExclusive}
onContentChange={onContentChange}
onSave={onSave}
activeStatus={activeStatus}
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
gitHistory={gitHistory}
noteList={noteList}
noteListFilter={noteListFilter}
handleViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
)
})

View File

@@ -2,6 +2,12 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { GitRequiredModal } from './GitRequiredModal'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('GitRequiredModal', () => {
it('renders title and explanation', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
@@ -48,4 +54,14 @@ describe('GitRequiredModal', () => {
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()} />)
const shell = screen.getByTestId('git-required-shell')
fireEvent.mouseDown(shell)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { GitBranch } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
interface GitRequiredModalProps {
onCreateRepo: () => Promise<void>
@@ -22,8 +23,12 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
}
return (
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<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">
@@ -52,6 +57,6 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
</button>
</div>
</div>
</div>
</OnboardingShell>
)
}

View File

@@ -1,5 +1,7 @@
import {
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type { VaultEntry } from '../types'
@@ -14,6 +16,12 @@ import {
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
} from './inlineWikilinkText'
import { serializeInlineNode } from './inlineWikilinkDom'
import {
buildPendingPasteState,
type PendingPasteState,
shouldRecoverPendingPaste,
} from './inlineWikilinkPasteRecovery'
import {
InlineWikilinkEditorField,
InlineWikilinkPaletteLayout,
@@ -48,7 +56,6 @@ function collapseSelectionRange(nextSelectionIndex: number) {
end: nextSelectionIndex,
}
}
function submitInlineValue({
onSubmit,
submitOnEmpty,
@@ -66,48 +73,6 @@ function submitInlineValue({
onSubmit(normalizedValue, references)
}
function renderInlineEditorField({
value,
placeholder,
disabled,
inputRef,
dataTestId,
editorClassName,
onInput,
onKeyDown,
onSelectionChange,
segments,
typeEntryMap,
}: {
value: string
placeholder?: string
disabled: boolean
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: ReturnType<typeof buildInlineWikilinkSegments>
typeEntryMap: Record<string, VaultEntry>
}) {
return (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={inputRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={onInput}
onKeyDown={onKeyDown}
onSelectionChange={onSelectionChange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
}
function renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,
@@ -157,12 +122,14 @@ export function InlineWikilinkInput({
paletteEmptyState,
paletteFooter,
}: InlineWikilinkInputProps) {
const [, forceRender] = useState(0)
const segments = useMemo(
() => buildInlineWikilinkSegments(value, entries),
[entries, value],
)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const {
editorRef,
selectionRange,
selectionIndex,
setSelectionRange,
@@ -175,6 +142,7 @@ export function InlineWikilinkInput({
onChange,
inputRef,
})
const pendingPasteRef = useRef<PendingPasteState | null>(null)
const activeQuery = useMemo(
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
@@ -209,6 +177,36 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
if (!pastedText) return
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
pendingPasteRef.current = buildPendingPasteState(value, selectionRange, pastedText)
event.preventDefault()
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handleInput = () => {
const editor = editorRef.current
const pendingPaste = pendingPasteRef.current
if (editor && pendingPaste) {
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
pendingPasteRef.current = null
if (shouldRecoverPendingPaste(nextValue, pendingPaste)) {
onChange(pendingPaste.expectedValue)
forceRender((current) => current + 1)
setSelectionRange({ ...pendingPaste.expectedSelection })
return
}
}
commitValueFromEditor()
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
@@ -223,19 +221,22 @@ export function InlineWikilinkInput({
canSubmit: onSubmit !== undefined,
onSubmit: submitValue,
})
const editor = renderInlineEditorField({
value,
placeholder,
disabled,
inputRef: setCombinedRef,
dataTestId,
editorClassName,
onInput: commitValueFromEditor,
onKeyDown: handleKeyDown,
onSelectionChange: syncSelectionRange,
segments,
typeEntryMap,
})
const editor = (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onSelectionChange={syncSelectionRange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
const suggestionList = renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,

View File

@@ -160,6 +160,7 @@ export function InlineWikilinkEditorField({
editorClassName,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
segments,
typeEntryMap,
@@ -172,6 +173,7 @@ export function InlineWikilinkEditorField({
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
)}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
onMouseUp={onSelectionChange}

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
@@ -156,14 +156,17 @@ describe('NoteList multi-select', () => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2])
})
it('clears multi-select and opens a new tab on Cmd+Click', () => {
const { onSelectNote } = renderNoteList()
it('clears multi-select and enters Neighborhood on Cmd+Click', async () => {
const { onEnterNeighborhood, onReplaceActiveTab } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[2])
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[2])
})
})
it('shows the bulk action bar with the selected count', () => {
@@ -262,6 +265,23 @@ describe('NoteList filter pills', () => {
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows only explicit Note entries for the Notes type filter', () => {
const noteEntries = [
makeEntry({ title: 'Note Type', isA: 'Type', path: '/types/note.md', filename: 'note.md' }),
makeEntry({ title: 'Explicit Note', isA: 'Note', path: '/explicit-note.md', filename: 'explicit-note.md' }),
makeEntry({ title: 'Untyped Note', isA: null, path: '/untyped-note.md', filename: 'untyped-note.md' }),
makeEntry({ title: 'Archived Explicit Note', isA: 'Note', archived: true, path: '/archived-note.md', filename: 'archived-note.md' }),
]
renderNoteList({ entries: noteEntries, selection: { kind: 'sectionGroup', type: 'Note' } })
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
expect(screen.queryByText('Untyped Note')).not.toBeInTheDocument()
expect(screen.queryByText('Archived Explicit Note')).not.toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('1')
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
})
it('shows the archived empty state when a section has no archived notes', () => {
renderNoteList({
entries: projectEntries.filter((entry) => !entry.archived),

View File

@@ -1,17 +1,22 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import {
allSelection,
mockEntries,
} from '../test-utils/noteListTestUtils'
import type { VaultEntry } from '../types'
import type { SidebarSelection, VaultEntry } from '../types'
import * as tabManagement from '../hooks/useTabManagement'
function NoteListKeyboardHarness({
onOpen,
onEnterNeighborhood = () => {},
selection = allSelection,
}: {
onOpen: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
selection?: SidebarSelection
}) {
const [selectedNote, setSelectedNote] = useState<VaultEntry | null>(null)
@@ -23,18 +28,23 @@ function NoteListKeyboardHarness({
return (
<NoteList
entries={mockEntries}
selection={allSelection}
selection={selection}
selectedNote={selectedNote}
noteListFilter="open"
onNoteListFilterChange={() => {}}
onSelectNote={handleOpen}
onReplaceActiveTab={handleOpen}
onEnterNeighborhood={onEnterNeighborhood}
onCreateNote={() => {}}
/>
)
}
describe('NoteList keyboard activation', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('focuses the list on click and continues arrow navigation from the clicked note', async () => {
const onOpen = vi.fn()
render(<NoteListKeyboardHarness onOpen={onOpen} />)
@@ -51,8 +61,10 @@ describe('NoteList keyboard activation', () => {
fireEvent.keyDown(container, { key: 'ArrowDown' })
expect(onOpen).toHaveBeenNthCalledWith(1, mockEntries[1])
expect(onOpen).toHaveBeenNthCalledWith(2, mockEntries[2])
await waitFor(() => {
expect(onOpen).toHaveBeenNthCalledWith(1, mockEntries[1])
expect(onOpen).toHaveBeenNthCalledWith(2, mockEntries[2])
})
})
it('navigates from global arrow keys when the editor is not focused', async () => {
@@ -71,4 +83,38 @@ describe('NoteList keyboard activation', () => {
expect(onOpen).toHaveBeenCalledWith(mockEntries[0])
})
})
it('supports Cmd+Enter to pivot the highlighted note into Neighborhood mode', async () => {
const onOpen = vi.fn()
const onEnterNeighborhood = vi.fn()
render(
<NoteListKeyboardHarness
onOpen={onOpen}
onEnterNeighborhood={onEnterNeighborhood}
selection={{ kind: 'entity', entry: mockEntries[0] }}
/>,
)
const container = screen.getByTestId('note-list-container')
fireEvent.keyDown(container, { key: 'ArrowDown' })
fireEvent.keyDown(container, { key: 'ArrowDown' })
fireEvent.keyDown(container, { key: 'Enter', metaKey: true })
await waitFor(() => {
expect(onOpen).toHaveBeenLastCalledWith(mockEntries[4])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[4])
})
})
it('prefetches note content on hover so click opens can use the warm path', () => {
const prefetchSpy = vi.spyOn(tabManagement, 'prefetchNoteContent').mockImplementation(() => {})
render(<NoteListKeyboardHarness onOpen={vi.fn()} />)
const noteRow = screen.getByText('Facebook Ads Strategy').closest('[data-note-path]')
expect(noteRow).not.toBeNull()
fireEvent.mouseEnter(noteRow!)
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
})
})

View File

@@ -1,13 +1,17 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
import {
allSelection,
buildNoteListProps,
makeEntry,
makeTypeDefinition,
mockEntries,
renderNoteList,
} from '../test-utils/noteListTestUtils'
import type { ViewFile } from '../types'
function makeBookTypeEntries(
displayProps: string[] = [],
@@ -28,6 +32,58 @@ function makeBookTypeEntries(
const noop = () => undefined
function makeViewDefinition(overrides: Partial<ViewFile> = {}): ViewFile {
return {
filename: 'active-books.yml',
definition: {
name: 'Active Books',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
...overrides.definition,
},
...overrides,
}
}
function renderManagedViewNoteList({
entries,
view = makeViewDefinition(),
}: {
entries: Parameters<typeof renderNoteList>[0]['entries']
view?: ViewFile
}) {
const built = buildNoteListProps({
entries,
selection: { kind: 'view', filename: view.filename },
views: [view],
})
function ManagedViewNoteList() {
const [views, setViews] = useState([view])
return (
<NoteList
{...built.props}
views={views}
onUpdateViewDefinition={(filename, patch) => {
setViews((currentViews) => currentViews.map((currentView) => (
currentView.filename === filename
? { ...currentView, definition: { ...currentView.definition, ...patch } }
: currentView
)))
}}
/>
)
}
return {
...render(<ManagedViewNoteList />),
...built,
}
}
function searchNoteList(query: string) {
const searchInput = screen.queryByPlaceholderText('Search notes...')
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
@@ -247,6 +303,124 @@ describe('NoteList rendering', () => {
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
})
it('shows no placeholder neighborhood groups when none exist', () => {
const standalone = makeEntry({
path: '/vault/solo.md',
filename: 'solo.md',
title: 'Standalone',
isA: 'Note',
})
renderNoteList({
entries: [standalone],
selection: { kind: 'entity', entry: standalone },
})
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
})
it('keeps existing neighborhood groups visible at zero after search filters them out', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const child = makeEntry({
path: '/vault/child.md',
filename: 'child.md',
title: 'Child Note',
isA: 'Note',
belongsTo: ['[[parent]]'],
})
renderNoteList({
entries: [parent, child],
selection: { kind: 'entity', entry: parent },
})
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
searchNoteList('missing-neighborhood-match')
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
})
it('shows the same note in multiple neighborhood groups when relationships overlap', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
relationships: { 'Related to': ['[[shared-note]]'] },
})
const shared = makeEntry({
path: '/vault/shared-note.md',
filename: 'shared-note.md',
title: 'Shared Note',
isA: 'Note',
relatedTo: ['[[parent]]'],
})
renderNoteList({
entries: [parent, shared],
selection: { kind: 'entity', entry: parent },
})
expect(screen.getByText('Related to')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
})
it('shows all real inverse relationship groups for custom relationship keys', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const topicNote = makeEntry({
path: '/vault/topic-note.md',
filename: 'topic-note.md',
title: 'Topic Note',
isA: 'Note',
relationships: { Topics: ['[[parent]]'] },
})
const mentorNote = makeEntry({
path: '/vault/mentor-note.md',
filename: 'mentor-note.md',
title: 'Mentor Note',
isA: 'Note',
relationships: { Mentors: ['[[parent]]'] },
})
const hostEvent = makeEntry({
path: '/vault/host-event.md',
filename: 'host-event.md',
title: 'Host Event',
isA: 'Event',
relationships: { Hosts: ['[[parent]]'] },
})
renderNoteList({
entries: [parent, topicNote, mentorNote, hostEvent],
selection: { kind: 'entity', entry: parent },
})
expect(screen.getByText('← Topics')).toBeInTheDocument()
expect(screen.getByText('← Mentors')).toBeInTheDocument()
expect(screen.getByText('← Hosts')).toBeInTheDocument()
expect(screen.getByText('Topic Note')).toBeInTheDocument()
expect(screen.getByText('Mentor Note')).toBeInTheDocument()
expect(screen.getByText('Host Event')).toBeInTheDocument()
})
it('collapses and expands entity groups', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -258,7 +432,7 @@ describe('NoteList rendering', () => {
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
})
it('shows the entity snippet in the prominent card', () => {
it('shows the pinned neighborhood note using the standard row content', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
})
@@ -354,6 +528,44 @@ describe('NoteList rendering', () => {
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
})
it('opens the view column picker from the global event and applies the saved columns', () => {
renderManagedViewNoteList({
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
})
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
act(() => {
openNoteListPropertiesPicker('view')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('shows an empty-state picker for views with no matching properties', () => {
renderManagedViewNoteList({
entries: makeBookTypeEntries(),
view: makeViewDefinition({
filename: 'empty-view.yml',
definition: {
name: 'Empty View',
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
},
}),
})
act(() => {
openNoteListPropertiesPicker('view')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByText('No properties match this search.')).toBeInTheDocument()
})
it('shows status in the type column picker when at least one note has it set', () => {
renderNoteList({
entries: makeBookTypeEntries([], { status: 'Active' }),
@@ -431,7 +643,7 @@ describe('NoteList rendering', () => {
expect(screen.queryByText('High')).not.toBeInTheDocument()
})
it('Cmd+clicks relationship chips through the note list without triggering the row click', () => {
it('Cmd+clicks relationship chips through the note list without triggering the row click', async () => {
const projectType = makeTypeDefinition('Project')
const taskType = makeTypeDefinition('Task', ['Belongs to'])
const projectEntry = makeEntry({
@@ -450,7 +662,7 @@ describe('NoteList rendering', () => {
createdAt: 1700000001,
})
const { onReplaceActiveTab, onSelectNote } = renderNoteList({
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({
entries: [projectType, taskType, projectEntry, taskEntry],
selection: { kind: 'sectionGroup', type: 'Task' },
})
@@ -459,57 +671,65 @@ describe('NoteList rendering', () => {
fireEvent.click(chip)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(projectEntry)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(projectEntry)
expect(onEnterNeighborhood).toHaveBeenCalledWith(projectEntry)
})
})
})
describe('NoteList click behavior', () => {
it('opens the current tab on a regular click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
})
it('opens a new tab on Cmd+Click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
it('enters Neighborhood on Cmd+Click', async () => {
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
})
})
it('opens a new tab on Ctrl+Click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
it('enters Neighborhood on Ctrl+Click', async () => {
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
})
})
it('supports Cmd+Click on the entity pinned card', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
it('supports Cmd+Click on the entity pinned card', async () => {
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
})
})
it('opens the current tab from the entity pinned card on regular click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1])
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
})
it('opens child notes from entity view in the current tab', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[1])
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
})
})

View File

@@ -1,8 +1,11 @@
import { useState } from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { NoteList } from './NoteList'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import { getSortComparator } from '../utils/noteListHelpers'
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
import { buildNoteListProps, makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
import type { ViewFile } from '../types'
describe('getSortComparator', () => {
it('sorts by modified date descending', () => {
@@ -121,6 +124,52 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
function makeView(overrides: Partial<ViewFile> = {}): ViewFile {
return {
filename: 'rated-books.yml',
definition: {
name: 'Rated Books',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
...overrides.definition,
},
...overrides,
}
}
function renderManagedViewSort(entries: typeof zamEntries, view = makeView()) {
const built = buildNoteListProps({
entries,
selection: { kind: 'view', filename: view.filename },
views: [view],
})
function ManagedViewNoteList() {
const [views, setViews] = useState([view])
return (
<NoteList
{...built.props}
views={views}
onUpdateViewDefinition={(filename, patch) => {
setViews((currentViews) => currentViews.map((currentView) => (
currentView.filename === filename
? { ...currentView, definition: { ...currentView.definition, ...patch } }
: currentView
)))
}}
/>
)
}
return {
...render(<ManagedViewNoteList />),
...built,
}
}
function openListSortMenu(entries = mockEntries) {
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
@@ -294,4 +343,38 @@ describe('NoteList sort controls', () => {
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['A', 'C', 'B'])
})
it('loads view sort properties from the current view results only', () => {
const entries = [
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', properties: { Rating: 3 } }),
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', properties: { Priority: 'High' } }),
makeEntry({ path: '/project-a.md', title: 'Project A', isA: 'Project', properties: { Owner: 'Luca' } }),
]
renderManagedViewSort(entries)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
expect(screen.queryByTestId('sort-option-property:Owner')).not.toBeInTheDocument()
})
it('supports keyboard selection for view sorting and persists the chosen property', () => {
const entries = [
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', modifiedAt: 1000, properties: { Rating: 3 } }),
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', modifiedAt: 3000, properties: { Rating: 1 } }),
makeEntry({ path: '/book-c.md', title: 'Book C', isA: 'Book', modifiedAt: 2000, properties: { Rating: 5 } }),
]
renderManagedViewSort(entries)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.keyDown(screen.getByTestId('sort-menu-__list__'), { key: 'End' })
expect(screen.getByTestId('sort-option-property:Rating')).toHaveFocus()
fireEvent.keyDown(screen.getByTestId('sort-option-property:Rating'), { key: 'Enter' })
const titles = screen.getAllByText(/^Book [ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['Book B', 'Book A', 'Book C'])
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,36 @@
import type { CSSProperties, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion'
interface OnboardingShellProps {
children: ReactNode
className?: string
contentClassName?: string
contentStyle?: CSSProperties
style?: CSSProperties
testId?: string
}
export function OnboardingShell({
children,
className,
contentClassName,
contentStyle,
style,
testId,
}: OnboardingShellProps) {
const { onMouseDown } = useDragRegion()
return (
<div
className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)}
style={style}
data-testid={testId}
onMouseDown={onMouseDown}
>
<div className={contentClassName} style={contentStyle} data-no-drag>
{children}
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUpRight } from '@phosphor-icons/react'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { XIcon } from 'lucide-react'
import { isValidCssColor } from '../utils/colorUtils'
@@ -154,6 +155,77 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
)
}
function NumberValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: ScalarEditProps) {
const [editValue, setEditValue] = useState(value)
const restoreValue = useCallback(() => {
setEditValue(value)
}, [value])
const commitValue = useCallback(() => {
const trimmed = editValue.trim()
if (trimmed === '') {
onSave('')
return
}
const parsed = Number(trimmed)
if (Number.isFinite(parsed)) {
onSave(trimmed)
return
}
restoreValue()
onCancel()
}, [editValue, onCancel, onSave, restoreValue])
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
commitValue()
return
}
if (event.key === 'Escape') {
restoreValue()
onCancel()
}
}, [commitValue, onCancel, restoreValue])
if (isEditing) {
return (
<Input
className="h-7 w-full border-ring bg-muted px-2 py-1 text-left font-mono text-[12px] tabular-nums"
type="text"
inputMode="decimal"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitValue}
autoFocus
data-testid="number-input"
/>
)
}
return (
<button
type="button"
className="inline-flex h-6 w-full min-w-0 items-center justify-start overflow-hidden rounded-md border-none bg-muted/60 px-2 text-left font-mono text-[12px] tabular-nums text-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="number-display"
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</button>
)
}
function DateValue({ value, onSave, autoOpen = false, onCancel }: {
value: string
onSave: (newValue: string) => void
@@ -352,48 +424,38 @@ function createScalarEditProps({
}
}
function renderScalarDisplayMode({
propKey,
value,
isEditing,
resolvedMode,
vaultStatuses,
vaultTags,
onSave,
onSaveList,
onStartEdit,
onUpdate,
editProps,
}: SmartCellProps & {
resolvedMode: PropertyDisplayMode
type ScalarRendererProps = SmartCellProps & {
editProps: ScalarEditProps
}) {
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'tags':
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
case 'date':
return (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(v) => onSave(propKey, v)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
)
case 'boolean': {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}
}
const SCALAR_DISPLAY_RENDERERS: Partial<Record<PropertyDisplayMode, (props: ScalarRendererProps) => ReactNode>> = {
status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => (
<StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
),
tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => (
<TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
),
date: ({ propKey, value, isEditing, onSave, onStartEdit }) => (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(nextValue) => onSave(propKey, nextValue)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
),
number: ({ editProps }) => <NumberValue {...editProps} />,
boolean: ({ propKey, value, onUpdate }) => {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
},
url: ({ editProps }) => <UrlValue {...editProps} />,
color: ({ editProps }) => <ColorEditableValue {...editProps} />,
}
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode]
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
}
function ScalarValueCell(props: SmartCellProps) {

View File

@@ -620,6 +620,96 @@ describe('Sidebar', () => {
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
})
it('preserves custom section colors in the customize popover', () => {
const entries: VaultEntry[] = [
...mockEntries,
{
path: '/vault/project-type.md',
filename: 'project-type.md',
title: 'Project',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: 'green',
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/recipe.md',
filename: 'recipe.md',
title: 'Recipe',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: 'orange',
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/recipe/pasta.md',
filename: 'pasta.md',
title: 'Pasta Carbonara',
isA: 'Recipe',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
expect(screen.getByLabelText('Toggle Projects').querySelector('svg')).toHaveStyle({ color: 'var(--accent-green)' })
expect(screen.getByLabelText('Toggle Recipes').querySelector('svg')).toHaveStyle({ color: 'var(--accent-orange)' })
})
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
const onToggleTypeVisibility = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onToggleTypeVisibility={onToggleTypeVisibility} />)
@@ -780,6 +870,14 @@ describe('Sidebar', () => {
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/binary-note.pdf', filename: 'binary-note.pdf', title: 'Binary Note',
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, modifiedAt: 1700000000,
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
outgoingLinks: [], properties: {}, fileKind: 'binary',
},
]
it('shows Notes section when Note entries exist', () => {
@@ -787,13 +885,40 @@ describe('Sidebar', () => {
expect(screen.getByText('Notes')).toBeInTheDocument()
})
it('counts both explicit and untyped notes in Notes section chip', () => {
it('counts only explicit Note entries in the Notes section chip', () => {
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
})
it('ignores non-markdown Note entries in the Notes section chip', () => {
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
expect(notesHeader.textContent).not.toContain('2')
})
it('keeps the Notes section count aligned when an entry changes to or from Note', () => {
const { rerender } = render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
let notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
const withoutExplicitNote = noteEntries.map((entry) =>
entry.path === '/vault/explicit-note.md' ? { ...entry, isA: null } : entry,
)
rerender(<Sidebar entries={withoutExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toBe('Notes')
const withNewExplicitNote = noteEntries.map((entry) =>
entry.path === '/vault/untyped-note.md' ? { ...entry, isA: 'Note' } : entry,
)
rerender(<Sidebar entries={withNewExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('2')
})
it('shows Notes section for untyped entries even without explicit Note entries', () => {
it('does not show Notes section for untyped entries without explicit Note entries', () => {
const untypedOnly: VaultEntry[] = [
{
path: '/vault/plain.md', filename: 'plain.md', title: 'Plain Note',
@@ -805,7 +930,7 @@ describe('Sidebar', () => {
},
]
render(<Sidebar entries={untypedOnly} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Notes')).toBeInTheDocument()
expect(screen.queryByText('Notes')).not.toBeInTheDocument()
})
})
@@ -874,6 +999,88 @@ describe('Sidebar', () => {
expect(topNav.children[0].textContent).toContain('All Notes')
})
it('excludes attachments-folder markdown from top-nav note totals', () => {
const entries: VaultEntry[] = [
{
path: '/vault/note/real-note.md',
filename: 'real-note.md',
title: 'Real Note',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 310, snippet: '', wordCount: 120,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/reference.md',
filename: 'reference.md',
title: 'Attachment Markdown',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 220, snippet: '', wordCount: 50,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/nested/archive.md',
filename: 'archive.md',
title: 'Attachment Archive',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: true,
modifiedAt: 1700000000, createdAt: null,
fileSize: 180, snippet: '', wordCount: 25,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/archive/real-archive.md',
filename: 'real-archive.md',
title: 'Real Archive',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: true,
modifiedAt: 1700000000, createdAt: null,
fileSize: 280, snippet: '', wordCount: 90,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/image.png',
filename: 'image.png',
title: 'image.png',
isA: null,
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 1024, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
fileKind: 'binary',
outgoingLinks: [], properties: {},
},
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
const topNav = screen.getByTestId('sidebar-top-nav')
expect(topNav.children[1].textContent).toContain('All Notes1')
expect(topNav.children[2].textContent).toContain('Archive1')
})
it('does not show inline entries — no child items in type sections', () => {
const entriesWithEmoji: VaultEntry[] = [
{
@@ -974,7 +1181,7 @@ describe('Sidebar', () => {
it('aligns the favorites header count pill with the shared sidebar count column', () => {
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
const favoritesHeader = screen.getByText('FAVORITES').closest('button') as HTMLElement
const favoritesHeader = screen.getByText('FAVORITES').closest('div') as HTMLElement
const countChip = within(favoritesHeader).getByTestId('sidebar-count-chip')
expect(favoritesHeader).toHaveStyle({ padding: '8px 8px 8px 16px' })

View File

@@ -19,6 +19,13 @@ export interface SectionGroup {
customColor?: string | null
}
function resolveSectionColors(type: string, customColor?: string | null) {
return {
sectionColor: getTypeColor(type, customColor),
sectionLightColor: getTypeLightColor(type, customColor),
}
}
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function isSelectionActive(current: SidebarSelection, check: SidebarSelection): boolean {
if (current.kind !== check.kind) return false
@@ -43,6 +50,33 @@ function getNavItemPadding(compact: boolean | undefined, hasCount: boolean) {
return hasCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular
}
function getNavItemIconSize(compact?: boolean) {
return compact ? 14 : 16
}
function getNavItemTextClass(compact?: boolean) {
return compact ? 'text-[12px]' : 'text-[13px]'
}
function resolveBadgeClassName(
isActive: boolean | undefined,
activeBadgeClassName: string | undefined,
badgeClassName: string | undefined,
) {
if (isActive && activeBadgeClassName) return activeBadgeClassName
return badgeClassName
}
function resolveBadgeStyle(
isActive: boolean | undefined,
activeBadgeClassName: string | undefined,
activeBadgeStyle: React.CSSProperties | undefined,
badgeStyle: React.CSSProperties | undefined,
) {
if (isActive && activeBadgeClassName) return activeBadgeStyle
return badgeStyle
}
function SidebarNavIcon({
Icon,
emoji,
@@ -82,6 +116,102 @@ export function SidebarCountPill({
)
}
function NavItemLabel({ label, compact }: { label: string; compact?: boolean }) {
return <span className={cn("flex-1 font-medium", getNavItemTextClass(compact))}>{label}</span>
}
function NavItemCount({
count,
className,
style,
compact,
}: {
count?: number
className?: string
style?: React.CSSProperties
compact?: boolean
}) {
if (!hasSidebarCount(count)) return null
return (
<SidebarCountPill
count={count}
className={className}
style={style}
compact={compact}
/>
)
}
function DisabledNavItem({
Icon,
emoji,
label,
compact,
disabledTooltip,
padding,
}: {
Icon: ComponentType<IconProps>
emoji?: string | null
label: string
compact?: boolean
disabledTooltip?: string
padding: ReturnType<typeof getNavItemPadding>
}) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
<SidebarNavIcon Icon={Icon} emoji={emoji} iconSize={getNavItemIconSize(compact)} />
<NavItemLabel label={label} compact={compact} />
</div>
)
}
function ClickableNavItem({
Icon,
emoji,
label,
count,
isActive,
activeClassName,
badgeClassName,
badgeStyle,
activeBadgeClassName,
activeBadgeStyle,
onClick,
compact,
padding,
}: {
Icon: ComponentType<IconProps>
emoji?: string | null
label: string
count?: number
isActive?: boolean
activeClassName: string
badgeClassName?: string
badgeStyle?: React.CSSProperties
activeBadgeClassName?: string
activeBadgeStyle?: React.CSSProperties
onClick?: () => void
compact?: boolean
padding: ReturnType<typeof getNavItemPadding>
}) {
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding, borderRadius: 4 }}
onClick={onClick}
>
<SidebarNavIcon Icon={Icon} emoji={emoji} iconSize={getNavItemIconSize(compact)} isActive={isActive} />
<NavItemLabel label={label} compact={compact} />
<NavItemCount
count={count}
className={resolveBadgeClassName(isActive, activeBadgeClassName, badgeClassName)}
style={resolveBadgeStyle(isActive, activeBadgeClassName, activeBadgeStyle, badgeStyle)}
compact={compact}
/>
</div>
)
}
export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
icon: ComponentType<IconProps>
emoji?: string | null
@@ -98,39 +228,36 @@ export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClass
disabledTooltip?: string
compact?: boolean
}) {
const iconSize = compact ? 14 : 16
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
const showCount = hasSidebarCount(count)
const padding = getNavItemPadding(compact, showCount)
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
const iconEl = <SidebarNavIcon Icon={Icon} emoji={emoji} iconSize={iconSize} isActive={isActive} />
const padding = getNavItemPadding(compact, hasSidebarCount(count))
if (disabled) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
{iconEl}
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
</div>
<DisabledNavItem
Icon={Icon}
emoji={emoji}
label={label}
compact={compact}
disabledTooltip={disabledTooltip}
padding={padding}
/>
)
}
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding, borderRadius: 4 }}
<ClickableNavItem
Icon={Icon}
emoji={emoji}
label={label}
count={count}
isActive={isActive}
activeClassName={activeClassName}
badgeClassName={badgeClassName}
badgeStyle={badgeStyle}
activeBadgeClassName={activeBadgeClassName}
activeBadgeStyle={activeBadgeStyle}
onClick={onClick}
>
{iconEl}
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
{showCount && (
<SidebarCountPill
count={count}
className={resolvedBadgeClass}
style={resolvedBadgeStyle}
compact={compact}
/>
)}
</div>
compact={compact}
padding={padding}
/>
)
}
@@ -155,8 +282,7 @@ export function SectionContent({
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const sectionColor = getTypeColor(type, customColor)
const sectionLightColor = getTypeLightColor(type, customColor)
const { sectionColor, sectionLightColor } = resolveSectionColors(type, customColor)
return (
<SectionHeader
@@ -206,6 +332,85 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
)
}
function getSectionHeaderBackground(isActive: boolean, sectionLightColor: string) {
if (!isActive) return undefined
return { background: sectionLightColor }
}
function getSectionHeaderIconWeight(isActive: boolean): IconProps['weight'] {
return isActive ? 'fill' : 'regular'
}
function getSectionHeaderTitleColor(isActive: boolean, sectionColor: string) {
if (!isActive) return undefined
return sectionColor
}
function getSectionSelectHandler(isRenaming: boolean | undefined, onSelect: () => void) {
if (isRenaming) return undefined
return onSelect
}
function getSectionContextMenuHandler(
isRenaming: boolean | undefined,
onContextMenu: (e: React.MouseEvent) => void,
) {
if (isRenaming) return undefined
return onContextMenu
}
function SectionHeaderLabel({
type,
label,
isActive,
sectionColor,
isRenaming,
renameInitialValue,
onRenameSubmit,
onRenameCancel,
}: {
type: string
label: string
isActive: boolean
sectionColor: string
isRenaming?: boolean
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
}) {
if (isRenaming && onRenameSubmit && onRenameCancel) {
return (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
)
}
return <span className="text-[13px] font-medium" style={{ marginLeft: 4, color: getSectionHeaderTitleColor(isActive, sectionColor) }}>{label}</span>
}
function SectionHeaderCountPill({
itemCount,
isActive,
sectionColor,
}: {
itemCount: number
isActive: boolean
sectionColor: string
}) {
if (itemCount <= 0) return null
return (
<SidebarCountPill
count={itemCount}
className={!isActive ? 'text-muted-foreground' : undefined}
style={isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }}
/>
)
}
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; sectionLightColor: string; itemCount: number; isActive: boolean
@@ -217,35 +422,56 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
return (
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: sectionLightColor } : {}) }}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
{...dragHandleProps}
onClick={() => { if (!isRenaming) onSelect() }}
onContextMenu={isRenaming ? undefined : onContextMenu}
onClick={getSectionSelectHandler(isRenaming, onSelect)}
onContextMenu={getSectionContextMenuHandler(isRenaming, onContextMenu)}
>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} weight={isActive ? 'fill' : 'regular'} style={{ color: sectionColor, flexShrink: 0 }} />
{isRenaming && onRenameSubmit && onRenameCancel ? (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
) : (
<span className="text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? sectionColor : undefined }}>{label}</span>
)}
</div>
{itemCount > 0 && (
<SidebarCountPill
count={itemCount}
className={!isActive ? 'text-muted-foreground' : undefined}
style={isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }}
<Icon size={16} weight={getSectionHeaderIconWeight(isActive)} style={{ color: sectionColor, flexShrink: 0 }} />
<SectionHeaderLabel
type={type}
label={label}
isActive={isActive}
sectionColor={sectionColor}
isRenaming={isRenaming}
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
/>
)}
</div>
<SectionHeaderCountPill itemCount={itemCount} isActive={isActive} sectionColor={sectionColor} />
</div>
)
}
function VisibilityPopoverItem({
group,
isVisible,
onToggle,
}: {
group: SectionGroup
isVisible: boolean
onToggle: (type: string) => void
}) {
const { label, type, Icon, customColor } = group
const { sectionColor } = resolveSectionColors(type, customColor)
return (
<button
type="button"
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => onToggle(type)}
aria-label={`Toggle ${label}`}
>
<Icon size={14} style={{ color: sectionColor }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isVisible} />
</button>
)
}
// --- Visibility Popover ---
export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
@@ -259,12 +485,13 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
{sections.map(({ label, type, Icon }) => (
<button key={type} className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent" style={{ padding: '6px 12px', gap: 8 }} onClick={() => onToggle(type)} aria-label={`Toggle ${label}`}>
<Icon size={14} style={{ color: getTypeColor(type) }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isSectionVisible(type)} />
</button>
{sections.map((group) => (
<VisibilityPopoverItem
key={group.type}
group={group}
isVisible={isSectionVisible(group.type)}
onToggle={onToggle}
/>
))}
</div>
)

View File

@@ -179,6 +179,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
}))),

View File

@@ -1,8 +1,237 @@
import { useState, useEffect, useRef } from 'react'
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'
interface SortItem {
value: SortOption
label: string
}
type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
function buildSortItems(customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label }))
const customItems = (customProperties ?? []).map((key) => ({
value: `property:${key}` as SortOption,
label: key,
}))
return [...builtInItems, ...customItems]
}
function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) {
const activeElement = document.activeElement as HTMLElement | null
const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1)
if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
const currentIndex = sortItems.findIndex((item) => item.value === current)
return currentIndex >= 0 ? currentIndex : 0
}
function focusSortItem(sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>, index: number) {
sortButtonRefs.current[index]?.focus()
}
function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null {
const lastIndex = itemCount - 1
switch (key) {
case 'Escape':
return { type: 'close' }
case 'ArrowDown':
return { type: 'focus', index: Math.min(lastIndex, focusIndex + 1) }
case 'ArrowUp':
return { type: 'focus', index: Math.max(0, focusIndex - 1) }
case 'Home':
return { type: 'focus', index: 0 }
case 'End':
return { type: 'focus', index: lastIndex }
default:
return null
}
}
function selectOnKeyboard(
event: React.KeyboardEvent<HTMLButtonElement>,
value: SortOption,
direction: SortDirection,
onSelect: (opt: SortOption, dir: SortDirection) => void,
) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onSelect(value, direction)
}
function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirection, buttonDirection: SortDirection) {
return cn(
'flex items-center rounded p-0.5 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive && activeDirection === buttonDirection ? 'text-foreground' : 'text-muted-foreground opacity-40',
)
}
function useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
}: {
groupLabel: string
current: SortOption
sortItems: SortItem[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const sortButtonRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (containerRef.current?.contains(event.target as Node)) return
setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
useEffect(() => {
if (!open) return
focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems))
}, [current, groupLabel, open, sortItems])
const closeMenu = useCallback(() => {
setOpen(false)
triggerRef.current?.focus()
}, [])
const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => {
onChange(groupLabel, option, nextDirection)
closeMenu()
}, [closeMenu, groupLabel, onChange])
const handleMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const action = resolveSortMenuAction(
event.key,
resolveFocusedIndex(groupLabel, current, sortItems),
sortItems.length,
)
if (!action) return
event.preventDefault()
if (action.type === 'close') {
closeMenu()
return
}
focusSortItem(sortButtonRefs, action.index)
}, [closeMenu, current, groupLabel, sortItems])
return {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
}
}
function SortDropdownTrigger({
triggerRef,
open,
current,
groupLabel,
direction,
onToggle,
}: {
triggerRef: React.RefObject<HTMLButtonElement | null>
open: boolean
current: SortOption
groupLabel: string
direction: SortDirection
onToggle: () => void
}) {
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
return (
<button
ref={triggerRef}
type="button"
className={cn('flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', open && 'bg-accent text-foreground')}
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
title={`Sort by ${getSortOptionLabel(current)}`}
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>
</button>
)
}
function SortDropdownMenu({
open,
groupLabel,
current,
direction,
sortItems,
sortButtonRefs,
onKeyDown,
onSelect,
}: {
open: boolean
groupLabel: string
current: SortOption
direction: SortDirection
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
}) {
if (!open) return null
const hasCustom = sortItems.length > SORT_OPTIONS.length
const builtInOptionCount = SORT_OPTIONS.length
return (
<div
role="menu"
aria-label={`Sort ${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}
data-testid={`sort-menu-${groupLabel}`}
>
{sortItems.map((item, index) => (
<SortRow
key={item.value}
index={index}
groupLabel={groupLabel}
value={item.value}
label={item.label}
current={current}
direction={direction}
buttonRef={(node) => {
sortButtonRefs.current[index] = node
}}
showSeparator={hasCustom && index === builtInOptionCount}
onSelect={onSelect}
/>
))}
</div>
)
}
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
groupLabel: string
current: SortOption
@@ -10,99 +239,142 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
customProperties?: string[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleSelect = (opt: SortOption, dir: SortDirection) => {
onChange(groupLabel, opt, dir)
setOpen(false)
}
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
const hasCustom = customProperties && customProperties.length > 0
const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties])
const {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
} = useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
})
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${getSortOptionLabel(current)}`}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
style={{ width: 170, padding: 4, maxHeight: 280, overflowY: 'auto' }}
data-testid={`sort-menu-${groupLabel}`}
>
{SORT_OPTIONS.map((opt) => (
<SortRow key={opt.value} value={opt.value} label={opt.label} current={current} direction={direction} onSelect={handleSelect} />
))}
{hasCustom && (
<>
<div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />
{customProperties.map((key) => {
const value: SortOption = `property:${key}`
return <SortRow key={value} value={value} label={key} current={current} direction={direction} onSelect={handleSelect} />
})}
</>
)}
</div>
)}
<div ref={containerRef} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<SortDropdownTrigger
triggerRef={triggerRef}
open={open}
current={current}
groupLabel={groupLabel}
direction={direction}
onToggle={() => setOpen((value) => !value)}
/>
<SortDropdownMenu
open={open}
groupLabel={groupLabel}
current={current}
direction={direction}
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}
/>
</div>
)
}
function SortRow({ value, label, current, direction, onSelect }: {
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: {
index: number
groupLabel: string
value: SortOption
label: string
current: SortOption
direction: SortDirection
buttonRef: (node: HTMLButtonElement | null) => void
showSeparator: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
const defaultDirection = isActive ? direction : getDefaultDirection(value)
const itemData = {
'data-sort-group-label': groupLabel,
'data-sort-item-index': String(index),
}
return (
<div
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${value}`}
onClick={(e) => { e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit truncate">
{label}
</span>
<span className="flex items-center gap-0.5 ml-1 shrink-0">
<>
{showSeparator && <div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />}
<div
className={cn('flex items-center justify-between gap-1 rounded px-1 text-[12px] text-popover-foreground hover:bg-accent', isActive && 'bg-accent font-medium')}
style={{ minHeight: 28 }}
>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'asc') }}
data-testid={`sort-dir-asc-${value}`}
title="Ascending"
ref={buttonRef}
type="button"
role="menuitemradio"
aria-checked={isActive}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-1 text-left text-inherit hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation()
onSelect(value, defaultDirection)
}}
onKeyDown={(event) => selectOnKeyboard(event, value, defaultDirection, onSelect)}
data-testid={`sort-option-${value}`}
{...itemData}
>
<ArrowUp size={12} />
<span className="truncate">{label}</span>
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'desc') }}
data-testid={`sort-dir-desc-${value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
<span className="flex items-center gap-0.5">
<SortDirectionButton
value={value}
direction="asc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowUp size={12} />}
itemData={itemData}
/>
<SortDirectionButton
value={value}
direction="desc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowDown size={12} />}
itemData={itemData}
/>
</span>
</div>
</>
)
}
function SortDirectionButton({
value,
direction,
activeDirection,
isActive,
onSelect,
icon,
itemData,
}: {
value: SortOption
direction: SortDirection
activeDirection: SortDirection
isActive: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
icon: React.ReactNode
itemData: Record<string, string>
}) {
return (
<button
type="button"
className={getDirectionButtonClass(isActive, activeDirection, direction)}
onClick={(event) => {
event.stopPropagation()
onSelect(value, direction)
}}
data-testid={`sort-dir-${direction}-${value}`}
title={direction === 'asc' ? 'Ascending' : 'Descending'}
{...itemData}
>
{icon}
</button>
)
}

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, render, screen, fireEvent } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { StatusBar } from './StatusBar'
import { StatusBarPrimarySection } from './status-bar/StatusBarSections'
import type { VaultOption } from './StatusBar'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url')
@@ -154,6 +156,24 @@ describe('StatusBar', () => {
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
})
it('shows "Create empty vault" option in vault menu', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
})
it('calls onCreateEmptyVault when clicking "Create empty vault"', () => {
const onCreateEmptyVault = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={onCreateEmptyVault} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Create empty vault'))
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('shows add-vault options in vault menu', () => {
render(
<StatusBar
@@ -161,11 +181,13 @@ describe('StatusBar', () => {
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCreateEmptyVault={vi.fn()}
onOpenLocalFolder={vi.fn()}
onCloneVault={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
expect(screen.getByText('Open local folder')).toBeInTheDocument()
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
})
@@ -202,6 +224,42 @@ describe('StatusBar', () => {
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
})
it('exposes a hover-revealed remove action for non-active vaults', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onRemoveVault={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByTestId('vault-menu-item-Work Vault').className).toContain('hover:bg-[var(--hover)]')
expect(screen.getByTestId('vault-menu-remove-Work Vault').className).toContain('group-hover:opacity-100')
expect(screen.getByRole('button', { name: 'Remove Work Vault from list' })).toBeInTheDocument()
})
it('calls onRemoveVault when clicking the remove action in the vault menu', () => {
const onRemoveVault = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onRemoveVault={onRemoveVault}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByRole('button', { name: 'Remove Work Vault from list' }))
expect(onRemoveVault).toHaveBeenCalledWith('/Users/luca/Work')
})
it('shows Changes badge with count when modifiedCount is > 0', () => {
render(<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.getByTestId('status-modified-count')).toBeInTheDocument()
@@ -327,6 +385,28 @@ describe('StatusBar', () => {
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
})
it('opens the add-remote flow when clicking the no-remote chip', () => {
const onAddRemote = vi.fn()
render(
<TooltipProvider>
<StatusBarPrimarySection
modifiedCount={0}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onAddRemote={onAddRemote}
syncStatus="idle"
lastSyncTime={null}
conflictCount={0}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
</TooltipProvider>
)
fireEvent.click(screen.getByTestId('status-no-remote'))
expect(onAddRemote).toHaveBeenCalledOnce()
})
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(

View File

@@ -21,6 +21,7 @@ interface StatusBarProps {
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -60,6 +61,7 @@ export function StatusBar({
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -121,6 +123,7 @@ export function StatusBar({
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TelemetryConsentDialog } from './TelemetryConsentDialog'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('TelemetryConsentDialog', () => {
it('renders the consent dialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
@@ -27,4 +33,19 @@ describe('TelemetryConsentDialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByText(/no vault content, note titles/i)).toBeDefined()
})
it('focuses the first action for keyboard users', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
})
it('uses the surrounding surface as a drag region and excludes the dialog card', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
const shell = screen.getByTestId('telemetry-consent-shell')
fireEvent.mouseDown(shell)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
interface TelemetryConsentDialogProps {
onAccept: () => void
@@ -7,14 +8,21 @@ interface TelemetryConsentDialogProps {
export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsentDialogProps) {
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
<OnboardingShell
className="fixed inset-0 z-50"
contentClassName="w-full rounded-lg border border-border bg-background shadow-xl"
style={{ background: 'rgba(0,0,0,0.4)' }}
contentStyle={{
width: 'min(440px, 100%)',
padding: 32,
display: 'flex',
flexDirection: 'column',
gap: 20,
alignItems: 'center',
}}
testId="telemetry-consent-shell"
>
<div
className="bg-background border border-border rounded-lg shadow-xl"
style={{ width: 440, padding: 32, display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}
>
<>
<ShieldCheck size={40} weight="duotone" style={{ color: 'var(--primary)' }} />
<div style={{ textAlign: 'center' }}>
@@ -46,6 +54,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
onClick={onDecline}
data-testid="telemetry-decline"
autoFocus
>
No thanks
</button>
@@ -62,7 +71,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>
You can change this anytime in Settings.
</p>
</div>
</div>
</>
</OnboardingShell>
)
}

View File

@@ -1,13 +1,19 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
const defaultProps = {
mode: 'welcome' as const,
defaultVaultPath: '~/Documents/Laputa',
onCreateVault: vi.fn(),
onRetryCreateVault: vi.fn(),
onCreateNewVault: vi.fn(),
onCreateEmptyVault: vi.fn(),
onOpenFolder: vi.fn(),
isOffline: false,
creatingAction: null as 'template' | 'empty' | null,
@@ -16,6 +22,10 @@ const defaultProps = {
}
describe('WelcomeScreen', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('welcome mode', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
@@ -25,14 +35,20 @@ describe('WelcomeScreen', () => {
it('shows all three option buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create a new vault')
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
it('shows default vault path in template option description', () => {
it('focuses the first action for keyboard users', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
})
it('shows the simplified template option description', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Download the getting started vault')).toBeInTheDocument()
expect(screen.queryByText(/~\/Documents\/Laputa/)).not.toBeInTheDocument()
})
it('shows offline guidance and disables the template option when offline', () => {
@@ -41,11 +57,33 @@ describe('WelcomeScreen', () => {
expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument()
})
it('calls onCreateNewVault when create new button is clicked', () => {
const onCreateNewVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)
it('calls onCreateEmptyVault when create empty button is clicked', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.click(screen.getByTestId('welcome-create-new'))
expect(onCreateNewVault).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateEmptyVault when create empty button is activated with Enter', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
const button = screen.getByTestId('welcome-create-new')
button.focus()
fireEvent.keyDown(button, { key: 'Enter' })
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateEmptyVault when create empty button is activated with Space', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
const button = screen.getByTestId('welcome-create-new')
button.focus()
fireEvent.keyDown(button, { key: ' ' })
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateVault when template button is clicked', () => {
@@ -62,6 +100,16 @@ describe('WelcomeScreen', () => {
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
fireEvent.keyDown(window, { key: 'Tab' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('disables all buttons while creating', () => {
render(<WelcomeScreen {...defaultProps} creatingAction="template" />)
expect(screen.getByTestId('welcome-create-new')).toBeDisabled()
@@ -141,5 +189,15 @@ describe('WelcomeScreen', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(<WelcomeScreen {...defaultProps} />)
const screenContainer = screen.getByTestId('welcome-screen')
fireEvent.mouseDown(screenContainer)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
})
})
})

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -8,7 +10,7 @@ interface WelcomeScreenProps {
defaultVaultPath: string
onCreateVault: () => void
onRetryCreateVault: () => void
onCreateNewVault: () => void
onCreateEmptyVault: () => void
onOpenFolder: () => void
isOffline: boolean
creatingAction: 'template' | 'empty' | null
@@ -25,17 +27,65 @@ interface WelcomeScreenPresentation {
title: string
}
const CONTAINER_STYLE: React.CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--sidebar)',
type WelcomeActionButtonRef = React.RefObject<HTMLButtonElement | null>
interface WelcomeAction {
disabled: boolean
run: () => void
}
function isWelcomeActivationKey(event: globalThis.KeyboardEvent): boolean {
return event.key === 'Enter' || event.key === ' '
}
function isWelcomeNavigationKey(event: globalThis.KeyboardEvent): boolean {
return event.key === 'Tab' || event.key === 'ArrowDown' || event.key === 'ArrowUp'
}
function nextWelcomeActionIndex(
currentIndex: number,
event: globalThis.KeyboardEvent,
actionCount: number,
): number {
const direction = event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1
return (currentIndex + direction + actionCount) % actionCount
}
function focusBelongsToWelcomeActions(
activeElement: Element | null,
actionButtonRefs: WelcomeActionButtonRef[],
): boolean {
return activeElement === document.body
|| actionButtonRefs.some(({ current }) => current === activeElement)
}
function getFocusedWelcomeActionIndex(
activeElement: Element | null,
actionButtonRefs: WelcomeActionButtonRef[],
): number {
return Math.max(
0,
actionButtonRefs.findIndex(({ current }) => current === activeElement),
)
}
function focusWelcomeAction(
actionButtonRefs: WelcomeActionButtonRef[],
actionIndex: number,
): void {
actionButtonRefs[actionIndex]?.current?.focus()
}
function triggerWelcomeAction(
actionIndex: number,
actions: WelcomeAction[],
): void {
const action = actions[actionIndex]
if (!action?.disabled) action.run()
}
const CARD_STYLE: React.CSSProperties = {
width: 520,
width: 'min(520px, 100%)',
background: 'var(--background)',
borderRadius: 12,
border: '1px solid var(--border)',
@@ -160,6 +210,8 @@ interface OptionButtonProps {
disabled: boolean
loading?: boolean
testId: string
autoFocus?: boolean
buttonRef?: React.RefObject<HTMLButtonElement | null>
}
function OptionButton({
@@ -173,11 +225,16 @@ function OptionButton({
disabled,
loading,
testId,
autoFocus = false,
buttonRef,
}: OptionButtonProps) {
const [hover, setHover] = useState(false)
return (
<button
<Button
type="button"
variant="outline"
size="lg"
style={{
...OPTION_BTN_STYLE,
background: hover ? 'var(--sidebar)' : 'var(--background)',
@@ -188,6 +245,9 @@ function OptionButton({
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
data-testid={testId}
autoFocus={autoFocus}
className="h-auto justify-start shadow-none"
ref={buttonRef}
>
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
@@ -196,7 +256,7 @@ function OptionButton({
<p style={OPTION_LABEL_STYLE}>{loading ? (loadingLabel ?? label) : label}</p>
<p style={OPTION_DESC_STYLE}>{loading ? (loadingDescription ?? description) : description}</p>
</div>
</button>
</Button>
)
}
@@ -213,7 +273,7 @@ function getWelcomeScreenPresentation(
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: `Download the starter vault template — suggested path: ${defaultVaultPath}`,
: 'Download the getting started vault',
title: 'Welcome to Tolaria',
}
}
@@ -225,17 +285,88 @@ function getWelcomeScreenPresentation(
subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: `Download the starter vault template — suggested path: ${defaultVaultPath}`,
: 'Download the getting started vault',
title: 'Vault not found',
}
}
function useWelcomeActionButtons({
mode,
busy,
isOffline,
onCreateEmptyVault,
onOpenFolder,
onCreateVault,
}: Pick<
WelcomeScreenProps,
'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault'
> & {
busy: boolean
}) {
const primaryActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const templateActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo(
() => [primaryActionRef, openFolderActionRef, templateActionRef],
[],
)
const actions = useMemo<WelcomeAction[]>(
() => ([
{ disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder },
{ disabled: isOffline, run: onCreateVault },
]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
)
useEffect(() => {
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
// onboarding always starts on "Create empty vault".
focusWelcomeAction(actionButtonRefs, 0)
}, [actionButtonRefs, busy, mode])
useEffect(() => {
if (busy) return
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
const activeElement = document.activeElement
if (!focusBelongsToWelcomeActions(activeElement, actionButtonRefs)) return
const actionIndex = getFocusedWelcomeActionIndex(activeElement, actionButtonRefs)
if (isWelcomeNavigationKey(event)) {
event.preventDefault()
focusWelcomeAction(
actionButtonRefs,
nextWelcomeActionIndex(actionIndex, event, actionButtonRefs.length),
)
return
}
if (!isWelcomeActivationKey(event)) return
event.preventDefault()
triggerWelcomeAction(actionIndex, actions)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [actionButtonRefs, actions, busy])
return {
primaryActionRef,
openFolderActionRef,
templateActionRef,
}
}
export function WelcomeScreen({
mode,
defaultVaultPath,
onCreateVault,
onRetryCreateVault,
onCreateNewVault,
onCreateEmptyVault,
onOpenFolder,
isOffline,
creatingAction,
@@ -244,10 +375,22 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
mode,
busy,
isOffline,
onCreateEmptyVault,
onOpenFolder,
onCreateVault,
})
return (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
<div style={CARD_STYLE}>
<OnboardingShell
style={{ background: 'var(--sidebar)' }}
contentStyle={CARD_STYLE}
testId="welcome-screen"
>
<>
<div
style={{
...ICON_WRAP_STYLE,
@@ -270,14 +413,16 @@ export function WelcomeScreen({
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
label="Create a new vault"
description="Start fresh in a folder you choose"
label="Create empty vault"
description="Start fresh in an empty folder with Tolaria defaults"
loadingLabel="Creating vault…"
loadingDescription="Preparing an empty vault in the selected folder"
onClick={onCreateNewVault}
loadingDescription="Preparing Tolaria defaults in the selected folder"
onClick={onCreateEmptyVault}
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
buttonRef={primaryActionRef}
/>
<OptionButton
@@ -288,6 +433,7 @@ export function WelcomeScreen({
onClick={onOpenFolder}
disabled={busy}
testId="welcome-open-folder"
buttonRef={openFolderActionRef}
/>
<OptionButton
@@ -301,6 +447,7 @@ export function WelcomeScreen({
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
buttonRef={templateActionRef}
/>
</div>
@@ -316,18 +463,20 @@ export function WelcomeScreen({
{error}
</p>
{canRetryTemplate && (
<button
<Button
type="button"
variant="outline"
style={RETRY_BUTTON_STYLE}
onClick={onRetryCreateVault}
data-testid="welcome-retry-template"
className="shadow-none"
>
Retry download
</button>
</Button>
)}
</div>
)}
</div>
</div>
</>
</OnboardingShell>
)
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
describe('blockNoteFormattingToolbarHoverGuard', () => {
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 368, y: 104 },
rect(300, 130, 140, 90),
rect(322, 78, 96, 24),
),
).toBe(true)
})
it('suppresses hover updates when the pointer is already over the toolbar', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = document.createElement('button')
toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: toolbarButton,
point: { x: 350, y: 90 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 368, y: 104 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 520, y: 220 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
})
})

View File

@@ -0,0 +1,223 @@
import type {
BlockNoteEditor,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core'
import { useEffect, useRef, type RefObject } from 'react'
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
const HOVER_BRIDGE_PADDING_X = 8
const HOVER_BRIDGE_PADDING_Y = 8
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
function isVisibleRect(rect: RectLike) {
return rect.right > rect.left && rect.bottom > rect.top
}
function getSelectedFileBlockBridgeElement(
container: HTMLElement,
blockId: string,
) {
const selectedBlock = container.querySelector<HTMLElement>(
`.bn-block[data-id="${blockId}"]`,
)
if (!selectedBlock) return null
return (
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-visual-media-wrapper',
) ??
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-file-name-with-icon',
) ??
selectedBlock.querySelector<HTMLElement>('[data-file-block] .bn-add-file-button') ??
selectedBlock.querySelector<HTMLElement>('[data-file-block]')
)
}
export function isWithinFormattingToolbarHoverBridge(
point: { x: number; y: number },
fileBlockRect: RectLike,
toolbarRect: RectLike,
) {
if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) {
return false
}
const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X
const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X
const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y
const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y
return (
point.x >= left &&
point.x <= right &&
point.y >= top &&
point.y <= bottom
)
}
export function shouldSuppressFormattingToolbarHoverUpdate({
eventTarget,
point,
container,
doc,
selectedFileBlockId,
}: {
eventTarget: EventTarget | null
point: { x: number; y: number }
container: HTMLElement | null
doc: Document
selectedFileBlockId: string | null
}) {
if (!container || !selectedFileBlockId) return false
if (
eventTarget instanceof Element &&
eventTarget.closest('.bn-formatting-toolbar')
) {
return true
}
const selectedFileBlock = getSelectedFileBlockBridgeElement(
container,
selectedFileBlockId,
)
const toolbar = doc.querySelector<HTMLElement>('.bn-formatting-toolbar')
if (!selectedFileBlock || !toolbar) return false
return isWithinFormattingToolbarHoverBridge(
point,
selectedFileBlock.getBoundingClientRect(),
toolbar.getBoundingClientRect(),
)
}
function getActiveFormattingToolbarFileBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
: null
}
function restoreFormattingToolbarFileBlockSelection(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedFileBlockIdRef: RefObject<string | null>,
) {
const selectedFileBlockId = selectedFileBlockIdRef.current
if (!selectedFileBlockId) return
if (getActiveFormattingToolbarFileBlockId(editor) === selectedFileBlockId) return
try {
editor.setTextCursorPosition(selectedFileBlockId)
} catch {
// The image block may have been deleted or replaced while the toolbar stayed open.
}
}
function useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId: string | null,
isOpen: boolean,
) {
const lastSelectedFileBlockIdRef = useRef<string | null>(selectedFileBlockId)
useEffect(() => {
if (selectedFileBlockId) {
lastSelectedFileBlockIdRef.current = selectedFileBlockId
return
}
if (!isOpen) {
lastSelectedFileBlockIdRef.current = null
}
}, [isOpen, selectedFileBlockId])
return lastSelectedFileBlockIdRef
}
function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) {
const doc = container?.ownerDocument
const view = doc?.defaultView
if (!container || !doc || !view) return null
return { container, doc, view }
}
function createFormattingToolbarHoverGuardHandler({
editor,
container,
doc,
selectedFileBlockIdRef,
}: {
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
container: HTMLElement
doc: Document
selectedFileBlockIdRef: RefObject<string | null>
}) {
return (event: MouseEvent) => {
if (
!shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: event.target,
point: { x: event.clientX, y: event.clientY },
container,
doc,
selectedFileBlockId: selectedFileBlockIdRef.current,
})
) {
return
}
restoreFormattingToolbarFileBlockSelection(editor, selectedFileBlockIdRef)
event.stopPropagation()
}
}
export function useBlockNoteFormattingToolbarHoverGuard({
editor,
container,
selectedFileBlockId,
isOpen,
}: {
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
container: HTMLElement | null
selectedFileBlockId: string | null
isOpen: boolean
}) {
const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId,
isOpen,
)
useEffect(() => {
if (!isOpen) return
const environment = getFormattingToolbarHoverGuardEnvironment(container)
if (!environment) return
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
editor,
container: environment.container,
doc: environment.doc,
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
})
environment.view.addEventListener('mousemove', handleMouseMove, true)
return () => {
environment.view.removeEventListener('mousemove', handleMouseMove, true)
}
}, [container, editor, isOpen, lastSelectedFileBlockIdRef])
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
buildPendingPasteState,
shouldRecoverPendingPaste,
} from './inlineWikilinkPasteRecovery'
describe('inlineWikilinkPasteRecovery', () => {
it('recognizes the expected post-paste value as recoverable', () => {
const pendingPaste = buildPendingPasteState(' ', { start: 1, end: 1 }, 'hello world')
expect(pendingPaste).toEqual({
duplicatedValue: ' hello worldhello world',
expectedValue: ' hello world',
expectedSelection: { start: 12, end: 12 },
})
expect(shouldRecoverPendingPaste(' hello world', pendingPaste)).toBe(true)
})
it('recognizes the duplicate native replay as recoverable when pasting into the middle of text', () => {
const pendingPaste = buildPendingPasteState('prefix suffix', { start: 7, end: 7 }, 'hello ')
expect(pendingPaste.duplicatedValue).toBe('prefix hello hello suffix')
expect(shouldRecoverPendingPaste('prefix hello hello suffix', pendingPaste)).toBe(true)
expect(shouldRecoverPendingPaste('prefix hello suffix!', pendingPaste)).toBe(false)
})
})

View File

@@ -0,0 +1,32 @@
import type { InlineSelectionRange } from './inlineWikilinkDom'
import { replaceInlineSelection } from './inlineWikilinkEdits'
export interface PendingPasteState {
duplicatedValue: string
expectedValue: string
expectedSelection: InlineSelectionRange
}
export function buildPendingPasteState(
value: string,
selectionRange: InlineSelectionRange,
pastedText: string,
): PendingPasteState {
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
return {
duplicatedValue: replaceInlineSelection(nextState.value, nextState.selection, pastedText).value,
expectedValue: nextState.value,
expectedSelection: nextState.selection,
}
}
export function shouldRecoverPendingPaste(
nextValue: string,
pendingPaste: PendingPasteState,
): boolean {
return (
nextValue === pendingPaste.expectedValue ||
nextValue === pendingPaste.duplicatedValue
)
}

View File

@@ -372,8 +372,6 @@ export function ListPropertiesPopover({
handleToggle,
} = useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave })
if (availableProperties.length === 0) return null
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>

View File

@@ -40,8 +40,6 @@ function NoteListContent({
toggleGroup,
handleSortChange,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
@@ -58,8 +56,6 @@ function NoteListContent({
| 'toggleGroup'
| 'handleSortChange'
| 'renderItem'
| 'typeEntryMap'
| 'handleClickNote'
| 'isArchivedView'
| 'isChangesView'
| 'isInboxView'
@@ -79,8 +75,6 @@ function NoteListContent({
onToggleGroup={toggleGroup}
onSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
onClickNote={handleClickNote}
/>
) : (
<ListView
@@ -113,8 +107,6 @@ function NoteListBody({
toggleGroup,
handleSortChange,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
@@ -140,8 +132,6 @@ function NoteListBody({
| 'toggleGroup'
| 'handleSortChange'
| 'renderItem'
| 'typeEntryMap'
| 'handleClickNote'
| 'isArchivedView'
| 'isChangesView'
| 'isInboxView'
@@ -173,8 +163,6 @@ function NoteListBody({
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
handleClickNote={handleClickNote}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
@@ -222,8 +210,6 @@ export function NoteListLayout({
sortPrefs,
toggleGroup,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
@@ -279,8 +265,6 @@ export function NoteListLayout({
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
handleClickNote={handleClickNote}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}

View File

@@ -5,7 +5,19 @@ import { PinnedCard } from './PinnedCard'
import { RelationshipGroupSection } from './RelationshipGroupSection'
import { EmptyMessage } from './TrashWarningBanner'
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isArchivedView: boolean, isInboxView: boolean, query: string): string {
function resolveEmptyText({
isChangesView,
changesError,
isArchivedView,
isInboxView,
query,
}: {
isChangesView: boolean
changesError: string | null | undefined
isArchivedView: boolean
isInboxView: boolean
query: string
}): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isArchivedView) return 'No archived notes'
@@ -13,16 +25,15 @@ function resolveEmptyText(isChangesView: boolean, changesError: string | null |
return query ? 'No matching notes' : 'No notes found'
}
export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onClickNote }: {
export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem }: {
entity: VaultEntry; groups: RelationshipGroup[]; query: string
collapsedGroups: Set<string>; sortPrefs: Record<string, SortConfig>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
}) {
return (
<div className="h-full overflow-y-auto">
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} showDate />
<PinnedCard entry={entity} renderItem={renderItem} />
{groups.length === 0
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
: groups.map((group) => (
@@ -39,7 +50,13 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, !!isArchivedView, !!isInboxView, query)
const emptyText = resolveEmptyText({
isChangesView: !!isChangesView,
changesError: changesError ?? null,
isArchivedView: !!isArchivedView,
isInboxView: !!isInboxView,
query,
})
if (searched.length === 0) {
return (

View File

@@ -1,29 +1,8 @@
import type { VaultEntry } from '../../types'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { relativeDate, getDisplayDate } from '../../utils/noteListHelpers'
import { NoteTitleIcon } from '../NoteTitleIcon'
export function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
export function PinnedCard({ entry, renderItem }: {
entry: VaultEntry
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
showDate?: boolean
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
}) {
const te = typeEntryMap[entry.isA ?? '']
const color = getTypeColor(entry.isA ?? '', te?.color)
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
const Icon = getTypeIcon(entry.isA, te?.icon)
return (
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={(e: React.MouseEvent) => onClickNote(entry, e)}>
{/* eslint-disable-next-line react-hooks/static-components */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" />
{entry.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
</div>
)
return renderItem(entry, { forceSelected: true })
}

View File

@@ -7,6 +7,7 @@ import {
} from '../../utils/noteListHelpers'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { SortDropdown } from '../SortDropdown'
import { Button } from '../ui/button'
export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
group: RelationshipGroup
@@ -14,26 +15,37 @@ export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onTogg
sortPrefs: Record<string, SortConfig>
onToggle: () => void
handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
}) {
const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction))
const customProperties = useMemo(() => extractSortableProperties(group.entries), [group.entries])
const contentId = `relationship-group-${group.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
return (
<div>
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
<button className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0" onClick={onToggle}>
<div className="flex w-full items-center justify-between bg-muted" style={{ minHeight: 32, paddingRight: 16 }}>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 flex-1 justify-start gap-1.5 rounded-none px-4 hover:bg-muted"
onClick={onToggle}
aria-controls={contentId}
aria-expanded={!isCollapsed}
>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="font-mono-label text-muted-foreground">{humanizePropertyKey(group.label)}</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
</Button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} customProperties={customProperties} onChange={handleSortChange} />
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
</button>
</span>
</div>
{!isCollapsed && sortedEntries.map((entry) => renderItem(entry))}
{!isCollapsed && (
<div id={contentId}>
{sortedEntries.map((entry) => renderItem(entry))}
</div>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewDefinition, ViewFile } from '../../types'
import {
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
@@ -109,6 +109,10 @@ interface NoteListDataParams {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const entityEntry = useMemo(() => {
if (!isEntityView || selection.kind !== 'entity') return null
return entries.find((entry) => entry.path === selection.entry.path) ?? selection.entry
}, [entries, isEntityView, selection])
const filteredEntries = useFilteredEntries({
entries,
@@ -127,15 +131,12 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
}, [filteredEntries, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
// Look up the fresh entry from the entries array to pick up relationship
// updates that happened after the selection was captured.
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
const groups = buildRelationshipGroups(freshEntry, entries)
if (!entityEntry) return []
const groups = buildRelationshipGroups(entityEntry, entries)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, query])
}, [entityEntry, entries, query])
return { isEntityView, isArchivedView, searched, searchedGroups }
return { entityEntry, isEntityView, isArchivedView, searched, searchedGroups }
}
// --- useNoteListSearch ---
@@ -156,29 +157,99 @@ export function useNoteListSearch() {
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
function findSelectedViewFile(selection: SidebarSelection, views?: ViewFile[]): ViewFile | null {
if (selection.kind !== 'view') return null
return views?.find((candidate) => candidate.filename === selection.filename) ?? null
}
function findSelectedTypeDocument(entries: VaultEntry[], selection: SidebarSelection): VaultEntry | null {
if (selection.kind !== 'sectionGroup') return null
return entries.find((entry) => entry.isA === 'Type' && entry.title === selection.type) ?? null
}
function resolveListSortConfig(
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
sortPrefs: Record<string, SortConfig>,
): SortConfig {
if (typeDocument?.sort) {
const parsed = parseSortConfig(typeDocument.sort)
if (parsed) return parsed
}
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
if (selectedView?.definition.sort) {
const parsed = parseSortConfig(selectedView.definition.sort)
if (parsed) return parsed
}
return selectedView ? DEFAULT_LIST_CONFIG : (sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG)
}
interface SortPersistence {
onUpdateTypeSort: (path: string, key: string, value: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
onUpdateTypeSort?: (path: string, key: string, value: string) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
}
function createSortPersistence(
onUpdateTypeSort?: SortPersistence['onUpdateTypeSort'],
updateEntry?: SortPersistence['updateEntry'],
onUpdateViewDefinition?: SortPersistence['onUpdateViewDefinition'],
): SortPersistence | null {
if (!onUpdateViewDefinition && !(onUpdateTypeSort && updateEntry)) return null
return { onUpdateTypeSort, updateEntry, onUpdateViewDefinition }
}
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
const serialized = serializeSortConfig(config)
persistence.onUpdateTypeSort(path, 'sort', serialized)
persistence.updateEntry(path, { sort: serialized })
persistence.onUpdateTypeSort?.(path, 'sort', serialized)
persistence.updateEntry?.(path, { sort: serialized })
clearListSortFromLocalStorage()
}
function resolveTypeSortPersistenceTarget(groupLabel: string, typeDocument: VaultEntry | null, persistence: SortPersistence | null) {
if (groupLabel !== '__list__' || !typeDocument || !persistence) return null
return { path: typeDocument.path, persistence }
function persistSortToView(
filename: string,
config: SortConfig,
onUpdateViewDefinition: NonNullable<SortPersistence['onUpdateViewDefinition']>,
) {
onUpdateViewDefinition(filename, { sort: serializeSortConfig(config) })
}
type SortPersistenceTarget =
| { kind: 'type'; path: string }
| { kind: 'view'; filename: string }
function canPersistTypeSort(
persistence: SortPersistence,
): persistence is SortPersistence & Required<Pick<SortPersistence, 'onUpdateTypeSort' | 'updateEntry'>> {
return Boolean(persistence.onUpdateTypeSort && persistence.updateEntry)
}
function resolveSortPersistenceTarget(
groupLabel: string,
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
persistence: SortPersistence | null,
): SortPersistenceTarget | null {
if (groupLabel !== '__list__' || !persistence) return null
if (typeDocument && canPersistTypeSort(persistence)) {
return { kind: 'type', path: typeDocument.path }
}
if (selectedView && persistence.onUpdateViewDefinition) {
return { kind: 'view', filename: selectedView.filename }
}
return null
}
function persistListSort(target: SortPersistenceTarget, config: SortConfig, persistence: SortPersistence) {
if (target.kind === 'type') {
persistSortToType(target.path, config, persistence)
return
}
if (persistence.onUpdateViewDefinition) {
persistSortToView(target.filename, config, persistence.onUpdateViewDefinition)
}
}
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
@@ -193,6 +264,24 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
function persistOrSaveGroupSort(
groupLabel: string,
option: SortOption,
direction: SortDirection,
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
persistence: SortPersistence | null,
) {
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
if (!persistenceTarget || !persistence) {
saveGroupSort(groupLabel, option, direction, setSortPrefs)
return
}
persistListSort(persistenceTarget, { option, direction }, persistence)
}
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
if (!configOption.startsWith('property:')) return configOption
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
@@ -205,22 +294,35 @@ export interface UseNoteListSortParams {
modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
views?: ViewFile[]
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
export function useNoteListSort({
entries,
selection,
modifiedPathSet,
modifiedSuffixes,
subFilter,
inboxPeriod,
views,
onUpdateTypeSort,
onUpdateViewDefinition,
updateEntry,
}: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => findSelectedTypeDocument(entries, selection), [entries, selection])
const selectedView = useMemo(
() => findSelectedViewFile(selection, views),
[selection, views],
)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
const listConfig = resolveListSortConfig(typeDocument, selectedView, sortPrefs)
const persistence = useMemo<SortPersistence | null>(
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
[onUpdateTypeSort, updateEntry],
() => createSortPersistence(onUpdateTypeSort, updateEntry, onUpdateViewDefinition),
[onUpdateTypeSort, onUpdateViewDefinition, updateEntry],
)
const migrationDoneRef = useRef<Set<string>>(new Set())
@@ -230,10 +332,16 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}, [typeDocument, sortPrefs, persistence])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
const typeSortTarget = resolveTypeSortPersistenceTarget(groupLabel, typeDocument, persistence)
if (!typeSortTarget) return saveGroupSort(groupLabel, option, direction, setSortPrefs)
persistSortToType(typeSortTarget.path, { option, direction }, typeSortTarget.persistence)
}, [typeDocument, persistence])
persistOrSaveGroupSort(
groupLabel,
option,
direction,
setSortPrefs,
typeDocument,
selectedView,
persistence,
)
}, [typeDocument, selectedView, persistence])
const filteredEntries = useFilteredEntries({
entries,
@@ -242,6 +350,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
modifiedSuffixes,
subFilter,
inboxPeriod,
views,
})
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
@@ -344,18 +453,27 @@ export function useChangeStatusResolver(isChangesView: boolean, modifiedFiles?:
interface VisibleNotesSyncParams {
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
isEntityView: boolean
entityEntry?: VaultEntry | null
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
}
export function useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }: VisibleNotesSyncParams) {
function flattenNeighborhoodEntries(
entityEntry: VaultEntry | null | undefined,
searchedGroups: Array<{ entries: VaultEntry[] }>,
): VaultEntry[] {
if (!entityEntry) return []
return [entityEntry, ...searchedGroups.flatMap((group) => group.entries)]
}
export function useVisibleNotesSync({ visibleNotesRef, isEntityView, entityEntry, searched, searchedGroups }: VisibleNotesSyncParams) {
useEffect(() => {
if (!visibleNotesRef) return
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((group) => group.entries).filter((entry) => !isDeletedNoteEntry(entry))
? flattenNeighborhoodEntries(entityEntry, searchedGroups).filter((entry) => !isDeletedNoteEntry(entry))
: searched.filter((entry) => !isDeletedNoteEntry(entry))
}, [visibleNotesRef, isEntityView, searched, searchedGroups])
}, [visibleNotesRef, entityEntry, isEntityView, searched, searchedGroups])
}
// --- useListPropertyPicker ---
@@ -393,6 +511,96 @@ function deriveDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string
return ordered
}
interface ScopedPropertyPickerState {
availableProperties: string[]
defaultDisplay: string[]
}
function useAllNotesPropertyPickerState(
entries: VaultEntry[],
selection: SidebarSelection,
isAllNotesView: boolean,
typeEntryMap: Record<string, VaultEntry>,
): ScopedPropertyPickerState {
const allNotesEntries = useMemo(
() => isAllNotesView
? [
...filterEntries(entries, selection, 'open'),
...filterEntries(entries, selection, 'archived'),
]
: [],
[entries, isAllNotesView, selection],
)
return {
availableProperties: useMemo(
() => collectAvailableProperties(allNotesEntries),
[allNotesEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
[allNotesEntries, typeEntryMap],
),
}
}
function useInboxPropertyPickerState(
entries: VaultEntry[],
inboxPeriod: InboxPeriod,
isInboxView: boolean,
typeEntryMap: Record<string, VaultEntry>,
): ScopedPropertyPickerState {
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
return {
availableProperties: useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
),
}
}
interface ViewPropertyPickerState extends ScopedPropertyPickerState {
selectedView: ViewFile | null
hasCustomProperties: boolean
}
function useViewPropertyPickerState(
entries: VaultEntry[],
selection: SidebarSelection,
views: ViewFile[] | undefined,
typeEntryMap: Record<string, VaultEntry>,
): ViewPropertyPickerState {
const selectedView = useMemo(
() => findSelectedViewFile(selection, views),
[selection, views],
)
const viewEntries = useMemo(
() => selectedView ? filterEntries(entries, selection, undefined, views) : [],
[entries, selection, selectedView, views],
)
return {
selectedView,
availableProperties: useMemo(
() => collectAvailableProperties(viewEntries),
[viewEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(viewEntries, typeEntryMap),
[viewEntries, typeEntryMap],
),
hasCustomProperties: Boolean(selectedView?.definition.listPropertiesDisplay?.length),
}
}
export interface NoteListPropertyPicker {
scope: NoteListPropertiesScope
availableProperties: string[]
@@ -457,6 +665,61 @@ function buildTypePropertyPicker({
}
}
interface BuildViewPropertyPickerParams {
selectedView: ViewFile | null
availableProperties: string[]
defaultDisplay: string[]
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
}
function buildViewPropertyPicker({
selectedView,
availableProperties,
defaultDisplay,
onUpdateViewDefinition,
}: BuildViewPropertyPickerParams): NoteListPropertyPicker | null {
if (!selectedView || !onUpdateViewDefinition) return null
const currentDisplay = (selectedView.definition.listPropertiesDisplay?.length ?? 0) > 0
? selectedView.definition.listPropertiesDisplay ?? []
: defaultDisplay
return {
scope: 'view',
availableProperties,
currentDisplay,
onSave: (value: string[] | null) => onUpdateViewDefinition(selectedView.filename, { listPropertiesDisplay: value ?? [] }),
triggerTitle: `Customize ${selectedView.definition.name} columns`,
}
}
function resolveDisplayPropsOverride({
isAllNotesView,
hasCustomAllNotesProperties,
allNotesNoteListProperties,
isInboxView,
hasCustomInboxProperties,
inboxNoteListProperties,
selectedView,
hasCustomViewProperties,
}: {
isAllNotesView: boolean
hasCustomAllNotesProperties: boolean
allNotesNoteListProperties?: string[] | null
isInboxView: boolean
hasCustomInboxProperties: boolean
inboxNoteListProperties?: string[] | null
selectedView: ViewFile | null
hasCustomViewProperties: boolean
}) {
if (selectedView && hasCustomViewProperties) {
return selectedView.definition.listPropertiesDisplay ?? null
}
if (isAllNotesView && hasCustomAllNotesProperties) return allNotesNoteListProperties ?? null
if (isInboxView && hasCustomInboxProperties) return inboxNoteListProperties ?? null
return null
}
interface UseListPropertyPickerParams {
entries: VaultEntry[]
selection: SidebarSelection
@@ -467,7 +730,9 @@ interface UseListPropertyPickerParams {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
views?: ViewFile[]
}
export function useListPropertyPicker({
@@ -480,68 +745,55 @@ export function useListPropertyPicker({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
views,
}: UseListPropertyPickerParams) {
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isSectionGroup = selection.kind === 'sectionGroup'
const allNotesEntries = useMemo(
() => isAllNotesView
? [
...filterEntries(entries, selection, 'open'),
...filterEntries(entries, selection, 'archived'),
]
: [],
[entries, isAllNotesView, selection],
)
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
const allNotesAvailableProperties = useMemo(
() => collectAvailableProperties(allNotesEntries),
[allNotesEntries],
)
const allNotesDefaultDisplay = useMemo(
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
[allNotesEntries, typeEntryMap],
)
const allNotesState = useAllNotesPropertyPickerState(entries, selection, isAllNotesView, typeEntryMap)
const inboxState = useInboxPropertyPickerState(entries, inboxPeriod, isInboxView, typeEntryMap)
const viewState = useViewPropertyPickerState(entries, selection, views, typeEntryMap)
const typeAvailableProperties = useMemo(
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
[entries, typeDocument],
)
const inboxAvailableProperties = useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
)
const inboxDefaultDisplay = useMemo(
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
)
const hasCustomAllNotesProperties = !!(allNotesNoteListProperties && allNotesNoteListProperties.length > 0)
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
const displayPropsOverride = isAllNotesView && hasCustomAllNotesProperties
? allNotesNoteListProperties
: (isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null)
const displayPropsOverride = resolveDisplayPropsOverride({
isAllNotesView,
hasCustomAllNotesProperties,
allNotesNoteListProperties,
isInboxView,
hasCustomInboxProperties,
inboxNoteListProperties,
selectedView: viewState.selectedView,
hasCustomViewProperties: viewState.hasCustomProperties,
})
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
return buildFilterPropertyPicker({
return buildViewPropertyPicker({
selectedView: viewState.selectedView,
availableProperties: viewState.availableProperties,
defaultDisplay: viewState.defaultDisplay,
onUpdateViewDefinition,
}) ?? buildFilterPropertyPicker({
scope: 'all',
isActive: isAllNotesView,
availableProperties: allNotesAvailableProperties,
availableProperties: allNotesState.availableProperties,
hasCustomProperties: hasCustomAllNotesProperties,
noteListProperties: allNotesNoteListProperties,
defaultDisplay: allNotesDefaultDisplay,
defaultDisplay: allNotesState.defaultDisplay,
onSave: onUpdateAllNotesNoteListProperties,
triggerTitle: 'Customize All Notes columns',
}) ?? buildFilterPropertyPicker({
scope: 'inbox',
isActive: isInboxView,
availableProperties: inboxAvailableProperties,
availableProperties: inboxState.availableProperties,
hasCustomProperties: hasCustomInboxProperties,
noteListProperties: inboxNoteListProperties,
defaultDisplay: inboxDefaultDisplay,
defaultDisplay: inboxState.defaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}) ?? buildTypePropertyPicker({
@@ -551,15 +803,19 @@ export function useListPropertyPicker({
typeAvailableProperties,
})
}, [
allNotesAvailableProperties,
allNotesDefaultDisplay,
allNotesState.availableProperties,
allNotesState.defaultDisplay,
allNotesNoteListProperties,
hasCustomAllNotesProperties,
isAllNotesView,
onUpdateAllNotesNoteListProperties,
hasCustomInboxProperties,
inboxAvailableProperties,
inboxDefaultDisplay,
isAllNotesView,
inboxState.availableProperties,
inboxState.defaultDisplay,
viewState.availableProperties,
viewState.defaultDisplay,
viewState.selectedView,
onUpdateViewDefinition,
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
isInboxView,
isSectionGroup,
@@ -576,13 +832,14 @@ export function useListPropertyPicker({
interface UseNoteListInteractionsParams {
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
selectedNotePath: string | null
selection: SidebarSelection
noteListFilter: NoteListFilter
isEntityView: boolean
isChangesView: boolean
entityEntry: VaultEntry | null
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
@@ -614,23 +871,38 @@ function openHighlightedChangesContextMenu(
})
}
export function useNoteListInteractions({
function resolveKeyboardEntries(
searched: VaultEntry[],
searchedGroups: Array<{ entries: VaultEntry[] }>,
entityEntry: VaultEntry | null,
): VaultEntry[] {
return entityEntry
? flattenNeighborhoodEntries(entityEntry, searchedGroups)
: searched
}
function useKeyboardInteractionState({
searched,
searchedGroups,
entityEntry,
selectedNotePath,
selection,
noteListFilter,
isEntityView,
isChangesView,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
onDiscardFile,
openContextMenuForEntry,
onCreateNote,
}: UseNoteListInteractionsParams) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
}: Pick<
UseNoteListInteractionsParams,
| 'searched'
| 'searchedGroups'
| 'entityEntry'
| 'selectedNotePath'
| 'onReplaceActiveTab'
| 'onEnterNeighborhood'
| 'onOpenDeletedNote'
>) {
const keyboardEntries = useMemo(
() => resolveKeyboardEntries(searched, searchedGroups, entityEntry),
[entityEntry, searched, searchedGroups],
)
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) {
@@ -644,24 +916,47 @@ export function useNoteListInteractions({
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
}, [])
const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) return
await onReplaceActiveTab(entry)
onEnterNeighborhood?.(entry)
}, [onEnterNeighborhood, onReplaceActiveTab])
const noteListKeyboard = useNoteListKeyboard({
items: searched,
items: keyboardEntries,
selectedNotePath,
onOpen: handleKeyboardOpen,
onEnterNeighborhood: handleNeighborhoodOpen,
onPrefetch: handleKeyboardPrefetch,
enabled: !isEntityView,
enabled: true,
})
const multiSelect = useMultiSelect(searched, selectedNotePath)
const multiSelect = useMultiSelect(keyboardEntries, selectedNotePath)
useEffect(() => {
multiSelect.clear()
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
return { handleNeighborhoodOpen, multiSelect, noteListKeyboard }
}
const handleClickNote = useCallback((entry: VaultEntry, event: React.MouseEvent) => {
function useNoteClickHandler({
isChangesView,
onReplaceActiveTab,
handleNeighborhoodOpen,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
multiSelect,
}: {
isChangesView: boolean
onReplaceActiveTab: (entry: VaultEntry) => void
handleNeighborhoodOpen: (entry: VaultEntry) => Promise<void>
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
multiSelect: MultiSelectState
}) {
return useCallback((entry: VaultEntry, event: React.MouseEvent) => {
if (isDeletedNoteEntry(entry)) {
routeNoteClick(entry, event, {
onReplace: () => onOpenDeletedNote?.(entry),
onSelect: () => onOpenDeletedNote?.(entry),
onEnterNeighborhood: () => onOpenDeletedNote?.(entry),
multiSelect,
})
return
@@ -669,7 +964,7 @@ export function useNoteListInteractions({
routeNoteClick(entry, event, {
onReplace: onReplaceActiveTab,
onSelect: onSelectNote,
onEnterNeighborhood: handleNeighborhoodOpen,
onOpenInNewWindow,
multiSelect,
})
@@ -684,15 +979,31 @@ export function useNoteListInteractions({
onOpenDeletedNote,
onOpenInNewWindow,
onReplaceActiveTab,
onSelectNote,
handleNeighborhoodOpen,
])
}
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
function useListKeyDownHandler({
isChangesView,
onDiscardFile,
highlightedPath,
searched,
openContextMenuForEntry,
handleKeyDown,
}: {
isChangesView: boolean
onDiscardFile?: (relativePath: string) => Promise<void>
highlightedPath: string | null
searched: VaultEntry[]
openContextMenuForEntry: (entry: VaultEntry, point: { x: number; y: number }) => void
handleKeyDown: (event: React.KeyboardEvent) => void
}) {
return useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const entry = resolveChangesContextMenuEntry(
event,
isChangesView,
onDiscardFile,
noteListKeyboard.highlightedPath,
highlightedPath,
searched,
)
if (entry) {
@@ -702,8 +1013,60 @@ export function useNoteListInteractions({
return
}
noteListKeyboard.handleKeyDown(event)
}, [isChangesView, noteListKeyboard, onDiscardFile, openContextMenuForEntry, searched])
handleKeyDown(event)
}, [handleKeyDown, highlightedPath, isChangesView, onDiscardFile, openContextMenuForEntry, searched])
}
export function useNoteListInteractions({
searched,
searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isChangesView,
entityEntry,
onReplaceActiveTab,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
onDiscardFile,
openContextMenuForEntry,
onCreateNote,
}: UseNoteListInteractionsParams) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const { handleNeighborhoodOpen, multiSelect, noteListKeyboard } = useKeyboardInteractionState({
searched,
searchedGroups,
entityEntry,
selectedNotePath,
onReplaceActiveTab,
onEnterNeighborhood,
onOpenDeletedNote,
})
useEffect(() => {
multiSelect.clear()
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
const handleClickNote = useNoteClickHandler({
isChangesView,
onReplaceActiveTab,
handleNeighborhoodOpen,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
multiSelect,
})
const handleListKeyDown = useListKeyDownHandler({
isChangesView,
onDiscardFile,
highlightedPath: noteListKeyboard.highlightedPath,
searched,
openContextMenuForEntry,
handleKeyDown: noteListKeyboard.handleKeyDown,
})
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)

View File

@@ -1,4 +1,4 @@
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all'
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view'
export interface OpenListPropertiesEventDetail {
scope: NoteListPropertiesScope

View File

@@ -66,5 +66,4 @@ export function filterGroupsByNoteListQuery(
...group,
entries: filterEntriesByNoteListQuery(group.entries, normalizedQuery, context),
}))
.filter((group) => group.entries.length > 0)
}

View File

@@ -18,7 +18,7 @@ function makeEntry(path = '/test.md'): VaultEntry {
function makeActions(): ClickActions {
return {
onReplace: vi.fn(),
onSelect: vi.fn(),
onEnterNeighborhood: vi.fn(),
onOpenInNewWindow: vi.fn(),
multiSelect: {
selectRange: vi.fn(),
@@ -49,12 +49,13 @@ describe('routeNoteClick', () => {
expect(actions.multiSelect.setAnchor).toHaveBeenCalledWith(entry.path)
})
it('Cmd+click opens as new tab', () => {
it('Cmd+click enters Neighborhood mode', () => {
const entry = makeEntry()
const actions = makeActions()
routeNoteClick(entry, makeMouseEvent({ metaKey: true }), actions)
expect(actions.onSelect).toHaveBeenCalledWith(entry)
expect(actions.onEnterNeighborhood).toHaveBeenCalledWith(entry)
expect(actions.multiSelect.clear).toHaveBeenCalled()
expect(actions.onReplace).not.toHaveBeenCalled()
})
it('Shift+click selects range', () => {
@@ -70,7 +71,7 @@ describe('routeNoteClick', () => {
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
expect(actions.onOpenInNewWindow).toHaveBeenCalledWith(entry)
expect(actions.onReplace).not.toHaveBeenCalled()
expect(actions.onSelect).not.toHaveBeenCalled()
expect(actions.onEnterNeighborhood).not.toHaveBeenCalled()
})
it('Cmd+Shift+click is a no-op when handler is undefined', () => {
@@ -79,6 +80,6 @@ describe('routeNoteClick', () => {
actions.onOpenInNewWindow = undefined
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
expect(actions.onReplace).not.toHaveBeenCalled()
expect(actions.onSelect).not.toHaveBeenCalled()
expect(actions.onEnterNeighborhood).not.toHaveBeenCalled()
})
})

View File

@@ -44,25 +44,44 @@ export function filterGroupsByQuery(groups: RelationshipGroup[], query: string):
export interface ClickActions {
onReplace: (entry: VaultEntry) => void
onSelect: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
}
function usesCommandModifier(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey'>): boolean {
return event.metaKey || event.ctrlKey
}
function isOpenInNewWindowClick(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>): boolean {
return usesCommandModifier(event) && event.shiftKey
}
function isRangeSelectionClick(event: Pick<React.MouseEvent, 'shiftKey'>): boolean {
return event.shiftKey
}
function isNeighborhoodClick(
event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey'>,
actions: ClickActions,
): boolean {
return usesCommandModifier(event) && Boolean(actions.onEnterNeighborhood)
}
export function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
if ((e.metaKey || e.ctrlKey) && e.shiftKey) {
if (isOpenInNewWindowClick(e)) {
actions.onOpenInNewWindow?.(entry)
return
}
if (e.shiftKey) {
if (isRangeSelectionClick(e)) {
actions.multiSelect.selectRange(entry.path)
return
}
actions.multiSelect.clear()
if (e.metaKey || e.ctrlKey) {
actions.onSelect(entry)
if (isNeighborhoodClick(e, actions)) {
actions.onEnterNeighborhood?.(entry)
return
}

View File

@@ -5,13 +5,15 @@ import type {
ModifiedFile,
NoteStatus,
InboxPeriod,
ViewDefinition,
ViewFile,
} from '../../types'
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
import { prefetchNoteContent } from '../../hooks/useTabManagement'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch'
import {
useChangeStatusResolver,
@@ -76,7 +78,7 @@ function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) {
return useMemo(() => {
if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type)
if (selection.kind === 'folder') return countAllByFilter(entries)
if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries)
if (selection.kind === 'filter' && selection.filter === 'all') return countAllNotesByFilter(entries)
return { open: 0, archived: 0 }
}, [entries, selection])
}
@@ -94,6 +96,7 @@ interface UseNoteListContentParams {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
views?: ViewFile[]
@@ -113,6 +116,7 @@ function useNoteListContent({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
updateEntry,
views,
@@ -129,7 +133,9 @@ function useNoteListContent({
modifiedSuffixes,
subFilter,
inboxPeriod: effectiveInboxPeriod,
views,
onUpdateTypeSort,
onUpdateViewDefinition,
updateEntry,
})
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
@@ -144,9 +150,12 @@ function useNoteListContent({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
views,
})
const {
entityEntry,
isEntityView,
isArchivedView,
searched: sortedEntries,
@@ -174,11 +183,12 @@ function useNoteListContent({
typeEntryMap,
displayPropsOverride,
}), [displayPropsOverride, entries, query, sortedGroups, typeEntryMap])
useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups })
useVisibleNotesSync({ visibleNotesRef, isEntityView, entityEntry, searched, searchedGroups })
return {
customProperties,
displayPropsOverride,
entityEntry,
handleSortChange,
isArchivedView,
isEntityView,
@@ -200,15 +210,16 @@ function useNoteListContent({
interface UseNoteListInteractionStateParams {
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
selectedNotePath: string | null
selection: SidebarSelection
noteListFilter: NoteListFilter
isArchivedView: boolean
isChangesView: boolean
isEntityView: boolean
entityEntry: VaultEntry | null
modifiedFiles?: ModifiedFile[]
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
@@ -220,15 +231,16 @@ interface UseNoteListInteractionStateParams {
function useNoteListInteractionState({
searched,
searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isArchivedView,
isChangesView,
isEntityView,
entityEntry,
modifiedFiles,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
@@ -248,13 +260,14 @@ function useNoteListInteractionState({
toggleGroup,
} = useNoteListInteractions({
searched,
searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isEntityView,
isChangesView,
entityEntry,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
@@ -316,11 +329,11 @@ function useRenderItem({
}: UseRenderItemParams) {
const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined
return useCallback((entry: VaultEntry) => (
return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => (
<NoteItem
key={entry.path}
entry={entry}
isSelected={selectedNotePath === entry.path}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
@@ -329,6 +342,7 @@ function useRenderItem({
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent}
onContextMenu={contextMenuHandler}
/>
), [
@@ -359,6 +373,7 @@ export interface NoteListProps {
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onCreateNote: (type?: string) => void
onBulkArchive?: (paths: string[]) => void
onBulkDeletePermanently?: (paths: string[]) => void
@@ -372,6 +387,7 @@ export interface NoteListProps {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
@@ -387,7 +403,7 @@ function buildNoteListLayoutModel(params: {
onOpenType: (entry: VaultEntry) => void
content: ReturnType<typeof useNoteListContent>
interaction: ReturnType<typeof useNoteListInteractionState> & {
renderItem: (entry: VaultEntry) => React.ReactNode
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
entitySelection: EntitySelection | null
}
}) {
@@ -451,8 +467,8 @@ export function useNoteListModel({
modifiedFilesError,
getNoteStatus,
sidebarCollapsed,
onSelectNote,
onReplaceActiveTab,
onEnterNeighborhood,
onCreateNote,
onBulkArchive,
onBulkDeletePermanently,
@@ -466,6 +482,7 @@ export function useNoteListModel({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
views,
visibleNotesRef,
}: NoteListProps) {
@@ -486,6 +503,7 @@ export function useNoteListModel({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
updateEntry,
views,
@@ -493,15 +511,16 @@ export function useNoteListModel({
})
const interaction = useNoteListInteractionState({
searched: content.searched,
searchedGroups: content.searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isArchivedView: content.isArchivedView,
isEntityView: content.isEntityView,
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
entityEntry: content.entityEntry,
modifiedFiles,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
@@ -538,7 +557,9 @@ export function useNoteListModel({
interaction: {
...interaction,
renderItem,
entitySelection: content.isEntityView && selection.kind === 'entity' ? selection : null,
entitySelection: content.isEntityView && selection.kind === 'entity'
? { ...selection, entry: content.entityEntry ?? selection.entry }
: null,
},
})
}

View File

@@ -19,18 +19,21 @@ export function SidebarGroupHeader({
children,
}: SidebarGroupHeaderProps) {
return (
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
<div
className="flex w-full items-center justify-between text-muted-foreground"
style={{ padding: count != null ? SIDEBAR_GROUP_HEADER_PADDING.withCount : SIDEBAR_GROUP_HEADER_PADDING.regular }}
onClick={onToggle}
>
<div className="flex items-center gap-1">
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer select-none items-center gap-1 border-none bg-transparent p-0 text-muted-foreground"
onClick={onToggle}
>
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>{label}</span>
</div>
</button>
{children ?? (count != null && (
<SidebarCountPill count={count} className="text-muted-foreground" compact style={{ background: 'var(--muted)' }} />
))}
</button>
</div>
)
}

View File

@@ -13,6 +13,7 @@ import { SlidersHorizontal } from 'lucide-react'
import {
CaretLeft, Plus,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import {
type SectionGroup, isSelectionActive, SectionContent, VisibilityPopover,
} from '../SidebarParts'
@@ -20,6 +21,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { countByFilter } from '../../utils/noteListHelpers'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -94,9 +96,7 @@ function SortableSection({
sectionProps: SidebarSectionProps
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const itemCount = sectionProps.entries.filter((entry) =>
!entry.archived && (group.type === 'Note' ? (entry.isA === 'Note' || !entry.isA) : entry.isA === group.type),
).length
const itemCount = countByFilter(sectionProps.entries, group.type).open
const isRenaming = sectionProps.renamingType === group.type
return (
@@ -162,21 +162,30 @@ export function TypesSection({
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
<SidebarGroupHeader label="TYPES" collapsed={collapsed} onToggle={onToggle}>
<div className="flex items-center gap-1.5">
<span
role="button"
<Button
type="button"
variant="ghost"
size="icon-xs"
title="Customize sections"
aria-label="Customize sections"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); setShowCustomize((value) => !value) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
</Button>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
<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-type-btn"
title="Create new type"
aria-label="Create new type"
onClick={(event) => { event.stopPropagation(); onCreateNewType() }}
/>
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)}
</div>
</SidebarGroupHeader>

View File

@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react
import type { VaultEntry } from '../../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { countAllNotesByFilter } from '../../utils/noteListHelpers'
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
@@ -58,13 +59,8 @@ export function useSidebarCollapsed() {
export function useEntryCounts(entries: VaultEntry[]) {
return useMemo(() => {
let active = 0
let archived = 0
for (const entry of entries) {
if (entry.archived) archived++
else active++
}
return { activeCount: active, archivedCount: archived }
const counts = countAllNotesByFilter(entries)
return { activeCount: counts.open, archivedCount: counts.archived }
}, [entries])
}

View File

@@ -329,9 +329,33 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
)
}
export function NoRemoteBadge({ remoteStatus }: { remoteStatus?: GitRemoteStatus | null }) {
export function NoRemoteBadge({
remoteStatus,
onAddRemote,
}: {
remoteStatus?: GitRemoteStatus | null
onAddRemote?: () => void
}) {
if (!isRemoteMissing(remoteStatus)) return null
if (onAddRemote) {
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarAction
copy={{ label: 'Add a remote to this vault' }}
onClick={onAddRemote}
testId="status-no-remote"
>
<span style={ICON_STYLE}>
<GitBranch size={12} />
No remote
</span>
</StatusBarAction>
</>
)
}
return (
<>
<span style={SEP_STYLE}>|</span>

View File

@@ -4,9 +4,11 @@ import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import { useStatusBarAddRemote } from '../../hooks/useStatusBarAddRemote'
import type { GitRemoteStatus, SyncStatus } from '../../types'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { AiAgentsBadge } from './AiAgentsBadge'
import { AddRemoteModal } from '../AddRemoteModal'
import { Button } from '@/components/ui/button'
import {
ClaudeCodeBadge,
@@ -35,8 +37,10 @@ interface StatusBarPrimarySectionProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onAddRemote?: () => void
onClickPending?: () => void
onClickPulse?: () => void
onCommitPush?: () => void
@@ -77,8 +81,10 @@ export function StatusBarPrimarySection({
vaults,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onAddRemote,
onClickPending,
onClickPulse,
onCommitPush,
@@ -104,6 +110,19 @@ export function StatusBarPrimarySection({
claudeCodeStatus,
claudeCodeVersion,
}: StatusBarPrimarySectionProps) {
const {
openAddRemote,
closeAddRemote,
showAddRemote,
visibleRemoteStatus,
handleRemoteConnected,
} = useStatusBarAddRemote({
vaultPath,
isGitVault,
remoteStatus,
onAddRemote,
})
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
<VaultMenu
@@ -111,6 +130,7 @@ export function StatusBarPrimarySection({
vaultPath={vaultPath}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
@@ -134,13 +154,18 @@ export function StatusBarPrimarySection({
</Button>
</ActionTooltip>
<OfflineBadge isOffline={isOffline} />
<NoRemoteBadge remoteStatus={remoteStatus} />
<NoRemoteBadge
remoteStatus={visibleRemoteStatus}
onAddRemote={() => {
void openAddRemote()
}}
/>
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} remoteStatus={remoteStatus} />
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}
remoteStatus={remoteStatus}
remoteStatus={visibleRemoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
@@ -159,6 +184,12 @@ export function StatusBarPrimarySection({
/>
)
: claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
<AddRemoteModal
open={showAddRemote}
vaultPath={vaultPath}
onClose={closeAddRemote}
onRemoteConnected={handleRemoteConnected}
/>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import type { VaultOption } from './types'
@@ -11,6 +11,7 @@ interface VaultMenuProps {
vaultPath: string
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
@@ -42,12 +43,24 @@ interface VaultAction {
}
function buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
}: Pick<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onCreateEmptyVault) {
items.push({
key: 'create-empty',
icon: <Plus size={12} />,
label: 'Create empty vault',
testId: 'vault-menu-create-empty',
accent: true,
onClick: onCreateEmptyVault,
})
}
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
@@ -88,67 +101,52 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
return <span style={{ width: 12 }} />
}
function vaultItemStyle(isActive: boolean, unavailable: boolean): CSSProperties {
return {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: unavailable ? 'not-allowed' : 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
fontSize: 12,
}
}
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
const unavailable = vault.available === false
const canHover = !isActive && !unavailable
const removeLabel = `Remove ${vault.label} from list`
return (
<div
role="button"
onClick={unavailable ? undefined : onSelect}
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
onMouseEnter={canHover ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={canHover ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
data-testid={`vault-menu-item-${vault.label}`}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
<div className="group flex items-center gap-1 rounded-sm">
<Button
type="button"
variant="ghost"
size="xs"
disabled={unavailable}
onClick={onSelect}
aria-current={isActive ? 'true' : undefined}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
data-testid={`vault-menu-item-${vault.label}`}
className={isActive
? 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-foreground hover:bg-[var(--hover)] hover:text-foreground'
: 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
}
style={{
height: 'auto',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
}}
>
<span className="flex items-center gap-1.5 truncate">
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
<span className="truncate">{vault.label}</span>
</span>
</Button>
{canRemove && onRemove && (
<span
role="button"
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
style={{
display: 'flex',
alignItems: 'center',
padding: 2,
borderRadius: 3,
cursor: 'pointer',
opacity: 0.5,
}}
title="Remove from list"
title={removeLabel}
aria-label={removeLabel}
data-testid={`vault-menu-remove-${vault.label}`}
onMouseEnter={(event) => {
event.currentTarget.style.opacity = '1'
event.currentTarget.style.background = 'var(--hover)'
}}
onMouseLeave={(event) => {
event.currentTarget.style.opacity = '0.5'
event.currentTarget.style.background = 'transparent'
}}
className="rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
>
<X size={10} />
</span>
</Button>
)}
</div>
)
@@ -156,27 +154,18 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
return (
<div
role="button"
<Button
type="button"
variant="ghost"
size="xs"
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: 'pointer',
background: 'transparent',
color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)',
fontSize: 12,
}}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
className="h-auto w-full justify-start rounded-sm px-2 py-1 text-xs font-normal"
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
data-testid={testId}
>
{icon}
{label}
</div>
</Button>
)
}
@@ -185,6 +174,7 @@ export function VaultMenu({
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
@@ -198,11 +188,12 @@ export function VaultMenu({
const actions = useMemo<VaultAction[]>(() => {
return buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
}, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>

View File

@@ -54,6 +54,7 @@ import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
@@ -166,6 +167,13 @@ type TolariaSelectedBlock = ReturnType<
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
>['block']
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
type TolariaBlockTypeSelectOption = ReturnType<
typeof getTolariaBlockTypeSelectItems
>[number] & {
@@ -263,6 +271,17 @@ function getTolariaBlockTypeSelectOptions(
}))
}
function getFormattingToolbarBridgeBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
: null
}
function updateSelectedBlocksToType(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
@@ -451,6 +470,20 @@ export function TolariaFormattingToolbarController(props: {
})
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const currentBridgeBlockId = useEditorState({
editor,
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
})
useBlockNoteFormattingToolbarHoverGuard({
editor,
container:
editor.domElement?.closest('.editor__blocknote-container') ??
editor.domElement ??
null,
selectedFileBlockId: currentBridgeBlockId,
isOpen,
})
const position = useEditorState({
editor,

View File

@@ -69,6 +69,7 @@ export function useInlineWikilinkSelection({
}, [selectionRange, value])
return {
editorRef,
selectionRange,
selectionIndex: selectionRange.end,
setSelectionRange,

View File

@@ -36,6 +36,7 @@ export const APP_COMMAND_IDS = {
vaultOpen: 'vault-open',
vaultRemove: 'vault-remove',
vaultRestoreGettingStarted: 'vault-restore-getting-started',
vaultAddRemote: 'vault-add-remote',
vaultCommitPush: 'vault-commit-push',
vaultPull: 'vault-pull',
vaultResolveConflicts: 'vault-resolve-conflicts',
@@ -92,6 +93,7 @@ type SimpleHandlerKey =
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onAddRemote'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
@@ -151,7 +153,13 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
[APP_COMMAND_IDS.fileQuickOpen]: {
route: { kind: 'handler', handler: 'onQuickOpen' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'p', code: 'KeyP', display: '⌘P' },
shortcut: {
combo: 'command-or-ctrl',
key: 'p',
aliases: ['o'],
code: 'KeyP',
display: '⌘P / ⌘O',
},
},
[APP_COMMAND_IDS.fileSave]: {
route: { kind: 'handler', handler: 'onSave' },
@@ -288,6 +296,10 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
route: { kind: 'handler', handler: 'onRestoreGettingStarted' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultAddRemote]: {
route: { kind: 'handler', handler: 'onAddRemote' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultCommitPush]: {
route: { kind: 'handler', handler: 'onCommitPush' },
menuOwned: true,

View File

@@ -77,6 +77,7 @@ describe('appCommandDispatcher', () => {
})
it('finds raw editor and AI shortcuts from the shared catalog', () => {
expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen)
expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor)
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
@@ -128,6 +129,16 @@ describe('appCommandDispatcher', () => {
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({
key: 'o',
code: 'KeyO',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: false,
}),
).toBe(APP_COMMAND_IDS.fileQuickOpen)
expect(
findShortcutCommandIdForEvent({
key: '¬',

View File

@@ -52,6 +52,7 @@ export interface AppCommandHandlers {
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onAddRemote?: () => void
onCommitPush?: () => void
onPull?: () => void
onResolveConflicts?: () => void
@@ -87,6 +88,7 @@ type SimpleHandlerKey = keyof Pick<
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onAddRemote'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
@@ -124,6 +126,7 @@ const SIMPLE_HANDLER_EXECUTORS: Record<SimpleHandlerKey, (handlers: AppCommandHa
onOpenVault: (handlers) => handlers.onOpenVault?.(),
onRemoveActiveVault: (handlers) => handlers.onRemoveActiveVault?.(),
onRestoreGettingStarted: (handlers) => handlers.onRestoreGettingStarted?.(),
onAddRemote: (handlers) => handlers.onAddRemote?.(),
onCommitPush: (handlers) => handlers.onCommitPush?.(),
onPull: (handlers) => handlers.onPull?.(),
onResolveConflicts: (handlers) => handlers.onResolveConflicts?.(),

View File

@@ -3,6 +3,8 @@ import type { SidebarSelection } from '../../types'
interface GitCommandsConfig {
modifiedCount: number
canAddRemote: boolean
onAddRemote?: () => void
onCommitPush: () => void
onPull?: () => void
onResolveConflicts?: () => void
@@ -10,9 +12,10 @@ interface GitCommandsConfig {
}
export function buildGitCommands(config: GitCommandsConfig): CommandAction[] {
const { modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect } = config
const { modifiedCount, canAddRemote, onAddRemote, onCommitPush, onPull, onResolveConflicts, onSelect } = config
return [
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
{ id: 'add-remote', label: 'Add Remote to Current Vault', group: 'Git', keywords: ['git', 'remote', 'connect', 'origin', 'no remote'], enabled: canAddRemote && !!onAddRemote, execute: () => onAddRemote?.() },
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },

View File

@@ -14,7 +14,7 @@ interface NavigationCommandsConfig {
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
const { onQuickOpen, onSelect, showInbox = true, onGoBack, onGoForward, canGoBack, canGoForward } = config
const commands: CommandAction[] = [
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },

View File

@@ -29,4 +29,21 @@ describe('buildSettingsCommands', () => {
enabled: true,
})
})
it('adds a create-empty-vault command when the handler is available', () => {
const onOpenSettings = vi.fn()
const onCreateEmptyVault = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings, onCreateEmptyVault })
const command = commands.find((item) => item.id === 'create-empty-vault')
expect(command).toMatchObject({
label: 'Create Empty Vault…',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
})
})

View File

@@ -7,6 +7,7 @@ interface SettingsCommandsConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCheckForUpdates?: () => void
@@ -39,10 +40,12 @@ function buildVaultSettingsCommands({
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onCreateEmptyVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
return [
{ id: 'create-empty-vault', label: 'Create Empty Vault…', group: 'Settings', keywords: ['vault', 'create', 'new', 'empty', 'folder'], enabled: !!onCreateEmptyVault, execute: () => onCreateEmptyVault?.() },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
@@ -65,7 +68,7 @@ function buildMaintenanceCommands({
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
@@ -75,6 +78,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}),

View File

@@ -32,7 +32,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },

View File

@@ -7,6 +7,7 @@ import type { CommandAction } from './useCommandRegistry'
import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useMenuEvents } from './useMenuEvents'
import type { SidebarSelection, SidebarFilter, VaultEntry } from '../types'
import { requestAddRemote } from '../utils/addRemoteEvents'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands'
@@ -51,6 +52,9 @@ interface AppCommandsConfig {
canGoBack?: boolean
canGoForward?: boolean
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onAddRemote?: () => void
canAddRemote?: boolean
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
@@ -82,10 +86,95 @@ interface AppCommandsConfig {
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel?: string
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
}
type CommandRegistryConfig = Parameters<typeof useCommandRegistry>[0]
type CommandRegistrySelectionState = Pick<
CommandRegistryConfig,
| 'activeNoteModified'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'zoomLevel'
| 'onSelect'
| 'showInbox'
| 'onGoBack'
| 'onGoForward'
| 'canGoBack'
| 'canGoForward'
| 'selection'
>
type CommandRegistryCoreActions = Pick<
CommandRegistryConfig,
| 'activeTabPath'
| 'entries'
| 'modifiedCount'
| 'onQuickOpen'
| 'onCreateNote'
| 'onCreateNoteOfType'
| 'onSave'
| 'onOpenSettings'
| 'onOpenFeedback'
| 'onDeleteNote'
| 'onArchiveNote'
| 'onUnarchiveNote'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onSetViewMode'
| 'onToggleInspector'
| 'onToggleDiff'
| 'onToggleRawEditor'
| 'onToggleAIChat'
>
type CommandRegistryVaultActions = Pick<
CommandRegistryConfig,
| 'onOpenVault'
| 'onCreateEmptyVault'
| 'onAddRemote'
| 'canAddRemote'
| 'onCheckForUpdates'
| 'onCreateType'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'isGettingStartedHidden'
| 'vaultCount'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
| 'canRestoreDeletedNote'
>
type CommandRegistryAiActions = Pick<
CommandRegistryConfig,
| 'mcpStatus'
| 'onInstallMcp'
| 'aiAgentsStatus'
| 'vaultAiGuidanceStatus'
| 'onOpenAiAgents'
| 'onRestoreVaultAiGuidance'
| 'onSetDefaultAiAgent'
| 'selectedAiAgent'
| 'onCycleDefaultAiAgent'
| 'selectedAiAgentLabel'
>
type CommandRegistryNoteActions = Pick<
CommandRegistryConfig,
| 'onSetNoteIcon'
| 'onRemoveNoteIcon'
| 'activeNoteHasIcon'
| 'noteListFilter'
| 'onSetNoteListFilter'
| 'onToggleFavorite'
| 'onToggleOrganized'
| 'onCustomizeNoteListColumns'
| 'canCustomizeNoteListColumns'
| 'noteListColumnsLabel'
>
function createKeyboardActions(
config: AppCommandsConfig,
): Omit<Parameters<typeof useAppKeyboard>[0], 'onArchiveNote'> {
@@ -119,6 +208,40 @@ function createMenuEventHandlers(
selectFilter: (filter: SidebarFilter) => void,
viewChanges: () => void,
): Omit<Parameters<typeof useMenuEvents>[0], 'onArchiveNote'> {
return {
...createMenuEventActionHandlers(config, selectFilter),
...createMenuEventVaultHandlers(config, viewChanges),
...createMenuEventState(config),
}
}
function createMenuEventActionHandlers(
config: AppCommandsConfig,
selectFilter: (filter: SidebarFilter) => void,
): Pick<
Omit<Parameters<typeof useMenuEvents>[0], 'onArchiveNote'>,
| 'onSetViewMode'
| 'onCreateNote'
| 'onCreateType'
| 'onQuickOpen'
| 'onSave'
| 'onOpenSettings'
| 'onToggleInspector'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onDeleteNote'
| 'onSearch'
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleAIChat'
| 'onToggleOrganized'
| 'onGoBack'
| 'onGoForward'
| 'onCheckForUpdates'
| 'onSelectFilter'
> {
return {
onSetViewMode: config.onSetViewMode,
onCreateNote: config.onCreateNote,
@@ -141,9 +264,33 @@ function createMenuEventHandlers(
onGoForward: config.onGoForward,
onCheckForUpdates: config.onCheckForUpdates,
onSelectFilter: selectFilter,
}
}
function createMenuEventVaultHandlers(
config: AppCommandsConfig,
viewChanges: () => void,
): Pick<
Omit<Parameters<typeof useMenuEvents>[0], 'onArchiveNote'>,
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onAddRemote'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onViewChanges'
| 'onInstallMcp'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
> {
return {
onOpenVault: config.onOpenVault,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
onAddRemote: config.onAddRemote ?? requestAddRemote,
onCommitPush: config.onCommitPush,
onPull: config.onPull,
onResolveConflicts: config.onResolveConflicts,
@@ -153,15 +300,52 @@ function createMenuEventHandlers(
onRepairVault: config.onRepairVault,
onOpenInNewWindow: config.onOpenInNewWindow,
onRestoreDeletedNote: config.onRestoreDeletedNote,
}
}
function createMenuEventState(
config: AppCommandsConfig,
): Pick<
Omit<Parameters<typeof useMenuEvents>[0], 'onArchiveNote'>,
| 'activeTabPathRef'
| 'multiSelectionCommandRef'
| 'activeTabPath'
| 'modifiedCount'
| 'hasRestorableDeletedNote'
| 'hasNoRemote'
> {
return {
activeTabPathRef: config.activeTabPathRef,
multiSelectionCommandRef: config.multiSelectionCommandRef,
activeTabPath: config.activeTabPath,
modifiedCount: config.modifiedCount,
hasRestorableDeletedNote: config.canRestoreDeletedNote,
hasNoRemote: config.canAddRemote ?? true,
}
}
function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<typeof useCommandRegistry>[0] {
function createCommandRegistrySelectionConfig(
config: AppCommandsConfig,
): CommandRegistrySelectionState {
return {
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
showInbox: config.showInbox,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
canGoBack: config.canGoBack,
canGoForward: config.canGoForward,
selection: config.selection,
}
}
function createCommandRegistryCoreConfig(
config: AppCommandsConfig,
): CommandRegistryCoreActions {
return {
activeTabPath: config.activeTabPath,
entries: config.entries,
@@ -183,24 +367,35 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
onToggleAIChat: config.onToggleAIChat,
}
}
function createCommandRegistryVaultConfig(
config: AppCommandsConfig,
): CommandRegistryVaultActions {
return {
onOpenVault: config.onOpenVault,
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
showInbox: config.showInbox,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
canGoBack: config.canGoBack,
canGoForward: config.canGoForward,
onCreateEmptyVault: config.onCreateEmptyVault,
onAddRemote: config.onAddRemote ?? requestAddRemote,
canAddRemote: config.canAddRemote ?? true,
onCheckForUpdates: config.onCheckForUpdates,
onCreateType: config.onCreateType,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onOpenInNewWindow: config.onOpenInNewWindow,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
}
}
function createCommandRegistryAiConfig(
config: AppCommandsConfig,
): CommandRegistryAiActions {
return {
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
aiAgentsStatus: config.aiAgentsStatus,
@@ -211,21 +406,33 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
selectedAiAgent: config.selectedAiAgent,
onCycleDefaultAiAgent: config.onCycleDefaultAiAgent,
selectedAiAgentLabel: config.selectedAiAgentLabel,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
}
}
function createCommandRegistryNoteConfig(
config: AppCommandsConfig,
): CommandRegistryNoteActions {
return {
onSetNoteIcon: config.onSetNoteIcon,
onRemoveNoteIcon: config.onRemoveNoteIcon,
activeNoteHasIcon: config.activeNoteHasIcon,
selection: config.selection,
noteListFilter: config.noteListFilter,
onSetNoteListFilter: config.onSetNoteListFilter,
onOpenInNewWindow: config.onOpenInNewWindow,
onToggleFavorite: config.onToggleFavorite,
onToggleOrganized: config.onToggleOrganized,
onCustomizeNoteListColumns: config.onCustomizeNoteListColumns,
canCustomizeNoteListColumns: config.canCustomizeNoteListColumns,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
noteListColumnsLabel: config.noteListColumnsLabel,
}
}
function createCommandRegistryConfig(config: AppCommandsConfig): CommandRegistryConfig {
return {
...createCommandRegistryCoreConfig(config),
...createCommandRegistrySelectionConfig(config),
...createCommandRegistryVaultConfig(config),
...createCommandRegistryAiConfig(config),
...createCommandRegistryNoteConfig(config),
}
}

View File

@@ -90,6 +90,13 @@ describe('useAppKeyboard', () => {
expect(actions.onQuickOpen).toHaveBeenCalled()
})
it('Cmd+O triggers quick open', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('o', { metaKey: true, code: 'KeyO' })
expect(actions.onQuickOpen).toHaveBeenCalled()
})
it('Cmd+N triggers create note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -461,6 +461,57 @@ describe('useAppSave', () => {
expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending')
})
it('does not run markdown title-sync renames for non-markdown text files', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const viewPath = '/vault/views/active-projects.yml'
const viewContent = 'name: Active Projects\nicon: rocket\ncolor: blue\n'
const viewEntry = {
...makeEntry(viewPath, 'Active Projects', 'active-projects.yml'),
fileKind: 'text' as const,
}
const { result } = renderSave({
tabs: [{ entry: viewEntry, content: viewContent }],
activeTabPath: viewPath,
unsavedPaths: new Set([viewPath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: viewPath,
content: viewContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('does not rename an existing markdown note when Cmd+S saves a desynced H1/title state', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const notePath = '/vault/note-b.md'
const noteContent = '# Breadcrumb Sync Target\n\nBody'
const entry = makeEntry(notePath, 'Breadcrumb Sync Target', 'note-b.md')
const { result } = renderSave({
tabs: [{ entry, content: noteContent }],
activeTabPath: notePath,
unsavedPaths: new Set([notePath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: notePath,
content: noteContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => {
const initialContent = '# Fresh Title\n\nInitial body'
const bufferedContent = '# Fresh Title\n\nBody typed right before rename'

View File

@@ -1,7 +1,6 @@
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { needsRenameOnSave } from './useNoteRename'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
@@ -77,20 +76,6 @@ function findUnsavedFallback({
return { path: activeTab.entry.path, content: activeTab.content }
}
function activeTabNeedsRename({
tabs,
activeTabPath,
}: {
tabs: TabState[]
activeTabPath: string | null
}): { path: string; title: string } | null {
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
if (!activeTab) return null
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
? { path: activeTab.entry.path, title: activeTab.entry.title }
: null
}
function isUntitledRenameCandidate(path: string): boolean {
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
@@ -546,16 +531,6 @@ function useRenameHandlers({
replaceRenamedEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
}) {
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
const currentPath = await preparePathForManualRename({
path,
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
})
await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles)
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => {
const currentPath = await preparePathForManualRename({
path,
@@ -578,12 +553,11 @@ function useRenameHandlers({
.catch((err) => console.error('Title rename failed:', err))
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
return { handleRenameTab, handleFilenameRename, handleTitleSync }
return { handleFilenameRename, handleTitleSync }
}
function useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,
@@ -591,7 +565,6 @@ function useHandleSaveAction({
resolveCurrentPath,
}: {
handleSaveRaw: (unsavedFallback?: { path: string; content: string }) => Promise<void>
handleRenameTab: (path: string, newTitle: string) => Promise<void>
tabs: TabState[]
activeTabPath: string | null
unsavedPaths: Set<string>
@@ -605,10 +578,8 @@ function useHandleSaveAction({
activeTabPath: resolvedActiveTabPath,
unsavedPaths,
}))
const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath })
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
}, [handleSaveRaw, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
}
function useEditorPersistence({
@@ -741,7 +712,7 @@ function useAppSaveHandlers({
setToastMessage,
flushPendingUntitledRename,
})
const { handleRenameTab, handleFilenameRename, handleTitleSync } = useRenameHandlers({
const { handleFilenameRename, handleTitleSync } = useRenameHandlers({
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
@@ -753,7 +724,6 @@ function useAppSaveHandlers({
})
const handleSave = useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,

View File

@@ -214,6 +214,12 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
const config = makeConfig({ onToggleRawEditor: undefined })
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
})
it('omits Inbox navigation when the explicit workflow is disabled', () => {
const config = makeConfig({ showInbox: false })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -45,6 +45,7 @@ interface CommandRegistryConfig {
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel?: string
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
onQuickOpen: () => void
@@ -54,6 +55,9 @@ interface CommandRegistryConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onAddRemote?: () => void
canAddRemote?: boolean
onCreateType?: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
@@ -92,7 +96,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,
@@ -119,9 +123,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const isArchived = activeEntry?.archived ?? false
const isFavorite = activeEntry?.favorite ?? false
const isSectionGroup = selection?.kind === 'sectionGroup'
const noteListColumnsLabel = selection?.kind === 'filter' && selection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
const noteListColumnsLabel = config.noteListColumnsLabel ?? (
selection?.kind === 'filter' && selection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
)
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
@@ -135,7 +141,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
}),
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
...buildGitCommands({
modifiedCount,
canAddRemote: config.canAddRemote ?? false,
onAddRemote: config.onAddRemote,
onCommitPush,
onPull,
onResolveConflicts,
onSelect,
}),
...buildViewCommands({
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
@@ -143,7 +157,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildAiAgentCommands({
@@ -162,7 +176,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDragRegion } from './useDragRegion'
const startDragging = vi.fn().mockResolvedValue(undefined)
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ startDragging }),
}))
function DragRegionHarness() {
const { onMouseDown } = useDragRegion()
return (
<div data-testid="drag-surface" onMouseDown={onMouseDown}>
<div data-testid="no-drag-card" data-no-drag>
<button type="button">Action</button>
</div>
</div>
)
}
describe('useDragRegion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts dragging from the background surface', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0 })
expect(startDragging).toHaveBeenCalledOnce()
})
it('does not start dragging from no-drag containers', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('no-drag-card'), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
})
it('does not start dragging from interactive descendants', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'Action' }), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
})
it('ignores non-primary mouse buttons', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 1 })
expect(startDragging).not.toHaveBeenCalled()
})
})

View File

@@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
const { startTransitionMock } = vi.hoisted(() => ({
startTransitionMock: vi.fn((callback: () => void) => callback()),
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
startTransition: startTransitionMock,
}
})
const mockHandleContentChange = vi.fn()
const mockHandleSave = vi.fn()
const mockSavePendingForPath = vi.fn()
@@ -25,6 +37,7 @@ describe('useEditorSaveWithLinks', () => {
setTabs = vi.fn()
setToastMessage = vi.fn()
onAfterSave = vi.fn()
startTransitionMock.mockClear()
mockHandleContentChange.mockClear()
mockHandleSave.mockClear()
mockSavePendingForPath.mockClear()
@@ -147,6 +160,8 @@ describe('useEditorSaveWithLinks', () => {
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Project',
status: 'Active',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -172,6 +187,8 @@ describe('useEditorSaveWithLinks', () => {
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Essay',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -181,6 +198,8 @@ describe('useEditorSaveWithLinks', () => {
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Note',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -199,6 +218,20 @@ describe('useEditorSaveWithLinks', () => {
expect(updateEntry).toHaveBeenCalledWith(path, expected)
})
it('defers H1 title sync updates in a transition so typing stays responsive', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/old-title.md', '# Renamed Note\n\nBody')
})
expect(startTransitionMock).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledWith('/old-title.md', {
title: 'Renamed Note',
hasH1: true,
})
})
it('spreads all properties from useEditorSave onto the return value', () => {
const { result } = renderHookWithLinks()

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { startTransition, useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { contentToEntryPatch } from './frontmatterOps'
@@ -35,6 +35,7 @@ export function useEditorSaveWithLinks(config: {
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmKeyRef = useRef('')
const prevTitleKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
@@ -45,19 +46,25 @@ export function useEditorSaveWithLinks(config: {
}
const frontmatterPatch = contentToEntryPatch(content)
const filename = path.split('/').pop() ?? path
const fmPatch = {
...frontmatterPatch,
...deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
}),
}
const titlePatch = deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
})
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
}
const titleKey = JSON.stringify(titlePatch)
if (titleKey !== prevTitleKeyRef.current) {
prevTitleKeyRef.current = titleKey
startTransition(() => {
updateEntry(path, titlePatch)
})
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -67,6 +67,7 @@ describe('useEditorTabSwap untitled rename continuity', () => {
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
@@ -104,4 +105,46 @@ describe('useEditorTabSwap untitled rename continuity', () => {
expect(editor.tryParseMarkdownToBlocks).toHaveBeenCalled()
})
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const onContentChange = vi.fn()
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
expect.stringContaining('Body typed live'),
)
})
})

View File

@@ -60,11 +60,7 @@ describe('extractEditorBody', () => {
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: 'My Title', styles: {} }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
@@ -81,41 +77,25 @@ describe('getH1TextFromBlocks', () => {
})
it('returns null for H2 heading', () => {
const blocks = [{
type: 'heading',
props: { level: 2 },
content: [{ type: 'text', text: 'Subtitle' }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
],
}]
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [],
}]
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: ' ' }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
@@ -125,14 +105,10 @@ describe('getH1TextFromBlocks', () => {
})
it('filters non-text inline content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
],
}]
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
@@ -187,7 +163,7 @@ function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
}
function makeMockEditor(docRef: { current: unknown[] }) {
return {
const editor = {
document: docRef.current,
get prosemirrorView() { return {} },
onMount: (cb: () => void) => { cb(); return () => {} },
@@ -199,39 +175,92 @@ function makeMockEditor(docRef: { current: unknown[] }) {
_tiptapEditor: { commands: { setContent: vi.fn() } },
_docRef: docRef,
}
Object.defineProperty(editor, 'document', { get: () => docRef.current })
return editor
}
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
async function flushEditorTick() {
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}
function installEditorDomSpies(scrollTop = 0) {
const scrollEl = { scrollTop }
const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 0
})
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
return { scrollEl, frameSpy }
}
type SwapHarnessProps = {
tabs: ReturnType<typeof makeTab>[]
activeTabPath: string | null
rawMode?: boolean
}
async function createSwapHarness(options: {
initialProps: SwapHarnessProps
onContentChange?: (path: string, content: string) => void
setupEditor?: (editor: ReturnType<typeof makeMockEditor>) => void
}) {
installEditorDomSpies()
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
options.setupEditor?.(mockEditor)
let currentProps = options.initialProps
const rendered = renderHook(
(props: SwapHarnessProps) => useEditorTabSwap({
...props,
editor: mockEditor as never,
onContentChange: options.onContentChange,
}),
{ initialProps: currentProps },
)
await flushEditorTick()
return {
...rendered,
mockEditor,
async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
currentProps = { ...currentProps, ...nextProps }
rendered.rerender(currentProps)
await flushEditorTick()
},
}
}
describe('useEditorTabSwap raw mode sync', () => {
afterEach(() => { vi.restoreAllMocks() })
it('swaps in the new note when the path updates before tabs catch up', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabB] })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('March 2024'),
@@ -240,31 +269,18 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('signals when the target tab content has been applied', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const swapListener = vi.fn()
window.addEventListener('laputa:editor-tab-swapped', swapListener)
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
swapListener.mockClear()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(swapListener).toHaveBeenCalledTimes(1)
const event = swapListener.mock.calls[0][0] as CustomEvent
@@ -274,59 +290,33 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('hard-resets the editor when the target note body is blank', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeBlankBodyTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
})
mockEditor._tiptapEditor.commands.setContent.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
})
it('renders empty H1 untitled notes via TipTap HTML content', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
@@ -334,35 +324,43 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('renders empty H1 typed notes with template content under the title', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
mockEditor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
},
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><h2>Objective</h2><p></p>')
})
it('reuses cached editor blocks when reopening a recently visited note', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [tabA], activeTabPath: 'a.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -11,7 +11,17 @@ interface Tab {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
type EditorBlocks = any[]
type CachedTabState = { blocks: EditorBlocks; scrollTop: number }
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
const TAB_STATE_CACHE_LIMIT = 24
interface TabSwapState {
cache: Map<string, CachedTabState>
prevPath: string | null
pathChanged: boolean
activeTab: Tab | undefined
previousTab: Tab | undefined
rawModeJustEnded: boolean
}
interface UseEditorTabSwapOptions {
tabs: Tab[]
@@ -35,23 +45,36 @@ export function extractEditorBody(rawFileContent: string): string {
return rawBody.trimStart()
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
type HeadingTextInline = { type?: string; text?: string }
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: Array<{ type?: string; text?: string }>
content?: HeadingTextInline[]
} | undefined
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
? first.content
: null
if (!first) return null
if (first.type !== 'heading') return null
if (first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
return first.content
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
const content = extractH1Content(blocks)
if (!content) return null
const text = content
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
return text.trim() || null
let text = ''
for (const item of content) {
if (item.type === 'text') {
text += item.text || ''
}
}
const trimmed = text.trim()
return trimmed || null
}
/** Replace the title: line in YAML frontmatter with a new title value. */
@@ -80,15 +103,19 @@ function readEditorScrollTop(): number {
function cacheEditorState(
cache: Map<string, CachedTabState>,
path: string,
blocks: EditorBlocks,
nextState: CachedTabState,
) {
cache.set(path, {
blocks,
scrollTop: readEditorScrollTop(),
})
if (cache.has(path)) cache.delete(path)
cache.set(path, nextState)
while (cache.size > TAB_STATE_CACHE_LIMIT) {
const oldestPath = cache.keys().next().value
if (!oldestPath) return
cache.delete(oldestPath)
}
}
function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null {
const { preprocessed } = options
const trimmed = preprocessed.trim()
if (!trimmed) {
@@ -111,11 +138,13 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
]
}
function isBlankBodyContent(content: string): boolean {
function isBlankBodyContent(options: { content: string }): boolean {
const { content } = options
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(content: string): string | null {
function extractBodyRemainderAfterEmptyH1(options: { content: string }): string | null {
const { content } = options
const body = extractEditorBody(content)
const [firstLine, secondLine, ...rest] = body.split('\n')
if (!firstLine) return null
@@ -153,23 +182,22 @@ async function resolveBlocksForTarget(
content: string,
): Promise<CachedTabState> {
const cached = cache.get(targetPath)
if (cached) return cached
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessWikilinks(body)
const fastPathBlocks = buildFastPathBlocks(preprocessed)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0 }
cache.set(targetPath, nextState)
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = await parseMarkdownBlocks(editor, preprocessed)
const withWikilinks = injectWikilinks(parsed)
if (withWikilinks.length > 0) {
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
return { blocks: withWikilinks, scrollTop: 0 }
const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
return nextState
}
function applyBlocksToEditor(
@@ -249,7 +277,7 @@ async function resolveEmptyHeadingHtml(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
): Promise<string | null> {
const remainder = extractBodyRemainderAfterEmptyH1(content)
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return '<h1></h1><p></p>'
@@ -258,7 +286,11 @@ async function resolveEmptyHeadingHtml(
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
function findActiveTab(options: {
tabs: Tab[]
activeTabPath: string | null
}): Tab | undefined {
const { tabs, activeTabPath } = options
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
: undefined
@@ -269,14 +301,21 @@ function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): str
return compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof editor.document))
}
function normalizeTabBody(content: string): string {
function normalizeTabBody(options: { content: string }): string {
const { content } = options
return compactMarkdown(extractEditorBody(content))
}
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
return currentBody === nextBody
|| currentBody.startsWith(nextBody)
|| nextBody.startsWith(currentBody)
function renameBodiesOverlap(options: {
currentBody: string
nextBody: string
}): boolean {
const { currentBody, nextBody } = options
const current = currentBody.trimEnd()
const next = nextBody.trimEnd()
return current === next
|| current.startsWith(next)
|| next.startsWith(current)
}
function isUntitledRenameTransition(
@@ -290,10 +329,10 @@ function isUntitledRenameTransition(
const currentHeading = getH1TextFromBlocks(editor.document)
if (!currentHeading || slugifyPathStem(currentHeading) !== pathStem(nextPath)) return false
return renameBodiesOverlap(
serializeEditorBody(editor),
normalizeTabBody(activeTab.content),
)
return renameBodiesOverlap({
currentBody: serializeEditorBody(editor),
nextBody: normalizeTabBody({ content: activeTab.content }),
})
}
function useLatestRef<T>(value: T): MutableRefObject<T> {
@@ -367,31 +406,77 @@ function consumeRawModeTransition(
function cachePreviousTabOnPathChange(options: {
prevPath: string | null
previousTab: Tab | undefined
pathChanged: boolean
editorMountedRef: MutableRefObject<boolean>
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
}) {
const { prevPath, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, editor.document)
const { prevPath, previousTab, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !previousTab || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: previousTab.content,
})
}
function rememberPendingTabArrival(
activeTabPath: string | null,
activeTab: Tab | undefined,
pendingTabArrivalPathRef: MutableRefObject<string | null>,
) {
if (!activeTabPath) {
pendingTabArrivalPathRef.current = null
function shouldWaitForActiveTab(options: {
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
}) {
const { pathChanged, activeTabPath, activeTab } = options
return pathChanged && !!activeTabPath && !activeTab
}
function syncActivePathTransition(options: {
prevPath: string | null
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
previousTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
}) {
const {
prevPath,
pathChanged,
activeTabPath,
activeTab,
previousTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
} = options
cachePreviousTabOnPathChange({
prevPath,
previousTab,
pathChanged,
editorMountedRef,
cache,
editor,
})
if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true
if (!preserveUntitledRenameState({
prevPath,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
})) {
prevActivePathRef.current = activeTabPath
return false
}
if (activeTab) {
pendingTabArrivalPathRef.current = null
return true
}
pendingTabArrivalPathRef.current = activeTabPath
return false
prevActivePathRef.current = activeTabPath
return true
}
function handleStableActivePath(options: {
@@ -399,7 +484,6 @@ function handleStableActivePath(options: {
rawModeJustEnded: boolean
activeTabPath: string | null
activeTab: Tab | undefined
pendingTabArrival: boolean
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
@@ -410,7 +494,6 @@ function handleStableActivePath(options: {
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
@@ -423,7 +506,6 @@ function handleStableActivePath(options: {
rawSwapPendingRef.current = true
return false
}
if (pendingTabArrival) return false
if (rawSwapPendingRef.current) return true
cacheStableActivePath({
@@ -452,7 +534,11 @@ function cacheStableActivePath(options: {
} = options
if (!activeTabPath || !activeTab || !editorMountedRef.current) return
cacheEditorState(cache, activeTabPath, editor.document)
cacheEditorState(cache, activeTabPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: activeTab.content,
})
}
function preserveUntitledRenameState(options: {
@@ -487,15 +573,21 @@ function preserveUntitledRenameState(options: {
return true
}
function signalTabSwap(path: string) {
function signalTabSwap(options: { path: string }) {
const { path } = options
requestAnimationFrame(() => signalEditorTabSwapped(path))
}
function clearStaleSwap(
targetPath: string,
function clearStaleSwap(options: {
targetPath: string
prevActivePathRef: MutableRefObject<string | null>,
suppressChangeRef: MutableRefObject<boolean>,
): boolean {
}): boolean {
const {
targetPath,
prevActivePathRef,
suppressChangeRef,
} = options
if (prevActivePathRef.current === targetPath) return false
suppressChangeRef.current = false
return true
@@ -504,19 +596,25 @@ function clearStaleSwap(
function applyBlankTabState(options: {
cache: Map<string, CachedTabState>
targetPath: string
content: string
editor: ReturnType<typeof useCreateBlockNote>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
cache,
targetPath,
content,
editor,
suppressChangeRef,
} = options
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
cacheEditorState(cache, targetPath, {
blocks: blankParagraphBlocks(),
scrollTop: 0,
sourceContent: content,
})
applyBlankStateToEditor(editor, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
}
function scheduleEmptyHeadingSwap(options: {
@@ -534,13 +632,13 @@ function scheduleEmptyHeadingSwap(options: {
suppressChangeRef,
} = options
if (extractBodyRemainderAfterEmptyH1(content) === null) return false
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
void resolveEmptyHeadingHtml(editor, content)
.then((html) => {
if (prevActivePathRef.current !== targetPath || !html) return
applyHtmlStateToEditor(editor, html, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
@@ -571,7 +669,7 @@ function scheduleParsedBlockSwap(options: {
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
@@ -603,11 +701,17 @@ function scheduleTabSwap(options: {
suppressChangeRef.current = true
const doSwap = () => {
if (clearStaleSwap(targetPath, prevActivePathRef, suppressChangeRef)) return
if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return
rawSwapPendingRef.current = false
if (isBlankBodyContent(activeTab.content)) {
applyBlankTabState({ cache, targetPath, editor, suppressChangeRef })
if (isBlankBodyContent({ content: activeTab.content })) {
applyBlankTabState({
cache,
targetPath,
content: activeTab.content,
editor,
suppressChangeRef,
})
return
}
@@ -638,7 +742,138 @@ function scheduleTabSwap(options: {
pendingSwapRef.current = doSwap
}
function useTabSwapEffect(options: {
function resolveTabSwapState(options: {
tabs: Tab[]
activeTabPath: string | null
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
rawModeJustEnded: boolean
}): TabSwapState {
const {
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
} = options
const prevPath = prevActivePathRef.current
return {
cache: tabCacheRef.current,
prevPath,
pathChanged: prevPath !== activeTabPath,
activeTab: findActiveTab({ tabs, activeTabPath }),
previousTab: findActiveTab({ tabs, activeTabPath: prevPath }),
rawModeJustEnded,
}
}
function shouldSkipScheduledTabSwap(options: {
state: TabSwapState
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
}) {
const {
state,
activeTabPath,
editor,
editorMountedRef,
prevActivePathRef,
rawSwapPendingRef,
} = options
if (syncActivePathTransition({
prevPath: state.prevPath,
pathChanged: state.pathChanged,
activeTabPath,
activeTab: state.activeTab,
previousTab: state.previousTab,
cache: state.cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return true
}
return handleStableActivePath({
pathChanged: state.pathChanged,
rawModeJustEnded: state.rawModeJustEnded,
activeTabPath,
activeTab: state.activeTab,
cache: state.cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})
}
function runTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
rawMode?: boolean
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
const state = resolveTabSwapState({
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
})
if (shouldSkipScheduledTabSwap({
state,
activeTabPath,
editor,
editorMountedRef,
prevActivePathRef,
rawSwapPendingRef,
})) {
return
}
if (!activeTabPath || !state.activeTab) return
scheduleTabSwap({
editor,
cache: state.cache,
targetPath: activeTabPath,
activeTab: state.activeTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
})
}
function useTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
@@ -647,7 +882,6 @@ function useTabSwapEffect(options: {
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
pendingTabArrivalPathRef: MutableRefObject<string | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
@@ -661,64 +895,22 @@ function useTabSwapEffect(options: {
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
useEffect(() => {
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const pendingTabArrival = activeTabPath !== null
&& pendingTabArrivalPathRef.current === activeTabPath
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
prevActivePathRef.current = activeTabPath
if (preserveUntitledRenameState({
prevPath,
runTabSwapEffect({
tabs,
activeTabPath,
activeTab,
cache,
editor,
rawMode,
tabCacheRef,
editorMountedRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
return
}
const targetPath = activeTabPath
const readyActiveTab = activeTab
if (!targetPath || !readyActiveTab) return
scheduleTabSwap({
editor,
cache,
targetPath,
activeTab: readyActiveTab,
pendingSwapRef,
prevActivePathRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
})
@@ -727,7 +919,6 @@ function useTabSwapEffect(options: {
editor,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevActivePathRef,
prevRawModeRef,
rawMode,
@@ -738,23 +929,6 @@ function useTabSwapEffect(options: {
])
}
function useTabCacheCleanup(
tabs: Tab[],
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>,
) {
const tabPathsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const currentPaths = new Set(tabs.map(t => t.entry.path))
for (const path of tabPathsRef.current) {
if (!currentPaths.has(path)) {
tabCacheRef.current.delete(path)
}
}
tabPathsRef.current = currentPaths
}, [tabs, tabCacheRef])
}
/**
* Manages the tab content-swap machinery for the BlockNote editor.
*
@@ -771,7 +945,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
const pendingTabArrivalPathRef = useRef<string | null>(null)
const prevRawModeRef = useRef(!!rawMode)
const rawSwapPendingRef = useRef(false)
const suppressChangeRef = useRef(false)
@@ -795,12 +968,10 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
})
useTabCacheCleanup(tabs, tabCacheRef)
return { handleEditorChange, editorMountedRef }
}

View File

@@ -45,6 +45,7 @@ function makeHandlers(): MenuEventHandlers {
onOpenVault: vi.fn(),
onRemoveActiveVault: vi.fn(),
onRestoreGettingStarted: vi.fn(),
onAddRemote: vi.fn(),
onCommitPush: vi.fn(),
onPull: vi.fn(),
onResolveConflicts: vi.fn(),
@@ -57,6 +58,7 @@ function makeHandlers(): MenuEventHandlers {
multiSelectionCommandRef: { current: null },
activeTabPath: '/vault/test.md',
hasRestorableDeletedNote: false,
hasNoRemote: false,
}
}
@@ -154,6 +156,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onCommandPalette).toHaveBeenCalled()
})
it('vault-add-remote triggers the add-remote flow', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-add-remote', h)
expect(h.onAddRemote).toHaveBeenCalled()
})
it('view-zoom-in triggers zoom in', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-in', h)

View File

@@ -12,6 +12,7 @@ export interface MenuEventHandlers extends AppCommandHandlers {
modifiedCount?: number
conflictCount?: number
hasRestorableDeletedNote?: boolean
hasNoRemote?: boolean
}
interface MenuStatePayload {
@@ -19,6 +20,7 @@ interface MenuStatePayload {
hasModifiedFiles?: boolean
hasConflicts?: boolean
hasRestorableDeletedNote?: boolean
hasNoRemote?: boolean
}
function readCustomEventDetail(event: Event): string | null {
@@ -129,6 +131,7 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
const hasRestorableDeletedNote = handlers.hasRestorableDeletedNote
const hasNoRemote = handlers.hasNoRemote
useEffect(() => {
ref.current = handlers
@@ -142,5 +145,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
hasModifiedFiles,
hasConflicts,
hasRestorableDeletedNote,
hasNoRemote,
})
}

View File

@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
removeEntry: (path: string) => void
entries: VaultEntry[]
flushBeforeNoteSwitch?: (path: string) => Promise<void>
flushBeforePathRename?: (path: string) => Promise<void>
reloadVault?: () => Promise<unknown>
setToastMessage: (msg: string | null) => void
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
config.onFrontmatterPersisted?.()
}
function buildTabManagementOptions(
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
) {
return flushBeforeNoteSwitch
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
: undefined
}
function useFrontmatterActionHandlers({
config,
renameTabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
}: {
config: NoteActionsConfig
renameTabsRef: TitleRenameDeps['tabsRef']
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
setToastMessage: (msg: string | null) => void
updateTabContent: (path: string, newContent: string) => void
runFrontmatterOp: (
op: 'update' | 'delete',
path: string,
key: string,
value?: FrontmatterValue,
options?: FrontmatterOpOptions,
) => Promise<string | undefined>
}) {
const handleUpdateFrontmatter = useCallback(async (
path: string,
key: string,
value: FrontmatterValue,
options?: FrontmatterOpOptions,
) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: renameTabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
return {
handleUpdateFrontmatter,
handleDeleteProperty,
handleAddProperty,
}
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
const updateTabContent = useCallback((path: string, newContent: string) => {
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
const frontmatterActions = useFrontmatterActionHandlers({
config,
renameTabsRef: rename.tabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
})
return {
...tabMgmt,
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: rename.tabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
handleAddProperty: frontmatterActions.handleAddProperty,
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
}

View File

@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'
import { useNoteListKeyboard } from './useNoteListKeyboard'
import type { VaultEntry } from '../types'
@@ -30,9 +30,41 @@ function keyEvent(key: string, opts: Partial<React.KeyboardEvent> = {}): React.K
return { key, preventDefault: vi.fn(), metaKey: false, ctrlKey: false, altKey: false, ...opts } as unknown as React.KeyboardEvent
}
function installAnimationFrameStub() {
let nextId = 1
const callbacks = new Map<number, FrameRequestCallback>()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextId++
callbacks.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
callbacks.delete(id)
})
return {
flushAnimationFrame: () => {
const pending = [...callbacks.entries()]
callbacks.clear()
for (const [, callback] of pending) callback(0)
},
}
}
describe('useNoteListKeyboard', () => {
const items = [makeEntry('/a.md', 'A'), makeEntry('/b.md', 'B'), makeEntry('/c.md', 'C')]
const onOpen = vi.fn()
let flushAnimationFrame: () => void
beforeEach(() => {
vi.clearAllMocks()
;({ flushAnimationFrame } = installAnimationFrameStub())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('initializes with no highlight', () => {
const { result } = renderHook(() =>
@@ -48,27 +80,38 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/a.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[0])
})
it('ArrowDown advances highlight', () => {
it('ArrowDown advances highlight and opens the latest highlighted note on the next frame', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/b.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[1])
})
it('ArrowDown clamps at end of list', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[2])
})
it('ArrowUp highlights last item from no selection', () => {
@@ -78,6 +121,8 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[2])
})
@@ -110,7 +155,10 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('Enter')))
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[0])
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
})
it('Enter does nothing when no item highlighted', () => {
@@ -185,6 +233,7 @@ describe('useNoteListKeyboard', () => {
})
expect(result.current.highlightedPath).toBe('/a.md')
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[0])
})
@@ -208,4 +257,23 @@ describe('useNoteListKeyboard', () => {
editor.remove()
})
it('coalesces rapid arrow navigation into a single open for the latest highlighted note', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[2])
})
})

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