Compare commits

...

3 Commits

Author SHA1 Message Date
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
10 changed files with 176 additions and 30 deletions

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,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

@@ -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

@@ -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)-/)
})
})