From 944efada947fa570464bc6a48f1c5578ad9e7794 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 25 Apr 2026 10:53:09 +0200 Subject: [PATCH] fix: load note windows without vault scan --- src-tauri/src/commands/vault/file_cmds.rs | 10 +++ src-tauri/src/lib.rs | 1 + src/App.test.tsx | 66 +++++++++++++++++++- src/App.tsx | 70 ++++++++++++--------- src/utils/openNoteWindow.test.ts | 19 +++++- src/utils/openNoteWindow.ts | 10 ++- src/utils/windowMode.test.ts | 76 ++++++++++++++++++++++- src/utils/windowMode.ts | 71 ++++++++++++++++++++- 8 files changed, 286 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs index 88952952..f47ca705 100644 --- a/src-tauri/src/commands/vault/file_cmds.rs +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -65,6 +65,16 @@ fn with_image_asset_scope( }) } +#[tauri::command] +pub fn sync_vault_asset_scope_for_window( + app_handle: tauri::AppHandle, + vault_path: PathBuf, +) -> Result<(), String> { + with_requested_root_path(vault_path.as_path(), |requested_root| { + sync_image_asset_scope(&app_handle, requested_root) + }) +} + fn with_writable_note_path( path: PathBuf, vault_path: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0baff49..9d85e1cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -278,6 +278,7 @@ macro_rules! app_invoke_handler { commands::stream_ai_agent, commands::reload_vault, commands::reload_vault_entry, + commands::sync_vault_asset_scope_for_window, commands::sync_note_title, commands::save_image, commands::copy_image_to_vault, diff --git a/src/App.test.tsx b/src/App.test.tsx index 4c90e2b1..4a62b42b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -59,13 +59,30 @@ const mockEntries = [ belongsTo: [], relatedTo: [], status: 'Active', + archived: false, owner: 'Luca', cadence: null, modifiedAt: 1700000000, createdAt: null, fileSize: 1024, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, template: null, sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', }, { path: '/vault/topic/dev.md', @@ -76,13 +93,30 @@ const mockEntries = [ belongsTo: [], relatedTo: [], status: null, + archived: false, owner: null, cadence: null, modifiedAt: 1700000000, createdAt: null, fileSize: 256, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, template: null, sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', }, ] @@ -108,6 +142,8 @@ const mockCommandResults: Record = { get_all_content: mockAllContent, get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', + reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null, + sync_vault_asset_scope_for_window: null, get_file_history: [], get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null }, git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, @@ -239,6 +275,8 @@ function resetMockCommandResults() { get_all_content: mockAllContent, get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', + reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null, + sync_vault_asset_scope_for_window: null, get_file_history: [], get_settings: { auto_pull_interval_minutes: null, @@ -367,6 +405,7 @@ describe('App', () => { vi.clearAllMocks() resetMockCommandResults() localStorage.clear() + window.history.replaceState({}, '', '/') localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1') }) @@ -391,6 +430,31 @@ describe('App', () => { }) }) + it('opens a note window by loading only the requested entry', async () => { + const listVault = vi.fn(() => mockEntries) + const reloadVaultEntry = vi.fn(({ path }: { path: string }) => + mockEntries.find((entry) => entry.path === path) ?? null, + ) + const getNoteContent = vi.fn(({ path }: { path: string }) => mockAllContent[path] ?? '') + mockCommandResults.list_vault = listVault + mockCommandResults.reload_vault_entry = reloadVaultEntry + mockCommandResults.get_note_content = getNoteContent + window.history.replaceState( + {}, + '', + '/?window=note&path=%2Fvault%2Fproject%2Ftest.md&vault=%2Fvault&title=Test+Project', + ) + + render() + + await waitFor(() => expect(reloadVaultEntry).toHaveBeenCalled()) + expect(reloadVaultEntry).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' }) + await waitFor(() => expect(getNoteContent).toHaveBeenCalled()) + expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' }) + await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md')) + expect(listVault).not.toHaveBeenCalled() + }) + it('shows keyboard shortcut hints', async () => { const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' }) const newNoteHint = formatShortcutDisplay({ display: '⌘N' }) @@ -685,7 +749,7 @@ describe('App', () => { render() - const noteListContainer = await screen.findByTestId('note-list-container') + const noteListContainer = await screen.findByTestId('note-list-container', {}, { timeout: 5000 }) const getHeader = () => getHeaderForNoteList(noteListContainer) await waitFor(() => { diff --git a/src/App.tsx b/src/App.tsx index 317172d8..41933a81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,7 +82,7 @@ import { initializeNoteProperties } from './utils/initializeNoteProperties' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' import { openNoteInNewWindow } from './utils/openNoteWindow' import { refreshPulledVaultState } from './utils/pulledVaultRefresh' -import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode' +import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' @@ -138,26 +138,26 @@ function shouldPreferOnboardingVaultPath( && !vaults.some((vault) => vault.path === onboardingState.vaultPath) } -async function resolveNoteWindowEntry( - noteWindowParams: NoteWindowParams, - entries: VaultEntry[], -): Promise { - const fallbackEntry = () => - findNoteWindowEntry(entries, noteWindowParams) - - if (!isTauri()) { - return fallbackEntry() - } - +async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise { for (const path of getNoteWindowPathCandidates(noteWindowParams)) { try { - return await invoke('reload_vault_entry', { path }) + const request = { path, vaultPath: noteWindowParams.vaultPath } + const entry = isTauri() + ? await invoke('reload_vault_entry', request) + : await mockInvoke('reload_vault_entry', request) + if (entry) return entry } catch { - // Try the next normalized candidate before falling back to the scanned entries. + // Try the next normalized candidate before reporting the note as unavailable. } } +} - return fallbackEntry() +async function loadNoteWindowContent(path: string, vaultPath: string): Promise { + const request = { path, vaultPath } + if (!isTauri()) return mockInvoke('get_note_content', request) + + await invoke('sync_vault_asset_scope_for_window', { vaultPath }) + return invoke('get_note_content', request) } function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry { @@ -253,7 +253,11 @@ function App() { // called on user interaction, never during render (refs inside the hook // guarantee the latest closure is always used). const vaultSwitcher = useVaultSwitcher({ - onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() }, + onSwitch: () => { + if (noteWindowParams) return + handleSetSelection(DEFAULT_SELECTION) + notes.closeAllTabs() + }, onToast: (msg) => setToastMessage(msg), }) const { @@ -324,7 +328,7 @@ function App() { setGitRepoState('ready') }, [resolvedPath]) - const vault = useVaultLoader(resolvedPath) + const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath) const { status: vaultAiGuidanceStatus, refresh: refreshVaultAiGuidance, @@ -494,6 +498,10 @@ function App() { closeAllTabs, openTabWithContent, } = notes + const noteWindowActionsRef = useRef({ handleSelectNote, openTabWithContent }) + useEffect(() => { + noteWindowActionsRef.current = { handleSelectNote, openTabWithContent } + }, [handleSelectNote, openTabWithContent]) const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => { await refreshPulledVaultState({ activeTabPath: notes.activeTabPath, @@ -529,30 +537,34 @@ function App() { const pulseCommitDiffRequestIdRef = useRef(0) const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState(null) - // Note window: auto-open the note from URL params once vault entries load + // Note window: auto-open the note from URL params without scanning the whole vault. const noteWindowOpenedRef = useRef(false) const noteWindowMissingPathRef = useRef(null) useEffect(() => { if (!noteWindowParams || noteWindowOpenedRef.current) return - let cancelled = false - void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => { - if (cancelled || noteWindowOpenedRef.current) return + void resolveNoteWindowEntry(noteWindowParams).then(async (entry) => { + if (noteWindowOpenedRef.current) return if (entry) { - noteWindowOpenedRef.current = true - noteWindowMissingPathRef.current = null - void handleSelectNote(entry) + try { + const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath) + if (noteWindowOpenedRef.current) return + noteWindowOpenedRef.current = true + noteWindowMissingPathRef.current = null + noteWindowActionsRef.current.openTabWithContent(entry, content) + } catch { + if (noteWindowOpenedRef.current) return + noteWindowOpenedRef.current = true + noteWindowMissingPathRef.current = null + void noteWindowActionsRef.current.handleSelectNote(entry) + } return } if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return noteWindowMissingPathRef.current = noteWindowParams.notePath setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`) }) - - return () => { - cancelled = true - } - }, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries]) + }, [noteWindowParams, setToastMessage]) // Note window: update window title when active note changes useEffect(() => { diff --git a/src/utils/openNoteWindow.test.ts b/src/utils/openNoteWindow.test.ts index 959d6656..b7c516c7 100644 --- a/src/utils/openNoteWindow.test.ts +++ b/src/utils/openNoteWindow.test.ts @@ -4,6 +4,17 @@ import { isTauri } from '../mock-tauri' import { shouldUseLinuxWindowChrome } from './platform' const webviewWindowCalls = vi.fn() +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(), @@ -28,6 +39,7 @@ describe('openNoteWindow', () => { vi.setSystemTime(new Date('2026-04-14T16:00:00Z')) vi.mocked(isTauri).mockReturnValue(false) vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false) + localStorage.clear() }) it('builds a root-app route that preserves the note window params', () => { @@ -55,7 +67,7 @@ describe('openNoteWindow', () => { expect(webviewWindowCalls).toHaveBeenCalledWith( 'note-1776182400000', expect.objectContaining({ - url: '/?window=note&path=%2Fvault%2FFolder%2FMy+Note.md&vault=%2FUsers%2Fluca%2FLaputa+Vault&title=AI+%2F+ML', + url: '/?window=note&path=%2Fvault%2FFolder%2FMy+Note.md&vault=%2FUsers%2Fluca%2FLaputa+Vault&title=AI+%2F+ML&windowLabel=note-1776182400000', title: 'AI / ML', width: 800, height: 700, @@ -65,6 +77,11 @@ describe('openNoteWindow', () => { decorations: true, }), ) + expect(JSON.parse(localStorage.getItem('tolaria:note-window:note-1776182400000') ?? '{}')).toEqual({ + notePath: '/vault/Folder/My Note.md', + vaultPath: '/Users/luca/Laputa Vault', + noteTitle: 'AI / ML', + }) }) it('drops native decorations when Linux window chrome is active', async () => { diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts index 4892d5ca..2b56c3f7 100644 --- a/src/utils/openNoteWindow.ts +++ b/src/utils/openNoteWindow.ts @@ -1,7 +1,8 @@ import { isTauri } from '../mock-tauri' import { shouldUseLinuxWindowChrome } from './platform' +import { rememberNoteWindowParams } from './windowMode' -export function buildNoteWindowUrl(notePath: string, vaultPath: string, noteTitle: string): string { +export function buildNoteWindowUrl(notePath: string, vaultPath: string, noteTitle: string, windowLabel?: string): string { const params = new URLSearchParams({ window: 'note', path: notePath, @@ -9,6 +10,10 @@ export function buildNoteWindowUrl(notePath: string, vaultPath: string, noteTitl title: noteTitle, }) + if (windowLabel) { + params.set('windowLabel', windowLabel) + } + return `/?${params.toString()}` } @@ -21,9 +26,10 @@ export async function openNoteInNewWindow(notePath: string, vaultPath: string, n const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const label = `note-${Date.now()}` + rememberNoteWindowParams(label, { notePath, vaultPath, noteTitle }) new WebviewWindow(label, { - url: buildNoteWindowUrl(notePath, vaultPath, noteTitle), + url: buildNoteWindowUrl(notePath, vaultPath, noteTitle, label), title: noteTitle, width: 800, height: 700, diff --git a/src/utils/windowMode.test.ts b/src/utils/windowMode.test.ts index 8b398038..0d1e16b0 100644 --- a/src/utils/windowMode.test.ts +++ b/src/utils/windowMode.test.ts @@ -1,6 +1,28 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import type { VaultEntry } from '../types' -import { isNoteWindow, getNoteWindowParams, findNoteWindowEntry, getNoteWindowPathCandidates } from './windowMode' +import { + isNoteWindow, + getNoteWindowParams, + findNoteWindowEntry, + getNoteWindowPathCandidates, + rememberNoteWindowParams, +} from './windowMode' + +type WindowWithTauriInternals = Window & { + __TAURI_INTERNALS__?: { metadata?: { currentWindow?: { label?: string } } } +} + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) function makeEntry(path: string, title = 'Test Note'): VaultEntry { return { @@ -43,6 +65,8 @@ describe('windowMode', () => { beforeEach(() => { originalSearch = window.location.search + localStorage.clear() + delete (window as WindowWithTauriInternals).__TAURI_INTERNALS__ }) afterEach(() => { @@ -50,6 +74,7 @@ describe('windowMode', () => { writable: true, value: { ...window.location, search: originalSearch }, }) + delete (window as WindowWithTauriInternals).__TAURI_INTERNALS__ }) function setSearch(search: string) { @@ -59,6 +84,12 @@ describe('windowMode', () => { }) } + function setCurrentWindowLabel(label: string) { + (window as WindowWithTauriInternals).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label } }, + } + } + describe('isNoteWindow', () => { it('returns false when no query params', () => { setSearch('') @@ -74,6 +105,18 @@ describe('windowMode', () => { setSearch('?window=main') expect(isNoteWindow()).toBe(false) }) + + it('returns true when params are stored for the current Tauri window', () => { + setSearch('') + setCurrentWindowLabel('note-1') + rememberNoteWindowParams('note-1', { + notePath: '/vault/test.md', + vaultPath: '/vault', + noteTitle: 'Stored Note', + }) + + expect(isNoteWindow()).toBe(true) + }) }) describe('getNoteWindowParams', () => { @@ -106,6 +149,37 @@ describe('windowMode', () => { const params = getNoteWindowParams() expect(params?.noteTitle).toBe('Untitled') }) + + it('recovers params from storage when a Tauri note window loses its query params', () => { + setSearch('') + setCurrentWindowLabel('note-2') + rememberNoteWindowParams('note-2', { + notePath: '/vault/stored.md', + vaultPath: '/vault', + noteTitle: 'Stored Note', + }) + + expect(getNoteWindowParams()).toEqual({ + notePath: '/vault/stored.md', + vaultPath: '/vault', + noteTitle: 'Stored Note', + }) + }) + + it('recovers params by query window label when the note route is incomplete', () => { + setSearch('?window=note&windowLabel=note-3') + rememberNoteWindowParams('note-3', { + notePath: '/vault/fallback.md', + vaultPath: '/vault', + noteTitle: 'Fallback Note', + }) + + expect(getNoteWindowParams()).toEqual({ + notePath: '/vault/fallback.md', + vaultPath: '/vault', + noteTitle: 'Fallback Note', + }) + }) }) describe('findNoteWindowEntry', () => { diff --git a/src/utils/windowMode.ts b/src/utils/windowMode.ts index 476ef242..bd298725 100644 --- a/src/utils/windowMode.ts +++ b/src/utils/windowMode.ts @@ -13,17 +13,82 @@ export interface NoteWindowParams { type NoteWindowPathContext = Pick +interface TauriWindowInternals { + metadata?: { currentWindow?: { label?: string } } +} + +const NOTE_WINDOW_STORAGE_PREFIX = 'tolaria:note-window:' + +function getCurrentWindowLabel(): string | null { + const internals = (window as Window & { __TAURI_INTERNALS__?: TauriWindowInternals }).__TAURI_INTERNALS__ + const label = internals?.metadata?.currentWindow?.label + return typeof label === 'string' && label.length > 0 ? label : null +} + +function noteWindowStorageKey(label: string): string { + return `${NOTE_WINDOW_STORAGE_PREFIX}${label}` +} + +function isStoredNoteWindowParams(value: Partial): value is NoteWindowParams { + if (typeof value.notePath !== 'string') return false + if (typeof value.vaultPath !== 'string') return false + return typeof value.noteTitle === 'string' +} + +function parseStoredNoteWindowParams(raw: string | null): NoteWindowParams | null { + if (!raw) return null + + try { + const parsed = JSON.parse(raw) as Partial + if (isStoredNoteWindowParams(parsed)) { + return { + notePath: parsed.notePath, + vaultPath: parsed.vaultPath, + noteTitle: parsed.noteTitle, + } + } + } catch { + return null + } + + return null +} + +function getStoredNoteWindowParams(label: string | null): NoteWindowParams | null { + if (!label) return null + + try { + return parseStoredNoteWindowParams(localStorage.getItem(noteWindowStorageKey(label))) + } catch { + return null + } +} + +function getNoteWindowLabel(params: URLSearchParams): string | null { + return params.get('windowLabel') ?? getCurrentWindowLabel() +} + +export function rememberNoteWindowParams(label: string, params: NoteWindowParams): void { + try { + localStorage.setItem(noteWindowStorageKey(label), JSON.stringify(params)) + } catch { + // Best-effort fallback for Tauri windows that lose their initial URL params. + } +} + export function isNoteWindow(): boolean { - return new URLSearchParams(window.location.search).get('window') === 'note' + const params = new URLSearchParams(window.location.search) + if (params.get('window') === 'note') return true + return getStoredNoteWindowParams(getCurrentWindowLabel()) !== null } export function getNoteWindowParams(): NoteWindowParams | null { const params = new URLSearchParams(window.location.search) - if (params.get('window') !== 'note') return null + if (params.get('window') !== 'note') return getStoredNoteWindowParams(getCurrentWindowLabel()) const notePath = params.get('path') const vaultPath = params.get('vault') const noteTitle = params.get('title') ?? 'Untitled' - if (!notePath || !vaultPath) return null + if (!notePath || !vaultPath) return getStoredNoteWindowParams(getNoteWindowLabel(params)) return { notePath, vaultPath, noteTitle } }