diff --git a/src/App.tsx b/src/App.tsx index 09e9ac87..b9f39913 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,7 +63,7 @@ import type { SidebarSelection, InboxPeriod, VaultEntry } from './types' import type { NoteListItem } from './utils/ai-context' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' import { openNoteInNewWindow } from './utils/openNoteWindow' -import { isNoteWindow, getNoteWindowParams } from './utils/windowMode' +import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' @@ -93,6 +93,28 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION +async function resolveNoteWindowEntry( + noteWindowParams: NoteWindowParams, + entries: VaultEntry[], +): Promise { + const fallbackEntry = () => + findNoteWindowEntry(entries, noteWindowParams) + + if (!isTauri()) { + return fallbackEntry() + } + + for (const path of getNoteWindowPathCandidates(noteWindowParams)) { + try { + return await invoke('reload_vault_entry', { path }) + } catch { + // Try the next normalized candidate before falling back to the scanned entries. + } + } + + return fallbackEntry() +} + /** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */ function App() { const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, []) @@ -269,17 +291,32 @@ function App() { onFrontmatterPersisted: vault.loadModifiedFiles, onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath), }) + const { handleSelectNote } = notes // Note window: auto-open the note from URL params once vault entries load const noteWindowOpenedRef = useRef(false) + const noteWindowMissingPathRef = useRef(null) useEffect(() => { - if (!noteWindowParams || noteWindowOpenedRef.current || vault.entries.length === 0) return - const entry = vault.entries.find(e => e.path === noteWindowParams.notePath) - if (entry) { - noteWindowOpenedRef.current = true - notes.handleSelectNote(entry) + if (!noteWindowParams || noteWindowOpenedRef.current) return + let cancelled = false + + void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => { + if (cancelled || noteWindowOpenedRef.current) return + if (entry) { + noteWindowOpenedRef.current = true + noteWindowMissingPathRef.current = null + void 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 } - }, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- run when entries load, params are stable + }, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries]) // Note window: update window title when active note changes useEffect(() => { diff --git a/src/utils/openNoteWindow.test.ts b/src/utils/openNoteWindow.test.ts new file mode 100644 index 00000000..1fd0773d --- /dev/null +++ b/src/utils/openNoteWindow.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildNoteWindowUrl, openNoteInNewWindow } from './openNoteWindow' +import { isTauri } from '../mock-tauri' + +const webviewWindowCalls = vi.fn() + +vi.mock('../mock-tauri', () => ({ + isTauri: vi.fn(), +})) + +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + WebviewWindow: class MockWebviewWindow { + constructor(label: string, options: unknown) { + webviewWindowCalls(label, options) + } + }, +})) + +describe('openNoteWindow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-14T16:00:00Z')) + vi.mocked(isTauri).mockReturnValue(false) + }) + + it('builds a root-app route that preserves the note window params', () => { + const url = buildNoteWindowUrl('/vault/Folder/My Note.md', '/Users/luca/Laputa Vault', 'AI / ML') + const parsed = new URL(url, 'https://tolaria.localhost') + + expect(parsed.pathname).toBe('/') + expect(parsed.searchParams.get('window')).toBe('note') + expect(parsed.searchParams.get('path')).toBe('/vault/Folder/My Note.md') + expect(parsed.searchParams.get('vault')).toBe('/Users/luca/Laputa Vault') + expect(parsed.searchParams.get('title')).toBe('AI / ML') + }) + + it('does nothing outside Tauri', async () => { + await openNoteInNewWindow('/vault/test.md', '/vault', 'Test Note') + + expect(webviewWindowCalls).not.toHaveBeenCalled() + }) + + it('opens a new Tauri window with the encoded note route', async () => { + vi.mocked(isTauri).mockReturnValue(true) + + await openNoteInNewWindow('/vault/Folder/My Note.md', '/Users/luca/Laputa Vault', 'AI / ML') + + 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', + title: 'AI / ML', + width: 800, + height: 700, + resizable: true, + titleBarStyle: 'overlay', + hiddenTitle: true, + }), + ) + }) +}) diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts index a8348d52..cf7ae1d2 100644 --- a/src/utils/openNoteWindow.ts +++ b/src/utils/openNoteWindow.ts @@ -1,5 +1,16 @@ import { isTauri } from '../mock-tauri' +export function buildNoteWindowUrl(notePath: string, vaultPath: string, noteTitle: string): string { + const params = new URLSearchParams({ + window: 'note', + path: notePath, + vault: vaultPath, + title: noteTitle, + }) + + return `/?${params.toString()}` +} + /** * Opens a note in a new Tauri window with a minimal editor-only layout. * In browser mode (non-Tauri), this is a no-op. @@ -9,10 +20,9 @@ export async function openNoteInNewWindow(notePath: string, vaultPath: string, n const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const label = `note-${Date.now()}` - const url = `index.html?window=note&path=${encodeURIComponent(notePath)}&vault=${encodeURIComponent(vaultPath)}&title=${encodeURIComponent(noteTitle)}` new WebviewWindow(label, { - url, + url: buildNoteWindowUrl(notePath, vaultPath, noteTitle), title: noteTitle, width: 800, height: 700, diff --git a/src/utils/windowMode.test.ts b/src/utils/windowMode.test.ts index ec5c630b..8b398038 100644 --- a/src/utils/windowMode.test.ts +++ b/src/utils/windowMode.test.ts @@ -1,5 +1,42 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { isNoteWindow, getNoteWindowParams } from './windowMode' +import type { VaultEntry } from '../types' +import { isNoteWindow, getNoteWindowParams, findNoteWindowEntry, getNoteWindowPathCandidates } from './windowMode' + +function makeEntry(path: string, title = 'Test Note'): VaultEntry { + return { + path, + filename: path.split('/').pop() ?? 'test.md', + title, + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: null, + createdAt: null, + fileSize: 0, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + } +} describe('windowMode', () => { let originalSearch: string @@ -70,4 +107,43 @@ describe('windowMode', () => { expect(params?.noteTitle).toBe('Untitled') }) }) + + describe('findNoteWindowEntry', () => { + it('returns direct and vault-expanded path candidates', () => { + expect(getNoteWindowPathCandidates({ + notePath: 'demo-vault-v2/untitled-note-29.md', + vaultPath: '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', + })).toEqual([ + 'demo-vault-v2/untitled-note-29.md', + '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/untitled-note-29.md', + ]) + }) + + it('matches an absolute note path against vault-relative entries', () => { + const entry = makeEntry('demo-vault-v2/untitled-note-29.md') + + expect(findNoteWindowEntry([entry], { + notePath: '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/untitled-note-29.md', + vaultPath: 'demo-vault-v2', + })).toBe(entry) + }) + + it('matches a vault-relative note path against absolute entries', () => { + const entry = makeEntry('/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/untitled-note-29.md') + + expect(findNoteWindowEntry([entry], { + notePath: 'demo-vault-v2/untitled-note-29.md', + vaultPath: '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', + })).toBe(entry) + }) + + it('returns undefined when the target note is absent', () => { + const entry = makeEntry('/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/other-note.md') + + expect(findNoteWindowEntry([entry], { + notePath: '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/untitled-note-29.md', + vaultPath: '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', + })).toBeUndefined() + }) + }) }) diff --git a/src/utils/windowMode.ts b/src/utils/windowMode.ts index bc4cef79..476ef242 100644 --- a/src/utils/windowMode.ts +++ b/src/utils/windowMode.ts @@ -1,3 +1,5 @@ +import type { VaultEntry } from '../types' + /** * Detects whether the current window is a secondary "note window" (opened via * "Open in New Window") by inspecting URL query parameters. @@ -9,6 +11,8 @@ export interface NoteWindowParams { noteTitle: string } +type NoteWindowPathContext = Pick + export function isNoteWindow(): boolean { return new URLSearchParams(window.location.search).get('window') === 'note' } @@ -22,3 +26,66 @@ export function getNoteWindowParams(): NoteWindowParams | null { if (!notePath || !vaultPath) return null return { notePath, vaultPath, noteTitle } } + +function trimTrailingSlash(path: string): string { + return path.replace(/\/+$/, '') +} + +function stripKnownVaultPrefix({ notePath, vaultPath }: NoteWindowPathContext): string { + const normalizedPath = trimTrailingSlash(notePath) + const normalizedVaultPath = trimTrailingSlash(vaultPath) + const vaultPrefix = `${normalizedVaultPath}/` + + if (normalizedVaultPath && normalizedPath.startsWith(vaultPrefix)) { + return normalizedPath.slice(vaultPrefix.length) + } + + const vaultName = normalizedVaultPath.split('/').pop() + if (vaultName && normalizedPath.startsWith(`${vaultName}/`)) { + return normalizedPath.slice(vaultName.length + 1) + } + + return normalizedPath.replace(/^\/+/, '') +} + +export function getNoteWindowPathCandidates({ notePath, vaultPath }: NoteWindowPathContext): string[] { + const normalizedPath = trimTrailingSlash(notePath) + const normalizedVaultPath = trimTrailingSlash(vaultPath) + const relativePath = stripKnownVaultPrefix({ notePath: normalizedPath, vaultPath: normalizedVaultPath }) + const candidates = new Set([normalizedPath]) + + if (normalizedVaultPath) { + candidates.add(`${normalizedVaultPath}/${relativePath}`) + } + + return [...candidates] +} + +function pathsMatch(leftPath: string, rightPath: string): boolean { + if (leftPath === rightPath) return true + return leftPath.endsWith(`/${rightPath}`) || rightPath.endsWith(`/${leftPath}`) +} + +function variantsOverlap(left: Set, right: Set): boolean { + for (const leftVariant of left) { + for (const rightVariant of right) { + if (pathsMatch(leftVariant, rightVariant)) { + return true + } + } + } + + return false +} + +export function findNoteWindowEntry( + entries: VaultEntry[], + pathContext: NoteWindowPathContext, +): VaultEntry | undefined { + const targetVariants = new Set(getNoteWindowPathCandidates(pathContext)) + + return entries.find((entry) => variantsOverlap(targetVariants, new Set(getNoteWindowPathCandidates({ + notePath: entry.path, + vaultPath: pathContext.vaultPath, + })))) +}