Compare commits

...

10 Commits

Author SHA1 Message Date
lucaronin
57a5693922 fix: stabilize new note editor focus 2026-04-12 00:06:49 +02:00
lucaronin
2ca8f1b2a6 fix: remove legacy title section fallback 2026-04-11 23:51:58 +02:00
lucaronin
eb65bb8f05 fix: dedupe new note command palette entries 2026-04-11 22:05:13 +02:00
lucaronin
8fb229ede3 fix: preserve square brackets in parsed note titles 2026-04-11 21:27:30 +02:00
lucaronin
ce84b34890 fix: restore cmd shift i properties shortcut 2026-04-11 20:59:16 +02:00
lucaronin
e98a186389 fix: align note list date row spacing 2026-04-11 20:30:48 +02:00
lucaronin
258b54b074 refactor: make shortcut QA modes explicit 2026-04-11 19:03:15 +02:00
lucaronin
f694b9b5e4 fix: unblock native ai panel shortcut in tauri 2026-04-11 18:34:39 +02:00
lucaronin
32ee2b781b test: stabilize keyboard shortcut smoke 2026-04-11 17:36:23 +02:00
lucaronin
c78793cfe3 fix: verify editor-focused keyboard shortcuts 2026-04-11 17:23:20 +02:00
53 changed files with 1009 additions and 1132 deletions

View File

@@ -14,7 +14,7 @@ These frontmatter field names have special meaning in Laputa's UI:
| Field | Meaning | UI behavior |
|---|---|---|
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically |
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel |
@@ -237,16 +237,17 @@ Laputa separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Field (UI)
### Title Surface (UI)
The dedicated `TitleField` is a fallback editing surface, not the canonical one:
The BlockNote body is the only title editing surface:
- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden.
- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename.
- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1.
- The first H1 is the canonical display title.
- There is no separate title row above the editor, even when a note has no H1.
- Notes without an H1 show the editor body and placeholder only.
- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect.
### Sidebar Selection

View File

@@ -729,11 +729,15 @@ Selection-dependent note actions are wired through both the command palette and
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs and modifier rules
- `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
- `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 both real key events in a Tauri-like environment and `trigger_menu_command` to prove the keyboard path and the native menu path without relying on flaky macOS key synthesis
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
- native menu-command proof through `trigger_menu_command`
- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical
## Auto-Release & In-App Updates

View File

@@ -65,7 +65,6 @@ laputa-app/
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
│ │ ├── TitleField.tsx # Editable note title above editor
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── CreateNoteDialog.tsx # New note modal
@@ -282,11 +281,16 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
For automated QA, prefer real key events in a Tauri-like environment for shortcut behavior and `window.__laputaTest.triggerMenuCommand()` for the native menu click path. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works.
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage
That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
## Running Tests
@@ -341,7 +345,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID and modifier rule, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0053"
title: "Webview-init prevention for browser-reserved shortcuts"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut handling the primary path for command execution, with native menu accelerators deduped afterward. That works for normal shortcuts, but native QA on macOS showed that `Cmd+Shift+L` still failed to reach the app even though the shared command path and the Note menu item both worked.
The gap is WKWebView itself: some browser-reserved chords are swallowed by the webview before the renderer-level shortcut listener can execute. That makes the shortcut untestable with the real native keypress even though the command bus is correct.
## Decision
**Laputa will keep renderer-first shortcut execution, but for macOS browser-reserved chords we will add a narrow Tauri webview-init prevention layer using `tauri-plugin-prevent-default` so the real keystroke reaches the shared command path.**
## Options considered
- **Option A** (chosen): Add a narrow `tauri-plugin-prevent-default` registration for only the known browser-reserved chords we actually use. This preserves ADR 0052, keeps the command bus unified, and fixes the real native keystroke path without broad shortcut capture.
- **Option B**: Keep relying on renderer capture listeners alone. Simpler, but it fails for chords that WKWebView consumes before renderer code sees them.
- **Option C**: Use a global shortcut plugin as the fallback path. This would catch the keystroke natively, but it reserves the chord outside Laputa and is too heavy for app-local shortcuts.
## Consequences
- Shortcut ownership stays unified: command IDs and execution still live in the shared renderer/native command bus.
- macOS-only browser-reserved chords now have one extra declaration point in `src-tauri/src/lib.rs`, and that list must stay intentionally small.
- Native QA remains mandatory for any shortcut added to that list, because browser dev and mocked Tauri tests do not exercise the webview-init layer.
- Re-evaluate this decision if Tauri/WKWebView exposes a better app-local native shortcut hook that does not require browser-reserved-key workarounds.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0054"
title: "Deterministic shortcut QA matrix"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy:
- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA
- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell
- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use
That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven.
## Decision
**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.**
## Options considered
- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS.
- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely.
- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today.
## Consequences
- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command.
- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA.
- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis.
- Native menu-command behavior can be verified deterministically through the Tauri command bridge.
- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords.

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0055"
title: "H1 is the only editor title surface"
status: active
date: 2026-04-11
supersedes: "0044"
---
## Context
ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone.
The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body.
## Decision
**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.**
Display-title behavior stays:
1. First H1 in the body
2. Legacy frontmatter `title:`
3. Filename-derived fallback
But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder.
Filename operations remain explicit:
- untitled notes still auto-rename from H1 on save
- manual filename rename/sync remains in the breadcrumb
## Options considered
- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model.
- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape.
- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce.
## Consequences
- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body.
- `TitleField` and the title-section render path are removed from the frontend.
- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body.
- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage.

View File

@@ -99,7 +99,7 @@ proposed → active → superseded
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) |
| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) |
| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active |
| [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active |
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
@@ -108,3 +108,6 @@ proposed → active → superseded
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) |
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |

51
src-tauri/Cargo.lock generated
View File

@@ -1012,6 +1012,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -2150,6 +2156,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2278,6 +2293,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
@@ -4504,6 +4520,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.115",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4887,6 +4924,20 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-prevent-default"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6260061932cad80647a823d0c5a3633f4eec62160a8f57bad0ab82b537477ef2"
dependencies = [
"bitflags 2.11.0",
"itertools",
"serde",
"strum",
"tauri",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"

View File

@@ -35,6 +35,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-updater = "2.10.0"
tauri-plugin-process = "2.3.1"
tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "4.0.4"
sentry = "0.37"
uuid = { version = "1", features = ["v4"] }

View File

@@ -86,6 +86,7 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
#[cfg(desktop)]
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_macos_webview_shortcut_prevention(app)?;
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
@@ -94,6 +95,35 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
Ok(())
}
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
#[cfg(all(desktop, target_os = "macos"))]
fn setup_macos_webview_shortcut_prevention(
app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey};
use tauri_plugin_prevent_default::{Flags, KeyboardShortcut};
let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty());
// 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_SHIFT_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
}
app.handle().plugin(builder.build())?;
Ok(())
}
#[cfg(not(all(desktop, target_os = "macos")))]
fn setup_macos_webview_shortcut_prevention(
_app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_common_plugins(app)?;
@@ -214,3 +244,13 @@ pub fn run() {
handle_run_event(app_handle, &event);
});
}
#[cfg(test)]
mod tests {
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
}

View File

@@ -212,9 +212,10 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ALL)
.accelerator("CmdOrCtrl+3")
.build(app)?;
// Keep Cmd+Shift+I on the renderer path. The menu item stays available,
// but the native accelerator has proven unreliable for this command.
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
.id(VIEW_TOGGLE_PROPERTIES)
.accelerator("CmdOrCtrl+Shift+I")
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)

View File

