Compare commits
6 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c84d3ce8 | ||
|
|
afbea1367c | ||
|
|
7933fe1bfb | ||
|
|
d16aa29d18 | ||
|
|
6188395141 | ||
|
|
1bf5a7d6e4 |
15
AGENTS.md
15
AGENTS.md
@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```bash
|
||||
pnpm tauri dev &
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
```
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
@@ -107,7 +107,10 @@ After any Tauri command, new component/hook, data model change, or new integrati
|
||||
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
- **Never commit or push** any test notes to the remote vault
|
||||
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
||||
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
||||
|
||||
### UI design
|
||||
|
||||
@@ -145,9 +148,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
### Diagrams
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -64,6 +64,7 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
@@ -97,10 +98,20 @@ function App() {
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const { showAIChat, toggleAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenAiChat = () => {
|
||||
if (!showAIChat) toggleAIChat()
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
}, [showAIChat, toggleAIChat])
|
||||
|
||||
// onSwitch closure captures `notes` declared below — safe because it's only
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
// guarantee the latest closure is always used).
|
||||
@@ -729,7 +740,13 @@ function App() {
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
claudeCodeReady={claudeCodeStatus === 'installed'}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
|
||||
@@ -2,16 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -48,6 +52,8 @@ describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
@@ -114,7 +120,7 @@ describe('AiPanel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
expect(input).toHaveAttribute('contenteditable', 'true')
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
@@ -128,14 +134,14 @@ describe('AiPanel', () => {
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
@@ -203,4 +209,20 @@ describe('AiPanel', () => {
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
|
||||
it('auto-sends a queued prompt from the command palette bridge', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
@@ -111,75 +115,89 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
|
||||
)
|
||||
}
|
||||
|
||||
function AiPanelComposer({
|
||||
entries,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
onChange,
|
||||
onSend,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
})
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [isActive, agent])
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -208,39 +226,15 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries ?? []}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => handleSend(input, pendingRefs)}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AiPanelComposer
|
||||
entries={entries ?? []}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import type { CommandAction } from '../hooks/useCommandRegistry'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
vi.mock('../utils/aiPromptBridge', () => ({
|
||||
queueAiPrompt: vi.fn(),
|
||||
requestOpenAiChat: vi.fn(),
|
||||
}))
|
||||
|
||||
const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => ({
|
||||
id: 'test-cmd',
|
||||
label: 'Test Command',
|
||||
@@ -25,6 +32,59 @@ const commands: CommandAction[] = [
|
||||
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
|
||||
]
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }),
|
||||
]
|
||||
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateAiInput(text: string) {
|
||||
const editor = screen.getByTestId('command-palette-ai-input')
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -166,6 +226,54 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByText('esc close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches into AI mode when the query starts with a leading space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to command mode when the leading space is deleted', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
updateAiInput('new')
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
||||
expect(screen.queryByTestId('command-palette-ai-input')).toBeNull()
|
||||
expect(input.value).toBe('new')
|
||||
expect(screen.getByText('New Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('queues a stripped AI prompt and closes on Enter in AI mode', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
const editor = updateAiInput(' hello world')
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).toHaveBeenCalledWith('hello world', [])
|
||||
expect(requestOpenAiChat).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes without queueing when AI mode only contains the trigger space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).not.toHaveBeenCalled()
|
||||
expect(requestOpenAiChat).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('relevance ranking', () => {
|
||||
const relevanceCommands: CommandAction[] = [
|
||||
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
||||
import { groupSortKey } from '../hooks/useCommandRegistry'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
commands: CommandAction[]
|
||||
entries?: VaultEntry[]
|
||||
claudeCodeReady?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -15,17 +21,17 @@ interface ScoredCommand {
|
||||
score: number
|
||||
}
|
||||
|
||||
function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, cmd.label)
|
||||
if (labelResult.match) return { command: cmd, score: labelResult.score }
|
||||
function matchCommand(query: string, command: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, command.label)
|
||||
if (labelResult.match) return { command, score: labelResult.score }
|
||||
|
||||
for (const kw of cmd.keywords ?? []) {
|
||||
const kwResult = fuzzyMatch(query, kw)
|
||||
if (kwResult.match) return { command: cmd, score: kwResult.score - 1 }
|
||||
for (const keyword of command.keywords ?? []) {
|
||||
const keywordResult = fuzzyMatch(query, keyword)
|
||||
if (keywordResult.match) return { command, score: keywordResult.score - 1 }
|
||||
}
|
||||
|
||||
const groupResult = fuzzyMatch(query, cmd.group)
|
||||
if (groupResult.match) return { command: cmd, score: groupResult.score - 2 }
|
||||
const groupResult = fuzzyMatch(query, command.group)
|
||||
if (groupResult.match) return { command, score: groupResult.score - 2 }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -34,83 +40,262 @@ function groupResults(
|
||||
commands: CommandAction[],
|
||||
byRelevance: boolean,
|
||||
): { group: CommandGroup; items: CommandAction[] }[] {
|
||||
const map = new Map<CommandGroup, CommandAction[]>()
|
||||
for (const cmd of commands) {
|
||||
const list = map.get(cmd.group)
|
||||
if (list) list.push(cmd)
|
||||
else map.set(cmd.group, [cmd])
|
||||
const groupedCommands = new Map<CommandGroup, CommandAction[]>()
|
||||
|
||||
for (const command of commands) {
|
||||
const existing = groupedCommands.get(command.group)
|
||||
if (existing) {
|
||||
existing.push(command)
|
||||
continue
|
||||
}
|
||||
groupedCommands.set(command.group, [command])
|
||||
}
|
||||
const entries = Array.from(map.entries())
|
||||
|
||||
const entries = Array.from(groupedCommands.entries())
|
||||
if (!byRelevance) {
|
||||
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
||||
entries.sort((left, right) => groupSortKey(left[0]) - groupSortKey(right[0]))
|
||||
}
|
||||
|
||||
return entries.map(([group, items]) => ({ group, items }))
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function usePaletteResults(commands: CommandAction[], query: string) {
|
||||
const enabledCommands = useMemo(
|
||||
() => commands.filter(c => c.enabled),
|
||||
() => commands.filter((command) => command.enabled),
|
||||
[commands],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) return enabledCommands
|
||||
return enabledCommands
|
||||
.map(cmd => matchCommand(query, cmd))
|
||||
.filter((r): r is ScoredCommand => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(r => r.command)
|
||||
.map((command) => matchCommand(query, command))
|
||||
.filter((result): result is ScoredCommand => result !== null)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((result) => result.command)
|
||||
}, [enabledCommands, query])
|
||||
|
||||
const hasQuery = !!query.trim()
|
||||
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
|
||||
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
|
||||
const hasQuery = query.trim().length > 0
|
||||
const groups = useMemo(
|
||||
() => groupResults(filteredCommands, hasQuery),
|
||||
[filteredCommands, hasQuery],
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatList: groups.flatMap((group) => group.items),
|
||||
}
|
||||
}
|
||||
|
||||
function CommandPaletteInput({
|
||||
inputRef,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
query: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteResults({
|
||||
groups,
|
||||
selectedIndex,
|
||||
listRef,
|
||||
onHover,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: { group: CommandGroup; items: CommandAction[] }[]
|
||||
selectedIndex: number
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onHover: (index: number) => void
|
||||
onSelect: (command: CommandAction) => void
|
||||
}) {
|
||||
const flatList = groups.flatMap((group) => group.items)
|
||||
|
||||
if (flatList.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = groups.reduce<Array<{ group: CommandGroup; items: CommandAction[]; startIndex: number }>>(
|
||||
(acc, group) => {
|
||||
const previous = acc.at(-1)
|
||||
acc.push({
|
||||
...group,
|
||||
startIndex: previous ? previous.startIndex + previous.items.length : 0,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{sections.map(({ group, items, startIndex }) => {
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((command, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
return (
|
||||
<CommandRow
|
||||
key={command.id}
|
||||
command={command}
|
||||
selected={globalIndex === selectedIndex}
|
||||
onHover={() => onHover(globalIndex)}
|
||||
onSelect={() => onSelect(command)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteFooter({
|
||||
aiMode,
|
||||
}: {
|
||||
aiMode: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? '↵ send' : '↵ select'}</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, ...props }: CommandPaletteProps) {
|
||||
if (!open) return null
|
||||
return <OpenCommandPalette {...props} />
|
||||
}
|
||||
|
||||
function OpenCommandPalette({
|
||||
commands,
|
||||
entries = [],
|
||||
claudeCodeReady = true,
|
||||
onClose,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [aiValue, setAiValue] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const aiInputRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const aiMode = aiValue.startsWith(' ')
|
||||
const { groups, flatList } = usePaletteResults(commands, query)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
||||
}, [query])
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
if (aiMode) {
|
||||
aiInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => window.clearTimeout(focusTimer)
|
||||
}, [aiMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
if (aiMode || !listRef.current) return
|
||||
const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' })
|
||||
}, [aiMode, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault(); onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, flatList.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const cmd = flatList[selectedIndex]
|
||||
if (cmd) { onClose(); cmd.execute() }
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (aiMode) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.min(current + 1, flatList.length - 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.max(current - 1, 0))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const command = flatList[selectedIndex]
|
||||
if (!command) return
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, flatList, selectedIndex, onClose])
|
||||
|
||||
if (!open) return null
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [aiMode, flatList, onClose, selectedIndex])
|
||||
|
||||
let runningIndex = 0
|
||||
const handleQueryChange = (nextQuery: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextQuery.startsWith(' ')) {
|
||||
setAiValue(nextQuery)
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(nextQuery)
|
||||
}
|
||||
|
||||
const handleAiValueChange = (nextValue: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextValue.startsWith(' ')) {
|
||||
setAiValue(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setAiValue('')
|
||||
setQuery(nextValue)
|
||||
}
|
||||
|
||||
const handleSelectCommand = (command: CommandAction) => {
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
|
||||
const handleSubmitAiPrompt = (text: string, references: NoteReference[]) => {
|
||||
if (!text.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (!claudeCodeReady) return
|
||||
|
||||
queueAiPrompt(text, references)
|
||||
requestOpenAiChat()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -118,53 +303,33 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[520px] max-w-[90vw] max-h-[440px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
|
||||
aiMode && 'min-h-[220px]',
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{flatList.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
) : (
|
||||
groups.map(({ group, items }) => {
|
||||
const startIndex = runningIndex
|
||||
runningIndex += items.length
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((cmd, i) => {
|
||||
const globalIdx = startIndex + i
|
||||
return (
|
||||
<CommandRow
|
||||
key={cmd.id}
|
||||
command={cmd}
|
||||
selected={globalIdx === selectedIndex}
|
||||
onHover={() => setSelectedIndex(globalIdx)}
|
||||
onSelect={() => { onClose(); cmd.execute() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>↑↓ navigate</span>
|
||||
<span>↵ select</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
{aiMode ? (
|
||||
<CommandPaletteAiMode
|
||||
entries={entries}
|
||||
value={aiValue}
|
||||
claudeCodeReady={claudeCodeReady}
|
||||
onChange={handleAiValueChange}
|
||||
onSubmit={handleSubmitAiPrompt}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
|
||||
<CommandPaletteResults
|
||||
groups={groups}
|
||||
selectedIndex={selectedIndex}
|
||||
listRef={listRef}
|
||||
onHover={setSelectedIndex}
|
||||
onSelect={handleSelectCommand}
|
||||
/>
|
||||
<CommandPaletteFooter aiMode={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
67
src/components/CommandPaletteAiMode.tsx
Normal file
67
src/components/CommandPaletteAiMode.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Sparkle } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface CommandPaletteAiModeProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
claudeCodeReady: boolean
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
function stripLeadingSpace(value: string): string {
|
||||
return value.startsWith(' ') ? value.slice(1) : value
|
||||
}
|
||||
|
||||
export function CommandPaletteAiMode({
|
||||
entries,
|
||||
value,
|
||||
claudeCodeReady,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: CommandPaletteAiModeProps) {
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
|
||||
submitOnEmpty={true}
|
||||
placeholder="Ask Claude Code..."
|
||||
dataTestId="command-palette-ai-input"
|
||||
editorClassName="border-none px-0 py-0 text-[15px]"
|
||||
suggestionListVariant="palette"
|
||||
paletteHeader={(
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<Sparkle size={12} weight="fill" />
|
||||
<span>Ask Claude Code</span>
|
||||
</div>
|
||||
)}
|
||||
paletteEmptyState={(
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{!claudeCodeReady ? (
|
||||
'Claude Code is not available on this machine.'
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
|
||||
<div>
|
||||
{value.trim().length === 0
|
||||
? 'Type your prompt after the leading space.'
|
||||
: 'Type [[ to insert a note reference inline.'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
paletteFooter={(
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Claude mode</span>
|
||||
<span>↵ send</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ vi.mock('@blocknote/mantine', () => ({
|
||||
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
|
||||
import { Editor } from './Editor'
|
||||
import { applyPendingRawExitContent } from './editorRawModeSync'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const mockEntry: VaultEntry = {
|
||||
@@ -98,6 +99,13 @@ This is a test note with some words to count.
|
||||
`
|
||||
|
||||
const mockTab = { entry: mockEntry, content: mockContent }
|
||||
const otherEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/other.md',
|
||||
filename: 'other.md',
|
||||
title: 'Other Note',
|
||||
}
|
||||
const otherTab = { entry: otherEntry, content: '# Other\n' }
|
||||
|
||||
const defaultProps = {
|
||||
tabs: [] as { entry: VaultEntry; content: string }[],
|
||||
@@ -270,6 +278,27 @@ describe('Editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPendingRawExitContent', () => {
|
||||
it('overrides only the matching tab when raw content is newer than tab state', () => {
|
||||
const pending = {
|
||||
path: mockEntry.path,
|
||||
content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n',
|
||||
}
|
||||
|
||||
const result = applyPendingRawExitContent([mockTab, otherTab], pending)
|
||||
|
||||
expect(result[0]).toEqual({ ...mockTab, content: pending.content })
|
||||
expect(result[1]).toBe(otherTab)
|
||||
})
|
||||
|
||||
it('returns the original tabs array when the pending raw content is already synced', () => {
|
||||
const tabs = [mockTab, otherTab]
|
||||
const pending = { path: mockEntry.path, content: mockContent }
|
||||
|
||||
expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click empty editor space', () => {
|
||||
it('focuses editor at end of last block when clicking empty space below content', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useRef, useEffect, useCallback, useState, memo } from 'react'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import '@blocknote/mantine/style.css'
|
||||
@@ -13,6 +13,16 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { schema } from './editorSchema'
|
||||
import { clearTableResizeState } from './tableResizeState'
|
||||
import {
|
||||
type PendingRawExitContent,
|
||||
applyPendingRawExitContent,
|
||||
buildPendingRawExitContent,
|
||||
rememberPendingRawExitContent,
|
||||
resolvePendingRawExitContent,
|
||||
resolveRawModeContent,
|
||||
syncActiveTabIntoRawBuffer,
|
||||
} from './editorRawModeSync'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -129,23 +139,45 @@ interface EditorSetupParams {
|
||||
}
|
||||
|
||||
function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
activeTabContent: string | null,
|
||||
onContentChange?: (path: string, content: string) => void,
|
||||
) {
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
|
||||
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
|
||||
|
||||
const handleFlushPending = useCallback(async () => {
|
||||
const syncedContent = syncActiveTabIntoRawBuffer({
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
})
|
||||
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
|
||||
clearTableResizeState(editor)
|
||||
return true
|
||||
}, [activeTabContent, activeTabPath, editor, onContentChange])
|
||||
|
||||
const handleBeforeRawEnd = useCallback(() => {
|
||||
if (rawLatestContentRef.current != null && activeTabPath) {
|
||||
onContentChange?.(activeTabPath, rawLatestContentRef.current)
|
||||
}
|
||||
setPendingRawExitContent(rememberPendingRawExitContent({
|
||||
activeTabPath,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
}))
|
||||
setRawModeContentOverride(null)
|
||||
rawLatestContentRef.current = null
|
||||
}, [activeTabPath, onContentChange])
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({
|
||||
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
|
||||
activeTabPath,
|
||||
onFlushPending: handleFlushPending,
|
||||
onBeforeRawEnd: handleBeforeRawEnd,
|
||||
})
|
||||
|
||||
return { rawMode, handleToggleRaw, rawLatestContentRef }
|
||||
return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride }
|
||||
}
|
||||
|
||||
function useEditorSetup({
|
||||
@@ -160,15 +192,33 @@ function useEditorSetup({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
})
|
||||
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
|
||||
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
|
||||
activeTabPath, onContentChange,
|
||||
const {
|
||||
rawMode,
|
||||
handleToggleRaw,
|
||||
rawLatestContentRef,
|
||||
pendingRawExitContent,
|
||||
setPendingRawExitContent,
|
||||
rawModeContentOverride,
|
||||
} = useRawModeWithFlush(
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTab?.content ?? null,
|
||||
onContentChange,
|
||||
)
|
||||
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
|
||||
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
|
||||
|
||||
useEffect(() => {
|
||||
setPendingRawExitContent((current) => resolvePendingRawExitContent({
|
||||
activeTabPath,
|
||||
tabs,
|
||||
pendingRawExitContent: current,
|
||||
}))
|
||||
}, [activeTabPath, setPendingRawExitContent, tabs])
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs, activeTabPath, editor, onContentChange, rawMode,
|
||||
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
@@ -185,7 +235,7 @@ function useEditorSetup({
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
|
||||
return {
|
||||
editor, activeTab, rawLatestContentRef,
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
@@ -209,7 +259,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef,
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
@@ -253,6 +303,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
|
||||
198
src/components/InlineWikilinkInput.tsx
Normal file
198
src/components/InlineWikilinkInput.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import {
|
||||
buildInlineWikilinkSegments,
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
findInlineChipDeletionRange,
|
||||
} from './inlineWikilinkText'
|
||||
import {
|
||||
InlineWikilinkEditorField,
|
||||
InlineWikilinkPaletteLayout,
|
||||
InlineWikilinkSuggestionList,
|
||||
} from './InlineWikilinkParts'
|
||||
import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
|
||||
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
|
||||
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface InlineWikilinkInputProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
dataTestId?: string
|
||||
editorClassName?: string
|
||||
suggestionListVariant?: 'floating' | 'palette'
|
||||
suggestionEmptyLabel?: string
|
||||
paletteHeader?: ReactNode
|
||||
paletteEmptyState?: ReactNode
|
||||
paletteFooter?: ReactNode
|
||||
}
|
||||
|
||||
function deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
}: {
|
||||
direction: 'backward' | 'forward'
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
selectionIndex: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
}) {
|
||||
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
|
||||
if (!deletionRange) return false
|
||||
|
||||
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
|
||||
onSelectionIndexChange(deletionRange.start)
|
||||
return true
|
||||
}
|
||||
|
||||
function submitInlineValue({
|
||||
onSubmit,
|
||||
submitOnEmpty,
|
||||
value,
|
||||
references,
|
||||
}: {
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty: boolean
|
||||
value: string
|
||||
references: NoteReference[]
|
||||
}) {
|
||||
if (!onSubmit) return
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
if (!submitOnEmpty && !normalizedValue.trim()) return
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
export function InlineWikilinkInput({
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitOnEmpty = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
inputRef,
|
||||
dataTestId = 'agent-input',
|
||||
editorClassName,
|
||||
suggestionListVariant = 'floating',
|
||||
suggestionEmptyLabel = 'No matching notes',
|
||||
paletteHeader,
|
||||
paletteEmptyState,
|
||||
paletteFooter,
|
||||
}: InlineWikilinkInputProps) {
|
||||
const segments = useMemo(
|
||||
() => buildInlineWikilinkSegments(value, entries),
|
||||
[entries, value],
|
||||
)
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
commitValueFromEditor,
|
||||
focusSelectionAt,
|
||||
} = useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const activeQuery = useMemo(
|
||||
() => findActiveWikilinkQuery(value, selectionIndex),
|
||||
[selectionIndex, value],
|
||||
)
|
||||
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
|
||||
const {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
} = useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey: activeQuery ? `${activeQuery.start}:${activeQuery.query}` : '',
|
||||
entries,
|
||||
query: activeQuery?.query ?? null,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
focusSelectionAt,
|
||||
})
|
||||
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
|
||||
return deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
})
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen: suggestions.length > 0,
|
||||
onCycleSuggestions: cycleSuggestions,
|
||||
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
|
||||
onDeleteAdjacentChip: deleteAdjacentChip,
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={commitValueFromEditor}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelectionChange={syncSelectionIndex}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
const suggestionList = suggestions.length > 0 ? (
|
||||
<InlineWikilinkSuggestionList
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedSuggestionIndex}
|
||||
onHover={setSuggestionIndex}
|
||||
onSelect={selectSuggestion}
|
||||
typeEntryMap={typeEntryMap}
|
||||
variant={suggestionListVariant}
|
||||
emptyLabel={suggestionEmptyLabel}
|
||||
/>
|
||||
) : null
|
||||
if (suggestionListVariant === 'palette') {
|
||||
return (
|
||||
<InlineWikilinkPaletteLayout
|
||||
header={paletteHeader}
|
||||
editor={editor}
|
||||
suggestionList={suggestionList}
|
||||
emptyState={paletteEmptyState}
|
||||
footer={paletteFooter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className="relative">{editor}{suggestionList}</div>
|
||||
}
|
||||
251
src/components/InlineWikilinkParts.tsx
Normal file
251
src/components/InlineWikilinkParts.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createElement } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { getTypeIcon } from './note-item/typeIcon'
|
||||
import type {
|
||||
InlineWikilinkChip,
|
||||
InlineWikilinkSegment,
|
||||
} from './inlineWikilinkText'
|
||||
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function InlineWikilinkChipView({
|
||||
chip,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
chip: InlineWikilinkChip
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = chip.entry.isA ? typeEntryMap[chip.entry.isA] : undefined
|
||||
const color = getTypeColor(chip.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(chip.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(chip.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<span
|
||||
contentEditable={false}
|
||||
data-chip-target={chip.target}
|
||||
data-testid="inline-wikilink-chip"
|
||||
className="mx-[1px] inline-flex max-w-full items-center gap-1 rounded-full align-baseline"
|
||||
style={{
|
||||
backgroundColor,
|
||||
color,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{chip.entry.icon ? (
|
||||
<NoteTitleIcon icon={chip.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
<span className="truncate">{chip.entry.title}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineSuggestionRow({
|
||||
suggestion,
|
||||
selected,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
suggestion: InlineWikilinkSuggestion
|
||||
selected: boolean
|
||||
onHover: () => void
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = suggestion.entry.isA ? typeEntryMap[suggestion.entry.isA] : undefined
|
||||
const color = getTypeColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 transition-colors',
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor, color }}
|
||||
>
|
||||
{suggestion.entry.icon ? (
|
||||
<NoteTitleIcon icon={suggestion.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
|
||||
</div>
|
||||
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{suggestion.entry.isA ?? 'Note'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkSuggestionList({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
variant = 'floating',
|
||||
emptyLabel = 'No matching notes',
|
||||
}: {
|
||||
suggestions: InlineWikilinkSuggestion[]
|
||||
selectedIndex: number
|
||||
onHover: (index: number) => void
|
||||
onSelect: (index: number) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
variant?: 'floating' | 'palette'
|
||||
emptyLabel?: string
|
||||
}) {
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-5 text-center text-[13px] text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={variant === 'floating'
|
||||
? 'absolute bottom-full left-0 right-0 z-10 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-lg'
|
||||
: 'py-1'}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<InlineSuggestionRow
|
||||
key={`${suggestion.entry.path}:${suggestion.target}`}
|
||||
suggestion={suggestion}
|
||||
selected={index === selectedIndex}
|
||||
onHover={() => onHover(index)}
|
||||
onSelect={() => onSelect(index)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled: boolean
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{value.length === 0 && placeholder && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-muted-foreground"
|
||||
style={{ padding: '8px 10px', fontSize: 13 }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={inputRef}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
role="textbox"
|
||||
aria-multiline="false"
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-placeholder={placeholder}
|
||||
data-testid={dataTestId}
|
||||
className={cn(
|
||||
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
editorClassName,
|
||||
)}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
segment.kind === 'text'
|
||||
? <span key={`text-${index}`}>{segment.text}</span>
|
||||
: (
|
||||
<InlineWikilinkChipView
|
||||
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
|
||||
chip={segment.chip}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkPaletteLayout({
|
||||
header,
|
||||
editor,
|
||||
suggestionList,
|
||||
emptyState,
|
||||
footer,
|
||||
}: {
|
||||
header?: React.ReactNode
|
||||
editor: React.ReactNode
|
||||
suggestionList: React.ReactNode
|
||||
emptyState?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
{header}
|
||||
{editor}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{suggestionList ?? emptyState}
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,72 @@ import type { VaultEntry } from '../types'
|
||||
import { _wikilinkEntriesRef } from './editorSchema'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
|
||||
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
|
||||
| --- | --- | --- |
|
||||
| A | B | C |
|
||||
| D | E | F |
|
||||
`
|
||||
|
||||
type TestTableBlock = {
|
||||
type?: string
|
||||
content?: { type?: string; columnWidths?: Array<number | null> }
|
||||
}
|
||||
|
||||
function applySeededColumnWidths(
|
||||
parsedBlocks: Array<TestTableBlock>,
|
||||
columnWidths?: Array<number | null>,
|
||||
) {
|
||||
const tableBlock = parsedBlocks[0]
|
||||
const tableContent = tableBlock?.content
|
||||
|
||||
if (
|
||||
!columnWidths ||
|
||||
tableBlock?.type !== 'table' ||
|
||||
tableContent?.type !== 'tableContent'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
tableContent.columnWidths = [...columnWidths]
|
||||
}
|
||||
|
||||
async function seedEditorWithTestTable(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
columnWidths?: Array<number | null>,
|
||||
) {
|
||||
const parsedBlocks = await Promise.resolve(
|
||||
editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN),
|
||||
) as Array<TestTableBlock>
|
||||
|
||||
applySeededColumnWidths(parsedBlocks, columnWidths)
|
||||
|
||||
const tableHtml = editor.blocksToHTMLLossy([
|
||||
...parsedBlocks,
|
||||
{ type: 'paragraph', content: [], children: [] },
|
||||
] as typeof editor.document)
|
||||
editor._tiptapEditor.commands.setContent(tableHtml)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
useEffect(() => {
|
||||
const seedBlockNoteTable = (columnWidths?: Array<number | null>) => (
|
||||
seedEditorWithTestTable(editor, columnWidths)
|
||||
)
|
||||
|
||||
window.__laputaTest = {
|
||||
...window.__laputaTest,
|
||||
seedBlockNoteTable,
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) {
|
||||
delete window.__laputaTest.seedBlockNoteTable
|
||||
}
|
||||
}
|
||||
}, [editor])
|
||||
}
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const editorRef = useRef(editor)
|
||||
@@ -54,6 +120,8 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
|
||||
useSeedBlockNoteTableBridge(editor)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -31,249 +31,154 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person', aliases: ['BLT'] }),
|
||||
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
|
||||
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
|
||||
]
|
||||
|
||||
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
|
||||
function Controlled({ onSend, ...props }: {
|
||||
entries: VaultEntry[]
|
||||
function Controlled({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: {
|
||||
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<WikilinkChatInput
|
||||
entries={props.entries}
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSend={onSend ?? vi.fn()}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Helper: type in the input and wait for debounce to flush. */
|
||||
async function typeAndWait(text: string) {
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: text, selectionStart: text.length },
|
||||
})
|
||||
await act(() => { vi.advanceTimersByTime(150) })
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditorText(text: string) {
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.focus(editor)
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
}
|
||||
|
||||
function clickFirstSuggestion() {
|
||||
const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
fireEvent.click(rows[0])
|
||||
}
|
||||
|
||||
describe('WikilinkChatInput', () => {
|
||||
it('renders input with placeholder', () => {
|
||||
render(<Controlled entries={entries} placeholder="Ask something..." />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask something...')
|
||||
it('renders the placeholder overlay for an empty draft', () => {
|
||||
render(<Controlled placeholder="Ask something..." />)
|
||||
expect(screen.getByText('Ask something...')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask something...')
|
||||
})
|
||||
|
||||
it('calls onChange when typing', () => {
|
||||
it('calls onChange when the draft changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
|
||||
|
||||
updateEditorText('hello')
|
||||
expect(onChange).toHaveBeenCalledWith('hello')
|
||||
})
|
||||
|
||||
it('shows wikilink menu when [[ is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('shows wikilink suggestions after typing [[', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[a')
|
||||
|
||||
it('filters suggestions matching query', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Alpha')
|
||||
expect(menu.textContent).not.toContain('Beta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('excludes archived entries from suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[arch')
|
||||
const menu = screen.queryByTestId('wikilink-menu')
|
||||
if (menu) {
|
||||
expect(menu.textContent).not.toContain('Archived')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
it('matches suggestions by alias', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[BLT')
|
||||
|
||||
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta')
|
||||
})
|
||||
|
||||
it('selects suggestion on click and creates pill', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
it('renders selected wikilinks inline instead of in a separate pill strip', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
fireEvent.click(items[0])
|
||||
|
||||
const pills = screen.queryAllByTestId('reference-pill')
|
||||
expect(pills.length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
expect(screen.queryByTestId('reference-pill')).toBeNull()
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input').textContent).toContain('Alpha')
|
||||
})
|
||||
|
||||
it('does not duplicate pills for same note', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
// First selection
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Second selection of same note
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('removes pill when x button is clicked', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
const pill = screen.getByTestId('reference-pill')
|
||||
fireEvent.click(pill.querySelector('button')!)
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('navigates suggestions with keyboard', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
|
||||
const input = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
||||
// Escape closes menu
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('selects suggestion with Enter key', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
it('selects a suggestion with Enter before sending the draft', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
updateEditorText('edit my [[alp')
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSend with text and references on Enter without menu', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
it('deletes an inline chip with a single Backspace', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
// Type non-wikilink text
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'hello', selectionStart: 5 },
|
||||
})
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
|
||||
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits serialized wikilink text and resolved references', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('edit my [[alpha]] essay')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).toHaveBeenCalledWith('hello', [])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('edit my [[alpha]] essay', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not send on Enter+Shift', () => {
|
||||
it('does not send on Shift+Enter', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('hello')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send on Enter when input is empty', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
it('marks the editor disabled when disabled is true', () => {
|
||||
render(<Controlled disabled />)
|
||||
|
||||
it('disables input when disabled prop is true', () => {
|
||||
render(<Controlled entries={entries} disabled />)
|
||||
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menu when ]] is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
|
||||
// Type ]] to close
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: '[[alp]]', selectionStart: 7 },
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows type badge for non-Note types in suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Project')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('matches by alias', async () => {
|
||||
vi.useFakeTimers()
|
||||
const entriesWithAlias = [
|
||||
...entries,
|
||||
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
|
||||
]
|
||||
render(<Controlled entries={entriesWithAlias} />)
|
||||
await typeAndWait('[[DLT')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Delta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends references with onSend after selecting pills', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
|
||||
// Select a pill first
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Type a message and send
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'tell me about it', selectionStart: 16 },
|
||||
})
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
|
||||
expect(onSend).toHaveBeenCalledOnce()
|
||||
const [text, refs] = onSend.mock.calls[0]
|
||||
expect(text).toBe('tell me about it')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0].title).toBe('Alpha')
|
||||
expect(refs[0].path).toBe('/vault/alpha.md')
|
||||
vi.useRealTimers()
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
expect(editor).toHaveAttribute('contenteditable', 'false')
|
||||
expect(editor).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
/**
|
||||
* Chat input with [[wikilink]] autocomplete.
|
||||
*
|
||||
* When the user types `[[`, a dropdown appears with vault note suggestions.
|
||||
* Selecting a note inserts a colored pill in the input and records a reference.
|
||||
*/
|
||||
import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { bestSearchRank } from '../utils/fuzzyMatch'
|
||||
|
||||
const MAX_SUGGESTIONS = 20
|
||||
const MIN_QUERY_LENGTH = 1
|
||||
const DEBOUNCE_MS = 100
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface WikilinkChatInputProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -21,248 +9,27 @@ interface WikilinkChatInputProps {
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
title: string
|
||||
path: string
|
||||
type: string | null
|
||||
color?: string
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
interface SuggestionEntry {
|
||||
title: string
|
||||
path: string
|
||||
isA: string | null
|
||||
color?: string
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
function matchEntries(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): SuggestionEntry[] {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const lower = query.toLowerCase()
|
||||
const matches = entries
|
||||
.filter(e =>
|
||||
!e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return {
|
||||
title: e.title,
|
||||
path: e.path,
|
||||
isA: e.isA,
|
||||
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
|
||||
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
|
||||
}
|
||||
})
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function WikilinkChatInput({
|
||||
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputRef,
|
||||
}: WikilinkChatInputProps) {
|
||||
const [pills, setPills] = useState<Pill[]>([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const internalRef = useRef<HTMLInputElement>(null)
|
||||
const inputRefToUse = externalRef ?? internalRef
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
|
||||
[entries, debouncedQuery, typeEntryMap, showMenu],
|
||||
)
|
||||
|
||||
// Clamp selection to valid range
|
||||
const clampedIndex = suggestions.length > 0
|
||||
? Math.min(selectedIndex, suggestions.length - 1)
|
||||
: 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRef.current || clampedIndex < 0) return
|
||||
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [clampedIndex])
|
||||
|
||||
function selectSuggestion(suggestion: SuggestionEntry) {
|
||||
const cursor = inputRefToUse.current?.selectionStart ?? value.length
|
||||
const textBefore = value.slice(0, cursor)
|
||||
const bracketIdx = textBefore.lastIndexOf('[[')
|
||||
if (bracketIdx < 0) return
|
||||
|
||||
const textAfter = value.slice(cursor)
|
||||
const newValue = textBefore.slice(0, bracketIdx) + textAfter
|
||||
|
||||
onChange(newValue)
|
||||
setPills(prev => {
|
||||
if (prev.some(p => p.path === suggestion.path)) return prev
|
||||
return [...prev, {
|
||||
title: suggestion.title,
|
||||
path: suggestion.path,
|
||||
type: suggestion.isA,
|
||||
color: suggestion.color,
|
||||
lightColor: suggestion.lightColor,
|
||||
}]
|
||||
})
|
||||
setShowMenu(false)
|
||||
setTimeout(() => inputRefToUse.current?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newValue = e.target.value
|
||||
onChange(newValue)
|
||||
|
||||
const cursor = e.target.selectionStart ?? newValue.length
|
||||
const textBefore = newValue.slice(0, cursor)
|
||||
const bracketIdx = textBefore.lastIndexOf('[[')
|
||||
|
||||
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
|
||||
const query = textBefore.slice(bracketIdx + 2)
|
||||
setShowMenu(true)
|
||||
setSelectedIndex(0)
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
|
||||
} else {
|
||||
setShowMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (showMenu && suggestions.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
selectSuggestion(suggestions[clampedIndex])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setShowMenu(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!value.trim() && pills.length === 0) return
|
||||
const references: NoteReference[] = pills.map(p => ({
|
||||
title: p.title,
|
||||
path: p.path,
|
||||
type: p.type,
|
||||
}))
|
||||
onSend(value, references)
|
||||
setPills([])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{pills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
|
||||
{pills.map(pill => (
|
||||
<span
|
||||
key={pill.path}
|
||||
className="inline-flex items-center gap-1 text-xs"
|
||||
style={{
|
||||
background: pill.lightColor ?? 'var(--muted)',
|
||||
color: pill.color ?? 'var(--foreground)',
|
||||
borderRadius: 9999,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
data-testid="reference-pill"
|
||||
>
|
||||
{pill.title}
|
||||
<button
|
||||
className="border-none bg-transparent p-0 cursor-pointer"
|
||||
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
|
||||
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRefToUse}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 border border-border bg-transparent text-foreground"
|
||||
style={{
|
||||
fontSize: 13, borderRadius: 8, padding: '8px 10px',
|
||||
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
{showMenu && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="wikilink-menu"
|
||||
style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0,
|
||||
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
|
||||
}}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.path}
|
||||
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 13,
|
||||
background: i === clampedIndex ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => selectSuggestion(s)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="truncate">{s.title}</span>
|
||||
{s.isA && s.isA !== 'Note' && (
|
||||
<span
|
||||
className="shrink-0 text-xs"
|
||||
style={{
|
||||
color: s.color,
|
||||
backgroundColor: s.lightColor,
|
||||
borderRadius: 9999,
|
||||
padding: '1px 8px',
|
||||
}}
|
||||
>
|
||||
{s.isA}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSend}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,13 +62,14 @@ function RawModeEditorSection({
|
||||
activeTab,
|
||||
entries,
|
||||
rawMode,
|
||||
rawModeContent,
|
||||
onRawContentChange,
|
||||
onSave,
|
||||
rawLatestContentRef,
|
||||
vaultPath,
|
||||
}: Pick<
|
||||
EditorContentModel,
|
||||
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'vaultPath'
|
||||
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
|
||||
> & {
|
||||
rawMode: boolean
|
||||
}) {
|
||||
@@ -77,7 +78,7 @@ function RawModeEditorSection({
|
||||
return (
|
||||
<RawEditorView
|
||||
key={activeTab.entry.path}
|
||||
content={activeTab.content}
|
||||
content={rawModeContent ?? activeTab.content}
|
||||
path={activeTab.entry.path}
|
||||
entries={entries}
|
||||
onContentChange={onRawContentChange ?? (() => {})}
|
||||
@@ -226,6 +227,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onEditorChange,
|
||||
isDeletedPreview,
|
||||
rawLatestContentRef,
|
||||
rawModeContent,
|
||||
} = model
|
||||
|
||||
if (!activeTab) {
|
||||
@@ -278,6 +280,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
activeTab={activeTab}
|
||||
entries={entries}
|
||||
rawMode={effectiveRawMode}
|
||||
rawModeContent={rawModeContent}
|
||||
onRawContentChange={onRawContentChange}
|
||||
onSave={onSave}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface EditorContentProps {
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawModeContent?: string | null
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
|
||||
116
src/components/editorRawModeSync.ts
Normal file
116
src/components/editorRawModeSync.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
|
||||
import { compactMarkdown } from '../utils/compact-markdown'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PendingRawExitContent {
|
||||
path: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function buildPendingRawExitContent(
|
||||
path: string | null,
|
||||
content: string | null,
|
||||
): PendingRawExitContent | null {
|
||||
if (!path || content === null) return null
|
||||
return { path, content }
|
||||
}
|
||||
|
||||
export function serializeEditorDocumentToMarkdown(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
tabContent: string,
|
||||
): string {
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
const [frontmatter] = splitFrontmatter(tabContent)
|
||||
return `${frontmatter}${bodyMarkdown}`
|
||||
}
|
||||
|
||||
export function applyPendingRawExitContent(
|
||||
tabs: Tab[],
|
||||
pendingRawExitContent: PendingRawExitContent | null,
|
||||
): Tab[] {
|
||||
if (!pendingRawExitContent) return tabs
|
||||
|
||||
let changed = false
|
||||
const nextTabs = tabs.map((tab) => {
|
||||
if (tab.entry.path !== pendingRawExitContent.path || tab.content === pendingRawExitContent.content) {
|
||||
return tab
|
||||
}
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...tab,
|
||||
content: pendingRawExitContent.content,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? nextTabs : tabs
|
||||
}
|
||||
|
||||
export function syncActiveTabIntoRawBuffer(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
activeTabPath: string | null
|
||||
activeTabContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options
|
||||
if (!activeTabPath || activeTabContent === null) return null
|
||||
|
||||
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
|
||||
rawLatestContentRef.current = syncedContent
|
||||
onContentChange?.(activeTabPath, syncedContent)
|
||||
return syncedContent
|
||||
}
|
||||
|
||||
export function rememberPendingRawExitContent(options: {
|
||||
activeTabPath: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { activeTabPath, rawLatestContentRef, onContentChange } = options
|
||||
const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current)
|
||||
if (!pendingRawExitContent) return null
|
||||
|
||||
onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content)
|
||||
return pendingRawExitContent
|
||||
}
|
||||
|
||||
export function resolvePendingRawExitContent(options: {
|
||||
activeTabPath: string | null
|
||||
tabs: Tab[]
|
||||
pendingRawExitContent: PendingRawExitContent | null
|
||||
}) {
|
||||
const { activeTabPath, tabs, pendingRawExitContent } = options
|
||||
if (!pendingRawExitContent) return null
|
||||
|
||||
if (activeTabPath !== pendingRawExitContent.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const syncedTab = tabs.find((tab) => tab.entry.path === pendingRawExitContent.path)
|
||||
if (syncedTab?.content === pendingRawExitContent.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return pendingRawExitContent
|
||||
}
|
||||
|
||||
export function resolveRawModeContent(options: {
|
||||
activeTab: Tab | null
|
||||
rawModeContentOverride: PendingRawExitContent | null
|
||||
}) {
|
||||
const { activeTab, rawModeContentOverride } = options
|
||||
if (!activeTab) return null
|
||||
if (rawModeContentOverride?.path === activeTab.entry.path) {
|
||||
return rawModeContentOverride.content
|
||||
}
|
||||
return activeTab.content
|
||||
}
|
||||
134
src/components/inlineWikilinkDom.ts
Normal file
134
src/components/inlineWikilinkDom.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export function serializeInlineNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeInlineWikilinkValue(node.textContent ?? '')
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.dataset.chipTarget) {
|
||||
return chipToken(node.dataset.chipTarget)
|
||||
}
|
||||
|
||||
if (node.tagName === 'BR') return ''
|
||||
}
|
||||
|
||||
return Array.from(node.childNodes).map(serializeInlineNode).join('')
|
||||
}
|
||||
|
||||
function selectionFallsOutsideEditor(
|
||||
selection: Selection | null,
|
||||
root: HTMLDivElement,
|
||||
): boolean {
|
||||
return (
|
||||
!selection ||
|
||||
selection.rangeCount === 0 ||
|
||||
!selection.anchorNode ||
|
||||
!root.contains(selection.anchorNode)
|
||||
)
|
||||
}
|
||||
|
||||
export function readSelectionIndex(root: HTMLDivElement): number {
|
||||
const currentSelection = window.getSelection()
|
||||
if (
|
||||
!currentSelection ||
|
||||
selectionFallsOutsideEditor(currentSelection, root)
|
||||
) {
|
||||
return serializeInlineNode(root).length
|
||||
}
|
||||
|
||||
const range = currentSelection.getRangeAt(0).cloneRange()
|
||||
range.setStart(root, 0)
|
||||
return serializeInlineNode(range.cloneContents()).length
|
||||
}
|
||||
|
||||
export function applySelectionIndex(
|
||||
root: HTMLDivElement,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const range = document.createRange()
|
||||
const clampedIndex = Math.max(0, selectionIndex)
|
||||
const found = placeRangeAtIndex(root, clampedIndex, range)
|
||||
|
||||
if (!found) {
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
}
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function placeRangeAtIndex(
|
||||
node: Node,
|
||||
selectionIndex: number,
|
||||
range: Range,
|
||||
): boolean {
|
||||
let remaining = selectionIndex
|
||||
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textPlacement = placeRangeInTextNode(child, remaining, range)
|
||||
if (textPlacement.placed) return true
|
||||
remaining = textPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (!(child instanceof HTMLElement)) continue
|
||||
|
||||
if (child.dataset.chipTarget) {
|
||||
const chipPlacement = placeRangeAroundChip(child, remaining, range)
|
||||
if (chipPlacement.placed) return true
|
||||
remaining = chipPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (placeRangeAtIndex(child, remaining, range)) return true
|
||||
remaining -= serializeInlineNode(child).length
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function placeRangeInTextNode(
|
||||
node: Node,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
|
||||
if (remaining <= textLength) {
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - textLength }
|
||||
}
|
||||
|
||||
function placeRangeAroundChip(
|
||||
node: HTMLElement,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
|
||||
|
||||
if (remaining <= 0) {
|
||||
range.setStartBefore(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
if (remaining <= tokenLength) {
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - tokenLength }
|
||||
}
|
||||
117
src/components/inlineWikilinkKeydown.ts
Normal file
117
src/components/inlineWikilinkKeydown.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type React from 'react'
|
||||
|
||||
interface HandleSuggestionKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
}
|
||||
|
||||
function handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
}: HandleSuggestionKeysArgs): boolean {
|
||||
if (!suggestionsOpen) return false
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(-1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onSelectSuggestion()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleDeleteKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
}
|
||||
|
||||
function handleDeleteKeys({
|
||||
event,
|
||||
onDeleteAdjacentChip,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleSubmitKeyArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
function handleSubmitKey({
|
||||
event,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleSubmitKeyArgs): boolean {
|
||||
if (!canSubmit) return false
|
||||
if (event.key !== 'Enter' || event.shiftKey) return false
|
||||
|
||||
event.preventDefault()
|
||||
onSubmit()
|
||||
return true
|
||||
}
|
||||
|
||||
interface HandleInlineWikilinkKeyDownArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
disabled: boolean
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
onDeleteAdjacentChip,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleInlineWikilinkKeyDownArgs) {
|
||||
if (disabled) return
|
||||
|
||||
if (handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSubmitKey({ event, canSubmit, onSubmit })
|
||||
}
|
||||
78
src/components/inlineWikilinkSuggestions.ts
Normal file
78
src/components/inlineWikilinkSuggestions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
MAX_RESULTS,
|
||||
deduplicateByPath,
|
||||
disambiguateTitles,
|
||||
preFilterWikilinks,
|
||||
type WikilinkBaseItem,
|
||||
} from '../utils/wikilinkSuggestions'
|
||||
import { toInlineWikilinkTarget } from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkSuggestion {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SuggestionItem extends WikilinkBaseItem {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
function toSuggestionItems(entries: VaultEntry[]): SuggestionItem[] {
|
||||
return entries
|
||||
.filter((entry) => !entry.archived)
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
target: toInlineWikilinkTarget(entry),
|
||||
title: entry.title,
|
||||
aliases: entry.aliases,
|
||||
group: entry.isA ?? 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))
|
||||
}
|
||||
|
||||
function toSuggestion(item: SuggestionItem): InlineWikilinkSuggestion {
|
||||
return {
|
||||
entry: item.entry,
|
||||
target: item.target,
|
||||
title: item.title,
|
||||
}
|
||||
}
|
||||
|
||||
function matchSingleCharacterQuery(
|
||||
items: SuggestionItem[],
|
||||
query: string,
|
||||
): SuggestionItem[] {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return items.filter((item) =>
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.aliases.some((alias) => alias.toLowerCase().includes(lowerQuery)) ||
|
||||
item.group.toLowerCase().includes(lowerQuery) ||
|
||||
item.path.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
}
|
||||
|
||||
function buildTopSuggestions(items: SuggestionItem[]): InlineWikilinkSuggestion[] {
|
||||
return [...items]
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
|
||||
export function buildInlineWikilinkSuggestions(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
): InlineWikilinkSuggestion[] {
|
||||
const items = toSuggestionItems(entries)
|
||||
if (query.length === 0) return buildTopSuggestions(items)
|
||||
|
||||
const matchedItems = query.length === 1
|
||||
? matchSingleCharacterQuery(items, query)
|
||||
: preFilterWikilinks(items, query)
|
||||
|
||||
return disambiguateTitles(deduplicateByPath(matchedItems))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
149
src/components/inlineWikilinkText.ts
Normal file
149
src/components/inlineWikilinkText.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
toInlineWikilinkTarget,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkChip {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
export type InlineWikilinkSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'chip'; chip: InlineWikilinkChip }
|
||||
|
||||
export interface ActiveWikilinkQuery {
|
||||
start: number
|
||||
query: string
|
||||
}
|
||||
|
||||
const INLINE_WIKILINK_PATTERN = /\[\[([^[\]\r\n]+?)\]\]/g
|
||||
|
||||
export function buildInlineWikilinkSegments(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): InlineWikilinkSegment[] {
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
const segments: InlineWikilinkSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
INLINE_WIKILINK_PATTERN.lastIndex = 0
|
||||
for (const match of normalizedValue.matchAll(INLINE_WIKILINK_PATTERN)) {
|
||||
const fullMatch = match[0]
|
||||
const target = match[1]
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor, start) })
|
||||
}
|
||||
|
||||
const entry = resolveEntry(entries, target)
|
||||
if (!entry) {
|
||||
segments.push({ kind: 'text', text: fullMatch })
|
||||
} else {
|
||||
segments.push({
|
||||
kind: 'chip',
|
||||
chip: { entry, target: toInlineWikilinkTarget(entry) },
|
||||
})
|
||||
}
|
||||
cursor = start + fullMatch.length
|
||||
}
|
||||
|
||||
if (cursor < normalizedValue.length) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: 'text', text: '' }]
|
||||
}
|
||||
|
||||
export function extractInlineWikilinkReferences(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): NoteReference[] {
|
||||
const references: NoteReference[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
for (const segment of buildInlineWikilinkSegments(value, entries)) {
|
||||
if (segment.kind !== 'chip') continue
|
||||
if (seenPaths.has(segment.chip.entry.path)) continue
|
||||
|
||||
seenPaths.add(segment.chip.entry.path)
|
||||
references.push({
|
||||
title: segment.chip.entry.title,
|
||||
path: segment.chip.entry.path,
|
||||
type: segment.chip.entry.isA,
|
||||
})
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
function hasClosedQuery(openText: string): boolean {
|
||||
return openText.includes(']]') || /[\r\n]/.test(openText)
|
||||
}
|
||||
|
||||
export function findActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
): ActiveWikilinkQuery | null {
|
||||
const clampedIndex = Math.max(0, Math.min(selectionIndex, value.length))
|
||||
const textBeforeCursor = value.slice(0, clampedIndex)
|
||||
const triggerStart = textBeforeCursor.lastIndexOf('[[')
|
||||
|
||||
if (triggerStart < 0) return null
|
||||
|
||||
const openText = textBeforeCursor.slice(triggerStart + 2)
|
||||
if (hasClosedQuery(openText)) return null
|
||||
|
||||
return { start: triggerStart, query: openText }
|
||||
}
|
||||
|
||||
export function replaceActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
target: string,
|
||||
): { value: string; nextSelectionIndex: number } | null {
|
||||
const activeQuery = findActiveWikilinkQuery(value, selectionIndex)
|
||||
if (!activeQuery) return null
|
||||
|
||||
const token = chipToken(target)
|
||||
return {
|
||||
value: value.slice(0, activeQuery.start) + token + value.slice(selectionIndex),
|
||||
nextSelectionIndex: activeQuery.start + token.length,
|
||||
}
|
||||
}
|
||||
|
||||
function segmentLength(segment: InlineWikilinkSegment): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.text.length
|
||||
: chipToken(segment.chip.target).length
|
||||
}
|
||||
|
||||
export function findInlineChipDeletionRange(
|
||||
segments: InlineWikilinkSegment[],
|
||||
selectionIndex: number,
|
||||
direction: 'backward' | 'forward',
|
||||
): { start: number; end: number } | null {
|
||||
let cursor = 0
|
||||
|
||||
for (const segment of segments) {
|
||||
const nextCursor = cursor + segmentLength(segment)
|
||||
|
||||
if (segment.kind === 'chip') {
|
||||
const removePreviousChip = direction === 'backward' && selectionIndex === nextCursor
|
||||
const removeNextChip = direction === 'forward' && selectionIndex === cursor
|
||||
|
||||
if (removePreviousChip || removeNextChip) {
|
||||
return { start: cursor, end: nextCursor }
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
16
src/components/inlineWikilinkTokens.ts
Normal file
16
src/components/inlineWikilinkTokens.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function toInlineWikilinkTarget(entry: VaultEntry): string {
|
||||
return entry.filename.replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
export function chipToken(target: string): string {
|
||||
return `[[${target}]]`
|
||||
}
|
||||
|
||||
export function normalizeInlineWikilinkValue(value: string): string {
|
||||
return value
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\u200B/g, '')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
}
|
||||
22
src/components/tableResizeState.ts
Normal file
22
src/components/tableResizeState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
|
||||
export function clearTableResizeState(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const view = editor._tiptapEditor?.view
|
||||
if (!view || view.isDestroyed) return
|
||||
|
||||
const resizePluginKey = view.state.plugins.find((plugin: { key?: unknown }) => (
|
||||
typeof plugin.key === 'string' && plugin.key.startsWith('tableColumnResizing')
|
||||
))?.key
|
||||
if (!resizePluginKey) return
|
||||
|
||||
try {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(resizePluginKey, {
|
||||
setHandle: -1,
|
||||
setDragging: null,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear table resize state before raw mode toggle:', error)
|
||||
}
|
||||
}
|
||||
53
src/components/useAiPanelContextSnapshot.ts
Normal file
53
src/components/useAiPanelContextSnapshot.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildContextSnapshot,
|
||||
collectLinkedEntries,
|
||||
type NoteListItem,
|
||||
} from '../utils/ai-context'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
|
||||
interface UseAiPanelContextSnapshotArgs {
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
input: string
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
export function useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: UseAiPanelContextSnapshotArgs) {
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const draftReferences = useMemo(
|
||||
() => extractInlineWikilinkReferences(input, entries ?? []),
|
||||
[entries, input],
|
||||
)
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: draftReferences.length > 0 ? draftReferences : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs])
|
||||
|
||||
return { linkedEntries, contextPrompt }
|
||||
}
|
||||
78
src/components/useAiPanelController.ts
Normal file
78
src/components/useAiPanelController.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useAiAgent, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type NoteListItem,
|
||||
type NoteReference,
|
||||
} from '../utils/ai-context'
|
||||
import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
|
||||
|
||||
interface UseAiPanelControllerArgs {
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: UseAiPanelControllerArgs) {
|
||||
const [input, setInput] = useState('')
|
||||
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [agent, isActive])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
return {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
}
|
||||
}
|
||||
42
src/components/useAiPanelFocus.ts
Normal file
42
src/components/useAiPanelFocus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
interface UseAiPanelFocusArgs {
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
panelRef: React.RefObject<HTMLElement | null>
|
||||
isActive: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
isActive,
|
||||
onClose,
|
||||
}: UseAiPanelFocusArgs) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef, isActive, panelRef])
|
||||
|
||||
const handleEscape = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (!panelRef.current?.contains(document.activeElement)) return
|
||||
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}, [onClose, panelRef])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
}
|
||||
44
src/components/useAiPanelPromptQueue.ts
Normal file
44
src/components/useAiPanelPromptQueue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { QueuedAiPrompt } from '../utils/aiPromptBridge'
|
||||
import { useQueuedAiPrompt } from './useQueuedAiPrompt'
|
||||
|
||||
interface AiAgentBridge {
|
||||
clearConversation: () => void
|
||||
sendMessage: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
interface UseAiPanelPromptQueueArgs {
|
||||
agent: AiAgentBridge
|
||||
input: string
|
||||
isActive: boolean
|
||||
setInput: (value: string) => void
|
||||
}
|
||||
|
||||
export function useAiPanelPromptQueue({
|
||||
agent,
|
||||
input,
|
||||
isActive,
|
||||
setInput,
|
||||
}: UseAiPanelPromptQueueArgs) {
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<QueuedAiPrompt | null>(null)
|
||||
|
||||
const handleQueuedPrompt = useCallback((prompt: QueuedAiPrompt) => {
|
||||
setInput(prompt.text)
|
||||
setQueuedPrompt(prompt)
|
||||
agent.clearConversation()
|
||||
}, [agent, setInput])
|
||||
|
||||
useQueuedAiPrompt(handleQueuedPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPrompt || isActive) return
|
||||
if (input !== queuedPrompt.text) return
|
||||
|
||||
agent.sendMessage(queuedPrompt.text, queuedPrompt.references)
|
||||
startTransition(() => {
|
||||
setInput('')
|
||||
setQueuedPrompt(null)
|
||||
})
|
||||
}, [agent, input, isActive, queuedPrompt, setInput])
|
||||
}
|
||||
72
src/components/useInlineWikilinkSelection.ts
Normal file
72
src/components/useInlineWikilinkSelection.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
readSelectionIndex,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface UseInlineWikilinkSelectionArgs {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
}: UseInlineWikilinkSelectionArgs) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const [selectionIndex, setSelectionIndex] = useState(value.length)
|
||||
|
||||
const setCombinedRef = useCallback((node: HTMLDivElement | null) => {
|
||||
editorRef.current = node
|
||||
if (inputRef) {
|
||||
inputRef.current = node
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
const syncSelectionIndex = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
setSelectionIndex(readSelectionIndex(editorRef.current))
|
||||
}, [])
|
||||
|
||||
const focusSelectionAt = useCallback((nextSelectionIndex: number) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
applySelectionIndex(editor, nextSelectionIndex)
|
||||
}, [])
|
||||
|
||||
const commitValueFromEditor = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editorRef.current))
|
||||
const nextSelectionIndex = readSelectionIndex(editorRef.current)
|
||||
|
||||
onChange(nextValue)
|
||||
setSelectionIndex(Math.min(nextSelectionIndex, nextValue.length))
|
||||
}, [onChange])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
if (document.activeElement !== editor) return
|
||||
applySelectionIndex(editor, selectionIndex)
|
||||
}, [selectionIndex, value])
|
||||
|
||||
return {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
focusSelectionAt,
|
||||
commitValueFromEditor,
|
||||
}
|
||||
}
|
||||
137
src/components/useInlineWikilinkSuggestionsState.ts
Normal file
137
src/components/useInlineWikilinkSuggestionsState.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildInlineWikilinkSuggestions,
|
||||
type InlineWikilinkSuggestion,
|
||||
} from './inlineWikilinkSuggestions'
|
||||
import { replaceActiveWikilinkQuery } from './inlineWikilinkText'
|
||||
|
||||
interface UseInlineWikilinkSuggestionsStateArgs {
|
||||
activeQueryKey: string
|
||||
entries: VaultEntry[]
|
||||
query: string | null
|
||||
value: string
|
||||
selectionIndex: number
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
focusSelectionAt: (selectionIndex: number) => void
|
||||
}
|
||||
|
||||
function selectedIndexForQuery(
|
||||
queryKey: string,
|
||||
currentState: { queryKey: string; index: number },
|
||||
suggestionCount: number,
|
||||
) {
|
||||
if (currentState.queryKey !== queryKey) return 0
|
||||
return Math.min(currentState.index, Math.max(suggestionCount - 1, 0))
|
||||
}
|
||||
|
||||
function buildInitialSuggestionState(
|
||||
queryKey: string,
|
||||
direction: 1 | -1,
|
||||
suggestionCount: number,
|
||||
) {
|
||||
return {
|
||||
queryKey,
|
||||
index: direction > 0 ? 0 : suggestionCount - 1,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCycledSuggestionState(
|
||||
currentIndex: number,
|
||||
queryKey: string,
|
||||
direction: 1 | -1,
|
||||
suggestionCount: number,
|
||||
) {
|
||||
const nextIndex = direction > 0
|
||||
? (currentIndex + 1) % suggestionCount
|
||||
: (currentIndex <= 0 ? suggestionCount - 1 : currentIndex - 1)
|
||||
|
||||
return { queryKey, index: nextIndex }
|
||||
}
|
||||
|
||||
function replacementForSuggestion(
|
||||
suggestions: InlineWikilinkSuggestion[],
|
||||
index: number,
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const suggestion = suggestions[index]
|
||||
if (!suggestion) return null
|
||||
return replaceActiveWikilinkQuery(value, selectionIndex, suggestion.target)
|
||||
}
|
||||
|
||||
export function useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey,
|
||||
entries,
|
||||
query,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
focusSelectionAt,
|
||||
}: UseInlineWikilinkSuggestionsStateArgs) {
|
||||
const [suggestionState, setSuggestionState] = useState({ queryKey: '', index: 0 })
|
||||
|
||||
const suggestions = useMemo<InlineWikilinkSuggestion[]>(
|
||||
() => (query === null ? [] : buildInlineWikilinkSuggestions(entries, query)),
|
||||
[entries, query],
|
||||
)
|
||||
|
||||
const selectedSuggestionIndex = selectedIndexForQuery(
|
||||
activeQueryKey,
|
||||
suggestionState,
|
||||
suggestions.length,
|
||||
)
|
||||
|
||||
const setSuggestionIndex = useCallback((index: number) => {
|
||||
setSuggestionState({ queryKey: activeQueryKey, index })
|
||||
}, [activeQueryKey])
|
||||
|
||||
const selectSuggestion = useCallback((index: number) => {
|
||||
const replacement = replacementForSuggestion(
|
||||
suggestions,
|
||||
index,
|
||||
value,
|
||||
selectionIndex,
|
||||
)
|
||||
if (!replacement) return
|
||||
|
||||
onChange(replacement.value)
|
||||
onSelectionIndexChange(replacement.nextSelectionIndex)
|
||||
setSuggestionState({ queryKey: '', index: 0 })
|
||||
window.setTimeout(() => focusSelectionAt(replacement.nextSelectionIndex), 0)
|
||||
}, [
|
||||
focusSelectionAt,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
selectionIndex,
|
||||
suggestions,
|
||||
value,
|
||||
])
|
||||
|
||||
const cycleSuggestions = useCallback((direction: 1 | -1) => {
|
||||
if (suggestions.length === 0) return
|
||||
|
||||
setSuggestionState((current) => {
|
||||
if (current.queryKey !== activeQueryKey) {
|
||||
return buildInitialSuggestionState(activeQueryKey, direction, suggestions.length)
|
||||
}
|
||||
|
||||
return buildCycledSuggestionState(
|
||||
current.index,
|
||||
activeQueryKey,
|
||||
direction,
|
||||
suggestions.length,
|
||||
)
|
||||
})
|
||||
}, [activeQueryKey, suggestions.length])
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
}
|
||||
}
|
||||
21
src/components/useQueuedAiPrompt.ts
Normal file
21
src/components/useQueuedAiPrompt.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
AI_PROMPT_QUEUED_EVENT,
|
||||
takeQueuedAiPrompt,
|
||||
type QueuedAiPrompt,
|
||||
} from '../utils/aiPromptBridge'
|
||||
|
||||
export function useQueuedAiPrompt(
|
||||
onPrompt: (prompt: QueuedAiPrompt) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const consumePrompt = () => {
|
||||
const queuedPrompt = takeQueuedAiPrompt()
|
||||
if (queuedPrompt) onPrompt(queuedPrompt)
|
||||
}
|
||||
|
||||
consumePrompt()
|
||||
window.addEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
|
||||
return () => window.removeEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
|
||||
}, [onPrompt])
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
executeAppCommand,
|
||||
@@ -8,18 +7,6 @@ import {
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MenuEventHandlers extends AppCommandHandlers {
|
||||
activeTabPath: string | null
|
||||
modifiedCount?: number
|
||||
|
||||
12
src/main.tsx
12
src/main.tsx
@@ -14,18 +14,6 @@ import {
|
||||
type AppCommandShortcutEventOptions,
|
||||
} from './hooks/appCommandCatalog'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
// Capture phase fires first → prevents native menu; React bubble phase still fires
|
||||
|
||||
21
src/types/laputaTestBridge.ts
Normal file
21
src/types/laputaTestBridge.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
AppCommandShortcutEventInit,
|
||||
AppCommandShortcutEventOptions,
|
||||
} from '../hooks/appCommandCatalog'
|
||||
|
||||
export interface LaputaTestBridge {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: LaputaTestBridge
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
34
src/utils/aiPromptBridge.ts
Normal file
34
src/utils/aiPromptBridge.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NoteReference } from './ai-context'
|
||||
|
||||
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
|
||||
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
|
||||
|
||||
export interface QueuedAiPrompt {
|
||||
id: number
|
||||
text: string
|
||||
references: NoteReference[]
|
||||
}
|
||||
|
||||
let nextQueuedPromptId = 1
|
||||
let pendingPrompt: QueuedAiPrompt | null = null
|
||||
|
||||
export function queueAiPrompt(text: string, references: NoteReference[]): QueuedAiPrompt {
|
||||
const queuedPrompt = {
|
||||
id: nextQueuedPromptId++,
|
||||
text,
|
||||
references,
|
||||
}
|
||||
pendingPrompt = queuedPrompt
|
||||
window.dispatchEvent(new Event(AI_PROMPT_QUEUED_EVENT))
|
||||
return queuedPrompt
|
||||
}
|
||||
|
||||
export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
|
||||
const queuedPrompt = pendingPrompt
|
||||
pendingPrompt = null
|
||||
return queuedPrompt
|
||||
}
|
||||
|
||||
export function requestOpenAiChat() {
|
||||
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ test.describe('AI chat empty body fix — no regression', () => {
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Send a message
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
const input = page.getByTestId('agent-input')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('What does this note contain?')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { triggerMenuCommand } from './testBridge'
|
||||
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
@@ -24,46 +24,15 @@ function trackUnexpectedErrors(page: Page): string[] {
|
||||
async function createUntitledNote(page: Page): Promise<void> {
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function insertTable(page: Page): Promise<void> {
|
||||
const editorSurface = page.locator('.bn-editor')
|
||||
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
|
||||
await editorSurface.click()
|
||||
await page.keyboard.type('/table')
|
||||
|
||||
const tableCommand = page.getByText('Table with editable cells', { exact: true })
|
||||
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
|
||||
await tableCommand.click()
|
||||
|
||||
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
await expect(firstCell).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await firstCell.boundingBox()
|
||||
if (!box) throw new Error('Could not measure first table cell')
|
||||
|
||||
const handleX = box.x + box.width - 1
|
||||
const handleY = box.y + (box.height / 2)
|
||||
|
||||
await page.mouse.move(handleX, handleY)
|
||||
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
async function seedResizedTable(page: Page): Promise<void> {
|
||||
await seedBlockNoteTable(page, [180, 120, 120])
|
||||
}
|
||||
|
||||
test.describe('table resize crash fix', () => {
|
||||
test.beforeEach((_, testInfo) => {
|
||||
test.beforeEach(({ page }, testInfo) => {
|
||||
void page
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
@@ -72,50 +41,20 @@ test.describe('table resize crash fix', () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
|
||||
test('switching to raw mode and back after a seeded resized table does not lose the table', async ({ page }) => {
|
||||
const errors = trackUnexpectedErrors(page)
|
||||
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
await createUntitledNote(page)
|
||||
await insertTable(page)
|
||||
await seedResizedTable(page)
|
||||
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
const before = await firstCell.boundingBox()
|
||||
if (!before) throw new Error('Could not measure first table cell before resize')
|
||||
|
||||
await dragFirstColumn(page, 80)
|
||||
|
||||
const after = await firstCell.boundingBox()
|
||||
if (!after) throw new Error('Could not measure first table cell after resize')
|
||||
|
||||
expect(after.width).toBeGreaterThan(before.width)
|
||||
await expect(page.locator('table')).toHaveCount(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
|
||||
const errors = trackUnexpectedErrors(page)
|
||||
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
await createUntitledNote(page)
|
||||
await insertTable(page)
|
||||
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
await expect(firstCell).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await firstCell.boundingBox()
|
||||
if (!box) throw new Error('Could not measure first table cell before resize')
|
||||
|
||||
const handleX = box.x + box.width - 1
|
||||
const handleY = box.y + (box.height / 2)
|
||||
|
||||
await page.mouse.move(handleX, handleY)
|
||||
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
|
||||
await page.mouse.down()
|
||||
await page.keyboard.press('Meta+Backslash')
|
||||
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
await expect(page.locator('.cm-content')).toContainText('| Head 1 | Head 2 | Head 3 |')
|
||||
|
||||
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
@@ -38,6 +38,19 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
|
||||
}, id)
|
||||
}
|
||||
|
||||
export async function seedBlockNoteTable(
|
||||
page: Page,
|
||||
columnWidths?: Array<number | null>,
|
||||
): Promise<void> {
|
||||
await page.evaluate((widths) => {
|
||||
const bridge = window.__laputaTest?.seedBlockNoteTable
|
||||
if (typeof bridge !== 'function') {
|
||||
throw new Error('Tolaria test bridge is missing seedBlockNoteTable')
|
||||
}
|
||||
return bridge(widths ?? undefined)
|
||||
}, columnWidths)
|
||||
}
|
||||
|
||||
export async function dispatchShortcutEvent(
|
||||
page: Page,
|
||||
init: AppCommandShortcutEventInit,
|
||||
|
||||
Reference in New Issue
Block a user