Compare commits

...

11 Commits

Author SHA1 Message Date
lucaronin
6105c7527c refactor: fix hook dependency lint warnings 2026-04-25 21:26:25 +02:00
lucaronin
92eee0d470 fix: keep editor selection stable around media blocks 2026-04-25 21:21:41 +02:00
lucaronin
f14fda09d5 fix: accept finder folder drops in ai composer 2026-04-25 20:41:56 +02:00
lucaronin
31d12d8cc8 test: stabilize smoke web server 2026-04-25 19:51:41 +02:00
lucaronin
6a002f0dc0 fix: guard missing inline input types 2026-04-25 19:23:00 +02:00
lucaronin
9d30d1165f fix: harden linux appimage startup 2026-04-25 18:54:38 +02:00
lucaronin
b709acd58a fix: type delete confirmation shortcuts 2026-04-25 18:23:37 +02:00
lucaronin
080e3498a7 fix: submit delete confirmation on enter 2026-04-25 18:20:32 +02:00
lucaronin
51010ca578 fix: show manual update feedback 2026-04-25 17:15:19 +02:00
lucaronin
15bc3a4701 fix: disable command palette text correction 2026-04-25 16:49:48 +02:00
lucaronin
3ebfa642fa fix: allow runtime stylesheet injection 2026-04-25 16:06:17 +02:00
32 changed files with 850 additions and 127 deletions

View File

@@ -186,6 +186,7 @@ Panels are separated by `ResizeHandle` components that support drag-to-resize.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that macOS uses for native menu clicks.
When Tolaria is launched from a Linux AppImage, `run()` also injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable. This keeps the workaround scoped to bundled WebKitGTK launches that are prone to Fedora/Wayland DMA-BUF crashes without changing native package installs.
## Multi-Window (Note Windows)
@@ -496,6 +497,7 @@ sequenceDiagram
participant MCP as MCP Server
participant U as User
T->>T: apply Linux AppImage WebKit env override<br/>(AppImage only)
T->>T: run_startup_tasks()<br/>(migrate + seed only)
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
T->>A: App mounts

View File

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

View File

