Compare commits
22 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5497d7252 | ||
|
|
046afe0e67 | ||
|
|
cb1cc4b394 | ||
|
|
00cb938766 | ||
|
|
80c13272f3 | ||
|
|
9534449307 | ||
|
|
2d5361b2e9 | ||
|
|
b22da65d44 | ||
|
|
862af864d3 | ||
|
|
9ecefc90d5 | ||
|
|
9693fe24c9 | ||
|
|
92ea32c0b8 | ||
|
|
2e2bd2f3b3 | ||
|
|
3556d934ee | ||
|
|
feef938174 | ||
|
|
67a9930ea1 | ||
|
|
aed4d05f1d | ||
|
|
cac7f12c6d | ||
|
|
72b5b52140 | ||
|
|
7deeb49751 | ||
|
|
8c0a2e7ec8 | ||
|
|
c4e66dc74f |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.86
|
||||
AVERAGE_THRESHOLD=9.7
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -496,7 +510,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.
|
||||
|
||||
|
||||
@@ -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,7 @@ 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.
|
||||
|
||||
### Remote Clone & Auth Model
|
||||
|
||||
@@ -578,7 +578,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 root config/type files such as `config.md` and `note.md` |
|
||||
| `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
|
||||
@@ -615,7 +615,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`, `config.md`, and `note.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 |
|
||||
@@ -741,7 +741,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 +756,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 +798,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
|
||||
```
|
||||
|
||||
@@ -292,8 +292,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}`)
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -57,6 +58,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,
|
||||
@@ -155,6 +157,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 +169,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()?)
|
||||
@@ -410,9 +417,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) {
|
||||
|
||||
@@ -4,19 +4,25 @@ 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.
|
||||
@@ -30,8 +36,15 @@ type: Type
|
||||
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`.
|
||||
";
|
||||
|
||||
@@ -80,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 {
|
||||
@@ -251,7 +266,7 @@ fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str
|
||||
|
||||
/// 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, "config.md", CONFIG_TYPE_DEFINITION);
|
||||
ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION);
|
||||
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
@@ -296,7 +311,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
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())
|
||||
@@ -445,12 +460,13 @@ mod tests {
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config.md").exists());
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
let config_content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
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!(config_content.contains("type: Type"));
|
||||
assert!(config_content.contains("icon: gear-six"));
|
||||
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());
|
||||
@@ -569,12 +585,15 @@ 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"));
|
||||
|
||||
@@ -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.
|
||||
@@ -649,7 +649,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 +687,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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -227,7 +227,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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -69,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'
|
||||
@@ -189,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[]>([])
|
||||
@@ -561,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')
|
||||
@@ -800,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)
|
||||
@@ -962,6 +984,17 @@ function App() {
|
||||
&& 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,
|
||||
@@ -995,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,
|
||||
@@ -1026,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,
|
||||
})
|
||||
@@ -1135,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} />
|
||||
@@ -1200,7 +1238,7 @@ function App() {
|
||||
</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={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() }} />
|
||||
<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} />
|
||||
@@ -1264,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" />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -138,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>
|
||||
)
|
||||
|
||||
@@ -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,51 +73,6 @@ function submitInlineValue({
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
function renderInlineEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
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
|
||||
onPaste: (event: React.ClipboardEvent<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}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderInlineSuggestionList({
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
@@ -160,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,
|
||||
@@ -178,6 +142,7 @@ export function InlineWikilinkInput({
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const pendingPasteRef = useRef<PendingPasteState | null>(null)
|
||||
const activeQuery = useMemo(
|
||||
() => selectionRange.start === selectionRange.end
|
||||
? findActiveWikilinkQuery(value, selectionIndex)
|
||||
@@ -218,11 +183,30 @@ export function InlineWikilinkInput({
|
||||
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
|
||||
if (!pastedText) return
|
||||
|
||||
event.preventDefault()
|
||||
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>) =>
|
||||
@@ -237,20 +221,22 @@ export function InlineWikilinkInput({
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = renderInlineEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef: setCombinedRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput: commitValueFromEditor,
|
||||
onKeyDown: handleKeyDown,
|
||||
onPaste: handlePaste,
|
||||
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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} />)
|
||||
@@ -1091,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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,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 +179,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()
|
||||
})
|
||||
|
||||
@@ -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()} />)
|
||||
@@ -32,4 +38,14 @@ describe('TelemetryConsentDialog', () => {
|
||||
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' }}>
|
||||
@@ -63,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,7 +35,7 @@ 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')
|
||||
})
|
||||
@@ -35,9 +45,10 @@ describe('WelcomeScreen', () => {
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows default vault path in template option description', () => {
|
||||
it('shows the simplified template option description', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
|
||||
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', () => {
|
||||
@@ -46,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', () => {
|
||||
@@ -67,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()
|
||||
@@ -146,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)',
|
||||
@@ -161,6 +211,7 @@ interface OptionButtonProps {
|
||||
loading?: boolean
|
||||
testId: string
|
||||
autoFocus?: boolean
|
||||
buttonRef?: React.RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
@@ -175,11 +226,15 @@ function OptionButton({
|
||||
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)',
|
||||
@@ -191,6 +246,8 @@ function OptionButton({
|
||||
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}
|
||||
@@ -199,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,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',
|
||||
}
|
||||
}
|
||||
@@ -228,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,
|
||||
@@ -247,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,
|
||||
@@ -273,15 +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
|
||||
@@ -292,6 +433,7 @@ export function WelcomeScreen({
|
||||
onClick={onOpenFolder}
|
||||
disabled={busy}
|
||||
testId="welcome-open-folder"
|
||||
buttonRef={openFolderActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
@@ -305,6 +447,7 @@ export function WelcomeScreen({
|
||||
disabled={busy || isOffline}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
buttonRef={templateActionRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -320,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
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
|
||||
@@ -90,6 +102,32 @@ export function shouldSuppressFormattingToolbarHoverUpdate({
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -120,10 +158,12 @@ function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null
|
||||
}
|
||||
|
||||
function createFormattingToolbarHoverGuardHandler({
|
||||
editor,
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockIdRef,
|
||||
}: {
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
|
||||
container: HTMLElement
|
||||
doc: Document
|
||||
selectedFileBlockIdRef: RefObject<string | null>
|
||||
@@ -141,15 +181,18 @@ function createFormattingToolbarHoverGuardHandler({
|
||||
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
|
||||
@@ -166,6 +209,7 @@ export function useBlockNoteFormattingToolbarHoverGuard({
|
||||
if (!environment) return
|
||||
|
||||
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
|
||||
editor,
|
||||
container: environment.container,
|
||||
doc: environment.doc,
|
||||
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
|
||||
@@ -175,5 +219,5 @@ export function useBlockNoteFormattingToolbarHoverGuard({
|
||||
return () => {
|
||||
environment.view.removeEventListener('mousemove', handleMouseMove, true)
|
||||
}
|
||||
}, [container, isOpen, lastSelectedFileBlockIdRef])
|
||||
}, [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, 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,
|
||||
@@ -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'
|
||||
@@ -161,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>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface StatusBarPrimarySectionProps {
|
||||
vaults: VaultOption[]
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCloneVault?: () => void
|
||||
onCloneGettingStarted?: () => void
|
||||
onClickPending?: () => void
|
||||
@@ -77,6 +78,7 @@ export function StatusBarPrimarySection({
|
||||
vaults,
|
||||
onSwitchVault,
|
||||
onOpenLocalFolder,
|
||||
onCreateEmptyVault,
|
||||
onCloneVault,
|
||||
onCloneGettingStarted,
|
||||
onClickPending,
|
||||
@@ -111,6 +113,7 @@ export function StatusBarPrimarySection({
|
||||
vaultPath={vaultPath}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCreateEmptyVault={onCreateEmptyVault}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onRemoveVault={onRemoveVault}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-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',
|
||||
@@ -158,6 +171,7 @@ export function VaultMenu({
|
||||
vaultPath,
|
||||
onSwitchVault,
|
||||
onOpenLocalFolder,
|
||||
onCreateEmptyVault,
|
||||
onCloneVault,
|
||||
onCloneGettingStarted,
|
||||
onRemoveVault,
|
||||
@@ -171,11 +185,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' }}>
|
||||
|
||||
@@ -476,6 +476,7 @@ export function TolariaFormattingToolbarController(props: {
|
||||
})
|
||||
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor,
|
||||
container:
|
||||
editor.domElement?.closest('.editor__blocknote-container') ??
|
||||
editor.domElement ??
|
||||
|
||||
@@ -69,6 +69,7 @@ export function useInlineWikilinkSelection({
|
||||
}, [selectionRange, value])
|
||||
|
||||
return {
|
||||
editorRef,
|
||||
selectionRange,
|
||||
selectionIndex: selectionRange.end,
|
||||
setSelectionRange,
|
||||
|
||||
@@ -151,7 +151,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' },
|
||||
|
||||
@@ -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: '¬',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -51,6 +51,7 @@ interface AppCommandsConfig {
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -82,6 +83,7 @@ interface AppCommandsConfig {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onCustomizeNoteListColumns?: () => void
|
||||
canCustomizeNoteListColumns?: boolean
|
||||
noteListColumnsLabel?: string
|
||||
onRestoreDeletedNote?: () => void
|
||||
canRestoreDeletedNote?: boolean
|
||||
}
|
||||
@@ -184,6 +186,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onCreateEmptyVault: config.onCreateEmptyVault,
|
||||
activeNoteModified: config.activeNoteModified,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
@@ -224,6 +227,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
|
||||
onToggleOrganized: config.onToggleOrganized,
|
||||
onCustomizeNoteListColumns: config.onCustomizeNoteListColumns,
|
||||
canCustomizeNoteListColumns: config.canCustomizeNoteListColumns,
|
||||
noteListColumnsLabel: config.noteListColumnsLabel,
|
||||
onRestoreDeletedNote: config.onRestoreDeletedNote,
|
||||
canRestoreDeletedNote: config.canRestoreDeletedNote,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,7 @@ interface CommandRegistryConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenFeedback?: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onDeleteNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
@@ -92,7 +94,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 +121,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])
|
||||
|
||||
@@ -143,7 +147,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 +166,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,
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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[]
|
||||
@@ -93,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) {
|
||||
@@ -124,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
|
||||
@@ -166,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(
|
||||
@@ -262,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>'
|
||||
|
||||
@@ -271,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
|
||||
@@ -282,11 +301,16 @@ 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 {
|
||||
function renameBodiesOverlap(options: {
|
||||
currentBody: string
|
||||
nextBody: string
|
||||
}): boolean {
|
||||
const { currentBody, nextBody } = options
|
||||
const current = currentBody.trimEnd()
|
||||
const next = nextBody.trimEnd()
|
||||
return current === next
|
||||
@@ -305,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> {
|
||||
@@ -382,21 +406,27 @@ 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 shouldWaitForActiveTab(
|
||||
pathChanged: boolean,
|
||||
activeTabPath: string | null,
|
||||
activeTab: Tab | undefined,
|
||||
) {
|
||||
function shouldWaitForActiveTab(options: {
|
||||
pathChanged: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
}) {
|
||||
const { pathChanged, activeTabPath, activeTab } = options
|
||||
return pathChanged && !!activeTabPath && !activeTab
|
||||
}
|
||||
|
||||
@@ -405,6 +435,7 @@ function syncActivePathTransition(options: {
|
||||
pathChanged: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
previousTab: Tab | undefined
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
@@ -415,14 +446,22 @@ function syncActivePathTransition(options: {
|
||||
pathChanged,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
previousTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
} = options
|
||||
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
|
||||
cachePreviousTabOnPathChange({
|
||||
prevPath,
|
||||
previousTab,
|
||||
pathChanged,
|
||||
editorMountedRef,
|
||||
cache,
|
||||
editor,
|
||||
})
|
||||
if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true
|
||||
|
||||
if (!preserveUntitledRenameState({
|
||||
prevPath,
|
||||
@@ -495,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: {
|
||||
@@ -530,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
|
||||
@@ -547,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: {
|
||||
@@ -577,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
|
||||
@@ -614,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
|
||||
@@ -646,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
|
||||
}
|
||||
|
||||
@@ -681,6 +742,75 @@ function scheduleTabSwap(options: {
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
|
||||
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
|
||||
@@ -708,46 +838,34 @@ function runTabSwapEffect(options: {
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
if (syncActivePathTransition({
|
||||
prevPath,
|
||||
pathChanged,
|
||||
const state = resolveTabSwapState({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
rawModeJustEnded,
|
||||
})
|
||||
|
||||
if (shouldSkipScheduledTabSwap({
|
||||
state,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeTabPath || !activeTab) return
|
||||
if (!activeTabPath || !state.activeTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
cache: state.cache,
|
||||
targetPath: activeTabPath,
|
||||
activeTab,
|
||||
activeTab: state.activeTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
@@ -811,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.
|
||||
*
|
||||
@@ -871,7 +972,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
useTabCacheCleanup(tabs, tabCacheRef)
|
||||
|
||||
return { handleEditorChange, editorMountedRef }
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ interface NoteListKeyboardOptions {
|
||||
items: VaultEntry[]
|
||||
selectedNotePath: string | null
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onPrefetch?: (entry: VaultEntry) => void
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -36,6 +37,26 @@ function isEditableElement(element: Element | null): boolean {
|
||||
return element.isContentEditable || !!element.closest('[contenteditable="true"]')
|
||||
}
|
||||
|
||||
function isInteractiveElement(element: Element | null): boolean {
|
||||
if (!element) return false
|
||||
if (isEditableElement(element)) return true
|
||||
if (!(element instanceof HTMLElement)) return false
|
||||
return element instanceof HTMLButtonElement
|
||||
|| element instanceof HTMLAnchorElement
|
||||
|| element.getAttribute('role') === 'button'
|
||||
}
|
||||
|
||||
function isNestedInteractiveTarget(
|
||||
target: EventTarget | null,
|
||||
currentTarget: EventTarget | null,
|
||||
): boolean {
|
||||
return target instanceof Element
|
||||
&& currentTarget instanceof Element
|
||||
&& target !== currentTarget
|
||||
&& currentTarget.contains(target)
|
||||
&& isInteractiveElement(target)
|
||||
}
|
||||
|
||||
function resolveCurrentIndex(
|
||||
items: VaultEntry[],
|
||||
highlightedPath: string | null,
|
||||
@@ -59,13 +80,20 @@ function moveHighlightIndex(
|
||||
return nextIndex
|
||||
}
|
||||
|
||||
export function useNoteListKeyboard({
|
||||
items, selectedNotePath, onOpen, onPrefetch, enabled,
|
||||
}: NoteListKeyboardOptions) {
|
||||
const [highlightedPathState, setHighlightedPath] = useState<string | null>(null)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const highlightedPathRef = useRef<string | null>(null)
|
||||
function resolveHighlightedEntry(items: VaultEntry[], highlightedPath: string | null): VaultEntry | undefined {
|
||||
if (!highlightedPath) return undefined
|
||||
return items.find((entry) => entry.path === highlightedPath)
|
||||
}
|
||||
|
||||
function usesCommandModifier(event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey'>): boolean {
|
||||
return event.metaKey || event.ctrlKey
|
||||
}
|
||||
|
||||
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
||||
return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey
|
||||
}
|
||||
|
||||
function useKeyboardItemRefs(items: VaultEntry[], selectedNotePath: string | null) {
|
||||
const itemsRef = useRef(items)
|
||||
const selectedNotePathRef = useRef(selectedNotePath)
|
||||
|
||||
@@ -74,16 +102,114 @@ export function useNoteListKeyboard({
|
||||
selectedNotePathRef.current = selectedNotePath
|
||||
}, [items, selectedNotePath])
|
||||
|
||||
return { itemsRef, selectedNotePathRef }
|
||||
}
|
||||
|
||||
function useHighlightedPath() {
|
||||
const [highlightedPathState, setHighlightedPath] = useState<string | null>(null)
|
||||
const highlightedPathRef = useRef<string | null>(null)
|
||||
|
||||
const syncHighlightedPath = useCallback((nextPath: string | null) => {
|
||||
highlightedPathRef.current = nextPath
|
||||
setHighlightedPath(nextPath)
|
||||
}, [])
|
||||
|
||||
const syncToCurrentSelection = useCallback(() => {
|
||||
syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current))
|
||||
}, [syncHighlightedPath])
|
||||
return { highlightedPathRef, highlightedPathState, syncHighlightedPath }
|
||||
}
|
||||
|
||||
const moveHighlight = useCallback((direction: 1 | -1) => {
|
||||
function useSelectionSync(
|
||||
itemsRef: React.RefObject<VaultEntry[]>,
|
||||
selectedNotePathRef: React.RefObject<string | null>,
|
||||
syncHighlightedPath: (nextPath: string | null) => void,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current))
|
||||
}, [itemsRef, selectedNotePathRef, syncHighlightedPath])
|
||||
}
|
||||
|
||||
interface ScheduledOpenState {
|
||||
entry: VaultEntry | null
|
||||
frameId: number | null
|
||||
}
|
||||
|
||||
function cancelScheduledOpen(stateRef: React.RefObject<ScheduledOpenState>): void {
|
||||
const frameId = stateRef.current.frameId
|
||||
if (frameId !== null) cancelAnimationFrame(frameId)
|
||||
stateRef.current.entry = null
|
||||
stateRef.current.frameId = null
|
||||
}
|
||||
|
||||
function flushScheduledOpen(
|
||||
stateRef: React.RefObject<ScheduledOpenState>,
|
||||
onOpen: (entry: VaultEntry) => void,
|
||||
entry?: VaultEntry,
|
||||
): void {
|
||||
if (entry) stateRef.current.entry = entry
|
||||
const nextEntry = stateRef.current.entry
|
||||
if (!nextEntry) return
|
||||
|
||||
if (stateRef.current.frameId !== null) cancelAnimationFrame(stateRef.current.frameId)
|
||||
stateRef.current.entry = null
|
||||
stateRef.current.frameId = null
|
||||
onOpen(nextEntry)
|
||||
}
|
||||
|
||||
function scheduleOpenForNextFrame(
|
||||
stateRef: React.RefObject<ScheduledOpenState>,
|
||||
onOpen: (entry: VaultEntry) => void,
|
||||
entry: VaultEntry,
|
||||
): void {
|
||||
stateRef.current.entry = entry
|
||||
if (stateRef.current.frameId !== null) return
|
||||
|
||||
stateRef.current.frameId = requestAnimationFrame(() => {
|
||||
flushScheduledOpen(stateRef, onOpen)
|
||||
})
|
||||
}
|
||||
|
||||
function useScheduledOpen(onOpen: (entry: VaultEntry) => void, enabled: boolean) {
|
||||
const stateRef = useRef<ScheduledOpenState>({ entry: null, frameId: null })
|
||||
|
||||
const scheduleOpen = useCallback((entry: VaultEntry) => {
|
||||
scheduleOpenForNextFrame(stateRef, onOpen, entry)
|
||||
}, [onOpen])
|
||||
|
||||
const flushOpen = useCallback((entry?: VaultEntry) => {
|
||||
flushScheduledOpen(stateRef, onOpen, entry)
|
||||
}, [onOpen])
|
||||
|
||||
const cancelOpen = useCallback(() => {
|
||||
cancelScheduledOpen(stateRef)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return
|
||||
cancelOpen()
|
||||
}, [cancelOpen, enabled])
|
||||
|
||||
useEffect(() => cancelOpen, [cancelOpen])
|
||||
|
||||
return { cancelOpen, flushOpen, scheduleOpen }
|
||||
}
|
||||
|
||||
function useMoveHighlight({
|
||||
items,
|
||||
selectedNotePath,
|
||||
highlightedPathRef,
|
||||
syncHighlightedPath,
|
||||
virtuosoRef,
|
||||
onPrefetch,
|
||||
scheduleOpen,
|
||||
}: {
|
||||
items: VaultEntry[]
|
||||
selectedNotePath: string | null
|
||||
highlightedPathRef: React.RefObject<string | null>
|
||||
syncHighlightedPath: (nextPath: string | null) => void
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle | null>
|
||||
onPrefetch?: (entry: VaultEntry) => void
|
||||
scheduleOpen: (entry: VaultEntry) => void
|
||||
}) {
|
||||
return useCallback((direction: 1 | -1) => {
|
||||
const currentIndex = resolveCurrentIndex(items, highlightedPathRef.current, selectedNotePath)
|
||||
const nextIndex = moveHighlightIndex(currentIndex, direction, items.length)
|
||||
const currentPath = highlightedPathRef.current ?? selectedNotePath
|
||||
@@ -92,31 +218,137 @@ export function useNoteListKeyboard({
|
||||
|
||||
syncHighlightedPath(nextItem.path)
|
||||
virtuosoRef.current?.scrollIntoView({ index: nextIndex, behavior: 'auto' })
|
||||
onOpen(nextItem)
|
||||
scheduleOpen(nextItem)
|
||||
onPrefetch?.(nextItem)
|
||||
}, [items, onOpen, onPrefetch, selectedNotePath, syncHighlightedPath])
|
||||
}, [highlightedPathRef, items, onPrefetch, scheduleOpen, selectedNotePath, syncHighlightedPath, virtuosoRef])
|
||||
}
|
||||
|
||||
const processKeyDown = useCallback((e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
|
||||
function resolveEntryForActivation(
|
||||
items: VaultEntry[],
|
||||
highlightedPathRef: React.RefObject<string | null>,
|
||||
): VaultEntry | undefined {
|
||||
return resolveHighlightedEntry(items, highlightedPathRef.current)
|
||||
}
|
||||
|
||||
function handleNeighborhoodActivation(options: {
|
||||
event: Pick<KeyboardEvent, 'preventDefault'>
|
||||
items: VaultEntry[]
|
||||
highlightedPathRef: React.RefObject<string | null>
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
}): boolean {
|
||||
const {
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
} = options
|
||||
|
||||
const highlightedItem = resolveEntryForActivation(items, highlightedPathRef)
|
||||
if (!highlightedItem) return false
|
||||
|
||||
event.preventDefault()
|
||||
cancelOpen()
|
||||
void onEnterNeighborhood?.(highlightedItem)
|
||||
return true
|
||||
}
|
||||
|
||||
function handleArrowNavigation(
|
||||
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
|
||||
moveHighlight: (direction: 1 | -1) => void,
|
||||
): boolean {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveHighlight(1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveHighlight(-1)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function handleHighlightedOpen(options: {
|
||||
event: Pick<KeyboardEvent, 'preventDefault'>
|
||||
items: VaultEntry[]
|
||||
highlightedPathRef: React.RefObject<string | null>
|
||||
flushOpen: (entry?: VaultEntry) => void
|
||||
}): boolean {
|
||||
const {
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
} = options
|
||||
|
||||
const highlightedItem = resolveEntryForActivation(items, highlightedPathRef)
|
||||
if (!highlightedItem) return false
|
||||
|
||||
event.preventDefault()
|
||||
flushOpen(highlightedItem)
|
||||
return true
|
||||
}
|
||||
|
||||
function useProcessKeyDown({
|
||||
enabled,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
moveHighlight,
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
}: {
|
||||
enabled: boolean
|
||||
items: VaultEntry[]
|
||||
highlightedPathRef: React.RefObject<string | null>
|
||||
moveHighlight: (direction: 1 | -1) => void
|
||||
flushOpen: (entry?: VaultEntry) => void
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
}) {
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
|
||||
if (!enabled || items.length === 0) return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
moveHighlight(1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
moveHighlight(-1)
|
||||
} else if (e.key === 'Enter' && highlightedPathRef.current) {
|
||||
e.preventDefault()
|
||||
const highlightedItem = items.find((entry) => entry.path === highlightedPathRef.current)
|
||||
if (highlightedItem) onOpen(highlightedItem)
|
||||
if (isNeighborhoodKey(event)) {
|
||||
handleNeighborhoodActivation({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
return
|
||||
}
|
||||
}, [enabled, items, moveHighlight, onOpen])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
processKeyDown(e)
|
||||
}, [processKeyDown])
|
||||
if (usesCommandModifier(event) || event.altKey) return
|
||||
|
||||
if (handleArrowNavigation(event, moveHighlight)) return
|
||||
|
||||
if (event.key !== 'Enter') return
|
||||
|
||||
handleHighlightedOpen({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
})
|
||||
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood])
|
||||
}
|
||||
|
||||
function useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
syncToCurrentSelection: () => void
|
||||
syncHighlightedPath: (nextPath: string | null) => void
|
||||
}) {
|
||||
const handleFocus = useCallback(() => {
|
||||
syncToCurrentSelection()
|
||||
}, [syncToCurrentSelection])
|
||||
@@ -133,20 +365,80 @@ export function useNoteListKeyboard({
|
||||
requestAnimationFrame(() => {
|
||||
if (isListActive(containerRef.current)) syncToCurrentSelection()
|
||||
})
|
||||
}, [syncToCurrentSelection])
|
||||
}, [containerRef, syncToCurrentSelection])
|
||||
|
||||
return { focusList, handleBlur, handleFocus }
|
||||
}
|
||||
|
||||
function useGlobalKeyboardHandling({
|
||||
enabled,
|
||||
containerRef,
|
||||
processKeyDown,
|
||||
}: {
|
||||
enabled: boolean
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
processKeyDown: (event: KeyboardEvent) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isEditableElement(document.activeElement)) return
|
||||
const activeElement = document.activeElement
|
||||
if (isEditableElement(activeElement)) return
|
||||
if (
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
) return
|
||||
processKeyDown(event)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [enabled, processKeyDown])
|
||||
}, [containerRef, enabled, processKeyDown])
|
||||
}
|
||||
|
||||
export function useNoteListKeyboard({
|
||||
items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled,
|
||||
}: NoteListKeyboardOptions) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath)
|
||||
const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath()
|
||||
const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath)
|
||||
const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled)
|
||||
const moveHighlight = useMoveHighlight({
|
||||
items,
|
||||
selectedNotePath,
|
||||
highlightedPathRef,
|
||||
syncHighlightedPath,
|
||||
virtuosoRef,
|
||||
onPrefetch,
|
||||
scheduleOpen,
|
||||
})
|
||||
const processKeyDown = useProcessKeyDown({
|
||||
enabled,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
moveHighlight,
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
||||
processKeyDown(event)
|
||||
}, [processKeyDown])
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown })
|
||||
useEffect(() => {
|
||||
cancelOpen()
|
||||
}, [cancelOpen, selectedNotePath])
|
||||
|
||||
const highlightedPath = items.some((entry) => entry.path === highlightedPathState)
|
||||
? highlightedPathState
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
|
||||
@@ -248,7 +248,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('does nothing when the empty-vault picker is cancelled', async () => {
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateNewVault()
|
||||
await onboarding.handleCreateEmptyVault()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export function useOnboarding(
|
||||
await createTemplateVault(lastTemplatePath)
|
||||
}, [createTemplateVault, lastTemplatePath])
|
||||
|
||||
const handleCreateNewVault = useCallback(async () => {
|
||||
const handleCreateEmptyVault = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const path = await pickFolder('Choose where to create your vault')
|
||||
@@ -174,7 +174,7 @@ export function useOnboarding(
|
||||
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
|
||||
handleCreateVault,
|
||||
retryCreateVault,
|
||||
handleCreateNewVault,
|
||||
handleCreateEmptyVault,
|
||||
handleOpenFolder,
|
||||
handleDismiss,
|
||||
userReadyVaultPath,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
getEffectiveDisplayMode,
|
||||
loadDisplayModeOverrides,
|
||||
saveDisplayModeOverride,
|
||||
removeDisplayModeOverride,
|
||||
@@ -22,6 +23,14 @@ function coerceValue(raw: string): FrontmatterValue {
|
||||
return raw
|
||||
}
|
||||
|
||||
function coerceNumberValue(raw: string): FrontmatterValue {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return ''
|
||||
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : raw
|
||||
}
|
||||
|
||||
function parseNewValue(rawValue: string): FrontmatterValue {
|
||||
if (!rawValue.includes(',')) return rawValue.trim() || ''
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
@@ -125,6 +134,7 @@ function isHiddenPropertyKey(key: string): boolean {
|
||||
|
||||
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
|
||||
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
|
||||
if (mode === 'number') return coerceNumberValue(rawValue)
|
||||
if (mode === 'tags') {
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
return items
|
||||
@@ -137,6 +147,37 @@ function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
|
||||
else saveDisplayModeOverride(key, mode)
|
||||
}
|
||||
|
||||
function saveScalarProperty({
|
||||
key,
|
||||
newValue,
|
||||
frontmatter,
|
||||
displayOverrides,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}: {
|
||||
key: string
|
||||
newValue: string
|
||||
frontmatter: ParsedFrontmatter
|
||||
displayOverrides: Record<string, PropertyDisplayMode>
|
||||
onUpdateProperty: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
}) {
|
||||
const currentValue = frontmatter[key] ?? null
|
||||
const displayMode = getEffectiveDisplayMode(key, currentValue, displayOverrides)
|
||||
if (displayMode !== 'number') {
|
||||
onUpdateProperty(key, coerceValue(newValue))
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = newValue.trim()
|
||||
if (trimmed === '') {
|
||||
onDeleteProperty?.(key)
|
||||
return
|
||||
}
|
||||
|
||||
onUpdateProperty(key, coerceNumberValue(newValue))
|
||||
}
|
||||
|
||||
export interface PropertyPanelDeps {
|
||||
entries: VaultEntry[] | undefined
|
||||
entryIsA: string | null
|
||||
@@ -159,8 +200,9 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
|
||||
}, [onUpdateProperty])
|
||||
if (!onUpdateProperty) return
|
||||
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
|
||||
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
|
||||
|
||||
const handleSaveList = useCallback((key: string, newItems: string[]) => {
|
||||
if (!onUpdateProperty) return
|
||||
|
||||
@@ -266,6 +266,20 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('activates a warmed note immediately while reusing the cached content', async () => {
|
||||
cacheNoteContent('/vault/note/warm.md', '# Warm content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/warm.md', title: 'Warm' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/warm.md')
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('# Warm content')
|
||||
})
|
||||
|
||||
it('reuses cached content when reopening a recently loaded note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
|
||||
@@ -14,14 +14,15 @@ type NotePath = VaultEntry['path']
|
||||
// Stores in-flight or recently loaded note content promises, keyed by path.
|
||||
// Cleared on vault reload to prevent stale content after external edits.
|
||||
// Latency profile: deduplicates rapid note switches and keeps revisits instant.
|
||||
const prefetchCache = new Map<string, Promise<string>>()
|
||||
const NOTE_CONTENT_CACHE_LIMIT = 48
|
||||
|
||||
interface NoteContentCacheEntry {
|
||||
path: NotePath
|
||||
promise: Promise<string>
|
||||
value: string | null
|
||||
}
|
||||
|
||||
const prefetchCache = new Map<string, NoteContentCacheEntry>()
|
||||
const NOTE_CONTENT_CACHE_LIMIT = 48
|
||||
|
||||
function trimPrefetchCache(): void {
|
||||
while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) {
|
||||
const oldestPath = prefetchCache.keys().next().value
|
||||
@@ -30,23 +31,35 @@ function trimPrefetchCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise<string> {
|
||||
function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry {
|
||||
const { path } = entry
|
||||
if (prefetchCache.has(path)) prefetchCache.delete(path)
|
||||
prefetchCache.set(path, promise)
|
||||
prefetchCache.set(path, entry)
|
||||
trimPrefetchCache()
|
||||
return promise
|
||||
return entry
|
||||
}
|
||||
|
||||
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
|
||||
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): NoteContentCacheEntry {
|
||||
const cacheEntry: NoteContentCacheEntry = {
|
||||
path,
|
||||
promise: Promise.resolve(''),
|
||||
value: null,
|
||||
}
|
||||
const promise = (isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
).catch((err) => {
|
||||
prefetchCache.delete(path)
|
||||
throw err
|
||||
})
|
||||
)
|
||||
.then((content) => {
|
||||
cacheEntry.value = content
|
||||
return content
|
||||
})
|
||||
.catch((err) => {
|
||||
prefetchCache.delete(path)
|
||||
throw err
|
||||
})
|
||||
|
||||
return rememberNoteContent({ path, promise })
|
||||
cacheEntry.promise = promise
|
||||
return rememberNoteContent(cacheEntry)
|
||||
}
|
||||
|
||||
/** Prefetch a note's content into the in-memory cache.
|
||||
@@ -58,7 +71,11 @@ export function prefetchNoteContent(path: string): void {
|
||||
}
|
||||
|
||||
export function cacheNoteContent(path: string, content: string): void {
|
||||
rememberNoteContent({ path, promise: Promise.resolve(content) })
|
||||
rememberNoteContent({
|
||||
path,
|
||||
promise: Promise.resolve(content),
|
||||
value: content,
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
|
||||
@@ -66,8 +83,12 @@ export function clearPrefetchCache(): void {
|
||||
prefetchCache.clear()
|
||||
}
|
||||
|
||||
function getCachedNoteContent(path: string): string | null {
|
||||
return prefetchCache.get(path)?.value ?? null
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
return prefetchCache.get(path) ?? requestNoteContent({ path })
|
||||
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
|
||||
}
|
||||
|
||||
export type { Tab }
|
||||
@@ -102,6 +123,76 @@ function isAlreadyViewingPath(
|
||||
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
|
||||
}
|
||||
|
||||
function startEntryNavigation(options: {
|
||||
entry: VaultEntry
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
} = options
|
||||
|
||||
const seq = ++navSeqRef.current
|
||||
const cachedContent = getCachedNoteContent(entry.path)
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
if (cachedContent !== null) {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: cachedContent })
|
||||
}
|
||||
|
||||
return { seq, cachedContent }
|
||||
}
|
||||
|
||||
function shouldApplyLoadedEntry(options: {
|
||||
seq: number
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
cachedContent: string | null
|
||||
content: string
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
path: string
|
||||
}) {
|
||||
const {
|
||||
seq,
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
activeTabPathRef,
|
||||
path,
|
||||
} = options
|
||||
|
||||
if (navSeqRef.current !== seq) return false
|
||||
return cachedContent !== content || activeTabPathRef.current !== path
|
||||
}
|
||||
|
||||
function handleEntryLoadFailure(options: {
|
||||
entry: VaultEntry
|
||||
seq: number
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
error: unknown
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
seq,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
setTabs,
|
||||
error,
|
||||
} = options
|
||||
|
||||
console.warn('Failed to load note content:', error)
|
||||
if (navSeqRef.current !== seq) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: '' })
|
||||
}
|
||||
|
||||
async function navigateToEntry(options: {
|
||||
entry: VaultEntry
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
@@ -125,17 +216,35 @@ async function navigateToEntry(options: {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++navSeqRef.current
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
const { seq, cachedContent } = startEntryNavigation({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
|
||||
try {
|
||||
const content = await loadNoteContent(entry.path)
|
||||
if (navSeqRef.current !== seq) return
|
||||
if (!shouldApplyLoadedEntry({
|
||||
seq,
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
activeTabPathRef,
|
||||
path: entry.path,
|
||||
})) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
} catch (err) {
|
||||
console.warn('Failed to load note content:', err)
|
||||
if (navSeqRef.current !== seq) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: '' })
|
||||
handleEntryLoadFailure({
|
||||
entry,
|
||||
seq,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
setTabs,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
type MockInvokeOverrides = {
|
||||
checkVaultExists?: boolean
|
||||
createEmptyVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
|
||||
createGettingStartedVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
describe('useVaultSwitcher', () => {
|
||||
const onSwitch = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
// Re-set default implementation after resetAllMocks
|
||||
const setMockInvokeBehavior = (overrides: MockInvokeOverrides = {}) => {
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
@@ -47,9 +50,37 @@ describe('useVaultSwitcher', () => {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(overrides.checkVaultExists ?? true)
|
||||
if (cmd === 'create_empty_vault' && overrides.createEmptyVault) {
|
||||
return Promise.resolve().then(() => overrides.createEmptyVault?.(args as { targetPath: string }))
|
||||
}
|
||||
if (cmd === 'create_getting_started_vault' && overrides.createGettingStartedVault) {
|
||||
return Promise.resolve().then(() => overrides.createGettingStartedVault?.(args as { targetPath: string }))
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
const renderLoadedVaultSwitcher = async () => {
|
||||
const hook = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.loaded).toBe(true)
|
||||
})
|
||||
return hook
|
||||
}
|
||||
|
||||
const setWorkVaultWithHiddenGettingStarted = () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
setMockInvokeBehavior()
|
||||
})
|
||||
|
||||
it('starts with default vaults', async () => {
|
||||
@@ -206,6 +237,42 @@ describe('useVaultSwitcher', () => {
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
|
||||
})
|
||||
|
||||
it('creates an empty vault and switches to it', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')
|
||||
setMockInvokeBehavior({
|
||||
createEmptyVault: ({ targetPath }) => targetPath,
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_empty_vault', { targetPath: '/Users/luca/New Vault' })
|
||||
expect(result.current.vaultPath).toBe('/Users/luca/New Vault')
|
||||
expect(result.current.allVaults.some(v => v.path === '/Users/luca/New Vault')).toBe(true)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "New Vault" created and opened')
|
||||
})
|
||||
|
||||
it('shows a friendly toast when empty-vault creation targets a non-empty folder', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Busy Folder')
|
||||
setMockInvokeBehavior({
|
||||
createEmptyVault: () => Promise.reject('Choose an empty folder to create a new vault'),
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(onToast).toHaveBeenCalledWith('Choose an empty folder to create a new vault')
|
||||
})
|
||||
|
||||
describe('removeVault', () => {
|
||||
it('removes an extra vault from the list', async () => {
|
||||
mockVaultListStore = {
|
||||
@@ -304,11 +371,7 @@ describe('useVaultSwitcher', () => {
|
||||
|
||||
describe('restoreGettingStarted', () => {
|
||||
it('un-hides the Getting Started vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
@@ -324,11 +387,7 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('switches to the Getting Started vault after restoring', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
@@ -342,25 +401,13 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('attempts to create vault on disk if it does not exist', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') return Promise.resolve(expectedDefaultVaultPath)
|
||||
return Promise.resolve(null)
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: false,
|
||||
createGettingStartedVault: ({ targetPath }) => targetPath,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
@@ -371,27 +418,13 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') {
|
||||
return Promise.reject('git clone failed: fatal: unable to access')
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: false,
|
||||
createGettingStartedVault: () => Promise.reject('git clone failed: fatal: unable to access'),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
|
||||
@@ -359,6 +359,21 @@ function formatGettingStartedRestoreError(err: unknown): string {
|
||||
return `Could not prepare Getting Started vault: ${message}`
|
||||
}
|
||||
|
||||
function formatCreateEmptyVaultError(err: unknown): string {
|
||||
const message =
|
||||
typeof err === 'string'
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `${err}`
|
||||
|
||||
if (message.includes('Choose an empty folder')) {
|
||||
return message
|
||||
}
|
||||
|
||||
return `Could not create empty vault: ${message}`
|
||||
}
|
||||
|
||||
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path })
|
||||
if (!exists) {
|
||||
@@ -505,6 +520,25 @@ function useOpenLocalFolderAction(
|
||||
}, [addAndSwitch, onToastRef])
|
||||
}
|
||||
|
||||
function useCreateEmptyVaultAction(
|
||||
addAndSwitch: (path: string, label: string) => void,
|
||||
onToastRef: MutableRefObject<(msg: string) => void>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
const targetPath = await pickFolder('Choose where to create your vault')
|
||||
if (!targetPath) return
|
||||
|
||||
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
|
||||
const label = labelFromPath({ path: vaultPath })
|
||||
addAndSwitch(vaultPath, label)
|
||||
onToastRef.current(`Vault "${label}" created and opened`)
|
||||
} catch (err) {
|
||||
onToastRef.current(formatCreateEmptyVaultError(err))
|
||||
}
|
||||
}, [addAndSwitch, onToastRef])
|
||||
}
|
||||
|
||||
function useRemoveVaultAction({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
@@ -589,6 +623,7 @@ function useVaultActions({
|
||||
}, [addVault, switchVault])
|
||||
|
||||
return {
|
||||
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
|
||||
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
|
||||
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
|
||||
removeVault: useRemoveVaultAction({
|
||||
@@ -656,7 +691,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
hiddenDefaults,
|
||||
extraVaults,
|
||||
)
|
||||
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
|
||||
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
|
||||
...persistedState,
|
||||
allVaults,
|
||||
defaultVaults,
|
||||
@@ -668,6 +703,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
return {
|
||||
allVaults,
|
||||
defaultPath,
|
||||
handleCreateEmptyVault,
|
||||
handleOpenLocalFolder,
|
||||
handleVaultCloned,
|
||||
isGettingStartedHidden,
|
||||
|
||||
@@ -372,7 +372,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
// In mock mode, the demo-vault-v2 path always "exists"
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
|
||||
create_empty_vault: (args: { targetPath?: string; target_path?: string }) => args.targetPath || args.target_path || '/Users/mock/Documents/My Vault',
|
||||
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
|
||||
@@ -132,6 +132,7 @@ export function createNoteListSpies() {
|
||||
return {
|
||||
onSelectNote: vi.fn(),
|
||||
onReplaceActiveTab: vi.fn(),
|
||||
onEnterNeighborhood: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onNoteListFilterChange: vi.fn(),
|
||||
}
|
||||
@@ -147,6 +148,7 @@ export function buildNoteListProps(overrides: Partial<NoteListProps> = {}) {
|
||||
onNoteListFilterChange: spies.onNoteListFilterChange,
|
||||
onSelectNote: spies.onSelectNote,
|
||||
onReplaceActiveTab: spies.onReplaceActiveTab,
|
||||
onEnterNeighborhood: spies.onEnterNeighborhood,
|
||||
onCreateNote: spies.onCreateNote,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface ViewDefinition {
|
||||
icon: string | null
|
||||
color: string | null
|
||||
sort: string | null
|
||||
listPropertiesDisplay?: string[]
|
||||
filters: FilterGroup
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
|
||||
import { buildRelationshipGroups, countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
|
||||
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('filterEntries', () => {
|
||||
@@ -127,3 +127,109 @@ describe('countAllNotesByFilter', () => {
|
||||
expect(countAllNotesByFilter(entries)).toEqual({ open: 1, archived: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRelationshipGroups', () => {
|
||||
it('omits computed neighborhood groups when they are absent', () => {
|
||||
const standalone = makeEntry({
|
||||
path: '/vault/solo.md',
|
||||
filename: 'solo.md',
|
||||
title: 'Standalone',
|
||||
isA: 'Note',
|
||||
})
|
||||
|
||||
const groups = buildRelationshipGroups(standalone, [standalone])
|
||||
|
||||
expect(groups).toEqual([])
|
||||
})
|
||||
|
||||
it('allows the same note to appear in multiple relationship groups', () => {
|
||||
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]]'],
|
||||
})
|
||||
|
||||
const groups = buildRelationshipGroups(parent, [parent, shared])
|
||||
|
||||
expect(groups.find((group) => group.label === 'Related to')?.entries).toEqual([shared])
|
||||
expect(groups.find((group) => group.label === 'Referenced By')?.entries).toEqual([shared])
|
||||
})
|
||||
|
||||
it('includes all inverse relationship groups for non-core 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]]'] },
|
||||
})
|
||||
|
||||
const groups = buildRelationshipGroups(parent, [parent, topicNote, mentorNote, hostEvent])
|
||||
|
||||
expect(groups.find((group) => group.label === '← Topics')?.entries).toEqual([topicNote])
|
||||
expect(groups.find((group) => group.label === '← Mentors')?.entries).toEqual([mentorNote])
|
||||
expect(groups.find((group) => group.label === '← Hosts')?.entries).toEqual([hostEvent])
|
||||
})
|
||||
|
||||
it('does not treat path-qualified refs for a different note as reverse matches', () => {
|
||||
const parent = makeEntry({
|
||||
path: '/vault/projects/alpha.md',
|
||||
filename: 'alpha.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Project',
|
||||
})
|
||||
const archiveAlpha = makeEntry({
|
||||
path: '/vault/archive/alpha.md',
|
||||
filename: 'alpha.md',
|
||||
title: 'Alpha Archive',
|
||||
isA: 'Project',
|
||||
})
|
||||
const unrelatedChild = makeEntry({
|
||||
path: '/vault/notes/child.md',
|
||||
filename: 'child.md',
|
||||
title: 'Child Note',
|
||||
isA: 'Note',
|
||||
belongsTo: ['[[archive/alpha]]'],
|
||||
})
|
||||
const unrelatedEvent = makeEntry({
|
||||
path: '/vault/events/event.md',
|
||||
filename: 'event.md',
|
||||
title: 'Review',
|
||||
isA: 'Event',
|
||||
relatedTo: ['[[archive/alpha]]'],
|
||||
})
|
||||
|
||||
const groups = buildRelationshipGroups(parent, [parent, archiveAlpha, unrelatedChild, unrelatedEvent])
|
||||
|
||||
expect(groups).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,8 +67,20 @@ export function formatSearchSubtitle(entry: VaultEntry): string {
|
||||
return parts.join(' \u00b7 ')
|
||||
}
|
||||
|
||||
function refMatchesEntry(ref: string, entry: VaultEntry): boolean {
|
||||
const target = wikilinkTarget(ref).trim()
|
||||
if (!target) return false
|
||||
|
||||
if (target.includes('/')) {
|
||||
const normalizedTarget = target.replace(/^\/+/, '').replace(/\.md$/, '').toLowerCase()
|
||||
return entry.path.toLowerCase().endsWith(`/${normalizedTarget}.md`)
|
||||
}
|
||||
|
||||
return resolveEntry([entry], target) !== undefined
|
||||
}
|
||||
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
return refs.some((ref) => resolveEntry([entry], wikilinkTarget(ref)) !== undefined)
|
||||
return refs.some((ref) => refMatchesEntry(ref, entry))
|
||||
}
|
||||
|
||||
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
||||
@@ -254,33 +266,109 @@ function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[]): VaultEntry
|
||||
|
||||
class GroupBuilder {
|
||||
readonly groups: RelationshipGroup[] = []
|
||||
private readonly seen: Set<string>
|
||||
private readonly entityPath: string
|
||||
private readonly allEntries: VaultEntry[]
|
||||
|
||||
constructor(entityPath: string, allEntries: VaultEntry[]) {
|
||||
this.seen = new Set([entityPath])
|
||||
this.entityPath = entityPath
|
||||
this.allEntries = allEntries
|
||||
}
|
||||
|
||||
add(label: string, entries: VaultEntry[]) {
|
||||
const unseen = entries.filter((e) => !this.seen.has(e.path))
|
||||
if (unseen.length > 0) {
|
||||
this.groups.push({ label, entries: unseen })
|
||||
unseen.forEach((e) => this.seen.add(e.path))
|
||||
const deduped = new Map<string, VaultEntry>()
|
||||
for (const entry of entries) {
|
||||
if (entry.path === this.entityPath || deduped.has(entry.path)) continue
|
||||
deduped.set(entry.path, entry)
|
||||
}
|
||||
|
||||
if (deduped.size === 0) return
|
||||
this.groups.push({ label, entries: [...deduped.values()] })
|
||||
}
|
||||
|
||||
addFromRefs(label: string, refs: string[]) {
|
||||
if (refs.length > 0) {
|
||||
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
|
||||
}
|
||||
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
|
||||
}
|
||||
|
||||
filterAndAdd(label: string, predicate: (e: VaultEntry) => boolean) {
|
||||
this.add(label, this.allEntries.filter((e) => !this.seen.has(e.path) && predicate(e)).sort(sortByModified))
|
||||
this.add(label, this.allEntries.filter(predicate).sort(sortByModified))
|
||||
}
|
||||
}
|
||||
|
||||
function appendInverseRelationshipEntries(
|
||||
inverseGroups: Map<string, VaultEntry[]>,
|
||||
label: string,
|
||||
entry: VaultEntry,
|
||||
) {
|
||||
const existing = inverseGroups.get(label)
|
||||
if (existing) {
|
||||
existing.push(entry)
|
||||
return
|
||||
}
|
||||
|
||||
inverseGroups.set(label, [entry])
|
||||
}
|
||||
|
||||
function resolveInverseRelationshipLabel(key: string, entry: VaultEntry): string {
|
||||
if (key === 'Belongs to') return entry.isA === 'Event' ? 'Events' : 'Children'
|
||||
if (key === 'Related to') return entry.isA === 'Event' ? 'Events' : 'Referenced By'
|
||||
return `← ${key}`
|
||||
}
|
||||
|
||||
function appendLegacyInverseRelationshipEntries(
|
||||
inverseGroups: Map<string, VaultEntry[]>,
|
||||
entity: VaultEntry,
|
||||
entry: VaultEntry,
|
||||
) {
|
||||
if (refsMatch(entry.belongsTo, entity)) {
|
||||
appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel('Belongs to', entry), entry)
|
||||
}
|
||||
|
||||
if (refsMatch(entry.relatedTo, entity)) {
|
||||
appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel('Related to', entry), entry)
|
||||
}
|
||||
}
|
||||
|
||||
function appendDynamicInverseRelationshipEntries(
|
||||
inverseGroups: Map<string, VaultEntry[]>,
|
||||
entity: VaultEntry,
|
||||
entry: VaultEntry,
|
||||
) {
|
||||
for (const [key, refs] of Object.entries(entry.relationships ?? {})) {
|
||||
if (key === 'Type' || !refsMatch(refs, entity)) continue
|
||||
appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel(key, entry), entry)
|
||||
}
|
||||
}
|
||||
|
||||
function orderInverseRelationshipLabels(inverseGroups: Map<string, VaultEntry[]>): string[] {
|
||||
const preferredOrder = ['Children', 'Events', 'Referenced By']
|
||||
const customLabels = [...inverseGroups.keys()]
|
||||
.filter((label) => !preferredOrder.includes(label))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return [...preferredOrder, ...customLabels]
|
||||
}
|
||||
|
||||
function collectInverseRelationshipGroups(
|
||||
entity: VaultEntry,
|
||||
allEntries: VaultEntry[],
|
||||
): RelationshipGroup[] {
|
||||
const inverseGroups = new Map<string, VaultEntry[]>()
|
||||
|
||||
for (const other of allEntries) {
|
||||
if (other.path === entity.path) continue
|
||||
appendLegacyInverseRelationshipEntries(inverseGroups, entity, other)
|
||||
appendDynamicInverseRelationshipEntries(inverseGroups, entity, other)
|
||||
}
|
||||
|
||||
return orderInverseRelationshipLabels(inverseGroups)
|
||||
.map((label) => {
|
||||
const entries = inverseGroups.get(label)
|
||||
if (!entries) return null
|
||||
return { label, entries: [...entries].sort(sortByModified) }
|
||||
})
|
||||
.filter((group): group is RelationshipGroup => group !== null)
|
||||
}
|
||||
|
||||
export function buildRelationshipGroups(
|
||||
entity: VaultEntry,
|
||||
allEntries: VaultEntry[],
|
||||
@@ -300,9 +388,7 @@ export function buildRelationshipGroups(
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
|
||||
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
collectInverseRelationshipGroups(entity, allEntries).forEach((group) => b.add(group.label, group.entries))
|
||||
b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified))
|
||||
|
||||
return b.groups
|
||||
|
||||
@@ -29,6 +29,11 @@ describe('detectPropertyType', () => {
|
||||
expect(detectPropertyType('published', false)).toBe('boolean')
|
||||
})
|
||||
|
||||
it('detects number from value type', () => {
|
||||
expect(detectPropertyType('estimate', 3)).toBe('number')
|
||||
expect(detectPropertyType('ratio', -1.5)).toBe('number')
|
||||
})
|
||||
|
||||
it('detects status from key name', () => {
|
||||
expect(detectPropertyType('status', 'Active')).toBe('status')
|
||||
expect(detectPropertyType('Status', 'Draft')).toBe('status')
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { getAppStorageItem } from '../constants/appStorage'
|
||||
import { isValidCssColor, isColorKeyName } from './colorUtils'
|
||||
import { updateVaultConfigField } from './vaultConfigStore'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from 'lucide-react'
|
||||
import { canonicalSystemMetadataKey } from './systemMetadata'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
export type PropertyDisplayMode = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
||||
@@ -33,17 +33,35 @@ function isDateString(value: string): boolean {
|
||||
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
|
||||
}
|
||||
|
||||
function isStatusKey(key: string): boolean {
|
||||
return keyMatchesPatterns(key, STATUS_KEY_PATTERNS)
|
||||
}
|
||||
|
||||
function isDateKey(key: string): boolean {
|
||||
return keyMatchesPatterns(key, DATE_KEY_PATTERNS)
|
||||
}
|
||||
|
||||
function isStatusString(key: string, value: string): boolean {
|
||||
if (isStatusKey(key)) return true
|
||||
if (isDateKey(key)) return false
|
||||
return STATUS_VALUES.has(value.toLowerCase())
|
||||
}
|
||||
|
||||
function isColorString(key: string, value: string): boolean {
|
||||
return isValidCssColor(value) && (value.startsWith('#') || isColorKeyName(key))
|
||||
}
|
||||
|
||||
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
|
||||
if (isIconKey(key)) return 'text'
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (isStatusString(key, strValue)) return 'status'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
|
||||
if (isColorString(key, strValue)) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (value === null || value === undefined) return 'text'
|
||||
if (typeof value === 'number') return 'number'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (isIconKey(key)) return 'text'
|
||||
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
|
||||
@@ -95,22 +113,25 @@ export function getEffectiveDisplayMode(
|
||||
return overrides[key] ?? detectPropertyType(key, value)
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
function resolveDateFromValue(value: string): Date | null {
|
||||
const isoMatch = value.match(ISO_DATE_RE)
|
||||
if (isoMatch) {
|
||||
const d = new Date(isoMatch[0])
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
const date = new Date(isoMatch[0])
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/)
|
||||
if (parts) {
|
||||
const d = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
}
|
||||
return value
|
||||
if (!parts) return null
|
||||
|
||||
const date = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
const date = resolveDateFromValue(value)
|
||||
return date
|
||||
? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: value
|
||||
}
|
||||
|
||||
export function toISODate(value: string): string {
|
||||
@@ -123,11 +144,12 @@ export function toISODate(value: string): string {
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
text: Type, number: Hash, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
|
||||
87
src/utils/releaseDownloadPage.test.ts
Normal file
87
src/utils/releaseDownloadPage.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
buildStableDownloadRedirectPage,
|
||||
extractStableDmgUrl,
|
||||
extractStableDmgUrlFromReleases,
|
||||
resolveStableDmgUrl,
|
||||
} from './releaseDownloadPage'
|
||||
|
||||
describe('extractStableDmgUrl', () => {
|
||||
it('returns the stable dmg url when present', () => {
|
||||
expect(
|
||||
extractStableDmgUrl({
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
dmg_url: 'https://example.com/Tolaria.dmg',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe('https://example.com/Tolaria.dmg')
|
||||
})
|
||||
|
||||
it('returns null when the stable dmg url is missing', () => {
|
||||
expect(
|
||||
extractStableDmgUrl({
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://example.com/Tolaria.app.tar.gz',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildStableDownloadRedirectPage', () => {
|
||||
it('builds a redirect page when a stable dmg exists', () => {
|
||||
const html = buildStableDownloadRedirectPage('https://example.com/Tolaria.dmg')
|
||||
|
||||
expect(html).toContain('Tolaria Stable Download')
|
||||
expect(html).toContain('window.location.replace("https://example.com/Tolaria.dmg");')
|
||||
expect(html).toContain('Download latest stable DMG')
|
||||
expect(html).toContain('meta http-equiv="refresh"')
|
||||
})
|
||||
|
||||
it('builds a fallback page when no stable dmg exists yet', () => {
|
||||
const html = buildStableDownloadRedirectPage(null)
|
||||
|
||||
expect(html).toContain('Tolaria Stable Download Unavailable')
|
||||
expect(html).toContain('View release history')
|
||||
expect(html).toContain('https://refactoringhq.github.io/tolaria/')
|
||||
expect(html).not.toContain('window.location.replace(')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveStableDmgUrl', () => {
|
||||
it('falls back to the latest stable github release dmg when latest.json has no dmg url', () => {
|
||||
const latestPayload = {
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://example.com/Tolaria.app.tar.gz',
|
||||
},
|
||||
},
|
||||
}
|
||||
const releasesPayload = [
|
||||
{
|
||||
prerelease: true,
|
||||
assets: [
|
||||
{
|
||||
name: 'Tolaria-alpha.dmg',
|
||||
browser_download_url: 'https://example.com/alpha.dmg',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: 'Tolaria.dmg',
|
||||
browser_download_url: 'https://example.com/stable.dmg',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(extractStableDmgUrlFromReleases(releasesPayload)).toBe('https://example.com/stable.dmg')
|
||||
expect(resolveStableDmgUrl(latestPayload, releasesPayload)).toBe('https://example.com/stable.dmg')
|
||||
})
|
||||
})
|
||||
211
src/utils/releaseDownloadPage.ts
Normal file
211
src/utils/releaseDownloadPage.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
const RELEASE_HISTORY_URL = 'https://refactoringhq.github.io/tolaria/'
|
||||
|
||||
type PlatformPayload = {
|
||||
dmg_url?: unknown
|
||||
}
|
||||
|
||||
type LatestReleasePayload = {
|
||||
platforms?: Record<string, PlatformPayload | undefined>
|
||||
}
|
||||
|
||||
type ReleaseAssetPayload = {
|
||||
browser_download_url?: unknown
|
||||
name?: unknown
|
||||
}
|
||||
|
||||
type GitHubReleasePayload = {
|
||||
assets?: ReleaseAssetPayload[]
|
||||
draft?: unknown
|
||||
prerelease?: unknown
|
||||
}
|
||||
|
||||
type DownloadPageContent = {
|
||||
buttonLabel: string
|
||||
destinationUrl: string
|
||||
helperText: string
|
||||
message: string
|
||||
shouldRedirect: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
const REDIRECT_PAGE_STYLES = `
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #f7f6f3;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100%, 460px);
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9e9e7;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
background: #155dff;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
background: #1248cc;
|
||||
}
|
||||
`
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
export function extractStableDmgUrl(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const { platforms } = payload as LatestReleasePayload
|
||||
if (!platforms || typeof platforms !== 'object') return null
|
||||
|
||||
const dmgUrl = platforms['darwin-aarch64']?.dmg_url
|
||||
if (typeof dmgUrl !== 'string') return null
|
||||
|
||||
const trimmedUrl = dmgUrl.trim()
|
||||
return trimmedUrl.length > 0 ? trimmedUrl : null
|
||||
}
|
||||
|
||||
function isPublicStableRelease(release: GitHubReleasePayload): boolean {
|
||||
return release.draft !== true && release.prerelease !== true
|
||||
}
|
||||
|
||||
function extractAssetDmgUrl(asset: ReleaseAssetPayload): string | null {
|
||||
if (typeof asset.name !== 'string' || !asset.name.endsWith('.dmg')) return null
|
||||
if (typeof asset.browser_download_url !== 'string') return null
|
||||
|
||||
const trimmedUrl = asset.browser_download_url.trim()
|
||||
return trimmedUrl.length > 0 ? trimmedUrl : null
|
||||
}
|
||||
|
||||
function findReleaseDmgUrl(release: GitHubReleasePayload): string | null {
|
||||
if (!isPublicStableRelease(release)) return null
|
||||
if (!Array.isArray(release.assets)) return null
|
||||
|
||||
for (const asset of release.assets) {
|
||||
const dmgUrl = extractAssetDmgUrl(asset)
|
||||
if (dmgUrl !== null) return dmgUrl
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractStableDmgUrlFromReleases(payload: unknown): string | null {
|
||||
if (!Array.isArray(payload)) return null
|
||||
|
||||
for (const release of payload) {
|
||||
if (!release || typeof release !== 'object') continue
|
||||
|
||||
const dmgUrl = findReleaseDmgUrl(release as GitHubReleasePayload)
|
||||
if (dmgUrl !== null) return dmgUrl
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveStableDmgUrl(
|
||||
latestPayload: unknown,
|
||||
releasesPayload: unknown,
|
||||
): string | null {
|
||||
return extractStableDmgUrl(latestPayload) ?? extractStableDmgUrlFromReleases(releasesPayload)
|
||||
}
|
||||
|
||||
function buildStableDownloadPageContent(dmgUrl: string | null): DownloadPageContent {
|
||||
if (dmgUrl !== null) {
|
||||
return {
|
||||
buttonLabel: 'Download latest stable DMG',
|
||||
destinationUrl: dmgUrl,
|
||||
helperText: 'If the download does not start automatically, use the button below.',
|
||||
message: 'Redirecting to the latest stable Tolaria DMG.',
|
||||
shouldRedirect: true,
|
||||
title: 'Tolaria Stable Download',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttonLabel: 'View release history',
|
||||
destinationUrl: RELEASE_HISTORY_URL,
|
||||
helperText: 'Use the button below to check the latest release history.',
|
||||
message: 'No stable Tolaria DMG is available yet.',
|
||||
shouldRedirect: false,
|
||||
title: 'Tolaria Stable Download Unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRedirectMarkup(destinationUrl: string, shouldRedirect: boolean): string {
|
||||
if (!shouldRedirect) return ''
|
||||
|
||||
const escapedDestinationUrl = escapeHtml(destinationUrl)
|
||||
return `
|
||||
<meta http-equiv="refresh" content="0; url=${escapedDestinationUrl}">
|
||||
<script>
|
||||
window.location.replace(${JSON.stringify(destinationUrl)});
|
||||
</script>`
|
||||
}
|
||||
|
||||
export function buildStableDownloadRedirectPage(dmgUrl: string | null): string {
|
||||
const page = buildStableDownloadPageContent(dmgUrl)
|
||||
const escapedDestinationUrl = escapeHtml(page.destinationUrl)
|
||||
const redirectMarkup = buildRedirectMarkup(page.destinationUrl, page.shouldRedirect)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${page.title}</title>${redirectMarkup}
|
||||
<style>${REDIRECT_PAGE_STYLES}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${page.title}</h1>
|
||||
<p>${page.message}</p>
|
||||
<p>${page.helperText}</p>
|
||||
<a id="download-link" href="${escapedDestinationUrl}">${page.buttonLabel}</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
224
tests/smoke/create-empty-vault-flows.spec.ts
Normal file
224
tests/smoke/create-empty-vault-flows.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { findCommand, openCommandPalette, sendShortcut } from './helpers'
|
||||
|
||||
interface MockEntry {
|
||||
path: string
|
||||
filename: string
|
||||
title: string
|
||||
isA: string
|
||||
aliases: string[]
|
||||
belongsTo: string[]
|
||||
relatedTo: string[]
|
||||
status: string | null
|
||||
archived: boolean
|
||||
modifiedAt: number | null
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
snippet: string
|
||||
wordCount: number
|
||||
relationships: Record<string, string[]>
|
||||
outgoingLinks: string[]
|
||||
properties: Record<string, unknown>
|
||||
template: null
|
||||
sort: null
|
||||
}
|
||||
|
||||
interface VaultSeed {
|
||||
label: string
|
||||
path: string
|
||||
noteTitle?: string
|
||||
}
|
||||
|
||||
interface EntryOptions {
|
||||
fileName?: string
|
||||
isA?: string
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
function untitledNoteRow(page: Page) {
|
||||
return page.getByText(/^Untitled Note(?: \d+)?$/i).first()
|
||||
}
|
||||
|
||||
async function expectFreshVaultSeedEntries(page: Page) {
|
||||
await expect(page.getByText('AGENTS.md — Tolaria Vault', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('CLAUDE.md', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Config', { exact: true })).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function installEmptyVaultMocks(
|
||||
page: Page,
|
||||
config: {
|
||||
createdVaultPath: string
|
||||
initialVaults: VaultSeed[]
|
||||
activeVault: string | null
|
||||
},
|
||||
) {
|
||||
await page.addInitScript((mockConfig) => {
|
||||
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
|
||||
|
||||
function buildEntry(vaultPath: string, title: string, options: EntryOptions = {}): MockEntry {
|
||||
const defaultStem = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const filename = options.fileName ?? `${defaultStem}.md`
|
||||
return {
|
||||
path: `${vaultPath}/${filename}`,
|
||||
filename,
|
||||
title,
|
||||
isA: options.isA ?? 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: options.snippet ?? `${title} snippet`,
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildFreshVaultSeedEntries(vaultPath: string): MockEntry[] {
|
||||
return [
|
||||
buildEntry(vaultPath, 'AGENTS.md — Tolaria Vault', { fileName: 'AGENTS.md' }),
|
||||
buildEntry(vaultPath, 'CLAUDE.md', { fileName: 'CLAUDE.md' }),
|
||||
buildEntry(vaultPath, 'Type', { fileName: 'type.md', isA: 'Type' }),
|
||||
buildEntry(vaultPath, 'Note', { fileName: 'note.md', isA: 'Type' }),
|
||||
]
|
||||
}
|
||||
|
||||
function syncEntryContent(entry: MockEntry) {
|
||||
allContent[entry.path] = `# ${entry.title}\n\n${entry.snippet}`
|
||||
}
|
||||
|
||||
let savedVaults = mockConfig.initialVaults.map(({ label, path }) => ({ label, path }))
|
||||
let activeVault = mockConfig.activeVault
|
||||
let hiddenDefaults: string[] = []
|
||||
|
||||
const entriesByVault = Object.fromEntries(
|
||||
mockConfig.initialVaults.map((vault) => [
|
||||
vault.path,
|
||||
vault.noteTitle ? [buildEntry(vault.path, vault.noteTitle)] : [],
|
||||
]),
|
||||
) satisfies Record<string, MockEntry[]>
|
||||
|
||||
const allContent = Object.fromEntries(
|
||||
Object.values(entriesByVault)
|
||||
.flat()
|
||||
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
|
||||
)
|
||||
|
||||
localStorage.clear()
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
Object.defineProperty(window, 'prompt', {
|
||||
configurable: true,
|
||||
value: () => mockConfig.createdVaultPath,
|
||||
})
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [...savedVaults],
|
||||
active_vault: activeVault,
|
||||
hidden_defaults: [...hiddenDefaults],
|
||||
})
|
||||
ref.save_vault_list = (args: {
|
||||
list: {
|
||||
vaults: Array<{ label: string; path: string }>
|
||||
active_vault: string | null
|
||||
hidden_defaults?: string[]
|
||||
}
|
||||
}) => {
|
||||
savedVaults = [...args.list.vaults]
|
||||
activeVault = args.list.active_vault
|
||||
hiddenDefaults = [...(args.list.hidden_defaults ?? [])]
|
||||
return null
|
||||
}
|
||||
ref.get_default_vault_path = () => gettingStartedPath
|
||||
ref.check_vault_exists = (args: { path?: string }) =>
|
||||
savedVaults.some((vault) => vault.path === args.path)
|
||||
|| args.path === mockConfig.createdVaultPath
|
||||
ref.create_empty_vault = (args: { targetPath?: string | null }) => {
|
||||
if (args.targetPath !== mockConfig.createdVaultPath) {
|
||||
throw new Error(`Unexpected empty vault target: ${args.targetPath}`)
|
||||
}
|
||||
entriesByVault[mockConfig.createdVaultPath] = buildFreshVaultSeedEntries(mockConfig.createdVaultPath)
|
||||
entriesByVault[mockConfig.createdVaultPath].forEach(syncEntryContent)
|
||||
return mockConfig.createdVaultPath
|
||||
}
|
||||
ref.list_vault = (args: { path?: string }) => entriesByVault[args.path ?? activeVault ?? ''] ?? []
|
||||
ref.list_vault_folders = () => []
|
||||
ref.list_views = () => []
|
||||
ref.get_all_content = () => allContent
|
||||
ref.get_note_content = (args: { path?: string }) => allContent[args.path ?? ''] ?? ''
|
||||
ref.get_modified_files = () => []
|
||||
ref.get_file_history = () => []
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
}, config)
|
||||
}
|
||||
|
||||
test('keyboard onboarding can create an empty vault and the first note', async ({ page }) => {
|
||||
await installEmptyVaultMocks(page, {
|
||||
createdVaultPath: '/Users/mock/Documents/Fresh Vault',
|
||||
initialVaults: [],
|
||||
activeVault: null,
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
await expectFreshVaultSeedEntries(page)
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('command palette and bottom bar expose empty-vault creation from the active app shell', async ({ page }) => {
|
||||
await installEmptyVaultMocks(page, {
|
||||
createdVaultPath: '/Users/mock/Documents/Client Vault',
|
||||
initialVaults: [{ label: 'Work Vault', path: '/Users/mock/Work', noteTitle: 'Work Home' }],
|
||||
activeVault: '/Users/mock/Work',
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('status-vault-trigger')).toContainText('Work Vault')
|
||||
|
||||
await openCommandPalette(page)
|
||||
expect(await findCommand(page, 'Create Empty Vault')).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const trigger = page.getByTestId('status-vault-trigger')
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const createEmptyItem = page.getByTestId('vault-menu-create-empty')
|
||||
await createEmptyItem.focus()
|
||||
await expect(createEmptyItem).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(trigger).toContainText('Client Vault')
|
||||
await expectFreshVaultSeedEntries(page)
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
@@ -62,6 +62,42 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
})
|
||||
|
||||
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
|
||||
await mockFreshStart(page, {
|
||||
activeVault: null,
|
||||
checkExistingPath: '/Users/mock/Documents/Getting Started',
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('telemetry-decline')).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('telemetry-accept')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
|
||||
|
||||
let dialogHandled = false
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain('Open vault folder')
|
||||
await dialog.dismiss()
|
||||
dialogHandled = true
|
||||
})
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => dialogHandled).toBe(true)
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
|
||||
})
|
||||
|
||||
for (const action of ['accept', 'decline'] as const) {
|
||||
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
|
||||
await mockFreshStart(page, {
|
||||
|
||||
@@ -91,6 +91,12 @@ async function moveMouseInSteps(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectImageBlockSelected(image: ReturnType<Page['locator']>) {
|
||||
await expect.poll(async () => (
|
||||
image.evaluate((node) => Boolean(node.closest('.ProseMirror-selectednode')))
|
||||
)).toBe(true)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(90_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
@@ -117,9 +123,12 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
|
||||
|
||||
await expect(toolbar).toBeVisible({ timeout: 5_000 })
|
||||
await expect(replaceButton).toBeVisible()
|
||||
await expectImageBlockSelected(image)
|
||||
|
||||
const replaceButtonBox = await replaceButton.boundingBox()
|
||||
expect(replaceButtonBox).not.toBeNull()
|
||||
const toolbarBox = await toolbar.boundingBox()
|
||||
expect(toolbarBox).not.toBeNull()
|
||||
|
||||
await moveMouseInSteps(
|
||||
page,
|
||||
@@ -127,6 +136,24 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
|
||||
x: imageBox!.x + imageBox!.width / 2,
|
||||
y: imageBox!.y + imageBox!.height / 2,
|
||||
},
|
||||
{
|
||||
x: toolbarBox!.x + toolbarBox!.width / 2,
|
||||
y: toolbarBox!.y + toolbarBox!.height + 10,
|
||||
},
|
||||
{ steps: 12, stepDelayMs: 35 },
|
||||
)
|
||||
|
||||
await page.waitForTimeout(180)
|
||||
await expect(toolbar).toBeVisible()
|
||||
await expect(replaceButton).toBeVisible()
|
||||
await expectImageBlockSelected(image)
|
||||
|
||||
await moveMouseInSteps(
|
||||
page,
|
||||
{
|
||||
x: toolbarBox!.x + toolbarBox!.width / 2,
|
||||
y: toolbarBox!.y + toolbarBox!.height + 10,
|
||||
},
|
||||
{
|
||||
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
|
||||
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
|
||||
@@ -136,6 +163,7 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
|
||||
|
||||
await expect(toolbar).toBeVisible()
|
||||
await expect(replaceButton).toBeVisible()
|
||||
await expectImageBlockSelected(image)
|
||||
|
||||
await replaceButton.click()
|
||||
await expect(page.locator('.bn-panel-popover')).toBeVisible()
|
||||
|
||||
@@ -64,6 +64,39 @@ test.describe('keyboard command routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('desktop shortcut bridge opens quick open through both Cmd+P and Cmd+O @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'p',
|
||||
code: 'KeyP',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByTestId('quick-open-palette')).not.toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'o',
|
||||
code: 'KeyO',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
|
||||
})
|
||||
|
||||
test('desktop menu-command bridge toggles organized state through the shared command path @smoke', async ({ page }) => {
|
||||
await openAlphaProjectInEditor(page)
|
||||
|
||||
|
||||
58
tests/smoke/number-property.spec.ts
Normal file
58
tests/smoke/number-property.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function alphaProjectPath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'project', 'alpha-project.md')
|
||||
}
|
||||
|
||||
test.describe('Number property editing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('properties panel saves number values across raw-mode switches and note reloads @smoke', async ({ page }) => {
|
||||
const notePath = alphaProjectPath(tempVaultDir)
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await noteList.getByText('Alpha Project', { exact: true }).click()
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
await expect(page.getByTestId('add-property-row')).toBeVisible()
|
||||
|
||||
await page.getByTestId('add-property-row').focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByTestId('add-property-form')).toBeVisible()
|
||||
|
||||
await page.keyboard.type('Estimate')
|
||||
await page.getByTestId('add-property-type-trigger').click()
|
||||
await page.getByRole('option', { name: 'Number', exact: true }).click()
|
||||
const numberInput = page.getByTestId('add-property-number-input')
|
||||
await expect(numberInput).toBeVisible()
|
||||
await numberInput.focus()
|
||||
await page.keyboard.type(' -12.5 ')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect.poll(() => fs.readFileSync(notePath, 'utf8')).toContain('Estimate: -12.5')
|
||||
await expect(page.getByTestId('number-display')).toContainText('-12.5')
|
||||
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
const rawEditor = page.locator('.cm-content')
|
||||
await expect(rawEditor).toBeVisible()
|
||||
await expect(rawEditor).toContainText('Estimate: -12.5')
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await noteList.getByText('Alpha Project', { exact: true }).click()
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
await expect(page.getByTestId('number-display')).toContainText('-12.5')
|
||||
})
|
||||
})
|
||||
@@ -22,5 +22,5 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user