@@ -12,7 +12,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
/// Bump this when VaultEntry fields change to force a full rescan.
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
const CACHE_VERSION: u32 = 12;
/// v13: preserve plain square brackets in parsed markdown H1 titles
const CACHE_VERSION: u32 = 13;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {

View File

@@ -249,7 +249,9 @@ fn extract_wikilink_display(inner: &str) -> &str {
inner.find('|').map_or(inner, |idx| &inner[idx + 1..])
}
/// Process a markdown link `[text](url)`, extracting only the link text.
/// Process bracketed text.
/// Real markdown links `[text](url)` are unwrapped to `text`.
/// Plain bracketed text `[text]` is preserved verbatim.
fn process_markdown_link(
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
result: &mut String,
@@ -258,8 +260,13 @@ fn process_markdown_link(
if chars.peek() == Some(&'(') {
chars.next();
skip_until(chars, ')');
result.push_str(&inner);
return;
}
result.push('[');
result.push_str(&inner);
result.push(']');
}
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
@@ -363,6 +370,15 @@ mod tests {
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
}
#[test]
fn test_extract_h1_title_preserves_plain_square_brackets() {
let content = "# [26Q2] Tolaria MVP\n\nBody.";
assert_eq!(
extract_h1_title(content),
Some("[26Q2] Tolaria MVP".to_string())
);
}
#[test]
fn test_extract_h1_title_none_when_no_h1() {
assert_eq!(extract_h1_title("Just body text."), None);
@@ -726,7 +742,7 @@ mod tests {
#[test]
fn test_strip_markdown_chars_bracket_without_url() {
assert_eq!(strip_markdown_chars("[just brackets]"), "just brackets");
assert_eq!(strip_markdown_chars("[just brackets]"), "[just brackets]");
}
#[test]

View File

@@ -707,7 +707,6 @@ function App() {
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={appSave.handleContentChange}
onSave={appSave.handleSave}
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}

View File

@@ -141,26 +141,16 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).toHaveClass('border-b', 'border-transparent')
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
it('uses the active separator state when raw mode forces the title into the breadcrumb', () => {
it('keeps the breadcrumb title visible in raw mode', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode onToggleRaw={vi.fn()} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
it('keeps the breadcrumb title visible when the separate title section is absent', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} showTitleSection={false} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — filename controls', () => {

View File

@@ -43,7 +43,6 @@ interface BreadcrumbBarProps {
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
showTitleSection?: boolean
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
@@ -515,17 +514,13 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
onRenameFilename,
showTitleSection = true,
...actionProps
}: BreadcrumbBarProps) {
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode
return (
<div
ref={barRef}
data-tauri-drag-region
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
data-title-hidden=""
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,

View File

@@ -19,7 +19,7 @@ const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => (
const commands: CommandAction[] = [
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find'] }),
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N' }),
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N' }),
makeCommand({ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'sync'] }),
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,' }),
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
@@ -47,7 +47,7 @@ describe('CommandPalette', () => {
it('shows all enabled commands grouped by category', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
expect(screen.getByText('Search Notes')).toBeInTheDocument()
expect(screen.getByText('Create New Note')).toBeInTheDocument()
expect(screen.getByText('New Note')).toBeInTheDocument()
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
expect(screen.getByText('Open Settings')).toBeInTheDocument()
// Disabled command should not appear
@@ -115,7 +115,7 @@ describe('CommandPalette', () => {
fireEvent.keyDown(window, { key: 'ArrowDown' })
fireEvent.keyDown(window, { key: 'Enter' })
// Second enabled command (Create New Note) should execute
// Second enabled command (New Note) should execute
expect(commands[1].execute).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
@@ -168,7 +168,7 @@ describe('CommandPalette', () => {
describe('relevance ranking', () => {
const relevanceCommands: CommandAction[] = [
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
]
@@ -182,24 +182,20 @@ describe('CommandPalette', () => {
).map(el => el.textContent)
}
it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => {
it('shows only the relevant raw command for query "raw"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
const labels = getVisibleLabels()
const rawIdx = labels.indexOf('Toggle Raw Editor')
const createIdx = labels.indexOf('Create New Note')
expect(rawIdx).toBeGreaterThanOrEqual(0)
expect(createIdx).toBeGreaterThanOrEqual(0)
expect(rawIdx).toBeLessThan(createIdx)
expect(labels).toEqual(['Toggle Raw Editor'])
})
it('ranks "Create New Note" first for query "new note"', () => {
it('ranks "New Note" first for query "new note"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Create New Note')
expect(labels[0]).toBe('New Note')
})
it('preserves default section order with empty query', () => {

View File

@@ -204,57 +204,6 @@
width: 100%;
}
/* --- Title Section: wraps icon + title + separator --- */
.title-section {
width: 100%;
flex-shrink: 0;
}
.title-section__heading {
position: relative;
}
.title-section__inline-add-icon {
position: absolute;
left: 8px;
top: 0;
z-index: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.title-section:hover .title-section__inline-add-icon,
.title-section__inline-add-icon:focus-within {
opacity: 1;
pointer-events: auto;
}
.title-section__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding-top: 8px;
margin-left: 8px;
}
/* No emoji: title aligns flush left (no indent for icon area) */
.title-section__row--no-icon {
margin-left: 0;
}
/* When emoji is present, restore top padding to the row itself */
.title-section__row:has(.note-icon-button--active) {
padding-top: 32px;
}
.title-section__separator {
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
margin-top: 12px;
margin-left: 8px;
}
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;
@@ -303,7 +252,6 @@
transition: opacity 0.15s;
}
.title-section:hover .note-icon-button--add,
.note-icon-button--add:focus-visible {
opacity: 1;
}
@@ -352,56 +300,6 @@
color: var(--destructive, #ef4444);
}
/* --- Title Field --- */
.title-field {
flex: 1;
min-width: 0;
}
.title-field__input {
display: block;
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--headings-h1-font-size, 32px);
font-weight: var(--headings-h1-font-weight, 700);
line-height: var(--headings-h1-line-height, 1.2);
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
color: var(--foreground);
padding: 0;
resize: none;
overflow: hidden;
font-family: inherit;
field-sizing: content;
}
.title-field__input::placeholder {
color: var(--text-faint, #ccc);
}
.title-field__input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.title-field__filename {
display: block;
font-size: 11px;
color: var(--text-faint, #999);
margin-top: 2px;
}
/* When the legacy title UI is shown, hide the first H1 heading in BlockNote
to avoid duplicate title display. Otherwise the editor's H1 remains visible
and serves as the title surface. */
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
display: none;
}
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
display: none;
}
/* =============================================
Disable BlockNote/Mantine editor animations
=============================================

View File

@@ -54,8 +54,6 @@ interface EditorProps {
onUnarchiveNote?: (path: string) => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
/** Called when the user edits the title in TitleField. */
onTitleSync?: (path: string, newTitle: string) => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
canGoBack?: boolean
@@ -205,7 +203,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync, onRenameFilename,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
@@ -256,7 +254,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}

View File

@@ -123,7 +123,7 @@ describe('NoteItem', () => {
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
const dateRow = screen.getByTestId('note-date-row')
expect(dateRow.className).toContain('justify-between')
expect(dateRow.className).toContain('grid')
expect(dateRow).toHaveTextContent('2d ago')
expect(dateRow).toHaveTextContent('Created 5d ago')
})

View File

@@ -163,7 +163,7 @@ function StandardNoteContent({
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="space-y-2 pr-5" data-testid="note-content-stack">
<div className="space-y-2" data-testid="note-content-stack">
<NoteTitleRow
entry={entry}
isBinary={isBinary}
@@ -206,7 +206,7 @@ function NoteTitleRow({
noteStatus: NoteStatus
}) {
return (
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
<div className={cn('truncate pr-5 text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
@@ -222,9 +222,9 @@ function NoteDateRow({ entry }: { entry: VaultEntry }) {
if (!modifiedLabel && !createdLabel) return null
return (
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<span>{modifiedLabel}</span>
{createdLabel && <span className="ml-auto">{createdLabel}</span>}
{createdLabel && <span className="justify-self-end text-right">{createdLabel}</span>}
</div>
)
}

View File

@@ -946,6 +946,14 @@ describe('Sidebar', () => {
expect(screen.getByText('My Favorite Note')).toBeInTheDocument()
})
it('preserves plain square brackets in favorite titles', () => {
const bracketedFavorite = { ...favEntry, title: '[26Q2] Tolaria MVP' }
render(<Sidebar entries={[...mockEntries, bracketedFavorite]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('[26Q2] Tolaria MVP')).toBeInTheDocument()
})
it('hides FAVORITES section when no favorites', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()

View File

@@ -1,168 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TitleField } from './TitleField'
describe('TitleField', () => {
it('renders the title in the input', () => {
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-input')).toHaveValue('My Note')
})
it('calls onTitleChange on blur with new value', () => {
const onChange = vi.fn()
render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'New Title' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith('New Title')
})
it('does not call onTitleChange if title unchanged', () => {
const onChange = vi.fn()
render(<TitleField title="Same Title" filename="same-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalled()
})
it('reverts to original title if input is emptied', () => {
const onChange = vi.fn()
render(<TitleField title="Keep This" filename="keep-this.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalled()
expect(input).toHaveValue('Keep This')
})
it('shows filename indicator when slug differs and title is focused', () => {
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
// Not shown when unfocused
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
// Shown when focused
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
})
it('does not show filename when slug matches and not editing', () => {
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('disables input when editable is false', () => {
render(<TitleField title="Read Only" filename="read-only.md" editable={false} onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-input')).toBeDisabled()
})
it('commits title on Enter key', () => {
const onChange = vi.fn()
render(<TitleField title="Before" filename="before.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'After' } })
fireEvent.keyDown(input, { key: 'Enter' })
// In jsdom, blur() after keyDown needs explicit blur event
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith('After')
})
it('reverts on Escape key', () => {
const onChange = vi.fn()
render(<TitleField title="Original" filename="original.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'Changed' } })
fireEvent.keyDown(input, { key: 'Escape' })
// Escape reverts value and blurs
expect(input).toHaveValue('Original')
})
it('shows new title optimistically after commit (before prop updates)', () => {
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'New Title' } })
fireEvent.blur(input)
// After commit, should show new title even though prop is still "Old Title"
expect(input).toHaveValue('New Title')
// After prop updates to match, should still show new title
rerender(<TitleField title="New Title" filename="new-title.md" onTitleChange={onChange} />)
expect(input).toHaveValue('New Title')
})
it('resets optimistic title when prop changes from external source', () => {
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Title A" filename="title-a.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
// Simulate external title change (e.g., tab switch)
rerender(<TitleField title="Title B" filename="title-b.md" onTitleChange={onChange} />)
expect(input).toHaveValue('Title B')
})
it('responds to laputa:focus-editor event with selectTitle', () => {
render(<TitleField title="Focus Me" filename="focus-me.md" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
expect(document.activeElement).toBe(input)
})
it('resets stale localValue when title prop changes after focus', () => {
// Regression: creating a new note fires focus-editor before React re-renders,
// so handleFocus captures the OLD note's title into localValue.
// When React re-renders with the new title, localValue should be cleared.
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
// Simulate: focus fires while title prop is still "Old Note"
fireEvent.focus(input)
expect(input).toHaveValue('Old Note')
// React re-renders with new note's title (tab switched)
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
expect(input).toHaveValue('Untitled note')
})
it('shows vault-relative path (without .md) only when title is focused', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
// Path hidden by default
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
// Focus title → path appears
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
// No bare filename shown when path is visible
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('hides path on blur', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
fireEvent.blur(input)
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path for notes at vault root even when focused', () => {
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path when vaultPath is not provided', () => {
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('resolves vault-relative path when paths differ by symlink prefix', () => {
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
})
it('handles vaultPath with trailing slash', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
})
})

View File

@@ -1,170 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { slugify } from '../hooks/useNoteCreation'
interface TitleFieldProps {
title: string
filename: string
editable?: boolean
/** Absolute path of the note file. */
notePath?: string
/** Absolute path of the vault root. */
vaultPath?: string
/** Called when the user finishes editing the title (blur or Enter). */
onTitleChange: (newTitle: string) => void
}
/** Manages local edit + optimistic title state for TitleField. */
function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
const [localValue, setLocalValue] = useState<string | null>(null)
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
const isFocusedRef = useRef(false)
const [prevTitle, setPrevTitle] = useState(title)
// Reset local edit when the title prop changes (e.g. note switch).
// This prevents a stale handleFocus closure from locking in the old note's title
// when focus-editor fires before React re-renders with the new tab.
if (prevTitle !== title) {
setPrevTitle(title)
if (localValue !== null) setLocalValue(null)
}
// Clear optimistic once the prop changes (rename completed or tab switched)
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
const value = localValue ?? optimisticValue ?? title
const isEditing = localValue !== null || optimisticValue !== null
const handleFocus = useCallback(() => {
isFocusedRef.current = true
setLocalValue(title)
}, [title])
const commitTitle = useCallback(() => {
isFocusedRef.current = false
const trimmed = (localValue ?? '').trim()
if (trimmed && trimmed !== title) {
setLocalValue(null)
setOptimistic([trimmed, title])
onTitleChange(trimmed)
} else {
setLocalValue(null)
}
}, [localValue, title, onTitleChange])
const revert = useCallback(() => setLocalValue(null), [])
const setEdit = useCallback((v: string) => setLocalValue(v), [])
return { value, isEditing, handleFocus, commitTitle, revert, setEdit }
}
/**
* Dedicated title input field above the editor.
* Displays the title as an editable field and shows the resulting filename below.
*/
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
const inputRef = useRef<HTMLTextAreaElement>(null)
const [isFocused, setIsFocused] = useState(false)
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
useOptimisticTitle(title, onTitleChange)
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
const autoResize = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}, [])
useEffect(() => {
autoResize()
// Schedule a second measurement after the browser has painted, to catch
// cases where scrollHeight is stale on the first read (e.g. tab switch).
const id = requestAnimationFrame(autoResize)
return () => cancelAnimationFrame(id)
}, [value, autoResize])
// Re-measure when container width changes (text may re-wrap)
useEffect(() => {
const el = inputRef.current
if (!el) return
const ro = new ResizeObserver(autoResize)
ro.observe(el)
return () => ro.disconnect()
}, [autoResize])
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail
if (detail?.selectTitle && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}
window.addEventListener('laputa:focus-editor', handler)
return () => window.removeEventListener('laputa:focus-editor', handler)
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
inputRef.current?.blur()
}
if (e.key === 'Escape') {
revert()
inputRef.current?.blur()
}
}, [revert])
const expectedSlug = slugify(value.trim() || title)
const currentStem = filename.replace(/\.md$/, '')
// Compute vault-relative path (only for notes in subdirectories)
const relativePath = (() => {
if (!notePath || !vaultPath) return null
const vp = vaultPath.replace(/\/+$/, '')
const np = notePath.replace(/\.md$/, '')
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
// Fallback: match by vault directory name for symlink-resolved paths
const vaultName = vp.split('/').pop()
if (!vaultName) return null
const segments = np.split('/')
const idx = segments.lastIndexOf(vaultName)
if (idx >= 0) return segments.slice(idx + 1).join('/')
return null
})()
const isSubdirectory = relativePath != null && relativePath.includes('/')
// Show path only when title is focused and note is in a subdirectory
const showRelativePath = isFocused && isSubdirectory
// Show filename hint when slug differs, but only when focused (not always)
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
return (
<div className="title-field" data-testid="title-field">
<textarea
ref={inputRef}
className="title-field__input"
value={value}
rows={1}
onChange={e => { setEdit(e.target.value); autoResize() }}
onFocus={() => { setIsFocused(true); handleFocus() }}
onBlur={() => { setIsFocused(false); commitTitle() }}
onKeyDown={handleKeyDown}
disabled={!editable}
placeholder="Untitled"
spellCheck={false}
data-testid="title-field-input"
/>
{showFilename && (
<span className="title-field__filename" data-testid="title-field-filename">
{expectedSlug}.md
</span>
)}
{showRelativePath && (
<span className="title-field__path" data-testid="title-field-path" style={{ display: 'block', fontSize: 11, color: 'var(--muted-foreground)', marginTop: 2 }}>
{relativePath}
</span>
)}
</div>
)
}

View File

@@ -7,18 +7,6 @@ vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
}))
vi.mock('../TitleField', () => ({
TitleField: () => <div data-testid="title-field-input" />,
}))
vi.mock('../NoteIcon', () => ({
NoteIcon: ({ icon }: { icon: string | null }) => (
icon
? <button type="button" data-testid="note-icon-display" />
: <button type="button" data-testid="note-icon-add" />
),
}))
vi.mock('../ArchivedNoteBanner', () => ({
ArchivedNoteBanner: () => <div data-testid="archived-banner" />,
}))
@@ -69,12 +57,7 @@ function createModel(overrides: Record<string, unknown> = {}) {
onKeepTheirs: vi.fn(),
breadcrumbBarRef: createRef<HTMLDivElement>(),
wordCount: 12,
titleSectionRef: createRef<HTMLDivElement>(),
showTitleSection: true,
hasDisplayIcon: false,
entryIcon: null,
vaultPath: '/vault',
onTitleChange: vi.fn(),
cssVars: {},
onNavigateWikilink: vi.fn(),
onEditorChange: vi.fn(),
@@ -95,27 +78,12 @@ function createModel(overrides: Record<string, unknown> = {}) {
}
describe('EditorContentLayout', () => {
it('does not render a standalone add-icon row when the note has no icon', () => {
it('never renders the legacy title section', () => {
const { container } = render(<EditorContentLayout {...createModel()} />)
expect(container.querySelector('.title-section__add-icon')).toBeNull()
expect(container.querySelector('.title-section__inline-add-icon')).not.toBeNull()
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
})
it('keeps the existing icon and title inside the same title row', () => {
const { container } = render(
<EditorContentLayout
{...createModel({
hasDisplayIcon: true,
entryIcon: 'rocket',
})}
/>,
)
const titleRow = container.querySelector('.title-section__row')
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
expect(container.querySelector('.title-section')).toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
expect(screen.getByTestId('single-editor-view')).toBeInTheDocument()
})
it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => {

View File

@@ -1,8 +1,6 @@
import type React from 'react'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { TitleField } from '../TitleField'
import { NoteIcon } from '../NoteIcon'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
import { ConflictNoteBanner } from '../ConflictNoteBanner'
import { RawEditorView } from '../RawEditorView'
@@ -99,14 +97,12 @@ function ActiveTabBreadcrumb({
barRef,
wordCount,
path,
showTitleSection,
actions,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
wordCount: number
path: string
showTitleSection: boolean
actions: BreadcrumbActions
}) {
return (
@@ -114,7 +110,6 @@ function ActiveTabBreadcrumb({
entry={activeTab.entry}
wordCount={wordCount}
barRef={barRef}
showTitleSection={showTitleSection}
showDiffToggle={actions.showDiffToggle}
diffMode={actions.diffMode}
diffLoading={actions.diffLoading}
@@ -136,50 +131,6 @@ function ActiveTabBreadcrumb({
)
}
function TitleSection({
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
}: Pick<
EditorContentModel,
'activeTab' | 'entryIcon' | 'hasDisplayIcon' | 'path' | 'showTitleSection' | 'titleSectionRef' | 'vaultPath' | 'onTitleChange'
>) {
if (!activeTab) return null
return (
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
{showTitleSection && (
<>
<div className="title-section__heading">
{!hasDisplayIcon && (
<div className="title-section__inline-add-icon">
<NoteIcon icon={null} editable />
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
</div>
<div className="title-section__separator" />
</>
)}
</div>
)
}
function EditorChrome({
isArchived,
onUnarchiveNote,
@@ -213,52 +164,28 @@ function EditorChrome({
function EditorCanvas({
showEditor,
cssVars,
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
editor,
entries,
onNavigateWikilink,
onEditorChange,
isDeletedPreview,
vaultPath,
}: Pick<
EditorContentModel,
| 'showEditor'
| 'cssVars'
| 'activeTab'
| 'entryIcon'
| 'hasDisplayIcon'
| 'path'
| 'showTitleSection'
| 'titleSectionRef'
| 'vaultPath'
| 'onTitleChange'
| 'editor'
| 'entries'
| 'onNavigateWikilink'
| 'onEditorChange'
| 'isDeletedPreview'
| 'vaultPath'
>) {
if (!showEditor) return null
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
@@ -293,12 +220,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onKeepTheirs,
breadcrumbBarRef,
wordCount,
titleSectionRef,
showTitleSection,
hasDisplayIcon,
entryIcon,
vaultPath,
onTitleChange,
cssVars,
onNavigateWikilink,
onEditorChange,
@@ -321,7 +243,6 @@ export function EditorContentLayout(model: EditorContentModel) {
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
showTitleSection={showTitleSection}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
@@ -365,14 +286,7 @@ export function EditorContentLayout(model: EditorContentModel) {
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -46,47 +46,47 @@ function deriveState(tab: EditorContentTab | null, overrides?: Partial<VaultEntr
}
describe('deriveEditorContentState', () => {
it('hides the legacy title section when loaded content contains a top-level H1', () => {
it('marks loaded content with a top-level H1 as titled', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Legacy Project\n---\n# Legacy Project\n\nBody',
})
expect(state.hasH1).toBe(true)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('keeps the title section for notes without an H1', () => {
it('keeps editor content visible for notes without an H1', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('hides the legacy title section when a frontmatter title drives the display title', () => {
it('keeps editor content visible when a legacy frontmatter title exists', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('keeps the title section when the document title still comes from the filename', () => {
it('does not fall back to a separate title section when the filename drives the display title', () => {
const state = deriveState({
entry: baseEntry,
content: '---\nstatus: Active\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(true)
expect(state.showEditor).toBe(true)
})
it('hides the title section for untitled drafts before they get an H1', () => {
it('keeps untitled drafts in the editor even before they get an H1', () => {
const draftEntry = {
...baseEntry,
path: '/vault/untitled-note-1700000000.md',
@@ -105,6 +105,6 @@ describe('deriveEditorContentState', () => {
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
})

View File

@@ -1,5 +1,5 @@
import type { NoteStatus, VaultEntry } from '../../types'
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
import { extractH1TitleFromContent } from '../../utils/noteTitle'
import { countWords } from '../../utils/wikilinks'
export interface EditorContentTab {
@@ -14,17 +14,11 @@ interface EditorContentStateInput {
activeStatus: NoteStatus
}
interface TitleSectionState {
hasDisplayTitle: boolean
hasH1: boolean
}
interface VisibilityState {
effectiveRawMode: boolean
isDeletedPreview: boolean
isNonMarkdownText: boolean
showEditor: boolean
showTitleSection: boolean
}
export interface EditorContentState {
@@ -35,7 +29,6 @@ export interface EditorContentState {
isNonMarkdownText: boolean
effectiveRawMode: boolean
showEditor: boolean
showTitleSection: boolean
path: string
wordCount: number
}
@@ -49,38 +42,18 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
}
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
}
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
}
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
return hasH1 || contentDefinesTitle(activeTab)
}
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
const hasH1 = resolveHasH1(activeTab, freshEntry)
return {
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
hasH1,
}
}
function deriveVisibilityState(input: {
activeStatus: NoteStatus
activeTab: EditorContentTab | null
freshEntry: VaultEntry | undefined
hasDisplayTitle: boolean
rawMode: boolean
}): VisibilityState {
const {
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle,
rawMode,
} = input
const isDeletedPreview = !!activeTab && !freshEntry
@@ -92,36 +65,23 @@ function deriveVisibilityState(input: {
isNonMarkdownText,
effectiveRawMode,
showEditor: !effectiveRawMode,
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
}
}
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
if (!activeTab) return false
if (!activeTab.entry.filename.startsWith('untitled-')) return false
return activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave'
}
export function deriveEditorContentState({
activeTab,
entries,
rawMode,
activeStatus,
}: EditorContentStateInput): EditorContentState {
export function deriveEditorContentState(input: EditorContentStateInput): EditorContentState {
const { activeTab, entries, rawMode } = input
const freshEntry = findFreshEntry(activeTab, entries)
const titleState = deriveTitleSectionState(activeTab, freshEntry)
const hasH1 = resolveHasH1(activeTab, freshEntry)
const visibilityState = deriveVisibilityState({
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle: titleState.hasDisplayTitle,
rawMode,
})
return {
freshEntry,
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
hasH1: titleState.hasH1,
hasH1,
...visibilityState,
path: activeTab?.entry.path ?? '',
wordCount: activeTab ? countWords(activeTab.content) : 0,

View File

@@ -1,9 +1,8 @@
import type React from 'react'
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { NoteStatus, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { deriveEditorContentState } from './editorContentState'
export interface Tab {
@@ -39,61 +38,17 @@ export interface EditorContentProps {
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawLatestContentRef?: React.MutableRefObject<string | null>
onTitleChange?: (path: string, newTitle: string) => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
}
function useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
}: {
showEditor: boolean
showTitleSection: boolean
path: string
breadcrumbBarRef: React.RefObject<HTMLDivElement | null>
titleSectionRef: React.RefObject<HTMLDivElement | null>
}) {
useEffect(() => {
if (!showEditor) return
const bar = breadcrumbBarRef.current
const titleSection = titleSectionRef.current
if (!bar || !titleSection) return
if (!showTitleSection) {
bar.setAttribute('data-title-hidden', '')
return () => {
bar.removeAttribute('data-title-hidden')
}
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(titleSection)
return () => {
observer.disconnect()
bar.removeAttribute('data-title-hidden')
}
}, [path, showEditor, showTitleSection, breadcrumbBarRef, titleSectionRef])
}
export function useEditorContentModel(props: EditorContentProps) {
const {
activeTab,
entries,
rawMode,
activeStatus,
diffMode,
} = props
@@ -105,29 +60,17 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
showEditor: showContentEditor,
path,
showTitleSection,
wordCount,
} = deriveEditorContentState({
activeTab,
entries,
rawMode,
activeStatus,
activeStatus: props.activeStatus,
})
const showEditor = !diffMode && showContentEditor
const entryIcon = activeTab?.entry.icon ?? null
const hasDisplayIcon = resolveNoteIcon(entryIcon).kind !== 'none'
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
})
return {
...props,
cssVars,
@@ -136,11 +79,7 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
forceRawMode: isNonMarkdownText || isDeletedPreview,
showEditor,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
breadcrumbBarRef,
wordCount,
}

View File

@@ -51,8 +51,27 @@ export type AppCommandShortcutCombo =
| 'command-or-ctrl'
| 'command-or-ctrl-shift'
| 'command-shift'
export type AppCommandDeterministicQaMode =
| 'renderer-shortcut-event'
| 'native-menu-command'
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
export interface AppCommandDeterministicQaDefinition {
preferredMode: AppCommandDeterministicQaMode
supportsRendererShortcutEvent: boolean
supportsNativeMenuCommand: boolean
requiresManualNativeAcceleratorQa: boolean
}
export interface AppCommandShortcutEventOptions {
preferControl?: boolean
}
export type AppCommandShortcutEventInit = Pick<
KeyboardEventInit,
'altKey' | 'bubbles' | 'cancelable' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'
>
type SimpleHandlerKey =
| 'onOpenSettings'
| 'onCheckForUpdates'
@@ -109,6 +128,7 @@ export interface AppCommandDefinition {
route: AppCommandRoute
menuOwned: boolean
shortcut?: AppCommandShortcutDefinition
preferredShortcutQaMode?: AppCommandDeterministicQaMode
}
export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition> = {
@@ -177,6 +197,7 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
[APP_COMMAND_IDS.viewToggleProperties]: {
route: { kind: 'handler', handler: 'onToggleInspector' },
menuOwned: true,
preferredShortcutQaMode: 'renderer-shortcut-event',
shortcut: { combo: 'command-or-ctrl-shift', key: 'i', code: 'KeyI', display: '⌘⇧I' },
},
[APP_COMMAND_IDS.viewToggleAiChat]: {
@@ -312,6 +333,19 @@ const NATIVE_MENU_COMMAND_SET = new Set<string>(
.map(([id]) => id),
)
const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set<AppCommandId>([
APP_COMMAND_IDS.appSettings,
APP_COMMAND_IDS.fileNewNote,
APP_COMMAND_IDS.fileDailyNote,
APP_COMMAND_IDS.fileQuickOpen,
APP_COMMAND_IDS.fileSave,
APP_COMMAND_IDS.editFindInVault,
APP_COMMAND_IDS.viewToggleAiChat,
APP_COMMAND_IDS.viewCommandPalette,
APP_COMMAND_IDS.noteToggleOrganized,
APP_COMMAND_IDS.noteToggleFavorite,
])
const shortcutKeyMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
@@ -353,6 +387,43 @@ export function isNativeMenuCommandId(value: string): value is AppCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function getDeterministicShortcutQaDefinition(
id: AppCommandId,
): AppCommandDeterministicQaDefinition | null {
const definition = APP_COMMAND_DEFINITIONS[id]
if (!definition.shortcut) return null
return {
preferredMode:
definition.preferredShortcutQaMode
?? (definition.menuOwned ? 'native-menu-command' : 'renderer-shortcut-event'),
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: definition.menuOwned,
requiresManualNativeAcceleratorQa: MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET.has(id),
}
}
export function getShortcutEventInit(
id: AppCommandId,
options: AppCommandShortcutEventOptions = {},
): AppCommandShortcutEventInit | null {
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
if (!shortcut) return null
const useControl = options.preferControl ?? false
return {
key: shortcut.key,
code: shortcut.code,
altKey: false,
bubbles: true,
cancelable: true,
ctrlKey: useControl,
metaKey: !useControl,
shiftKey: shortcut.combo !== 'command-or-ctrl',
}
}
export function shortcutCombosForEvent({
altKey,
ctrlKey,

View File

@@ -10,6 +10,10 @@ import {
resetAppCommandDispatchStateForTests,
type AppCommandHandlers,
} from './appCommandDispatcher'
import {
getDeterministicShortcutQaDefinition,
getShortcutEventInit,
} from './appCommandCatalog'
function makeHandlers(): AppCommandHandlers {
return {
@@ -73,6 +77,45 @@ describe('appCommandDispatcher', () => {
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
it('gives every shortcut command an explicit deterministic QA strategy', () => {
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.fileNewNote)).toMatchObject({
preferredMode: 'native-menu-command',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: true,
requiresManualNativeAcceleratorQa: true,
})
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.viewToggleProperties)).toMatchObject({
preferredMode: 'renderer-shortcut-event',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: true,
requiresManualNativeAcceleratorQa: false,
})
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.noteToggleFavorite)).toMatchObject({
preferredMode: 'renderer-shortcut-event',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: false,
requiresManualNativeAcceleratorQa: true,
})
})
it('builds deterministic keyboard events from the shared shortcut manifest', () => {
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat)).toMatchObject({
key: 'l',
code: 'KeyL',
metaKey: true,
ctrlKey: false,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat, { preferControl: true })).toMatchObject({
key: 'l',
code: 'KeyL',
metaKey: false,
ctrlKey: true,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.appCheckForUpdates)).toBeNull()
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({

View File

@@ -35,7 +35,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
} = config
return [
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'create', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },

View File

@@ -7,6 +7,15 @@ const PLURAL_OVERRIDES: Record<string, string> = {
}
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
const DEFAULT_TYPE_CANONICAL_CASE = new Map(
DEFAULT_TYPES.map(type => [type.toLowerCase(), type] as const),
)
function canonicalizeTypeName(type: string): string | null {
const trimmedType = type.trim()
if (!trimmedType) return null
return DEFAULT_TYPE_CANONICAL_CASE.get(trimmedType.toLowerCase()) ?? trimmedType
}
export function pluralizeType(type: string): string {
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
@@ -16,16 +25,26 @@ export function pluralizeType(type: string): string {
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
const typeMap = new Map<string, string>()
for (const e of entries) {
if (e.isA === 'Type' && e.title) {
typeSet.add(e.title)
} else if (e.isA && e.isA !== 'Type') {
typeSet.add(e.isA)
const rawType =
e.isA === 'Type'
? e.title
: e.isA && e.isA !== 'Type'
? e.isA
: null
if (!rawType) continue
const canonicalType = canonicalizeTypeName(rawType)
if (!canonicalType) continue
const typeKey = canonicalType.toLowerCase()
if (!typeMap.has(typeKey)) {
typeMap.set(typeKey, canonicalType)
}
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()
if (typeMap.size === 0) return DEFAULT_TYPES
return Array.from(typeMap.values()).sort()
}
export function buildTypeCommands(
@@ -34,19 +53,27 @@ export function buildTypeCommands(
onSelect: (sel: SidebarSelection) => void,
): CommandAction[] {
return types.flatMap((type) => {
const slug = type.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(type)
return [
{
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as const,
keywords: ['new', 'create', type.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(type),
},
{
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as const,
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
},
]
const canonicalType = canonicalizeTypeName(type)
if (!canonicalType) return []
const slug = canonicalType.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(canonicalType)
const commands: CommandAction[] = []
if (canonicalType.toLowerCase() !== 'note') {
commands.push({
id: `new-${slug}`, label: `New ${canonicalType}`, group: 'Note' as const,
keywords: ['new', 'create', canonicalType.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(canonicalType),
})
}
commands.push({
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as const,
keywords: ['list', 'show', 'filter', canonicalType.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type: canonicalType }),
})
return commands
})
}

View File

@@ -0,0 +1,85 @@
const EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"], .ProseMirror[contenteditable="true"]'
const MAX_FOCUS_ATTEMPTS = 12
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
}
export interface TiptapEditor {
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
chain: () => TiptapChain
}
export interface FocusableEditor {
focus: () => void
_tiptapEditor?: TiptapEditor
}
/** Select all text in the first heading block via the TipTap chain API. */
function selectFirstHeading(editor: FocusableEditor): void {
const tiptap = editor._tiptapEditor
if (!tiptap?.state?.doc) return
let from = -1
let to = -1
tiptap.state.doc.descendants((node, pos) => {
if (from !== -1) return false
if (node.type.name === 'heading') {
from = pos + 1
to = pos + node.nodeSize - 1
return false
}
})
if (from === -1 || from >= to) return
tiptap.chain().setTextSelection({ from, to }).run()
}
function hasEditableFocus(): boolean {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}
function focusEditableNode(): boolean {
const editable = document.querySelector<HTMLElement>(EDITABLE_SELECTOR)
if (!editable) return false
editable.focus()
return true
}
function logFocusTiming(t0: number | undefined, label: 'focus' | 'focus+select'): void {
if (!t0) return
console.debug(`[perf] createNote → ${label}: ${(performance.now() - t0).toFixed(1)}ms`)
}
export function focusEditorWithRetries(
editor: FocusableEditor,
selectTitle: boolean,
t0: number | undefined,
attempt = 0,
): void {
editor.focus()
if (!hasEditableFocus()) {
focusEditableNode()
}
if (!hasEditableFocus() && attempt < MAX_FOCUS_ATTEMPTS) {
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0, attempt + 1))
return
}
if (!selectTitle) {
logFocusTiming(t0, 'focus')
return
}
// Defer selection to the next animation frame so the new note's content
// (applied via queueMicrotask inside a React effect triggered by the tab
// change) is in the document before we try to select the heading.
// Between two rAF callbacks, all pending macrotasks — including React's
// MessageChannel re-render and the subsequent queueMicrotask content swap
// — complete, so the heading block is guaranteed to exist by rAF 2.
requestAnimationFrame(() => {
selectFirstHeading(editor)
logFocusTiming(t0, 'focus+select')
})
}

View File

@@ -157,6 +157,18 @@ describe('useAppKeyboard', () => {
expect(actions.onArchiveNote).not.toHaveBeenCalled()
})
it('Cmd+E still works when editor focus stops propagation', () => {
const actions = makeActions()
const onToggleOrganized = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleOrganized }))
withFocusedContentEditable((editable) => {
editable.addEventListener('keydown', (event) => event.stopPropagation())
fireKeyOnTarget(editable, 'e', { metaKey: true })
expect(onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
expect(actions.onArchiveNote).not.toHaveBeenCalled()
})
})
it('Cmd+J triggers open daily note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -219,6 +219,24 @@ describe('useCommandRegistry', () => {
cmd!.execute()
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
it('keeps a single canonical New Note command when generic note types are present', () => {
const config = makeConfig({
entries: [
{ path: '/type-note.md', title: 'Note', isA: 'Type' },
{ path: '/lowercase-note.md', title: 'lowercase-note', isA: 'note' },
],
})
const { result } = renderHook(() => useCommandRegistry(config))
const newNoteCommands = result.current.filter(command => command.label.toLowerCase() === 'new note')
expect(newNoteCommands).toHaveLength(1)
expect(newNoteCommands[0]).toMatchObject({
id: 'create-note',
shortcut: '⌘N',
})
})
})
describe('pluralizeType', () => {
@@ -274,6 +292,15 @@ describe('extractVaultTypes', () => {
expect(types).toHaveLength(2)
})
it('deduplicates default types case-insensitively and keeps canonical casing', () => {
const entries = [
{ path: '/note-type.md', title: 'note', isA: 'Type' },
{ path: '/note-instance.md', title: 'Example', isA: 'Note' },
{ path: '/project-instance.md', title: 'Project Plan', isA: 'project' },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Note', 'Project'])
})
})
describe('groupSortKey', () => {
@@ -396,4 +423,16 @@ describe('buildTypeCommands', () => {
expect(commands[2].id).toBe('new-event')
expect(commands[3].id).toBe('list-event')
})
it('omits the generic Note create command while keeping navigation for notes', () => {
const onCreateNoteOfType = vi.fn()
const onSelect = vi.fn()
const commands = buildTypeCommands(['Note', 'Project'], onCreateNoteOfType, onSelect)
expect(commands.map(command => command.id)).toEqual([
'list-note',
'new-project',
'list-project',
])
})
})

View File

@@ -16,14 +16,21 @@ function makeTiptapMock(hasHeading = true) {
}
describe('useEditorFocus', () => {
afterEach(() => { vi.restoreAllMocks() })
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
const editor = { focus: vi.fn(() => editable.focus()), _tiptapEditor: tiptap } as any
const mountedRef = { current: isMounted }
renderHook(() => useEditorFocus(editor, mountedRef))
return { editor, tiptap }
return { editor, tiptap, editable }
}
it('focuses editor via rAF when already mounted', async () => {
@@ -84,8 +91,12 @@ describe('useEditorFocus', () => {
})
it('cleans up event listener on unmount', () => {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn() } as any
const editor = { focus: vi.fn(() => editable.focus()) } as any
const mountedRef = { current: true }
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
@@ -96,6 +107,25 @@ describe('useEditorFocus', () => {
expect(editor.focus).not.toHaveBeenCalled()
})
it('falls back to focusing the editable DOM node when editor.focus does not make it active', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const editable = document.createElement('div')
editable.className = 'ProseMirror'
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
const editableFocus = vi.spyOn(editable, 'focus')
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn(), _tiptapEditor: undefined } as any
const mountedRef = { current: true }
renderHook(() => useEditorFocus(editor, mountedRef))
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
expect(editor.focus).toHaveBeenCalled()
expect(editableFocus).toHaveBeenCalled()
})
describe('selectTitle behavior', () => {
it('selects H1 text when selectTitle is true and editor is mounted', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -1,38 +1,54 @@
import { useEffect } from 'react'
import { focusEditorWithRetries, type FocusableEditor } from './editorFocusUtils'
const TAB_SWAP_EVENT_NAME = 'laputa:editor-tab-swapped'
const FOCUS_EVENT_NAME = 'laputa:focus-editor'
const SWAP_WAIT_FALLBACK_MS = 250
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
interface FocusEventDetail {
t0?: number
selectTitle?: boolean
path?: string | null
}
interface TiptapEditor {
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
chain: () => TiptapChain
function scheduleEditorFocus(
editor: FocusableEditor,
editorMountedRef: React.RefObject<boolean>,
selectTitle: boolean,
t0: number | undefined,
): void {
if (editorMountedRef.current) {
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0))
return
}
setTimeout(() => focusEditorWithRetries(editor, selectTitle, t0), 80)
}
/** Select all text in the first heading block via the TipTap chain API. */
function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
const tiptap = editor._tiptapEditor
if (!tiptap?.state?.doc) return
function registerPendingTabFocus(
targetPath: string,
scheduleFocus: () => void,
pendingCleanups: Set<() => void>,
): void {
const handleTabSwap = (event: Event) => {
const swapPath = (event as CustomEvent).detail?.path
if (swapPath !== targetPath) return
cleanupPending()
scheduleFocus()
}
let from = -1
let to = -1
const fallbackTimer = window.setTimeout(() => {
cleanupPending()
scheduleFocus()
}, SWAP_WAIT_FALLBACK_MS)
tiptap.state.doc.descendants((node, pos) => {
if (from !== -1) return false
if (node.type.name === 'heading') {
from = pos + 1
to = pos + node.nodeSize - 1
return false
}
})
const cleanupPending = () => {
window.clearTimeout(fallbackTimer)
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
pendingCleanups.delete(cleanupPending)
}
if (from === -1 || from >= to) return
tiptap.chain().setTextSelection({ from, to }).run()
pendingCleanups.add(cleanupPending)
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
}
/**
@@ -42,68 +58,24 @@ function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
* When selectTitle is true, also selects all text in the first H1 block.
*/
export function useEditorFocus(
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
editor: FocusableEditor,
editorMountedRef: React.RefObject<boolean>,
) {
useEffect(() => {
const pendingCleanups = new Set<() => void>()
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean; path?: string | null } | undefined
const detail = (e as CustomEvent).detail as FocusEventDetail | undefined
const t0 = detail?.t0
const selectTitle = detail?.selectTitle ?? false
const targetPath = detail?.path ?? null
const doFocus = () => {
editor.focus()
if (!selectTitle) {
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
return
}
// Defer selection to the next animation frame so the new note's content
// (applied via queueMicrotask inside a React effect triggered by the tab
// change) is in the document before we try to select the heading.
// Between two rAF callbacks, all pending macrotasks — including React's
// MessageChannel re-render and the subsequent queueMicrotask content swap
// — complete, so the heading block is guaranteed to exist by rAF 2.
requestAnimationFrame(() => {
selectFirstHeading(editor)
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
})
}
const scheduleFocus = () => {
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)
return
}
setTimeout(doFocus, 80)
}
const scheduleFocus = () => scheduleEditorFocus(editor, editorMountedRef, selectTitle, t0)
if (!targetPath) {
scheduleFocus()
return
}
const handleTabSwap = (event: Event) => {
const swapPath = (event as CustomEvent).detail?.path
if (swapPath !== targetPath) return
cleanupPending()
scheduleFocus()
}
const fallbackTimer = window.setTimeout(() => {
cleanupPending()
scheduleFocus()
}, SWAP_WAIT_FALLBACK_MS)
const cleanupPending = () => {
window.clearTimeout(fallbackTimer)
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
pendingCleanups.delete(cleanupPending)
}
pendingCleanups.add(cleanupPending)
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
registerPendingTabFocus(targetPath, scheduleFocus, pendingCleanups)
}
window.addEventListener(FOCUS_EVENT_NAME, handler)

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
import {
APP_COMMAND_EVENT_NAME,
executeAppCommand,
@@ -11,8 +12,10 @@ declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}

View File

@@ -8,13 +8,20 @@ import {
isAppCommandId,
isNativeMenuCommandId,
} from './hooks/appCommandDispatcher'
import {
getShortcutEventInit,
type AppCommandShortcutEventInit,
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
@@ -27,6 +34,15 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
}
function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) {
const target =
document.activeElement instanceof HTMLElement
? document.activeElement
: document.body ?? window
target.dispatchEvent(new KeyboardEvent('keydown', init))
}
window.__laputaTest = {
dispatchAppCommand(id: string) {
if (!isAppCommandId(id)) {
@@ -34,6 +50,9 @@ window.__laputaTest = {
}
window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, { detail: id }))
},
dispatchShortcutEvent(init: AppCommandShortcutEventInit) {
dispatchDeterministicShortcutEvent(init)
},
async triggerMenuCommand(id: string) {
if (!isNativeMenuCommandId(id)) {
throw new Error(`Unknown native menu command: ${id}`)
@@ -51,6 +70,18 @@ window.__laputaTest = {
window.__laputaTest.dispatchBrowserMenuCommand(id)
return undefined
},
triggerShortcutCommand(id: string, options?: AppCommandShortcutEventOptions) {
if (!isAppCommandId(id)) {
throw new Error(`Unknown app command: ${id}`)
}
const init = getShortcutEventInit(id, options)
if (!init) {
throw new Error(`Command ${id} does not define a keyboard shortcut`)
}
dispatchDeterministicShortcutEvent(init)
},
}
createRoot(document.getElementById('root')!).render(

View File

@@ -24,6 +24,11 @@ describe('extractH1TitleFromContent', () => {
expect(extractH1TitleFromContent(content)).toBe('Bold Link and code')
})
it('preserves plain square brackets in the H1', () => {
const content = '# [26Q2] Tolaria MVP'
expect(extractH1TitleFromContent(content)).toBe('[26Q2] Tolaria MVP')
})
it('returns null when the first non-empty line is not an H1', () => {
expect(extractH1TitleFromContent('Body first\n# Not the title')).toBeNull()
})
@@ -82,4 +87,12 @@ describe('deriveDisplayTitleState', () => {
hasH1: false,
})
})
it('keeps plain square brackets when deriving the display title from H1', () => {
const content = '# [26Q2] Tolaria MVP\n\nBody'
expect(deriveDisplayTitleState({ content, filename: 'tolaria-mvp.md' })).toEqual({
title: '[26Q2] Tolaria MVP',
hasH1: true,
})
})
})

View File

@@ -5,6 +5,8 @@ import path from 'path'
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
const FIXTURE_VAULT_REMOVE_RETRIES = 10
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true })
@@ -27,7 +29,12 @@ export function createFixtureVaultCopy(): string {
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
if (!tempVaultDir) return
fs.rmSync(tempVaultDir, { recursive: true, force: true })
fs.rmSync(tempVaultDir, {
recursive: true,
force: true,
maxRetries: FIXTURE_VAULT_REMOVE_RETRIES,
retryDelay: FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS,
})
}
export async function openFixtureVault(
@@ -113,7 +120,14 @@ export async function openFixtureVault(
})
}
export async function openFixtureVaultTauri(
/**
* Browser harness for desktop command-routing tests.
*
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
* renderer shortcut dispatch and desktop menu-command dispatch without a native
* shell. It is deterministic, but it is not a substitute for real native QA.
*/
export async function openFixtureVaultDesktopHarness(
page: Page,
vaultPath: string,
): Promise<void> {
@@ -122,6 +136,116 @@ export async function openFixtureVaultTauri(
const jsonHeaders = { 'Content-Type': 'application/json' }
const nativeFetch = window.fetch.bind(window)
const FRONTMATTER_OPEN = '---\n'
const FRONTMATTER_CLOSE = '\n---\n'
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const splitFrontmatter = (content: string) => {
if (!content.startsWith(FRONTMATTER_OPEN)) {
return { frontmatter: null as string | null, body: content }
}
const closeIndex = content.indexOf(FRONTMATTER_CLOSE, FRONTMATTER_OPEN.length)
if (closeIndex === -1) {
return { frontmatter: null as string | null, body: content }
}
return {
frontmatter: content.slice(FRONTMATTER_OPEN.length, closeIndex),
body: content.slice(closeIndex + FRONTMATTER_CLOSE.length),
}
}
const splitFrontmatterEntries = (frontmatter: string) => {
const lines = frontmatter.split('\n')
const entries: Array<{ key: string; lines: string[] }> = []
let current: { key: string; lines: string[] } | null = null
for (const line of lines) {
const match = line.match(/^([^:\n]+):(.*)$/)
if (match && !line.startsWith(' ')) {
if (current) entries.push(current)
current = { key: match[1].trim(), lines: [line] }
continue
}
if (current) {
current.lines.push(line)
} else if (line.trim() !== '') {
current = { key: '', lines: [line] }
}
}
if (current) entries.push(current)
return entries
}
const serializeFrontmatterValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
if (value.length === 0) return ['[]']
return [''].concat(value.map((item) => ` - ${JSON.stringify(String(item))}`))
}
if (typeof value === 'boolean' || typeof value === 'number') {
return [String(value)]
}
return [JSON.stringify(String(value ?? ''))]
}
const replaceFrontmatterEntry = (content: string, key: string, value: unknown) => {
const { frontmatter, body } = splitFrontmatter(content)
const entryLines = serializeFrontmatterValue(value)
const nextEntryLines =
entryLines[0] === ''
? [`${key}:`, ...entryLines.slice(1)]
: [`${key}: ${entryLines[0]}`]
if (frontmatter === null) {
return `${FRONTMATTER_OPEN}${nextEntryLines.join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const entries = splitFrontmatterEntries(frontmatter).filter((entry) => entry.key !== '')
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
let replaced = false
const nextEntries = entries.map((entry) => {
if (!keyPattern.test(entry.key)) return entry
replaced = true
return { key, lines: nextEntryLines }
})
if (!replaced) {
nextEntries.push({ key, lines: nextEntryLines })
}
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const removeFrontmatterEntry = (content: string, key: string) => {
const { frontmatter, body } = splitFrontmatter(content)
if (frontmatter === null) return content
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
const nextEntries = splitFrontmatterEntries(frontmatter)
.filter((entry) => entry.key !== '' && !keyPattern.test(entry.key))
if (nextEntries.length === 0) {
return body
}
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const persistFrontmatterChange = async (path: string, transform: (content: string) => string) => {
const current = await readJson(`/api/vault/content?path=${encodeURIComponent(path)}`) as { content: string }
const updatedContent = transform(current.content)
await readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ path, content: updatedContent }),
})
return updatedContent
}
const readJson = async (url: string, init?: RequestInit) => {
const response = await nativeFetch(url, init)
if (!response.ok) {
@@ -199,6 +323,16 @@ export async function openFixtureVaultTauri(
headers: jsonHeaders,
body: JSON.stringify({ path: args?.path, content: args?.content }),
})
case 'update_frontmatter':
return persistFrontmatterChange(
String(args?.path ?? ''),
(content) => replaceFrontmatterEntry(content, String(args?.key ?? ''), args?.value),
)
case 'delete_frontmatter_property':
return persistFrontmatterChange(
String(args?.path ?? ''),
(content) => removeFrontmatterEntry(content, String(args?.key ?? '')),
)
case 'rename_note':
return readJson('/api/vault/rename', {
method: 'POST',
@@ -261,3 +395,5 @@ export async function openFixtureVaultTauri(
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
}
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness

View File

@@ -56,8 +56,7 @@ test('vault loads entries from fixture files @smoke', async ({ page }) => {
// Open a note and verify editor shows its content from disk
await openNote(page, 'Alpha Project')
// Verify the stable title field rather than the editor heading rendering.
await expect(page.getByTestId('title-field-input')).toHaveValue('Alpha Project', { timeout: 5_000 })
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
})
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,26 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette } from './helpers'
test.describe('Command palette new note command dedupe', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-testid="sidebar-top-nav"]')).toBeVisible({ timeout: 10_000 })
})
test('searching new note shows one canonical New Note command with Cmd+N', async ({ page }) => {
await openCommandPalette(page)
const commandInput = page.locator('input[placeholder="Type a command..."]')
await commandInput.fill('new note')
const newNoteRow = page
.locator('div.mx-1.flex.cursor-pointer')
.filter({ has: page.getByText('New Note', { exact: true }) })
await expect(newNoteRow).toHaveCount(1)
await expect(newNoteRow.getByText('⌘N', { exact: true })).toBeVisible()
await expect(page.getByText('Create New Note', { exact: true })).toHaveCount(0)
await expect(page.getByText('New note', { exact: true })).toHaveCount(0)
})
})

View File

@@ -30,12 +30,10 @@ test('create note via sidebar + button does not crash', async ({ page }) => {
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
await page.waitForTimeout(500)
const plusButtons = page.locator('button[aria-label*="Create new"]')
if (await plusButtons.count() > 0) {
await plusButtons.first().click()
await page.waitForTimeout(2000)
}
await page.locator('button[title="Create new note"]').first().click()
await page.waitForTimeout(2000)
expect(errors).toHaveLength(0)
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i)
})

View File

@@ -1,100 +0,0 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */
const KNOWN_EDITOR_ERRORS = ['isConnected']
function isKnownEditorError(msg: string): boolean {
return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k))
}
/**
* Helper: create a new note and rename it via TitleField.
*/
async function createNoteWithTitle(page: import('@playwright/test').Page, title: string) {
// 1. Cmd+N → new "Untitled note"
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// 2. Edit the title via TitleField
const titleInput = page.getByTestId('title-field-input')
await titleInput.waitFor({ timeout: 3000 })
await titleInput.click()
await titleInput.fill(title)
await titleInput.press('Enter')
// 3. Wait for async rename to complete
await page.waitForTimeout(1000)
}
test.describe('Note filename updates on title change + save', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Wait for startup toast to dismiss
await page.waitForTimeout(2500)
})
test('Cmd+N creates untitled note, editing TitleField triggers rename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
await createNoteWithTitle(page, 'Test Note ABC')
// The toast should show "Renamed" after the TitleField commit
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Test Note ABC', { timeout: 2000 })
// Cmd+S should NOT trigger another rename (filename already matches)
await sendShortcut(page, 's', ['Control'])
await page.waitForTimeout(500)
// No unexpected JS errors
expect(errors).toEqual([])
})
test('saving a note whose filename already matches does not trigger rename', async ({ page }) => {
// Click "All Notes" to show all notes regardless of section grouping
const allNotes = page.locator('text=All Notes').first()
await allNotes.click()
await page.waitForTimeout(500)
// Click on the first note in the note list
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const noteItem = noteListContainer.locator('.cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
// Cmd+S — should show "Saved" or "Nothing to save", NOT "Renamed"
await sendShortcut(page, 's', ['Control'])
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toBeVisible({ timeout: 3000 })
const toastText = await toast.textContent()
expect(toastText).not.toContain('Renamed')
})
test('rapid TitleField edits only rename to final title', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
await createNoteWithTitle(page, 'First Title')
// Edit TitleField again with final title
const titleInput = page.getByTestId('title-field-input')
await titleInput.click()
await titleInput.fill('Final Title')
await titleInput.press('Enter')
// Wait for rename
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
expect(errors).toEqual([])
})
})

View File

@@ -86,6 +86,23 @@ test('@smoke older notes with a document title do not render the legacy title se
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
})
test('deleting the H1 does not resurrect the legacy title section', async ({ page }) => {
await openNote(page, 'Alpha Project')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
expect(rawContent).toContain('# Alpha Project')
await setRawEditorContent(page, rawContent.replace('# Alpha Project\n\n', ''))
await page.keyboard.press('Meta+s')
await openBlockNoteMode(page)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
await expect(page.locator('.title-section')).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toHaveCount(0)
})
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {
const updatedTitle = 'Updated Display Title'
const noteList = page.locator('[data-testid="note-list-container"]')

View File

@@ -1,13 +1,18 @@
import { test, expect } from '@playwright/test'
import { triggerMenuCommand } from './testBridge'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import {
dispatchShortcutEvent,
triggerMenuCommand,
triggerShortcutCommand,
} from './testBridge'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
let tempVaultDir: string
function untitledNoteListMatcher(typeLabel: string) {
return new RegExp(`Untitled ${typeLabel}(?: \\d+)?`, 'i')
}
test.describe('keyboard command routing', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
@@ -17,59 +22,91 @@ test.describe('keyboard command routing', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('native menu trigger creates a note through the shared command path @smoke', async ({ page }) => {
test('desktop menu-command bridge creates a note through the shared command path @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openFixtureVaultTauri(page, tempVaultDir)
await triggerMenuCommand(page, 'file-new-note')
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await triggerMenuCommand(page, APP_COMMAND_IDS.fileNewNote)
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { timeout: 5_000 })
await expect(
page.locator('[data-testid="note-list-container"]').getByText(untitledNoteListMatcher('note')).first(),
).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
test('Meta+Shift+I toggles the properties panel in Tauri mode through the shared keyboard path @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('desktop menu-command bridge toggles the properties panel through the shared command path @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await page.keyboard.press('Meta+Shift+I')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press('Meta+Shift+I')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
})
test('Meta+Backslash toggles the raw editor in Tauri mode through the shared keyboard path @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('desktop keyboard shortcut toggles the properties panel through the renderer shortcut path @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await page.keyboard.press('Meta+Backslash')
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+I' : 'Control+Shift+I')
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+I' : 'Control+Shift+I')
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
})
test('desktop menu-command bridge toggles organized state through the shared command path @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, APP_COMMAND_IDS.noteToggleOrganized)
await expect(page.getByTitle('Mark as unorganized (back to Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, APP_COMMAND_IDS.noteToggleOrganized)
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
})
test('renderer shortcut bridge toggles the raw editor through the shared keyboard handler @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press('Meta+Backslash')
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
})
test('Meta+Shift+L toggles the AI panel in Tauri mode, while Ctrl+Shift+L does not @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
test('desktop menu-command bridge toggles the AI panel, while the wrong modifier event does not @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await page.keyboard.press('Control+Shift+L')
await dispatchShortcutEvent(page, {
key: 'l',
code: 'KeyL',
ctrlKey: true,
metaKey: false,
shiftKey: true,
altKey: false,
bubbles: true,
cancelable: true,
})
await page.waitForTimeout(200)
await expect(page.getByTestId('ai-panel')).not.toBeVisible()
await page.keyboard.press('Meta+Shift+L')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleAiChat)
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTitle('Close AI panel')).toBeVisible()
await page.keyboard.press('Meta+Shift+L')
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleAiChat)
await expect(page.getByTestId('ai-panel')).not.toBeVisible({ timeout: 5_000 })
})
})

View File

@@ -1,37 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Renaming a note updates wikilinks across the vault', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('title field rename triggers rename flow and shows toast', async ({ page }) => {
// 1. Click the first note in the list to open it
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const firstNote = noteListContainer.locator('.cursor-pointer').first()
await firstNote.click()
await page.waitForTimeout(500)
// 2. Find the title field in the editor
const titleField = page.locator('[data-testid="title-field"] input, [data-testid="title-field"]')
await expect(titleField.first()).toBeVisible({ timeout: 5000 })
const originalTitle = await titleField.first().inputValue().catch(() => titleField.first().textContent())
expect(originalTitle).toBeTruthy()
// 3. Click the title field and change the title
await titleField.first().click()
await page.keyboard.press('Meta+a')
const newTitle = `${originalTitle} Renamed`
await page.keyboard.type(newTitle)
await page.keyboard.press('Tab') // blur to trigger rename
await page.waitForTimeout(1500)
// 4. Verify the toast message appeared (confirms rename flow ran)
const toast = page.getByText('Renamed', { exact: true })
await expect(toast).toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,4 +1,9 @@
import { type Page } from '@playwright/test'
import type {
AppCommandId,
AppCommandShortcutEventInit,
AppCommandShortcutEventOptions,
} from '../../src/hooks/appCommandCatalog'
export async function triggerMenuCommand(page: Page, id: string): Promise<void> {
await page.evaluate(async (commandId) => {
@@ -32,3 +37,30 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
}, id)
}
export async function dispatchShortcutEvent(
page: Page,
init: AppCommandShortcutEventInit,
): Promise<void> {
await page.evaluate((eventInit) => {
const bridge = window.__laputaTest?.dispatchShortcutEvent
if (typeof bridge !== 'function') {
throw new Error('Laputa test bridge is missing dispatchShortcutEvent')
}
bridge(eventInit)
}, init)
}
export async function triggerShortcutCommand(
page: Page,
id: AppCommandId,
options?: AppCommandShortcutEventOptions,
): Promise<void> {
await page.evaluate((payload) => {
const bridge = window.__laputaTest?.triggerShortcutCommand
if (typeof bridge !== 'function') {
throw new Error('Laputa test bridge is missing triggerShortcutCommand')
}
bridge(payload.id, payload.options)
}, { id, options })
}

View File

@@ -1,99 +0,0 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */
const KNOWN_EDITOR_ERRORS = ['isConnected']
function isKnownEditorError(msg: string): boolean {
return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k))
}
test.describe('Title/filename sync', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2500)
})
test('new note renamed via TitleField updates frontmatter and filename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
// Create note via Cmd+N
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// Edit the title via TitleField (not H1)
const titleInput = page.getByTestId('title-field-input')
await titleInput.waitFor({ timeout: 3000 })
await titleInput.click()
await titleInput.fill('Career Tracks Depend on Company Shape')
await titleInput.press('Enter')
// Wait for async rename
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Career Tracks Depend on Company Shape', { timeout: 2000 })
expect(errors).toEqual([])
})
test('opening a note shows title from frontmatter in tab/breadcrumb', async ({ page }) => {
// Click "All Notes" to see all notes
const allNotes = page.locator('text=All Notes').first()
await allNotes.click()
await page.waitForTimeout(500)
// Click on the first note in the note list
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const noteItem = noteListContainer.locator('.cursor-pointer').first()
const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
await noteItem.click()
await page.waitForTimeout(500)
// Breadcrumb should show the note title
if (noteTitle) {
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText(noteTitle.trim(), { timeout: 2000 })
}
})
test('rename via TitleField updates both title and filename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
// Create a note
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// First rename via TitleField
const titleInput = page.getByTestId('title-field-input')
await titleInput.waitFor({ timeout: 3000 })
await titleInput.click()
await titleInput.fill('Original Title XYZ')
await titleInput.press('Enter')
// Wait for rename
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5000 })
// Second rename via TitleField
await titleInput.click()
await titleInput.fill('Renamed Title XYZ')
await titleInput.press('Enter')
// Wait for second rename
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Renamed Title XYZ', { timeout: 2000 })
expect(errors).toEqual([])
})
})

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
test('clicking + in type section creates note with that type', async ({ page }) => {
await page.goto('/')
@@ -11,26 +12,19 @@ test('clicking + in type section creates note with that type', async ({ page })
// Click the "+" button to create a new note
await page.click('[title="Create new note"]')
await page.waitForTimeout(1000)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
// The new note should have type-based naming (e.g., "Untitled project N")
const titleInput = page.locator('[data-testid="title-field-input"]')
if (await titleInput.count() > 0) {
const value = await titleInput.inputValue()
console.log('New note title:', value)
expect(value.toLowerCase()).toContain('project')
}
// The new note should use a type-specific untitled filename.
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-project-\d+/i)
// Toggle raw editor to see frontmatter
await page.keyboard.press('Meta+Shift+m')
await page.waitForTimeout(500)
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5000 })
const rawEditor = page.locator('.cm-content')
if (await rawEditor.count() > 0) {
const content = await rawEditor.textContent()
console.log('Raw content:', content?.substring(0, 200))
expect(content).toContain('type: Project')
}
const content = await rawEditor.textContent()
expect(content).toContain('type: Project')
})
test('clicking + in All Notes creates generic note', async ({ page }) => {
@@ -43,14 +37,9 @@ test('clicking + in All Notes creates generic note', async ({ page }) => {
// Click the "+" button
await page.click('[title="Create new note"]')
await page.waitForTimeout(1000)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
// The new note should be a generic "Note" type
const titleInput = page.locator('[data-testid="title-field-input"]')
if (await titleInput.count() > 0) {
const value = await titleInput.inputValue()
console.log('New note title:', value)
expect(value.toLowerCase()).toContain('note')
expect(value.toLowerCase()).not.toContain('project')
}
// The new note should be a generic untitled note, not a typed one.
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i)
await expect(page.getByTestId('breadcrumb-filename-trigger')).not.toContainText(/untitled-project-\d+/i)
})

View File

@@ -75,8 +75,6 @@ test.describe('Wikilink insertion and navigation', () => {
const expected = targetTitle?.substring(0, 4) ?? ''
await expect.poll(async () => {
const titleInput = page.getByTestId('title-field-input')
if (await titleInput.count()) return await titleInput.inputValue()
const heading = page.locator('.bn-editor h1').first()
if (await heading.count()) return (await heading.textContent()) ?? ''
return ''