Compare commits
53 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5cc5ffb4f | ||
|
|
374b4f6686 | ||
|
|
36c20f34f4 | ||
|
|
8551d819bd | ||
|
|
d5497d7252 | ||
|
|
046afe0e67 | ||
|
|
cb1cc4b394 | ||
|
|
00cb938766 | ||
|
|
80c13272f3 | ||
|
|
9534449307 | ||
|
|
2d5361b2e9 | ||
|
|
b22da65d44 | ||
|
|
862af864d3 | ||
|
|
9ecefc90d5 | ||
|
|
9693fe24c9 | ||
|
|
92ea32c0b8 | ||
|
|
2e2bd2f3b3 | ||
|
|
3556d934ee | ||
|
|
feef938174 | ||
|
|
67a9930ea1 | ||
|
|
aed4d05f1d | ||
|
|
cac7f12c6d | ||
|
|
72b5b52140 | ||
|
|
7deeb49751 | ||
|
|
8c0a2e7ec8 | ||
|
|
c4e66dc74f | ||
|
|
c0bb722db5 | ||
|
|
bc11c869aa | ||
|
|
502ecf0572 | ||
|
|
8bbbba98b7 | ||
|
|
86adf860b5 | ||
|
|
cec1627a81 | ||
|
|
b00183419e | ||
|
|
ed9ceff1d2 | ||
|
|
e18491bcd3 | ||
|
|
fd0cfc7fb6 | ||
|
|
6541ce5755 | ||
|
|
4456801526 | ||
|
|
db7aaf1385 | ||
|
|
54b2616fa4 | ||
|
|
6a90e9f770 | ||
|
|
b79c78a5ba | ||
|
|
48a8882970 | ||
|
|
98276702a2 | ||
|
|
857d55a7cd | ||
|
|
b78dfad384 | ||
|
|
9ac51bab9a | ||
|
|
1fee994863 | ||
|
|
764bbf0e4c | ||
|
|
ff05ba0430 | ||
|
|
347df47c62 | ||
|
|
7536169508 | ||
|
|
34de384738 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.86
|
||||
AVERAGE_THRESHOLD=9.68
|
||||
HOTSPOT_THRESHOLD=9.88
|
||||
AVERAGE_THRESHOLD=9.73
|
||||
|
||||
12
.github/workflows/release-stable.yml
vendored
12
.github/workflows/release-stable.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -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.
@@ -1,3 +1,4 @@
|
||||
[](https://codescene.io/projects/76865) [](https://codescene.io/projects/76865)
|
||||
# Tolaria App
|
||||
|
||||
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
39
scripts/build-release-download-page.ts
Normal file
39
scripts/build-release-download-page.ts
Normal 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}`)
|
||||
27
src-tauri/src/commands/git_connect.rs
Normal file
27
src-tauri/src/commands/git_connect.rs
Normal 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())
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
577
src-tauri/src/git/connect.rs
Normal file
577
src-tauri/src/git/connect.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
156
src/App.test.tsx
156
src/App.test.tsx
@@ -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(() => {
|
||||
|
||||
200
src/App.tsx
200
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
96
src/components/AddRemoteModal.test.tsx
Normal file
96
src/components/AddRemoteModal.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
171
src/components/AddRemoteModal.tsx
Normal file
171
src/components/AddRemoteModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 · Cmd+N to create</span>
|
||||
<span className="text-xs text-muted-foreground">Cmd+P or Cmd+O to search · 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
36
src/components/OnboardingShell.tsx
Normal file
36
src/components/OnboardingShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}))),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal file
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
223
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal file
223
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal 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])
|
||||
}
|
||||
26
src/components/inlineWikilinkPasteRecovery.test.ts
Normal file
26
src/components/inlineWikilinkPasteRecovery.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
32
src/components/inlineWikilinkPasteRecovery.ts
Normal file
32
src/components/inlineWikilinkPasteRecovery.ts
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all'
|
||||
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view'
|
||||
|
||||
export interface OpenListPropertiesEventDetail {
|
||||
scope: NoteListPropertiesScope
|
||||
|
||||
@@ -66,5 +66,4 @@ export function filterGroupsByNoteListQuery(
|
||||
...group,
|
||||
entries: filterEntriesByNoteListQuery(group.entries, normalizedQuery, context),
|
||||
}))
|
||||
.filter((group) => group.entries.length > 0)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -69,6 +69,7 @@ export function useInlineWikilinkSelection({
|
||||
}, [selectionRange, value])
|
||||
|
||||
return {
|
||||
editorRef,
|
||||
selectionRange,
|
||||
selectionIndex: selectionRange.end,
|
||||
setSelectionRange,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '¬',
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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' }) },
|
||||
|
||||
@@ -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' }) },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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?.() },
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
59
src/hooks/useDragRegion.test.tsx
Normal file
59
src/hooks/useDragRegion.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user