Compare commits

...

3 Commits

Author SHA1 Message Date
lucaronin
857d55a7cd fix: stop cmd+s from renaming note titles 2026-04-18 08:25:43 +02:00
lucaronin
b78dfad384 fix: resume onboarding after telemetry on fresh start 2026-04-18 03:10:41 +02:00
lucaronin
9ac51bab9a test: pin app test active vault 2026-04-18 02:35:41 +02:00
9 changed files with 321 additions and 114 deletions

View File

@@ -270,6 +270,42 @@ describe('App', () => {
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it.each([
['telemetry-accept', 'Allow anonymous reporting'],
['telemetry-decline', 'No thanks'],
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = {
vaults: [],
active_vault: rememberedDefaultVaultPath,
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(buttonTestId))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('renders sidebar with correct default selection (All Notes)', async () => {
render(<App />)
await waitFor(() => {
@@ -280,6 +316,11 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',
hidden_defaults: [],
}
const disabledWorkflowConfig = JSON.stringify({
zoom: null,
view_mode: null,

View File

@@ -61,7 +61,6 @@ import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useVaultBridge } from './hooks/useVaultBridge'
import { getAppStorageItem } from './constants/appStorage'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -207,7 +206,7 @@ function App() {
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const { allVaults, handleVaultCloned, switchVault } = vaultSwitcher
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
if (!vaultPath) return
@@ -1019,17 +1018,13 @@ function App() {
const shouldResumeFreshStartOnboarding = useMemo(() => {
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
try {
if (getAppStorageItem('welcomeDismissed') === '1') return false
} catch {
return false
}
return vaultSwitcher.allVaults.length === 1
return remembersOnlyDefaultVault
&& vaultSwitcher.allVaults.length === 1
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [onboarding.state, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {

View File

@@ -488,6 +488,30 @@ describe('useAppSave', () => {
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('does not rename an existing markdown note when Cmd+S saves a desynced H1/title state', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const notePath = '/vault/note-b.md'
const noteContent = '# Breadcrumb Sync Target\n\nBody'
const entry = makeEntry(notePath, 'Breadcrumb Sync Target', 'note-b.md')
const { result } = renderSave({
tabs: [{ entry, content: noteContent }],
activeTabPath: notePath,
unsavedPaths: new Set([notePath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: notePath,
content: noteContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => {
const initialContent = '# Fresh Title\n\nInitial body'
const bufferedContent = '# Fresh Title\n\nBody typed right before rename'

View File

@@ -1,7 +1,6 @@
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { needsRenameOnSave } from './useNoteRename'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
@@ -77,20 +76,6 @@ function findUnsavedFallback({
return { path: activeTab.entry.path, content: activeTab.content }
}
function activeTabNeedsRename({
tabs,
activeTabPath,
}: {
tabs: TabState[]
activeTabPath: string | null
}): { path: string; title: string } | null {
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
if (!activeTab) return null
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
? { path: activeTab.entry.path, title: activeTab.entry.title }
: null
}
function isUntitledRenameCandidate(path: string): boolean {
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
@@ -546,16 +531,6 @@ function useRenameHandlers({
replaceRenamedEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
}) {
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
const currentPath = await preparePathForManualRename({
path,
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
})
await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles)
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => {
const currentPath = await preparePathForManualRename({
path,
@@ -578,12 +553,11 @@ function useRenameHandlers({
.catch((err) => console.error('Title rename failed:', err))
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
return { handleRenameTab, handleFilenameRename, handleTitleSync }
return { handleFilenameRename, handleTitleSync }
}
function useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,
@@ -591,7 +565,6 @@ function useHandleSaveAction({
resolveCurrentPath,
}: {
handleSaveRaw: (unsavedFallback?: { path: string; content: string }) => Promise<void>
handleRenameTab: (path: string, newTitle: string) => Promise<void>
tabs: TabState[]
activeTabPath: string | null
unsavedPaths: Set<string>
@@ -605,10 +578,8 @@ function useHandleSaveAction({
activeTabPath: resolvedActiveTabPath,
unsavedPaths,
}))
const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath })
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
}, [handleSaveRaw, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
}
function useEditorPersistence({
@@ -741,7 +712,7 @@ function useAppSaveHandlers({
setToastMessage,
flushPendingUntitledRename,
})
const { handleRenameTab, handleFilenameRename, handleTitleSync } = useRenameHandlers({
const { handleFilenameRename, handleTitleSync } = useRenameHandlers({
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
@@ -753,7 +724,6 @@ function useAppSaveHandlers({
})
const handleSave = useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,

View File

@@ -148,6 +148,16 @@ describe('useVaultSwitcher', () => {
expect(onSwitch).toHaveBeenCalled()
})
it('does not persist the implicit default vault as an active selection', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBeNull()
expect(mockVaultListStore.active_vault).toBeNull()
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {
@@ -413,6 +423,20 @@ describe('useVaultSwitcher', () => {
expect(result.current.vaultPath).toBe(persistedPath)
})
it('treats a remembered default vault as unselected when it is the only vault', async () => {
mockVaultListStore = {
vaults: [],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBeNull()
})
})
describe('isGettingStartedHidden', () => {

View File

@@ -31,8 +31,10 @@ interface PersistedVaultState {
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
selectedVaultPath: string | null
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
@@ -48,10 +50,12 @@ interface PersistedVaultStore {
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
selectedVaultPath: string | null
setDefaultPath: Dispatch<SetStateAction<string>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setLoaded: Dispatch<SetStateAction<boolean>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
@@ -77,10 +81,13 @@ interface RemainingVaultOptions {
}
interface RemoveVaultStateOptions extends RemainingVaultOptions {
selectedVaultPath: string | null
onSwitchRef: MutableRefObject<() => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
interface RemoveVaultActionOptions {
@@ -91,10 +98,17 @@ interface RemoveVaultActionOptions {
onToastRef: MutableRefObject<(msg: string) => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
selectedVaultPath: string | null
vaultPath: string
}
function labelFromPath(path: string): string {
interface VaultPathInput {
path: string
}
function labelFromPath({ path }: VaultPathInput): string {
return path.split('/').pop() || 'Local Vault'
}
@@ -127,22 +141,37 @@ async function loadInitialVaultState() {
return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults }
}
function buildDefaultVaults(defaultPath: string): VaultOption[] {
function buildDefaultVaults({ defaultPath }: { defaultPath: string }): VaultOption[] {
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
}
function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] {
function buildVisibleDefaultVaults({
defaultVaults,
hiddenDefaults,
}: {
defaultVaults: VaultOption[]
hiddenDefaults: string[]
}): VaultOption[] {
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
}
function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] {
function buildAllVaults({
visibleDefaults,
extraVaults,
}: {
visibleDefaults: VaultOption[]
extraVaults: VaultOption[]
}): VaultOption[] {
return [...visibleDefaults, ...extraVaults]
}
function applyResolvedDefaultPath(
resolvedDefaultPath: string,
setDefaultPath: Dispatch<SetStateAction<string>>,
) {
function applyResolvedDefaultPath({
resolvedDefaultPath,
setDefaultPath,
}: {
resolvedDefaultPath: string
setDefaultPath: Dispatch<SetStateAction<string>>
}) {
if (!resolvedDefaultPath) {
return
}
@@ -151,14 +180,35 @@ function applyResolvedDefaultPath(
syncDefaultVaultExport(resolvedDefaultPath)
}
function applyInitialVaultTarget(
function normalizeInitialSelectedVaultPath(
activeVault: string | null,
resolvedDefaultPath: string,
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
) {
vaults: VaultOption[],
): string | null {
if (!activeVault) {
return null
}
const isRememberedDefaultOnlySelection = activeVault === resolvedDefaultPath && vaults.length === 0
return isRememberedDefaultOnlySelection ? null : activeVault
}
function applyInitialVaultTarget({
activeVault,
resolvedDefaultPath,
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
}: {
activeVault: string | null
resolvedDefaultPath: string
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
onSwitchRef: MutableRefObject<() => void>
}) {
if (activeVault) {
setVaultPath(activeVault)
setSelectedVaultPath(activeVault)
onSwitchRef.current()
return
}
@@ -174,15 +224,15 @@ function useVaultCollections(
extraVaults: VaultOption[],
): VaultCollections {
const defaultVaults = useMemo(
() => buildDefaultVaults(defaultPath),
() => buildDefaultVaults({ defaultPath }),
[defaultPath],
)
const visibleDefaults = useMemo(
() => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults),
() => buildVisibleDefaultVaults({ defaultVaults, hiddenDefaults }),
[defaultVaults, hiddenDefaults],
)
const allVaults = useMemo(
() => buildAllVaults(visibleDefaults, extraVaults),
() => buildAllVaults({ visibleDefaults, extraVaults }),
[extraVaults, visibleDefaults],
)
const isGettingStartedHidden = useMemo(
@@ -202,6 +252,7 @@ function useLoadPersistedVaultState(
setExtraVaults,
setHiddenDefaults,
setLoaded,
setSelectedVaultPath,
setVaultPath,
} = store
@@ -214,8 +265,14 @@ function useLoadPersistedVaultState(
setExtraVaults(vaults)
setHiddenDefaults(hidden)
applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath)
applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef)
applyResolvedDefaultPath({ resolvedDefaultPath, setDefaultPath })
applyInitialVaultTarget({
activeVault: normalizeInitialSelectedVaultPath(activeVault, resolvedDefaultPath, vaults),
resolvedDefaultPath,
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
})
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
@@ -225,23 +282,24 @@ function useLoadPersistedVaultState(
})
return () => { cancelled = true }
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath])
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}
function usePersistedVaultStorage(store: PersistedVaultStore) {
const { extraVaults, hiddenDefaults, loaded, vaultPath } = store
const { extraVaults, hiddenDefaults, loaded, selectedVaultPath } = store
useEffect(() => {
if (!loaded) return
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, hiddenDefaults, loaded, vaultPath])
}, [extraVaults, hiddenDefaults, loaded, selectedVaultPath])
}
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
const [selectedVaultPath, setSelectedVaultPath] = useState<string | null>(null)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
@@ -252,10 +310,12 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
setDefaultPath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}
@@ -268,8 +328,10 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}
@@ -304,23 +366,34 @@ async function ensureGettingStartedVaultReady(path: string): Promise<void> {
}
}
function addVaultToList(
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
path: string,
label: string,
) {
function addVaultToList({
setExtraVaults,
path,
label,
}: {
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
path: string
label: string
}) {
setExtraVaults(previousVaults => {
const exists = previousVaults.some(vault => vault.path === path)
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
})
}
function switchVaultPath(
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
path: string,
) {
function switchVaultPath({
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
path,
}: {
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
onSwitchRef: MutableRefObject<() => void>
path: string
}) {
trackEvent('vault_switched')
setSelectedVaultPath(path)
setVaultPath(path)
onSwitchRef.current()
}
@@ -349,7 +422,10 @@ function removeVaultFromState({
removedPath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}: RemoveVaultStateOptions) {
if (isDefault) {
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
@@ -357,43 +433,52 @@ function removeVaultFromState({
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
}
setVaultPath(currentPath => {
if (currentPath !== removedPath) {
return currentPath
if (vaultPath !== removedPath) {
if (selectedVaultPath === removedPath) {
setSelectedVaultPath(null)
}
return
}
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
if (remainingVaults.length === 0) {
return currentPath
}
onSwitchRef.current()
return remainingVaults[0].path
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
if (remainingVaults.length === 0) {
setSelectedVaultPath(null)
return
}
const nextPath = remainingVaults[0].path
setSelectedVaultPath(nextPath)
setVaultPath(nextPath)
onSwitchRef.current()
}
function getRemovedVaultLabel(
path: string,
defaultVaults: VaultOption[],
extraVaults: VaultOption[],
): string {
function getRemovedVaultLabel({
path,
defaultVaults,
extraVaults,
}: {
path: string
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
}): string {
const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path)
return removedVault?.label ?? labelFromPath(path)
return removedVault?.label ?? labelFromPath({ path })
}
function useSwitchVaultAction(
onSwitchRef: MutableRefObject<() => void>,
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>,
setVaultPath: Dispatch<SetStateAction<string>>,
) {
return useCallback((path: string) => {
switchVaultPath(setVaultPath, onSwitchRef, path)
}, [onSwitchRef, setVaultPath])
switchVaultPath({ setSelectedVaultPath, setVaultPath, onSwitchRef, path })
}, [onSwitchRef, setSelectedVaultPath, setVaultPath])
}
function useVaultClonedAction(
@@ -414,7 +499,7 @@ function useOpenLocalFolderAction(
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
const label = labelFromPath({ path })
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch, onToastRef])
@@ -428,7 +513,10 @@ function useRemoveVaultAction({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}: RemoveVaultActionOptions) {
return useCallback((path: string) => {
const isDefault = defaultVaults.some(vault => vault.path === path)
@@ -442,9 +530,12 @@ function useRemoveVaultAction({
removedPath: path,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
})
onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`)
onToastRef.current(`Vault "${getRemovedVaultLabel({ path, defaultVaults, extraVaults })}" removed from list`)
}, [
defaultVaults,
extraVaults,
@@ -453,7 +544,10 @@ function useRemoveVaultAction({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
])
}
@@ -479,13 +573,16 @@ function useVaultActions({
onToastRef,
setExtraVaults,
setHiddenDefaults,
selectedVaultPath,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}: VaultActionOptions) {
const addVault = useCallback((path: string, label: string) => {
addVaultToList(setExtraVaults, path, label)
addVaultToList({ setExtraVaults, path, label })
}, [setExtraVaults])
const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath)
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
@@ -502,7 +599,10 @@ function useVaultActions({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}),
restoreGettingStarted: useRestoreGettingStartedAction({
defaultPath,
@@ -543,7 +643,14 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
const persistedState = usePersistedVaultState(onSwitchRef)
const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState
const {
defaultPath,
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
vaultPath,
} = persistedState
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
defaultPath,
hiddenDefaults,
@@ -559,7 +666,16 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
})
return {
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
removeVault, restoreGettingStarted, isGettingStartedHidden,
allVaults,
defaultPath,
handleOpenLocalFolder,
handleVaultCloned,
isGettingStartedHidden,
loaded,
removeVault,
restoreGettingStarted,
selectedVaultPath,
switchVault,
vaultPath,
}
}

View File

@@ -28,7 +28,7 @@ export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVa
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
}
export function saveVaultList(vaults: VaultOption[], activeVault: string, hiddenDefaults: string[] = []): Promise<void> {
export function saveVaultList(vaults: VaultOption[], activeVault: string | null, hiddenDefaults: string[] = []): Promise<void> {
const list: PersistedVaultList = {
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
active_vault: activeVault,

View File

@@ -57,7 +57,7 @@ async function setRawEditorContent(page: Page, content: string) {
}, content)
}
test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => {
test('@smoke Cmd+S preserves the existing filename until the explicit breadcrumb sync action', async ({ page }) => {
const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md')
const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md')
const originalPath = path.join(tempVaultDir, 'note', 'note-b.md')

View File

@@ -1,8 +1,20 @@
import { test, expect } from '@playwright/test'
import { test, expect, type Page } from '@playwright/test'
test('accepting telemetry consent on a fresh start opens the vault choice wizard @smoke', async ({ page }) => {
await page.addInitScript(() => {
const REMEMBERED_DEFAULT_VAULT_PATH = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
async function mockFreshStart(
page: Page,
options: {
activeVault: string | null
checkExistingPath: string
rememberWelcomeDismissal?: boolean
},
) {
await page.addInitScript((config) => {
localStorage.clear()
if (config.rememberWelcomeDismissal) {
localStorage.setItem('tolaria_welcome_dismissed', '1')
}
let ref: Record<string, unknown> | null = null
@@ -21,16 +33,23 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
})
ref.load_vault_list = () => ({
vaults: [],
active_vault: null,
active_vault: config.activeVault,
hidden_defaults: [],
})
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
ref.check_vault_exists = (args: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
ref.get_default_vault_path = () => config.checkExistingPath
ref.check_vault_exists = (args: { path?: string }) => args?.path === config.checkExistingPath
},
get() {
return ref
},
})
}, options)
}
test('accepting telemetry consent on a fresh start opens the vault choice wizard @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
@@ -42,3 +61,21 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
await mockFreshStart(page, {
activeVault: REMEMBERED_DEFAULT_VAULT_PATH,
checkExistingPath: REMEMBERED_DEFAULT_VAULT_PATH,
rememberWelcomeDismissal: true,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
await page.getByTestId(`telemetry-${action}`).click()
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
})
}