Compare commits
4 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc11c869aa | ||
|
|
502ecf0572 | ||
|
|
8bbbba98b7 | ||
|
|
86adf860b5 |
@@ -358,6 +358,30 @@ describe('App', () => {
|
||||
expect(appShell).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches vaults from the bottom bar after onboarding is ready', async () => {
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [
|
||||
{ label: 'Test Vault', path: '/work' },
|
||||
{ label: 'Work Vault', path: '/vault-2' },
|
||||
],
|
||||
active_vault: '/work',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Test Vault')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByTestId('vault-menu-item-Work Vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault')
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
|
||||
34
src/App.tsx
34
src/App.tsx
@@ -105,6 +105,16 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
|
||||
function shouldPreferOnboardingVaultPath(
|
||||
onboardingState: { status: string; vaultPath?: string },
|
||||
vaults: Array<{ path: string }>,
|
||||
): onboardingState is { status: 'ready'; vaultPath: string } {
|
||||
return onboardingState.status === 'ready'
|
||||
&& typeof onboardingState.vaultPath === 'string'
|
||||
&& onboardingState.vaultPath.length > 0
|
||||
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
|
||||
}
|
||||
|
||||
async function resolveNoteWindowEntry(
|
||||
noteWindowParams: NoteWindowParams,
|
||||
entries: VaultEntry[],
|
||||
@@ -234,16 +244,26 @@ function App() {
|
||||
})
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (onboarding.state.status !== 'ready') return
|
||||
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
|
||||
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
|
||||
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
|
||||
const onboardingVaultPath = onboarding.userReadyVaultPath
|
||||
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
|
||||
|
||||
// The active vault path can temporarily come from onboarding before the
|
||||
// persisted vault switcher catches up to the newly cloned starter vault.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
|
||||
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
|
||||
rememberOnboardingVaultChoice(onboardingVaultPath)
|
||||
}
|
||||
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
|
||||
|
||||
// Onboarding can briefly own the vault path for a newly created/opened vault
|
||||
// before the persisted switcher catches up, but once the path is already in
|
||||
// the switcher list we should trust the explicit switcher state.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (
|
||||
shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults)
|
||||
? onboarding.state.vaultPath
|
||||
: vaultSwitcher.vaultPath
|
||||
)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
useEffect(() => {
|
||||
|
||||
@@ -75,6 +75,7 @@ function renderInlineEditorField({
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
@@ -87,6 +88,7 @@ function renderInlineEditorField({
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
@@ -101,6 +103,7 @@ function renderInlineEditorField({
|
||||
editorClassName={editorClassName}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
@@ -209,6 +212,17 @@ export function InlineWikilinkInput({
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
|
||||
if (!pastedText) return
|
||||
|
||||
event.preventDefault()
|
||||
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
@@ -232,6 +246,7 @@ export function InlineWikilinkInput({
|
||||
editorClassName,
|
||||
onInput: commitValueFromEditor,
|
||||
onKeyDown: handleKeyDown,
|
||||
onPaste: handlePaste,
|
||||
onSelectionChange: syncSelectionRange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
|
||||
@@ -160,6 +160,7 @@ export function InlineWikilinkEditorField({
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
@@ -172,6 +173,7 @@ export function InlineWikilinkEditorField({
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
|
||||
)}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -88,67 +88,49 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
|
||||
return <span style={{ width: 12 }} />
|
||||
}
|
||||
|
||||
function vaultItemStyle(isActive: boolean, unavailable: boolean): CSSProperties {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
|
||||
fontSize: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
|
||||
const unavailable = vault.available === false
|
||||
const canHover = !isActive && !unavailable
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
onMouseEnter={canHover ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={canHover ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={unavailable}
|
||||
onClick={onSelect}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
className="w-full justify-between rounded-sm px-2 py-1 text-xs font-normal"
|
||||
style={{
|
||||
height: 'auto',
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</span>
|
||||
</Button>
|
||||
{canRemove && onRemove && (
|
||||
<span
|
||||
role="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
title="Remove from list"
|
||||
data-testid={`vault-menu-remove-${vault.label}`}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.opacity = '1'
|
||||
event.currentTarget.style.background = 'var(--hover)'
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.opacity = '0.5'
|
||||
event.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
className="rounded-sm"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -156,27 +138,18 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
|
||||
|
||||
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
className="h-auto w-full justify-start rounded-sm px-2 py-1 text-xs font-normal"
|
||||
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useOnboarding(
|
||||
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
|
||||
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -110,6 +111,7 @@ export function useOnboarding(
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
setUserReadyVaultPath(vaultPath)
|
||||
onTemplateVaultReady?.(vaultPath)
|
||||
} catch (err) {
|
||||
setError(formatGettingStartedCloneError(err))
|
||||
@@ -138,6 +140,7 @@ export function useOnboarding(
|
||||
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
setUserReadyVaultPath(vaultPath)
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
|
||||
} finally {
|
||||
@@ -152,6 +155,7 @@ export function useOnboarding(
|
||||
if (!path) return
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath: path })
|
||||
setUserReadyVaultPath(path)
|
||||
} catch (err) {
|
||||
setError(`Failed to open folder: ${err}`)
|
||||
}
|
||||
@@ -173,5 +177,6 @@ export function useOnboarding(
|
||||
handleCreateNewVault,
|
||||
handleOpenFolder,
|
||||
handleDismiss,
|
||||
userReadyVaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { openCommandPalette } from './helpers'
|
||||
import {
|
||||
expectEditorSelectionRange,
|
||||
expectNoPageErrors,
|
||||
expectNormalizedEditorText,
|
||||
selectEditorTextRange,
|
||||
trackPageErrors,
|
||||
writeClipboardText,
|
||||
} from './inlineWikilinkEditorHelpers'
|
||||
|
||||
async function readCaretGapAfterChip(page: Page) {
|
||||
@@ -72,4 +74,57 @@ test.describe('Command palette AI mode regression', () => {
|
||||
await expect(aiInput).toBeVisible()
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
|
||||
test('keeps pasted text, caret movement, and selection replacement stable in AI mode', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page)
|
||||
const aiInputTarget = { dataTestId: 'command-palette-ai-input' }
|
||||
await openCommandPalette(page)
|
||||
await page.locator('input[placeholder="Type a command..."]').pressSequentially(' ')
|
||||
|
||||
const aiInput = page.getByTestId('command-palette-ai-input')
|
||||
await expect(aiInput).toBeVisible()
|
||||
await expect(aiInput).toBeFocused()
|
||||
|
||||
await writeClipboardText(page, { text: 'hello world' })
|
||||
await page.keyboard.press('Meta+V')
|
||||
await expectNormalizedEditorText(aiInput, 'hello world')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 12, end: 12 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 7, end: 7 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 7, end: 9 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.type('XY')
|
||||
await expectNormalizedEditorText(aiInput, 'hello XYrld')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 9, end: 9 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 12, end: 12 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Backspace')
|
||||
await expectNormalizedEditorText(aiInput, 'hello XYrl')
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
expectEditorSelectionRange,
|
||||
expectNoPageErrors,
|
||||
expectNormalizedEditorText,
|
||||
selectEditorTextRange,
|
||||
trackPageErrors,
|
||||
writeClipboardText,
|
||||
} from './inlineWikilinkEditorHelpers'
|
||||
|
||||
test.describe('Inline wikilink editor regression', () => {
|
||||
@@ -38,4 +40,53 @@ test.describe('Inline wikilink editor regression', () => {
|
||||
await expect(editor).toBeVisible()
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
|
||||
test('keeps pasted text, caret movement, and selection replacement stable in the AI panel chat input', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page)
|
||||
const agentInputTarget = { dataTestId: 'agent-input' }
|
||||
const editor = page.getByTestId('agent-input')
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
await writeClipboardText(page, { text: 'hello world' })
|
||||
await page.keyboard.press('Meta+V')
|
||||
await expectNormalizedEditorText(editor, 'hello world')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 11, end: 11 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 6, end: 6 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 6, end: 8 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.type('XY')
|
||||
await expectNormalizedEditorText(editor, 'hello XYrld')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 8, end: 8 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 11, end: 11 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Backspace')
|
||||
await expectNormalizedEditorText(editor, 'hello XYrl')
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,24 @@ interface SelectEditorTextRangeArgs {
|
||||
startOffset: number
|
||||
}
|
||||
|
||||
interface ClipboardWriteArgs {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface EditorSelectionRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface EditorTarget {
|
||||
dataTestId: string
|
||||
}
|
||||
|
||||
interface ExpectEditorSelectionRangeArgs {
|
||||
expectedRange: EditorSelectionRange
|
||||
target: EditorTarget
|
||||
}
|
||||
|
||||
export function trackPageErrors(page: Page): string[] {
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
@@ -34,6 +52,28 @@ export async function expectNoPageErrors(pageErrors: string[]): Promise<void> {
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
export async function writeClipboardText(
|
||||
page: Page,
|
||||
{ text }: ClipboardWriteArgs,
|
||||
): Promise<void> {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.evaluate(async ({ text: nextText }) => {
|
||||
await navigator.clipboard.writeText(nextText)
|
||||
}, { text })
|
||||
}
|
||||
|
||||
export async function expectEditorSelectionRange(
|
||||
page: Page,
|
||||
{ expectedRange, target }: ExpectEditorSelectionRangeArgs,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.evaluate(readEditorSelectionRangeInBrowser, target),
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
.toEqual(expectedRange)
|
||||
}
|
||||
|
||||
function normalizeEditorText(value: string | null): string {
|
||||
return value?.replace(/\s+/g, ' ').trim() ?? ''
|
||||
}
|
||||
@@ -55,3 +95,28 @@ function selectEditorTextRangeInBrowser({
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function readEditorSelectionRangeInBrowser({
|
||||
dataTestId,
|
||||
}: EditorTarget): EditorSelectionRange | null {
|
||||
const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !selection || selection.rangeCount === 0) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) return null
|
||||
|
||||
const startRange = document.createRange()
|
||||
startRange.selectNodeContents(editor)
|
||||
startRange.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
const endRange = document.createRange()
|
||||
endRange.selectNodeContents(editor)
|
||||
endRange.setEnd(range.endContainer, range.endOffset)
|
||||
const normalizeSelectionText = (value: string) => value.replace(/\u200B/g, '')
|
||||
|
||||
return {
|
||||
start: normalizeSelectionText(startRange.toString()).length,
|
||||
end: normalizeSelectionText(endRange.toString()).length,
|
||||
}
|
||||
}
|
||||
|
||||
167
tests/smoke/vault-switcher-bottom-bar.spec.ts
Normal file
167
tests/smoke/vault-switcher-bottom-bar.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
interface MockEntry {
|
||||
path: string
|
||||
filename: string
|
||||
title: string
|
||||
isA: string
|
||||
aliases: string[]
|
||||
belongsTo: string[]
|
||||
relatedTo: string[]
|
||||
status: string | null
|
||||
archived: boolean
|
||||
modifiedAt: number | null
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
snippet: string
|
||||
wordCount: number
|
||||
relationships: Record<string, string[]>
|
||||
outgoingLinks: string[]
|
||||
properties: Record<string, unknown>
|
||||
template: null
|
||||
sort: null
|
||||
}
|
||||
|
||||
async function installVaultSwitcherMocks(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const workVaultPath = '/Users/mock/Work'
|
||||
const personalVaultPath = '/Users/mock/Personal'
|
||||
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
|
||||
|
||||
const entriesByVault = {
|
||||
[gettingStartedPath]: [({
|
||||
path: `${gettingStartedPath}/getting-started.md`,
|
||||
filename: 'getting-started.md',
|
||||
title: 'Getting Started Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Getting Started snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
})],
|
||||
[workVaultPath]: [({
|
||||
path: `${workVaultPath}/work-home.md`,
|
||||
filename: 'work-home.md',
|
||||
title: 'Work Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Work Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
})],
|
||||
[personalVaultPath]: [({
|
||||
path: `${personalVaultPath}/personal-home.md`,
|
||||
filename: 'personal-home.md',
|
||||
title: 'Personal Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Personal Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
})],
|
||||
} satisfies Record<string, MockEntry[]>
|
||||
|
||||
const allContent = Object.fromEntries(
|
||||
Object.values(entriesByVault)
|
||||
.flat()
|
||||
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
|
||||
)
|
||||
|
||||
localStorage.clear()
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [
|
||||
{ label: 'Work Vault', path: workVaultPath },
|
||||
{ label: 'Personal Vault', path: personalVaultPath },
|
||||
],
|
||||
active_vault: workVaultPath,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => gettingStartedPath
|
||||
ref.check_vault_exists = (args: { path?: string }) => typeof args?.path === 'string' && args.path.length > 0
|
||||
ref.list_vault = (args: { path?: string }) => entriesByVault[args?.path ?? ''] ?? []
|
||||
ref.list_vault_folders = () => []
|
||||
ref.list_views = () => []
|
||||
ref.get_all_content = () => allContent
|
||||
ref.get_note_content = (args: { path?: string }) => allContent[args?.path ?? ''] ?? ''
|
||||
ref.get_modified_files = () => []
|
||||
ref.get_file_history = () => []
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('bottom bar vault switching works with keyboard and mouse @smoke', async ({ page }) => {
|
||||
await installVaultSwitcherMocks(page)
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const trigger = page.getByTestId('status-vault-trigger')
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await expect(trigger).toContainText('Work Vault')
|
||||
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
|
||||
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('vault-menu-item-Getting Started')).toBeFocused()
|
||||
await page.getByTestId('vault-menu-item-Personal Vault').focus()
|
||||
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(trigger).toContainText('Personal Vault')
|
||||
await expect(noteList.getByText('Personal Home', { exact: true })).toBeVisible()
|
||||
await expect(noteList.getByText('Work Home', { exact: true })).toHaveCount(0)
|
||||
|
||||
await trigger.click()
|
||||
await page.getByTestId('vault-menu-item-Work Vault').click()
|
||||
|
||||
await expect(trigger).toContainText('Work Vault')
|
||||
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
|
||||
await expect(noteList.getByText('Personal Home', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -1,84 +1,62 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const SOURCE_NOTE_TITLE = 'Grow Newsletter'
|
||||
const INSERTED_WIKILINK_QUERY = '[[Mana'
|
||||
const INSERTED_WIKILINK_TITLE = 'Manage Sponsorships'
|
||||
|
||||
async function insertWikilink(page: Page) {
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const firstParagraph = editor.locator('p').first()
|
||||
await expect(
|
||||
firstParagraph,
|
||||
).toContainText('Build a sustainable audience through high-quality weekly essays', { timeout: 5000 })
|
||||
await firstParagraph.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
await page.keyboard.type(INSERTED_WIKILINK_QUERY)
|
||||
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
|
||||
const matchingWikilinks = editor.locator('.wikilink').filter({ hasText: INSERTED_WIKILINK_TITLE })
|
||||
const existingCount = await matchingWikilinks.count()
|
||||
await suggestionMenu.getByText(INSERTED_WIKILINK_TITLE, { exact: true }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await expect(matchingWikilinks).toHaveCount(existingCount + 1)
|
||||
return matchingWikilinks.nth(existingCount)
|
||||
}
|
||||
|
||||
test.describe('Wikilink insertion and navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Select first note to open in editor
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').filter({ hasText: SOURCE_NOTE_TITLE }).first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
test('[[ autocomplete inserts wikilink that is not broken', async ({ page }) => {
|
||||
// Focus editor and move to a new line
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
await editor.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(200)
|
||||
const wikilink = await insertWikilink(page)
|
||||
|
||||
// Type [[ then a query (>= 2 chars for MIN_QUERY_LENGTH)
|
||||
await page.keyboard.type('[[Ma')
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// The wikilink suggestion menu should appear
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Select the first suggestion
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// A wikilink should have been inserted
|
||||
const wikilinks = page.locator('.wikilink')
|
||||
const count = await wikilinks.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// The wikilink should NOT be broken
|
||||
const lastWikilink = wikilinks.last()
|
||||
const isBroken = await lastWikilink.evaluate(
|
||||
const isBroken = await wikilink.evaluate(
|
||||
el => el.classList.contains('wikilink--broken'),
|
||||
)
|
||||
expect(isBroken).toBe(false)
|
||||
|
||||
// The wikilink should have a data-target attribute
|
||||
const target = await lastWikilink.getAttribute('data-target')
|
||||
const target = await wikilink.getAttribute('data-target')
|
||||
expect(target).toBeTruthy()
|
||||
})
|
||||
|
||||
test('@smoke Cmd+clicking an inserted wikilink navigates to the note', async ({ page }) => {
|
||||
// Insert a wikilink via autocomplete
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
await editor.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(200)
|
||||
await page.keyboard.type('[[Ma')
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Get the wikilink that was just inserted
|
||||
const wikilink = page.locator('.wikilink').last()
|
||||
const wikilink = await insertWikilink(page)
|
||||
await expect(wikilink).toBeVisible()
|
||||
const targetTitle = await wikilink.textContent()
|
||||
|
||||
// Cmd+click the wikilink to navigate
|
||||
await wikilink.click({ modifiers: ['Meta'] })
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const expected = targetTitle?.substring(0, 4) ?? ''
|
||||
await expect.poll(async () => {
|
||||
const heading = page.locator('.bn-editor h1').first()
|
||||
if (await heading.count()) return (await heading.textContent()) ?? ''
|
||||
return ''
|
||||
}, { timeout: 5000 }).toContain(expected)
|
||||
await expect(page.locator('.bn-editor h1').first()).toHaveText(INSERTED_WIKILINK_TITLE, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user