Compare commits
3 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d2182c0e | ||
|
|
80baa74175 | ||
|
|
f71e6d07e7 |
@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
|
||||
})
|
||||
|
||||
// --- Flow 8: Wiki-link navigation ---
|
||||
|
||||
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
|
||||
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
|
||||
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify we opened the right note
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
|
||||
|
||||
// Click the wikilink — use mouse.click to fire real mousedown
|
||||
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
|
||||
await expect(wikilink).toBeVisible()
|
||||
const box = await wikilink.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// New tab should open with the target note active
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
|
||||
|
||||
// Editor should show the target note's content
|
||||
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
dirs::document_dir()
|
||||
.map(|d| d.join("Laputa"))
|
||||
.map(|d| d.join("Getting Started"))
|
||||
.ok_or_else(|| "Could not determine Documents directory".to_string())
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ mod tests {
|
||||
let path = default_vault_path().unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("Documents"));
|
||||
assert!(path_str.ends_with("Laputa"));
|
||||
assert!(path_str.ends_with("Getting Started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,7 +71,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
get_default_vault_path: '/Users/mock/Documents/Laputa',
|
||||
get_default_vault_path: '/Users/mock/Documents/Getting Started',
|
||||
list_themes: [],
|
||||
get_vault_settings: { theme: null },
|
||||
}
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -277,6 +277,18 @@ function App() {
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
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')
|
||||
}
|
||||
// 'available' → UpdateBanner handles it automatically
|
||||
}, [updateActions, setToastMessage])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
@@ -314,10 +326,10 @@ function App() {
|
||||
},
|
||||
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
|
||||
})
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist
|
||||
|
||||
@@ -54,6 +54,8 @@ interface AppCommandsConfig {
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
onOpenVault?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -151,6 +153,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onOpenTheme: config.onOpenTheme,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
isUpdating: config.isUpdating,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -281,6 +281,41 @@ describe('useCommandRegistry', () => {
|
||||
expect(onOpenDailyNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('check-updates command', () => {
|
||||
it('has check-updates command in Settings group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'check-updates')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('Check for Updates')
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.keywords).toContain('update')
|
||||
expect(cmd!.keywords).toContain('version')
|
||||
})
|
||||
|
||||
it('is enabled when not updating', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ isUpdating: false })),
|
||||
)
|
||||
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when updating', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ isUpdating: true })),
|
||||
)
|
||||
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onCheckForUpdates when executed', () => {
|
||||
const onCheckForUpdates = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ onCheckForUpdates })),
|
||||
)
|
||||
result.current.find(c => c.id === 'check-updates')!.execute()
|
||||
expect(onCheckForUpdates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('type-aware commands', () => {
|
||||
it('generates "New [Type]" commands from vault entries', () => {
|
||||
const entries = [
|
||||
|
||||
@@ -34,6 +34,8 @@ interface CommandRegistryConfig {
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -175,6 +177,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
onCheckForUpdates, isUpdating,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -229,6 +232,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
// Settings
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
@@ -240,6 +244,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCheckForUpdates, isUpdating,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('transitions to ready when vault exists', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return true
|
||||
return null
|
||||
})
|
||||
@@ -50,7 +50,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('shows welcome screen when vault does not exist', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -60,14 +60,14 @@ describe('useOnboarding', () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
|
||||
})
|
||||
|
||||
it('shows vault-missing when previously dismissed and vault gone', async () => {
|
||||
localStorage.setItem('laputa_welcome_dismissed', '1')
|
||||
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -80,15 +80,15 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state).toEqual({
|
||||
status: 'vault-missing',
|
||||
vaultPath: '/vault/deleted',
|
||||
defaultPath: '/mock/Documents/Laputa',
|
||||
defaultPath: '/mock/Documents/Getting Started',
|
||||
})
|
||||
})
|
||||
|
||||
it('handleCreateVault creates vault and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Getting Started'
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -102,13 +102,13 @@ describe('useOnboarding', () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('handleCreateVault sets error on failure', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
|
||||
return null
|
||||
@@ -130,7 +130,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -152,7 +152,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('handleOpenFolder does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -173,7 +173,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('handleDismiss marks dismissed and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -199,4 +199,73 @@ describe('useUpdater', () => {
|
||||
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('checkForUpdates (manual)', () => {
|
||||
it('returns up-to-date when no update is available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('up-to-date')
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('returns available and sets status when update exists', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '3.0.0',
|
||||
body: 'Major release',
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('available')
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '3.0.0',
|
||||
notes: 'Major release',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error on network failure', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockRejectedValue(new Error('No internet'))
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('error')
|
||||
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
|
||||
})
|
||||
|
||||
it('returns up-to-date when not in Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('up-to-date')
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,10 @@ export type UpdateStatus =
|
||||
| { state: 'ready'; version: string }
|
||||
| { state: 'error' }
|
||||
|
||||
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
|
||||
|
||||
export interface UpdateActions {
|
||||
checkForUpdates: () => Promise<UpdateCheckResult>
|
||||
startDownload: () => void
|
||||
openReleaseNotes: () => void
|
||||
dismiss: () => void
|
||||
@@ -21,31 +24,32 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
|
||||
const updateRef = useRef<unknown>(null)
|
||||
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return 'up-to-date'
|
||||
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return 'up-to-date'
|
||||
|
||||
updateRef.current = update
|
||||
setStatus({
|
||||
state: 'available',
|
||||
version: update.version,
|
||||
notes: update.body ?? undefined,
|
||||
})
|
||||
return 'available'
|
||||
} catch {
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
return 'error'
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return // up to date
|
||||
|
||||
updateRef.current = update
|
||||
setStatus({
|
||||
state: 'available',
|
||||
version: update.version,
|
||||
notes: update.body ?? undefined,
|
||||
})
|
||||
} catch {
|
||||
// Network error or 404 — fail silently
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
}
|
||||
}
|
||||
|
||||
// Delay so the app can render first
|
||||
const timer = setTimeout(checkForUpdates, 3000)
|
||||
const timer = setTimeout(() => { checkForUpdates() }, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
}, [checkForUpdates])
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
const update = updateRef.current as {
|
||||
@@ -88,7 +92,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
setStatus({ state: 'idle' })
|
||||
}, [])
|
||||
|
||||
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
|
||||
return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = isTauri()
|
||||
? [
|
||||
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
{ label: 'Laputa', path: '/Users/luca/Laputa' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
]
|
||||
export const DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
]
|
||||
|
||||
interface UseVaultSwitcherOptions {
|
||||
onSwitch: () => void
|
||||
|
||||
@@ -245,12 +245,12 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}))
|
||||
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
|
||||
},
|
||||
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
|
||||
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
|
||||
check_vault_exists: (args: { path: string }) => {
|
||||
// In mock mode, the demo-vault-v2 path always "exists"
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
list_themes: (): ThemeFile[] => [...mockThemes],
|
||||
get_theme: (args: { themeId: string }): ThemeFile => {
|
||||
|
||||
Reference in New Issue
Block a user