@@ -30,7 +30,7 @@ export default defineConfig({
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --host 127.0.0.1 --port ${port} --strictPort`,
command: `node scripts/playwright-smoke-server.mjs ${port}`,
url: baseURL,
reuseExistingServer,
timeout: 30_000,

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
const port = process.argv[2] ?? process.env.PORT ?? '41741'
const child = spawn(
'pnpm',
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
{
cwd: process.cwd(),
env: process.env,
stdio: ['pipe', 'inherit', 'inherit'],
},
)
function forwardSignal(signal) {
if (child.killed) return
child.kill(signal)
}
process.on('SIGINT', () => forwardSignal('SIGINT'))
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})

View File

@@ -47,6 +47,45 @@ struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
#[cfg(any(test, all(desktop, target_os = "linux")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupEnvOverride {
key: &'static str,
value: &'static str,
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
let is_appimage = ["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()));
if !is_appimage {
return Vec::new();
}
if get_var("WEBKIT_DISABLE_DMABUF_RENDERER").is_some_and(|value| !value.trim().is_empty()) {
return Vec::new();
}
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
}]
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_linux_appimage_startup_env_overrides() {
for env_override in linux_appimage_startup_env_overrides_with(|key| std::env::var(key).ok()) {
std::env::set_var(env_override.key, env_override.value);
}
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn apply_linux_appimage_startup_env_overrides() {}
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
@@ -338,6 +377,7 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
apply_linux_appimage_startup_env_overrides();
let builder = tauri::Builder::default();
#[cfg(desktop)]
@@ -357,6 +397,8 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::linux_appimage_startup_env_overrides_with;
use super::StartupEnvOverride;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(all(desktop, unix))]
@@ -367,6 +409,40 @@ mod tests {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[test]
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn linux_appimage_startup_env_overrides_disable_dmabuf_for_appimages() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
}]
);
}
#[test]
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert!(overrides.is_empty());
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {

View File

@@ -32,7 +32,7 @@
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
"style-src": "'self' 'unsafe-inline' 'nonce-tolaria-codemirror-style' https://fonts.googleapis.com",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"object-src": "'none'"

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
import { invoke } from '@tauri-apps/api/core'
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
const localStorageMock = (() => {
@@ -303,7 +304,7 @@ function resolveMockCommandResult(cmd: string, args?: unknown) {
}
vi.mock('./mock-tauri', () => ({
isTauri: () => false,
isTauri: vi.fn(() => false),
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
@@ -317,6 +318,24 @@ vi.mock('./utils/ai-chat', () => ({
streamClaudeChat: vi.fn(async () => 'mock-session'),
}))
vi.mock('./hooks/useUpdater', async () => {
const actual = await vi.importActual<typeof import('./hooks/useUpdater')>('./hooks/useUpdater')
return {
...actual,
useUpdater: vi.fn(() => ({
status: { state: 'idle' },
actions: {
checkForUpdates: vi.fn(async () => ({ kind: 'up-to-date' })),
startDownload: vi.fn(),
openReleaseNotes: vi.fn(),
dismiss: vi.fn(),
},
})),
restartApp: vi.fn(),
}
})
// Mock BlockNote components (they need DOM APIs not available in jsdom)
vi.mock('@blocknote/core', () => ({
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
@@ -396,14 +415,33 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
}))
import App from './App'
import { useUpdater } from './hooks/useUpdater'
import { isTauri } from './mock-tauri'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
function createMockUpdaterResult(
checkForUpdates: () => Promise<{ kind: 'up-to-date' } | { kind: 'available'; version: string; displayVersion: string } | { kind: 'error'; message: string }> = async () => ({ kind: 'up-to-date' }),
) {
return {
status: { state: 'idle' as const },
actions: {
checkForUpdates,
startDownload: vi.fn(),
openReleaseNotes: vi.fn(),
dismiss: vi.fn(),
},
}
}
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockCommandResults()
vi.mocked(invoke).mockImplementation(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args))
vi.mocked(isTauri).mockReturnValue(false)
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult())
localStorage.clear()
window.history.replaceState({}, '', '/')
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
@@ -480,6 +518,45 @@ describe('App', () => {
})
})
it('shows visible feedback when a manual update check finds an update', async () => {
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({
kind: 'available',
version: '2026.4.25',
displayVersion: '2026.4.25',
})))
render(<App />)
await waitFor(() => {
expect(screen.getByText('All Notes')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('status-build-number'))
await waitFor(() => {
expect(screen.getByText('Tolaria 2026.4.25 is available')).toBeInTheDocument()
})
})
it('shows visible feedback when a menu-driven update check finds no eligible update', async () => {
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({ kind: 'up-to-date' })))
render(<App />)
await waitFor(() => {
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
})
act(() => {
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
})
await waitFor(() => {
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
})
})
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)

View File

@@ -89,6 +89,7 @@ import { openNoteListPropertiesPicker } from './components/note-list/noteListPro
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry'
import { normalizeReleaseChannel } from './lib/releaseChannel'
import {
buildVaultAiGuidanceRefreshKey,
} from './lib/vaultAiGuidance'
@@ -1097,12 +1098,15 @@ function App() {
return
}
const result = await updateActions.checkForUpdates()
if (result === 'up-to-date') {
setToastMessage("You're on the latest version")
} else if (result === 'error') {
setToastMessage('Could not check for updates')
if (result.kind === 'up-to-date') {
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
setToastMessage(`No newer ${checkedChannel} update is available right now`)
} else if (result.kind === 'available') {
setToastMessage(`Tolaria ${result.displayVersion} is available`)
} else {
setToastMessage(result.message)
}
}, [updateActions, updateStatus.state, setToastMessage])
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return

View File

@@ -105,6 +105,16 @@ describe('CommandPalette', () => {
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
})
it('opts the command input out of spellcheck and text correction', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
const input = screen.getByPlaceholderText('Type a command...')
expect(input).toHaveAttribute('spellcheck', 'false')
expect(input).toHaveAttribute('autocorrect', 'off')
expect(input).toHaveAttribute('autocapitalize', 'off')
expect(input).toHaveAttribute('autocomplete', 'off')
})
it('shows all enabled commands grouped by category', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
expect(screen.getByText('Search Notes')).toBeInTheDocument()
@@ -181,6 +191,35 @@ describe('CommandPalette', () => {
expect(onClose).toHaveBeenCalled()
})
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
const changeNoteType = makeCommand({
id: 'change-note-type',
label: 'Change Note Type…',
group: 'Note',
})
render(
<CommandPalette
open={true}
commands={[
changeNoteType,
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings' }),
]}
onClose={onClose}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'ch' } })
fireEvent.keyDown(window, { key: 'ArrowDown' })
const selectedRow = screen.getByText('Change Note Type…').closest('[data-selected]')
expect(selectedRow).toHaveAttribute('data-selected', 'true')
fireEvent.keyDown(window, { key: 'Enter' })
expect(changeNoteType.execute).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('does not go below the last item', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)

View File

@@ -8,6 +8,7 @@ import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
import { Input } from './ui/input'
interface CommandPaletteProps {
open: boolean
@@ -124,12 +125,16 @@ function CommandPaletteInput({
onChange: (value: string) => void
}) {
return (
<input
<Input
ref={inputRef}
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
className="h-auto rounded-none border-x-0 border-t-0 border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground shadow-none transition-none outline-none placeholder:text-muted-foreground focus-visible:border-border focus-visible:ring-0 md:text-[15px]"
type="text"
placeholder="Type a command..."
value={query}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
autoComplete="off"
onChange={(event) => onChange(event.target.value)}
/>
)

View File

@@ -1,81 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
describe('ConfirmDeleteDialog', () => {
const onConfirm = vi.fn()
const onCancel = vi.fn()
const defaultProps = {
open: true,
title: 'Delete permanently?',
message: 'This cannot be undone.',
}
const onConfirm = vi.fn()
const onCancel = vi.fn()
function renderDialog(overrides: Partial<React.ComponentProps<typeof ConfirmDeleteDialog>> = {}) {
return render(
<ConfirmDeleteDialog
{...defaultProps}
onConfirm={onConfirm}
onCancel={onCancel}
{...overrides}
/>,
)
}
describe('ConfirmDeleteDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders with title and message', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
renderDialog()
expect(screen.getByText('Delete permanently?')).toBeInTheDocument()
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
})
it('calls onConfirm when delete button clicked', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
renderDialog()
fireEvent.click(screen.getByTestId('confirm-delete-btn'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('focuses the destructive action when the dialog opens', async () => {
renderDialog()
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-btn')).toHaveFocus()
})
})
it('submits the destructive action when Enter is pressed', () => {
renderDialog()
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter' })
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('does not submit twice when Enter repeats', () => {
renderDialog()
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter' })
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter', repeat: true })
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('treats Enter as the primary confirm shortcut even from the cancel button', () => {
renderDialog()
fireEvent.keyDown(screen.getByText('Cancel'), { key: 'Enter' })
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button clicked', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
renderDialog()
fireEvent.click(screen.getByText('Cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('does not render when open is false', () => {
render(
<ConfirmDeleteDialog
open={false}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
renderDialog({ open: false })
expect(screen.queryByText('Delete permanently?')).not.toBeInTheDocument()
})
it('uses custom confirm label when provided', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Empty Trash?"
message="Delete all notes?"
confirmLabel="Empty Trash"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
renderDialog({
title: 'Empty Trash?',
message: 'Delete all notes?',
confirmLabel: 'Empty Trash',
})
expect(screen.getByText('Empty Trash')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'
import { memo, useCallback, useEffect, useRef } from 'react'
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'
import { Trash } from '@phosphor-icons/react'
import {
Dialog,
@@ -19,6 +20,48 @@ interface ConfirmDeleteDialogProps {
onCancel: () => void
}
function focusConfirmButton(confirmButton: HTMLButtonElement | null) {
confirmButton?.focus()
}
function focusPrimaryDeleteButton(root: ParentNode | null) {
focusConfirmButton(root?.querySelector<HTMLButtonElement>('[data-confirm-delete-primary="true"]') ?? null)
}
function scheduleConfirmButtonFocus(root: ParentNode | null) {
const focusDelays = [0, 50, 150]
return focusDelays.map((delay) => (
window.setTimeout(() => {
focusPrimaryDeleteButton(root)
}, delay)
))
}
type ConfirmShortcutEvent = Pick<
KeyboardEvent,
'key' | 'defaultPrevented' | 'repeat' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'
> & {
isComposing?: boolean
nativeEvent?: Pick<KeyboardEvent, 'isComposing'>
}
function isComposingEvent(event: ConfirmShortcutEvent) {
return event.isComposing || event.nativeEvent?.isComposing === true
}
function isConfirmShortcut(event: ConfirmShortcutEvent) {
return (
event.key === 'Enter'
&& !event.defaultPrevented
&& !event.repeat
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
&& !isComposingEvent(event)
)
}
export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
open,
title,
@@ -27,9 +70,88 @@ export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
onConfirm,
onCancel,
}: ConfirmDeleteDialogProps) {
const confirmingRef = useRef(false)
const cancelFocusIsIntentionalRef = useRef(false)
useEffect(() => {
confirmingRef.current = false
cancelFocusIsIntentionalRef.current = false
if (!open) {
return
}
focusPrimaryDeleteButton(document)
const timeoutIds = scheduleConfirmButtonFocus(document)
return () => {
timeoutIds.forEach((timeoutId) => {
window.clearTimeout(timeoutId)
})
}
}, [open])
const handleConfirm = useCallback(() => {
if (confirmingRef.current) {
return
}
confirmingRef.current = true
onConfirm()
}, [onConfirm])
const handleSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
handleConfirm()
}, [handleConfirm])
useEffect(() => {
if (!open) {
return
}
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!isConfirmShortcut(event)) {
return
}
event.preventDefault()
handleConfirm()
}
window.addEventListener('keydown', handleWindowKeyDown, true)
return () => {
window.removeEventListener('keydown', handleWindowKeyDown, true)
}
}, [handleConfirm, open])
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!isConfirmShortcut(event)) {
return
}
event.preventDefault()
handleConfirm()
}, [handleConfirm])
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
<DialogContent showCloseButton={false} data-testid="confirm-delete-dialog">
<DialogContent
showCloseButton={false}
data-testid="confirm-delete-dialog"
onKeyDown={handleKeyDown}
onKeyDownCapture={(event) => {
if (event.key === 'Tab') {
cancelFocusIsIntentionalRef.current = true
}
}}
onOpenAutoFocus={(event) => {
event.preventDefault()
const dialogRoot = event.currentTarget as ParentNode | null
focusPrimaryDeleteButton(dialogRoot)
scheduleConfirmButtonFocus(dialogRoot)
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash size={18} className="text-destructive" />
@@ -37,12 +159,38 @@ export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-btn">
{confirmLabel}
</Button>
</DialogFooter>
<form onSubmit={handleSubmit}>
<DialogFooter>
<Button
type="submit"
variant="destructive"
data-testid="confirm-delete-btn"
data-confirm-delete-primary="true"
autoFocus
>
{confirmLabel}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
onFocus={() => {
if (cancelFocusIsIntentionalRef.current) {
return
}
focusPrimaryDeleteButton(document)
}}
onPointerDown={() => {
cancelFocusIsIntentionalRef.current = true
}}
data-confirm-delete-cancel="true"
tabIndex={-1}
>
Cancel
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)

View File

@@ -16,7 +16,11 @@ import {
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
} from './inlineWikilinkText'
import { serializeInlineNode } from './inlineWikilinkDom'
import { extractDroppedPathText } from './inlineWikilinkDropText'
import {
readSelectionRange,
serializeInlineNode,
} from './inlineWikilinkDom'
import {
buildPendingPasteState,
type PendingPasteState,
@@ -31,6 +35,7 @@ import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
import { isInsertBeforeInput } from './inlineWikilinkBeforeInput'
interface InlineWikilinkInputProps {
entries: VaultEntry[]
@@ -162,6 +167,7 @@ export function InlineWikilinkInput({
const pendingPasteRef = useRef<PendingPasteState | null>(null)
const isComposingRef = useRef(false)
const pendingCompositionInputRef = useRef(false)
const handledFileDropRef = useRef(false)
const activeQuery = useMemo(
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
@@ -190,6 +196,14 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const insertTransferText = (text: string) => {
const currentSelectionRange = editorRef.current
? readSelectionRange(editorRef.current)
: selectionRange
const nextState = replaceInlineSelection(value, currentSelectionRange, text)
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE)
const recoverUnsupportedMutation = () => {
pendingCompositionInputRef.current = false
@@ -208,14 +222,44 @@ export function InlineWikilinkInput({
if (disabled) return
const nativeEvent = event.nativeEvent as InputEvent
if (!nativeEvent.inputType.startsWith('insert')) return
if (!isInsertBeforeInput(nativeEvent)) return
const dataTransfer = nativeEvent.dataTransfer
if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return
if (nativeEvent.inputType === 'insertFromDrop' && handledFileDropRef.current) {
handledFileDropRef.current = false
event.preventDefault()
return
}
if (nativeEvent.inputType === 'insertFromDrop') {
const droppedPathText = extractDroppedPathText(dataTransfer)
if (droppedPathText) {
event.preventDefault()
insertTransferText(droppedPathText)
return
}
}
event.preventDefault()
notifyUnsupportedPaste()
}
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (disabled) return
if (!hasUnsupportedClipboardPayload(event.dataTransfer)) return
handledFileDropRef.current = true
const droppedPathText = extractDroppedPathText(event.dataTransfer)
event.preventDefault()
if (!droppedPathText) {
notifyUnsupportedPaste()
return
}
insertTransferText(droppedPathText)
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
@@ -307,6 +351,7 @@ export function InlineWikilinkInput({
onCompositionStart={handleCompositionStart}
onInput={handleInput}
onKeyDown={handleKeyDown}
onDrop={handleDrop}
onPaste={handlePaste}
onSelectionChange={syncSelectionRange}
segments={segments}

View File

@@ -163,6 +163,7 @@ export function InlineWikilinkEditorField({
onCompositionStart,
onInput,
onKeyDown,
onDrop,
onPaste,
onSelectionChange,
segments,
@@ -179,6 +180,7 @@ export function InlineWikilinkEditorField({
onCompositionStart: () => void
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onDrop: (event: React.DragEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
@@ -215,6 +217,7 @@ export function InlineWikilinkEditorField({
onCompositionStart={onCompositionStart}
onInput={onInput}
onKeyDown={onKeyDown}
onDrop={onDrop}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}

View File

@@ -492,6 +492,34 @@ describe('SingleEditorView', () => {
expect(editor.focus).not.toHaveBeenCalled()
})
it('falls back to the nearest editable block when the trailing block has no inline content', () => {
const editor = createEditor()
editor.document = [
{ id: 'paragraph-block', type: 'paragraph', content: [], children: [] },
{ id: 'image-block', type: 'image', children: [] },
]
editor.setTextCursorPosition = vi.fn((blockId: string) => {
if (blockId === 'image-block') {
throw new Error('Attempting to set selection anchor in block without content (id image-block)')
}
})
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
expect(() => fireEvent.click(container!)).not.toThrow()
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('paragraph-block', 'end')
expect(editor.focus).toHaveBeenCalled()
})
it('routes the custom link-toolbar open action through openExternalUrl', () => {
render(
<SingleEditorView

View File

@@ -36,6 +36,7 @@ import {
} from './tolariaEditorFormatting'
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
import { useEditorLinkActivation } from './useEditorLinkActivation'
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| --- | --- | --- |
@@ -241,7 +242,11 @@ function queueTitleHeadingCursorRepair(
const firstBlock = editor.document[0]
if (firstBlock?.type !== 'heading') return
editor.setTextCursorPosition(firstBlock.id, 'end')
try {
editor.setTextCursorPosition(firstBlock.id, 'end')
} catch {
return
}
editor.focus()
})
@@ -261,7 +266,14 @@ function useEditorContainerClickHandler(options: {
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
const targetBlock = findNearestTextCursorBlock(blocks, blocks.length - 1)
if (targetBlock) {
try {
editor.setTextCursorPosition(targetBlock.id, 'end')
} catch {
// Ignore transient BlockNote selection errors and at least restore focus.
}
}
}
editor.focus()
}, [editor, editable])

View File

@@ -8,7 +8,11 @@ import {
waitFor,
} from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import { extractDroppedPathText } from './inlineWikilinkDropText'
import {
UNSUPPORTED_INLINE_PASTE_MESSAGE,
} from './InlineWikilinkInput'
import { isInsertBeforeInput } from './inlineWikilinkBeforeInput'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -119,6 +123,24 @@ function fireComposingKeyDown(editor: HTMLElement, key: string) {
fireEvent(editor, event)
}
function createFileLikeDataTransfer({
plainText = '',
uriList = '',
}: {
plainText?: string
uriList?: string
}) {
return {
getData: vi.fn((type: string) => {
if (type === 'text/plain') return plainText
if (type === 'text/uri-list') return uriList
return ''
}),
files: [new File(['folder'], 'Projects')],
items: [{ kind: 'file', type: '' }],
}
}
describe('WikilinkChatInput', () => {
it('renders the placeholder overlay for an empty draft', () => {
render(<Controlled placeholder="Ask something..." />)
@@ -271,6 +293,43 @@ describe('WikilinkChatInput', () => {
expect(editor.textContent).toContain('still works')
})
it('extracts dropped folder paths from text/plain payloads', () => {
expect(extractDroppedPathText(
createFileLikeDataTransfer({
plainText: '/Users/test/Projects',
}) as DataTransfer,
)).toBe('/Users/test/Projects')
})
it('falls back to file URLs exposed through uri lists', () => {
expect(extractDroppedPathText(
createFileLikeDataTransfer({
uriList: 'file:///Users/test/My%20Folder',
}) as DataTransfer,
)).toBe('"/Users/test/My Folder"')
})
it('treats missing inputType as a non-insert beforeinput event', () => {
expect(() => isInsertBeforeInput({} as InputEvent)).not.toThrow()
expect(isInsertBeforeInput({} as InputEvent)).toBe(false)
expect(isInsertBeforeInput({ inputType: 'insertFromPaste' } as InputEvent)).toBe(true)
})
it('ignores beforeinput events without inputType instead of crashing', () => {
render(<Controlled />)
const editor = screen.getByTestId('agent-input')
const beforeInputEvent = new Event('beforeinput', {
bubbles: true,
cancelable: true,
})
expect(() => fireEvent(editor, beforeInputEvent)).not.toThrow()
updateEditorText('still works')
expect(editor.textContent).toContain('still works')
})
it('recovers if unsupported media lands in the editor DOM', async () => {
const onUnsupportedPaste = vi.fn()
render(<Controlled onUnsupportedPaste={onUnsupportedPaste} />)

View File

@@ -0,0 +1,45 @@
interface CursorTargetBlockLike {
id: string
content?: unknown
}
function blockSupportsTextCursor(block: CursorTargetBlockLike | undefined): block is CursorTargetBlockLike {
return Array.isArray(block?.content)
}
export function findNearestTextCursorBlock(
blocks: CursorTargetBlockLike[],
targetIndex: number,
): CursorTargetBlockLike | null {
if (blocks.length === 0) return null
const clampedTargetIndex = Math.min(Math.max(targetIndex, 0), blocks.length - 1)
const targetBlock = blocks[clampedTargetIndex]
if (blockSupportsTextCursor(targetBlock)) {
return targetBlock
}
for (let distance = 1; distance < blocks.length; distance += 1) {
const forwardBlock = blocks[clampedTargetIndex + distance]
if (blockSupportsTextCursor(forwardBlock)) {
return forwardBlock
}
const backwardBlock = blocks[clampedTargetIndex - distance]
if (blockSupportsTextCursor(backwardBlock)) {
return backwardBlock
}
}
return null
}
export function findNearestTextCursorBlockById(
blocks: CursorTargetBlockLike[],
targetBlockId: string,
): CursorTargetBlockLike | null {
const targetIndex = blocks.findIndex((block) => block.id === targetBlockId)
if (targetIndex === -1) return null
return findNearestTextCursorBlock(blocks, targetIndex)
}

View File

@@ -12,13 +12,14 @@ import {
interface MockBlock {
id: string
markdown: string
content?: unknown
}
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
const blocks: MockBlock[] = [
{ id: 'title', markdown: '# Title' },
{ id: 'details', markdown: 'Paragraph one' },
{ id: 'tail', markdown: '## Tail' },
{ id: 'title', markdown: '# Title', content: [] },
{ id: 'details', markdown: 'Paragraph one', content: [] },
{ id: 'tail', markdown: '## Tail', content: [] },
]
function makeEditor(blocks: MockBlock[]): BlockNotePositionEditor {
@@ -153,4 +154,36 @@ describe('editorModePosition', () => {
expect(editor.setSelection).toHaveBeenCalledWith('title', 'tail')
expect(editor.focus).toHaveBeenCalled()
})
it('falls back to the nearest content block when raw restore lands on media', () => {
const contentWithMedia = '---\ntitle: Demo\n---\n# Title\n\n![media](media.png)\n\nParagraph tail'
const mediaBlocks: MockBlock[] = [
{ id: 'title', markdown: '# Title', content: [] },
{ id: 'image', markdown: '![media](media.png)' },
{ id: 'tail', markdown: 'Paragraph tail', content: [] },
]
const editor = makeEditor(mediaBlocks)
const view: CodeMirrorViewLike = {
state: {
doc: { toString: () => contentWithMedia },
selection: {
main: {
anchor: contentWithMedia.indexOf('![media]') + 3,
head: contentWithMedia.indexOf('![media]') + 3,
},
},
},
scrollDOM: { scrollTop: 48 },
dispatch: vi.fn(),
focus: vi.fn(),
}
installRawView(view)
const snapshot = captureRawEditorPositionSnapshot(document)
const restored = restoreBlockNoteView(editor, snapshot!, document)
expect(restored).toBe(true)
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('tail', 'end')
expect(editor.focus).toHaveBeenCalled()
})
})

View File

@@ -1,8 +1,10 @@
import { compactMarkdown } from '../utils/compact-markdown'
import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks'
import { findNearestTextCursorBlockById } from './blockNoteCursorTarget'
interface BlockLike {
id: string
content?: unknown
}
interface BlockSelectionLike {
@@ -234,9 +236,19 @@ function buildBlockNoteRestoreState(
const headIndex = findNearestBlockIndex({ ranges, targetLine: headLine })
const startIndex = Math.min(anchorIndex, headIndex)
const endIndex = Math.max(anchorIndex, headIndex)
const startBlockId = findNearestTextCursorBlockById(
editor.document,
editor.document[startIndex].id,
)?.id
const endBlockId = findNearestTextCursorBlockById(
editor.document,
editor.document[endIndex].id,
)?.id
if (!startBlockId || !endBlockId) return null
return {
startBlockId: editor.document[startIndex].id,
endBlockId: editor.document[endIndex].id,
startBlockId,
endBlockId,
}
}
@@ -337,10 +349,14 @@ export function restoreBlockNoteView(
const state = buildBlockNoteRestoreState(editor, snapshot)
if (!state) return false
if (state.startBlockId === state.endBlockId) {
editor.setTextCursorPosition(state.endBlockId, 'end')
} else {
editor.setSelection(state.startBlockId, state.endBlockId)
try {
if (state.startBlockId === state.endBlockId) {
editor.setTextCursorPosition(state.endBlockId, 'end')
} else {
editor.setSelection(state.startBlockId, state.endBlockId)
}
} catch {
return false
}
editor.focus()
documentObject

View File

@@ -0,0 +1,4 @@
export function isInsertBeforeInput(nativeEvent: Pick<InputEvent, 'inputType'>) {
return typeof nativeEvent.inputType === 'string'
&& nativeEvent.inputType.startsWith('insert')
}

View File

@@ -0,0 +1,64 @@
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
function normalizeDroppedFileUrl(value: string): string {
if (!value.startsWith('file://')) return value
try {
const parsed = new URL(value)
if (parsed.protocol !== 'file:') return value
const decodedPath = decodeURIComponent(parsed.pathname)
if (parsed.hostname) return `//${parsed.hostname}${decodedPath}`
if (/^\/[A-Za-z]:/.test(decodedPath)) return decodedPath.slice(1)
return decodedPath
} catch {
return value
}
}
function formatDroppedPath(path: string): string {
return /\s/.test(path) ? JSON.stringify(path) : path
}
function normalizeDroppedTransferText(rawText: string): string | null {
const trimmedText = normalizeInlineWikilinkValue(rawText).trim()
if (!trimmedText) return null
const lines = trimmedText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (lines.length > 0 && lines.every((line) => line.startsWith('file://'))) {
return lines.map((line) => formatDroppedPath(normalizeDroppedFileUrl(line))).join(' ')
}
return trimmedText
}
export function extractDroppedPathText(dataTransfer: DataTransfer): string | null {
const plainText = normalizeDroppedTransferText(dataTransfer.getData('text/plain'))
if (plainText) return plainText
const uriPaths = dataTransfer.getData('text/uri-list')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map(normalizeDroppedFileUrl)
if (uriPaths.length > 0) {
return uriPaths.map(formatDroppedPath).join(' ')
}
const filePaths = Array.from(dataTransfer.files)
.map((file) => (typeof (file as File & { path?: string }).path === 'string'
? (file as File & { path?: string }).path!.trim()
: ''))
.filter(Boolean)
if (filePaths.length > 0) {
return filePaths.map(formatDroppedPath).join(' ')
}
return null
}

View File

@@ -14,13 +14,14 @@ import { useEditorModePositionSync } from './useEditorModePositionSync'
interface MockBlock {
id: string
markdown: string
content?: unknown
}
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
const blocks: MockBlock[] = [
{ id: 'title', markdown: '# Title' },
{ id: 'details', markdown: 'Paragraph one' },
{ id: 'tail', markdown: '## Tail' },
{ id: 'title', markdown: '# Title', content: [] },
{ id: 'details', markdown: 'Paragraph one', content: [] },
{ id: 'tail', markdown: '## Tail', content: [] },
]
function makeEditor(): BlockNotePositionEditor {
@@ -114,7 +115,7 @@ describe('useEditorModePositionSync', () => {
expect(focus).toHaveBeenCalled()
})
it('restores the BlockNote cursor after toggling back from raw mode', () => {
it('restores the BlockNote cursor after toggling back from raw mode', async () => {
const editor = makeEditor()
const paragraphOffset = content.indexOf('Paragraph one') + 5
const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null }
@@ -149,6 +150,9 @@ describe('useEditorModePositionSync', () => {
result.current.pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document)
})
rerender({ rawMode: false })
await act(async () => {
await Promise.resolve()
})
act(() => {
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
detail: { path: 'note.md' },

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { CODEMIRROR_CSP_NONCE, useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
const noop = () => {}
const noopCallbacks: CodeMirrorCallbacks = {
@@ -31,14 +31,6 @@ describe('useCodeMirror', () => {
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
})
it('mounts CodeMirror generated styles with the configured CSP nonce', () => {
const ref = { current: container }
renderHook(() => useCodeMirror(ref, 'hello', noopCallbacks))
const style = document.head.querySelector(`style[nonce="${CODEMIRROR_CSP_NONCE}"]`)
expect(style?.textContent).toContain('.cm-scroller')
})
it('calls requestMeasure when laputa-zoom-change event fires', () => {
const ref = { current: container }
const { result } = renderHook(() =>

View File

@@ -16,8 +16,6 @@ const RAW_EDITOR_COLORS = {
gutterBorder: 'var(--border-subtle)',
gutterText: 'var(--text-muted)',
}
export const CODEMIRROR_CSP_NONCE = 'tolaria-codemirror-style'
export interface CodeMirrorCallbacks {
onDocChange: (doc: string) => void
onCursorActivity: (view: EditorView) => void
@@ -145,7 +143,6 @@ export function useCodeMirror(
buildArrowLigaturesExtension(),
keymap.of([...defaultKeymap, ...historyKeymap]),
buildSaveKeymap(callbacksRef),
EditorView.cspNonce.of(CODEMIRROR_CSP_NONCE),
buildBaseTheme(),
markdownLanguage(),
frontmatterHighlightTheme(),

View File

@@ -550,28 +550,21 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
}
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, config.onNewNotePersisted, removeEntry])
const creationDeps = {
entries,
vaultPath,
setToastMessage,
persistResolvedEntry,
}
const handleCreateNote = useCallback((title: string, type: string): Promise<boolean> =>
createNamedNote({ ...creationDeps, title, type, creationPath: 'plus_button' }),
[creationDeps])
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type, creationPath: 'plus_button' }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
const handleCreateType = useCallback((typeName: string): Promise<boolean> =>
createTypeFromName({ ...creationDeps, typeName }),
[creationDeps])
createTypeFromName({ entries, vaultPath, setToastMessage, persistResolvedEntry, typeName }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
const createTypeEntrySilent = useCallback((typeName: string): Promise<VaultEntry> =>
createTypeSilently({ ...creationDeps, typeName }),
[creationDeps])
createTypeSilently({ entries, vaultPath, setToastMessage, persistResolvedEntry, typeName }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> =>
createNamedNote({ ...creationDeps, title, type: 'Note' }),
[creationDeps])
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type: 'Note' }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
const handleCreateNoteImmediate = useImmediateCreateQueue({
entries,

View File

@@ -171,7 +171,7 @@ describe('useUpdater', () => {
it('returns up-to-date when no update is available', async () => {
const { result, outcome } = await performManualCheck('stable', null)
expect(outcome).toBe('up-to-date')
expect(outcome).toEqual({ kind: 'up-to-date' })
expect(result.current.status).toEqual({ state: 'idle' })
})
@@ -181,7 +181,11 @@ describe('useUpdater', () => {
makeUpdate({ body: undefined }),
)
expect(outcome).toBe('available')
expect(outcome).toEqual({
kind: 'available',
version: '2026.4.16',
displayVersion: '2026.4.16',
})
expect(result.current.status).toEqual({
state: 'available',
version: '2026.4.16',
@@ -210,7 +214,10 @@ describe('useUpdater', () => {
new Error('network error'),
)
expect(outcome).toBe('error')
expect(outcome).toEqual({
kind: 'error',
message: 'Could not check for updates: network error',
})
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
expect(result.current.status).toEqual({ state: 'idle' })
})

View File

@@ -23,7 +23,10 @@ export type UpdateStatus =
| ({ state: 'ready' } & UpdateVersionInfo)
| { state: 'error' }
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
export type UpdateCheckResult =
| { kind: 'up-to-date' }
| ({ kind: 'available' } & UpdateVersionInfo)
| { kind: 'error'; message: string }
export interface UpdateActions {
checkForUpdates: () => Promise<UpdateCheckResult>
@@ -57,6 +60,16 @@ function createVersionInfo(version: string): UpdateVersionInfo {
}
}
function buildUpdateCheckErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return `Could not check for updates: ${error.message}`
}
if (typeof error === 'string' && error.trim()) {
return `Could not check for updates: ${error}`
}
return 'Could not check for updates'
}
function toAvailableStatus(update: AppUpdateMetadata): UpdateStatus {
return {
state: 'available',
@@ -96,22 +109,23 @@ export function useUpdater(
const updateRef = useRef<AppUpdateMetadata | null>(null)
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
if (!isTauri()) return 'up-to-date'
if (!isTauri()) return { kind: 'up-to-date' }
try {
const update = await checkForAppUpdate(releaseChannel)
if (!update) {
updateRef.current = null
setStatus({ state: 'idle' })
return 'up-to-date'
return { kind: 'up-to-date' }
}
const versionInfo = createVersionInfo(update.version)
updateRef.current = update
setStatus(toAvailableStatus(update))
return 'available'
} catch {
return { kind: 'available', ...versionInfo }
} catch (error) {
console.warn('[updater] Failed to check for updates')
return 'error'
return { kind: 'error', message: buildUpdateCheckErrorMessage(error) }
}
}, [releaseChannel])

View File

@@ -408,6 +408,7 @@ function useLoadPersistedVaultState(
onSwitchRef: MutableRefObject<() => void>,
) {
const {
lastPersistedSnapshotRef,
setDefaultAvailable,
setDefaultPath,
setExtraVaults,
@@ -424,7 +425,7 @@ function useLoadPersistedVaultState(
.then(({ activeVault, defaultAvailable, hiddenDefaults: hidden, persistedSnapshot, resolvedDefaultPath, vaults }) => {
if (cancelled) return
store.lastPersistedSnapshotRef.current = persistedSnapshot
lastPersistedSnapshotRef.current = persistedSnapshot
setExtraVaults(vaults)
setHiddenDefaults(hidden)
applyResolvedDefaultPath({
@@ -448,7 +449,7 @@ function useLoadPersistedVaultState(
})
return () => { cancelled = true }
}, [onSwitchRef, setDefaultAvailable, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}, [lastPersistedSnapshotRef, onSwitchRef, setDefaultAvailable, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}
function usePersistedVaultStorage(store: PersistedVaultStore) {

View File

@@ -0,0 +1,11 @@
import { readFileSync } from 'node:fs'
describe('Tauri Content Security Policy', () => {
it('keeps broad inline styles available when runtime libraries inject style tags', () => {
const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8'))
const styleSrc = config.app.security.csp['style-src'] as string
expect(styleSrc).toContain("'unsafe-inline'")
expect(styleSrc).not.toMatch(/'nonce-|sha(256|384|512)-/)
})
})

View File

@@ -35,7 +35,7 @@ test.describe('responsive note deletion', () => {
await triggerShortcutCommand(page, APP_COMMAND_IDS.noteDelete)
await expect(page.getByTestId('confirm-delete-dialog')).toBeVisible({ timeout: 5_000 })
await page.getByTestId('confirm-delete-btn').focus()
await expect(page.getByTestId('confirm-delete-btn')).toBeFocused()
await page.keyboard.press('Enter')
const progressNotice = page.getByTestId('delete-progress-notice')

View File

@@ -65,7 +65,6 @@ test.describe('multi-selection shortcuts', () => {
await expect(dialog).toBeVisible({ timeout: 5_000 })
await expect(dialog).toContainText(/Delete \d+ notes permanently\?/)
await page.keyboard.press('Tab')
await expect(confirmButton).toBeFocused()
await page.keyboard.press('Enter')