fix: clear codacy high severity batches
This commit is contained in:
17
.codacy.yaml
Normal file
17
.codacy.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "coverage/**"
|
||||
- "dist/**"
|
||||
- "e2e/**"
|
||||
- "node_modules/**"
|
||||
- "scripts/**"
|
||||
- "src/test/**"
|
||||
- "src-tauri/gen/**"
|
||||
- "src-tauri/resources/agent-docs/**"
|
||||
- "src-tauri/target/**"
|
||||
- "target/**"
|
||||
- "test-results/**"
|
||||
- "tests/**"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "vite.config.ts"
|
||||
@@ -447,8 +447,8 @@ import { useUpdater } from './hooks/useUpdater'
|
||||
import { isTauri } from './mock-tauri'
|
||||
import { streamAiAgent } from './utils/streamAiAgent'
|
||||
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:claude-code-onboarding-dismissed'
|
||||
const SLOW_APP_READY_TIMEOUT_MS = 10_000
|
||||
|
||||
function createMockUpdaterResult(
|
||||
@@ -474,7 +474,7 @@ describe('App', () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult())
|
||||
localStorage.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME, '1')
|
||||
})
|
||||
|
||||
it('renders the four-panel layout', async () => {
|
||||
@@ -692,8 +692,8 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
|
||||
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
|
||||
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
|
||||
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME)
|
||||
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME)
|
||||
mockCommandResults.get_ai_agents_status = {
|
||||
claude_code: { installed: true, version: '2.1.90' },
|
||||
codex: { installed: true, version: '0.122.0-alpha.1' },
|
||||
|
||||
@@ -316,8 +316,8 @@ function OpenCommandPalette({
|
||||
|
||||
useEffect(() => {
|
||||
if (aiMode || !listRef.current) return
|
||||
const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' })
|
||||
const selectedHTMLElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
selectedHTMLElement?.scrollIntoView({ block: 'nearest' })
|
||||
}, [aiMode, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useCallback, useMemo, useEffect, type ComponentType, type SVGAttributes } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import './WikilinkSuggestionMenu.css'
|
||||
@@ -8,6 +9,8 @@ import './WikilinkSuggestionMenu.css'
|
||||
const MIN_QUERY_LENGTH = 2
|
||||
const MAX_RESULTS = 10
|
||||
|
||||
type AutocompleteKeyAction = 'next' | 'previous' | 'select' | 'close'
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
entries: VaultEntry[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
@@ -29,26 +32,106 @@ interface MatchedEntry {
|
||||
TypeIcon?: ComponentType<SVGAttributes<SVGSVGElement>>
|
||||
}
|
||||
|
||||
function entryMatchesQuery(entry: VaultEntry, lowerQuery: string): boolean {
|
||||
return entry.title.toLowerCase().includes(lowerQuery)
|
||||
|| entry.aliases.some(alias => alias.toLowerCase().includes(lowerQuery))
|
||||
}
|
||||
|
||||
function buildMatchedEntry(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): MatchedEntry {
|
||||
const isA = entry.isA
|
||||
const typeEntry = typeEntryMap[isA ?? '']
|
||||
const noteType = isA || undefined
|
||||
return {
|
||||
title: entry.title,
|
||||
noteIcon: entry.icon,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(isA, typeEntry?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(isA, typeEntry?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(isA, typeEntry?.icon) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string): MatchedEntry[] {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const matches = entries.filter(e =>
|
||||
e.title.toLowerCase().includes(lowerQuery) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lowerQuery)),
|
||||
return entries
|
||||
.filter(entry => entryMatchesQuery(entry, lowerQuery))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(entry => buildMatchedEntry(entry, typeEntryMap))
|
||||
}
|
||||
|
||||
function resolveOpenAutocompleteKeyAction(key: string): AutocompleteKeyAction | null {
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
return 'next'
|
||||
case 'ArrowUp':
|
||||
return 'previous'
|
||||
case 'Enter':
|
||||
return 'select'
|
||||
case 'Escape':
|
||||
return 'close'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nextAutocompleteSelectionIndex(currentIndex: number, matchCount: number): number {
|
||||
return (currentIndex + 1) % matchCount
|
||||
}
|
||||
|
||||
function previousAutocompleteSelectionIndex(currentIndex: number, matchCount: number): number {
|
||||
return currentIndex <= 0 ? matchCount - 1 : currentIndex - 1
|
||||
}
|
||||
|
||||
function handleClosedAutocompleteKey(
|
||||
key: string,
|
||||
value: string,
|
||||
onSelect: (noteTitle: string) => void,
|
||||
onEscape: (() => void) | undefined,
|
||||
): void {
|
||||
if (key === 'Enter') {
|
||||
onSelect(value)
|
||||
return
|
||||
}
|
||||
if (key === 'Escape') onEscape?.()
|
||||
}
|
||||
|
||||
function preventAutocompleteMouseDown(event: React.MouseEvent): void {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
interface NoteAutocompleteMenuItemProps {
|
||||
item: MatchedEntry
|
||||
selected: boolean
|
||||
onSelect: (title: string) => void
|
||||
onHover: () => void
|
||||
}
|
||||
|
||||
function NoteAutocompleteMenuItem({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: NoteAutocompleteMenuItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`wikilink-menu__item${selected ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={preventAutocompleteMouseDown}
|
||||
onClick={() => onSelect(item.title)}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
<NoteTitleIcon icon={item.noteIcon} size={14} />
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return matches.slice(0, MAX_RESULTS).map(e => {
|
||||
const isA = e.isA
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const noteType = isA || undefined
|
||||
return {
|
||||
title: e.title,
|
||||
noteIcon: e.icon,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSelect, onEscape, placeholder, autoFocus, testId }: NoteAutocompleteProps) {
|
||||
@@ -65,8 +148,7 @@ export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSel
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !menuRef.current) return
|
||||
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
scrollSelectedHTMLChildIntoView(menuRef.current, selectedIndex)
|
||||
}, [selectedIndex])
|
||||
|
||||
// Close on outside click
|
||||
@@ -95,28 +177,31 @@ export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSel
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!open || matches.length === 0) {
|
||||
if (e.key === 'Enter') { onSelect(value); return }
|
||||
if (e.key === 'Escape') { onEscape?.(); return }
|
||||
handleClosedAutocompleteKey(e.key, value, onSelect, onEscape)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % matches.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < matches.length) {
|
||||
handleSelect(matches[selectedIndex].title)
|
||||
} else {
|
||||
onSelect(value)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
|
||||
const action = resolveOpenAutocompleteKeyAction(e.key)
|
||||
if (!action) return
|
||||
|
||||
e.preventDefault()
|
||||
if (action === 'next') {
|
||||
setSelectedIndex(i => nextAutocompleteSelectionIndex(i, matches.length))
|
||||
return
|
||||
}
|
||||
if (action === 'previous') {
|
||||
setSelectedIndex(i => previousAutocompleteSelectionIndex(i, matches.length))
|
||||
return
|
||||
}
|
||||
if (action === 'close') {
|
||||
setOpen(false)
|
||||
onEscape?.()
|
||||
return
|
||||
}
|
||||
|
||||
const match = matches[selectedIndex]
|
||||
if (match) handleSelect(match.title)
|
||||
else onSelect(value)
|
||||
}, [open, matches, selectedIndex, value, handleSelect, onSelect, onEscape])
|
||||
|
||||
return (
|
||||
@@ -136,24 +221,13 @@ export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSel
|
||||
{open && matches.length > 0 && (
|
||||
<div className="wikilink-menu" ref={menuRef} style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, minWidth: 'auto' }}>
|
||||
{matches.map((item, index) => (
|
||||
<div
|
||||
<NoteAutocompleteMenuItem
|
||||
key={item.title}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => handleSelect(item.title)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
<NoteTitleIcon icon={item.noteIcon} size={14} />
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
item={item}
|
||||
selected={index === selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={() => setSelectedIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, type ComponentType, type SVGAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
|
||||
export interface NoteSearchResultItem {
|
||||
@@ -22,6 +23,55 @@ interface NoteSearchListProps<T extends NoteSearchResultItem> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface NoteSearchListItemProps<T extends NoteSearchResultItem> {
|
||||
item: T
|
||||
index: number
|
||||
selected: boolean
|
||||
onItemClick: (item: T, index: number) => void
|
||||
onItemHover?: (index: number) => void
|
||||
}
|
||||
|
||||
function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
item,
|
||||
index,
|
||||
selected,
|
||||
onItemClick,
|
||||
onItemHover,
|
||||
}: NoteSearchListItemProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 transition-colors',
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onClick={() => onItemClick(item, index)}
|
||||
onMouseEnter={() => onItemHover?.(index)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
|
||||
{item.TypeIcon && (
|
||||
<item.TypeIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="shrink-0"
|
||||
style={item.typeColor ? { color: item.typeColor } : undefined}
|
||||
/>
|
||||
)}
|
||||
<NoteTitleIcon icon={item.noteIcon} size={14} testId="note-search-item-icon" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-[11px]"
|
||||
style={item.typeColor ? { color: item.typeColor, backgroundColor: item.typeLightColor } : undefined}
|
||||
>
|
||||
{item.noteType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteSearchList<T extends NoteSearchResultItem>({
|
||||
items,
|
||||
selectedIndex,
|
||||
@@ -34,9 +84,7 @@ export function NoteSearchList<T extends NoteSearchResultItem>({
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const el = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
scrollSelectedHTMLChildIntoView(listRef.current, selectedIndex)
|
||||
}, [selectedIndex])
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -52,37 +100,14 @@ export function NoteSearchList<T extends NoteSearchResultItem>({
|
||||
return (
|
||||
<div ref={listRef} className={cn('py-1', className)}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
<NoteSearchListItem
|
||||
key={getItemKey(item, i)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 transition-colors',
|
||||
i === selectedIndex ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onClick={() => onItemClick(item, i)}
|
||||
onMouseEnter={() => onItemHover?.(i)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
|
||||
{item.TypeIcon && (
|
||||
<item.TypeIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="shrink-0"
|
||||
style={item.typeColor ? { color: item.typeColor } : undefined}
|
||||
/>
|
||||
)}
|
||||
<NoteTitleIcon icon={item.noteIcon} size={14} testId="note-search-item-icon" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-[11px]"
|
||||
style={item.typeColor ? { color: item.typeColor, backgroundColor: item.typeLightColor } : undefined}
|
||||
>
|
||||
{item.noteType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
item={item}
|
||||
index={i}
|
||||
selected={i === selectedIndex}
|
||||
onItemClick={onItemClick}
|
||||
onItemHover={onItemHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, type HTMLAttributes } from 'react'
|
||||
import { RUNTIME_STYLE_NONCE } from '@/lib/runtimeStyleNonce'
|
||||
|
||||
interface SafeHtmlSpanProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
html: string
|
||||
markup: string
|
||||
}
|
||||
|
||||
interface SafeSvgDivProps extends HTMLAttributes<HTMLDivElement> {
|
||||
@@ -17,8 +17,8 @@ const MERMAID_SVG_SANITIZE_CONFIG = {
|
||||
HTML_INTEGRATION_POINTS: { foreignobject: true },
|
||||
}
|
||||
|
||||
function importSanitizedHtmlNodes(html: string): Node[] {
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
function importSanitizedMarkupNodes(markup: string): Node[] {
|
||||
const sanitized = DOMPurify.sanitize(markup, {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
})
|
||||
const parsed = new DOMParser().parseFromString(sanitized, 'text/html')
|
||||
@@ -38,12 +38,12 @@ function importSanitizedSvgNode(svg: string): Node | null {
|
||||
return svgNode
|
||||
}
|
||||
|
||||
export function SafeHtmlSpan({ html, ...props }: SafeHtmlSpanProps) {
|
||||
export function SafeHtmlSpan({ markup, ...props }: SafeHtmlSpanProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current?.replaceChildren(...importSanitizedHtmlNodes(html))
|
||||
}, [html])
|
||||
ref.current?.replaceChildren(...importSanitizedMarkupNodes(markup))
|
||||
}, [markup])
|
||||
|
||||
return <span {...props} ref={ref} />
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRef, useEffect, useCallback, useLayoutEffect } from 'react'
|
||||
import { createElement, useRef, useEffect, useCallback, useLayoutEffect } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SearchResult, VaultEntry } from '../types'
|
||||
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
|
||||
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { formatSearchSubtitle } from '../utils/noteListHelpers'
|
||||
import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
|
||||
@@ -16,6 +17,32 @@ interface SearchPanelProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SearchKeyboardAction = 'close' | 'next' | 'previous' | 'select'
|
||||
|
||||
function resolveSearchKeyboardAction(key: string): SearchKeyboardAction | null {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
return 'close'
|
||||
case 'ArrowDown':
|
||||
return 'next'
|
||||
case 'ArrowUp':
|
||||
return 'previous'
|
||||
case 'Enter':
|
||||
return 'select'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nextSearchSelectionIndex(
|
||||
action: Extract<SearchKeyboardAction, 'next' | 'previous'>,
|
||||
currentIndex: number,
|
||||
resultCount: number,
|
||||
): number {
|
||||
if (action === 'next') return Math.min(currentIndex + 1, resultCount - 1)
|
||||
return Math.max(currentIndex - 1, 0)
|
||||
}
|
||||
|
||||
export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: SearchPanelProps) {
|
||||
const {
|
||||
query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs,
|
||||
@@ -27,9 +54,7 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
const selectedIndexRef = useRef(selectedIndex)
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
selected?.scrollIntoView({ block: 'nearest' })
|
||||
scrollSelectedHTMLChildIntoView(listRef.current, selectedIndex)
|
||||
}, [selectedIndex])
|
||||
|
||||
const handleSelect = useCallback((result: SearchResult) => {
|
||||
@@ -50,20 +75,22 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
}, [open])
|
||||
|
||||
const handleKeyDown = useCallback((e: { key: string; preventDefault: () => void }) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
const action = resolveSearchKeyboardAction(e.key)
|
||||
if (!action) return
|
||||
|
||||
e.preventDefault()
|
||||
if (action === 'close') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.min(i + 1, resultsRef.current.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'select') {
|
||||
const result = resultsRef.current[selectedIndexRef.current]
|
||||
if (result) handleSelect(result)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedIndex(i => nextSearchSelectionIndex(action, i, resultsRef.current.length))
|
||||
}, [handleSelect, onClose, setSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -170,6 +197,59 @@ interface SearchContentProps {
|
||||
onHover: (index: number) => void
|
||||
}
|
||||
|
||||
interface SearchResultRowProps {
|
||||
result: SearchResult
|
||||
entry: VaultEntry | undefined
|
||||
selected: boolean
|
||||
index: number
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onSelect: (result: SearchResult) => void
|
||||
onHover: (index: number) => void
|
||||
}
|
||||
|
||||
function SearchResultRow({
|
||||
result, entry, selected, index, typeEntryMap, onSelect, onHover,
|
||||
}: SearchResultRowProps) {
|
||||
const isA = entry?.isA ?? result.noteType
|
||||
const noteType = isA || null
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const typeColor = noteType ? getTypeColor(isA, te?.color) : undefined
|
||||
const TypeIcon = getTypeIcon(isA ?? null, te?.icon)
|
||||
const subtitle = entry ? formatSearchSubtitle(entry) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-2.5 transition-colors",
|
||||
selected ? "bg-accent" : "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{createElement(TypeIcon, {
|
||||
width: 14,
|
||||
height: 14,
|
||||
className: 'shrink-0',
|
||||
style: { color: typeColor ?? 'var(--muted-foreground)' },
|
||||
})}
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
<NoteTitleIcon icon={entry?.icon} size={14} className="mr-1" />
|
||||
{entry?.title ?? result.title}
|
||||
</span>
|
||||
{noteType && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchContent({
|
||||
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover,
|
||||
}: SearchContentProps) {
|
||||
@@ -204,42 +284,18 @@ function SearchContent({
|
||||
</span>
|
||||
</div>
|
||||
<div ref={listRef}>
|
||||
{results.map((result, i) => {
|
||||
const entry = entryLookup.get(result.path)
|
||||
const isA = entry?.isA ?? result.noteType
|
||||
const noteType = isA || null
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const typeColor = noteType ? getTypeColor(isA, te?.color) : undefined
|
||||
const TypeIcon = getTypeIcon(isA ?? null, te?.icon)
|
||||
const subtitle = entry ? formatSearchSubtitle(entry) : null
|
||||
return (
|
||||
<div
|
||||
key={result.path}
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-2.5 transition-colors",
|
||||
i === selectedIndex ? "bg-accent" : "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
<NoteTitleIcon icon={entry?.icon} size={14} className="mr-1" />
|
||||
{entry?.title ?? result.title}
|
||||
</span>
|
||||
{noteType && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{results.map((result, i) => (
|
||||
<SearchResultRow
|
||||
key={result.path}
|
||||
result={result}
|
||||
entry={entryLookup.get(result.path)}
|
||||
selected={i === selectedIndex}
|
||||
index={i}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onSelect={onSelect}
|
||||
onHover={onHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -35,9 +35,12 @@ function buildSortItems(locale: AppLocale, customProperties?: string[]): SortIte
|
||||
}
|
||||
|
||||
function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1)
|
||||
if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
const activeIndexText = activeElement.dataset.sortItemIndex
|
||||
const activeIndex = activeIndexText === undefined ? -1 : Number(activeIndexText)
|
||||
if (activeElement.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
|
||||
}
|
||||
|
||||
const currentIndex = sortItems.findIndex((item) => item.value === current)
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
|
||||
@@ -79,7 +79,7 @@ function MathRender({ latex, displayMode }: { latex: string; displayMode: boolea
|
||||
aria-label={`Math: ${latex}`}
|
||||
className={displayMode ? 'math math--block' : 'math math--inline'}
|
||||
data-latex={latex}
|
||||
html={renderMathToHtml({ latex, displayMode })}
|
||||
markup={renderMathToHtml({ latex, displayMode })}
|
||||
role="img"
|
||||
title={source}
|
||||
/>
|
||||
|
||||
@@ -401,8 +401,12 @@ export function useNoteListSort({
|
||||
// --- useMultiSelectKeyboard ---
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement
|
||||
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable
|
||||
const activeElement = document.activeElement
|
||||
if (!(activeElement instanceof HTMLElement)) return false
|
||||
|
||||
return activeElement.tagName === 'INPUT'
|
||||
|| activeElement.tagName === 'TEXTAREA'
|
||||
|| activeElement.isContentEditable
|
||||
}
|
||||
|
||||
function handleEscapeKey(e: KeyboardEvent, multiSelect: MultiSelectState) {
|
||||
|
||||
@@ -10,8 +10,12 @@ interface UseMultiSelectKeyboardOptions {
|
||||
}
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement
|
||||
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable
|
||||
const activeElement = document.activeElement
|
||||
if (!(activeElement instanceof HTMLElement)) return false
|
||||
|
||||
return activeElement.tagName === 'INPUT'
|
||||
|| activeElement.tagName === 'TEXTAREA'
|
||||
|| activeElement.isContentEditable
|
||||
}
|
||||
|
||||
function usesCommandModifier(event: KeyboardEvent): boolean {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const LEGACY_CLAUDE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const LEGACY_CLAUDE_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
function wasDismissed(): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY) === '1'
|
||||
|| localStorage.getItem(LEGACY_CLAUDE_ONBOARDING_DISMISSED_KEY) === '1'
|
||||
localStorage.getItem(AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME) === '1'
|
||||
|| localStorage.getItem(LEGACY_CLAUDE_ONBOARDING_DISMISSED_STORAGE_NAME) === '1'
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
@@ -16,8 +16,8 @@ function wasDismissed(): boolean {
|
||||
|
||||
function markDismissed(): void {
|
||||
try {
|
||||
localStorage.setItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY, '1')
|
||||
localStorage.setItem(LEGACY_CLAUDE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
localStorage.setItem(AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME, '1')
|
||||
localStorage.setItem(LEGACY_CLAUDE_ONBOARDING_DISMISSED_STORAGE_NAME, '1')
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted contexts
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const localStorageMock = (() => {
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
const DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
const DISMISSED_STORAGE_NAME = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
describe('useClaudeCodeOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,7 +34,7 @@ describe('useClaudeCodeOnboarding', () => {
|
||||
})
|
||||
|
||||
it('starts hidden when the prompt was already dismissed', () => {
|
||||
localStorage.setItem(DISMISSED_KEY, '1')
|
||||
localStorage.setItem(DISMISSED_STORAGE_NAME, '1')
|
||||
|
||||
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
|
||||
|
||||
@@ -49,6 +49,6 @@ describe('useClaudeCodeOnboarding', () => {
|
||||
})
|
||||
|
||||
expect(result.current.showPrompt).toBe(false)
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
|
||||
expect(localStorage.getItem(DISMISSED_STORAGE_NAME)).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
function wasDismissed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY) === '1'
|
||||
return localStorage.getItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -12,7 +12,7 @@ function wasDismissed(): boolean {
|
||||
|
||||
function markDismissed(): void {
|
||||
try {
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME, '1')
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted contexts
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ async function readNoteContentForWidthPersistence({
|
||||
return isTauri()
|
||||
? await invoke<MarkdownContent>('get_note_content', { path })
|
||||
: await mockInvoke<MarkdownContent>('get_note_content', { path })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
return fallbackContent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,8 @@ async function clearMissingActiveVault(missingPath: string): Promise<boolean> {
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
// Best effort only — onboarding should still proceed
|
||||
return false
|
||||
}
|
||||
@@ -300,7 +301,8 @@ export function useOnboarding(
|
||||
} else {
|
||||
setState({ status: 'welcome', defaultPath })
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
// If commands fail (e.g. mock mode), just proceed
|
||||
if (!cancelled) setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
async function readRemoteStatus(vaultPath: string): Promise<GitRemoteStatus | null> {
|
||||
try {
|
||||
return await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
type AppUpdateDownloadEvent,
|
||||
type AppUpdateMetadata,
|
||||
} from '../lib/appUpdater'
|
||||
import { formatCalendarVersionForDisplay } from '../utils/calendarVersion'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/tolaria/'
|
||||
const CALENDAR_VERSION_PATTERN = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-(alpha|stable)\.(\d+))?$/
|
||||
|
||||
interface UpdateVersionInfo {
|
||||
version: string
|
||||
@@ -41,17 +41,7 @@ function formatReleaseDisplayVersion(version: string): string {
|
||||
if (!normalizedVersion) return normalizedVersion
|
||||
|
||||
const baseVersion = normalizedVersion.split('+')[0]
|
||||
const match = baseVersion.match(CALENDAR_VERSION_PATTERN)
|
||||
if (!match) return baseVersion
|
||||
|
||||
const [, year, month, day, channel, sequence] = match
|
||||
const calendarVersion = `${Number(year)}.${Number(month)}.${Number(day)}`
|
||||
|
||||
if (channel === 'alpha' && sequence) {
|
||||
return `Alpha ${calendarVersion}.${Number(sequence)}`
|
||||
}
|
||||
|
||||
return calendarVersion
|
||||
return formatCalendarVersionForDisplay(baseVersion) ?? baseVersion
|
||||
}
|
||||
|
||||
function createVersionInfo(version: string): UpdateVersionInfo {
|
||||
@@ -181,7 +171,8 @@ export async function restartApp(): Promise<void> {
|
||||
try {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
await relaunch()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
console.warn('[updater] Failed to relaunch')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,9 +717,7 @@ function switchVaultPath({
|
||||
|
||||
async function ensureVaultCanBeRegistered(path: string): Promise<void> {
|
||||
const exists = await checkVaultAvailability(path)
|
||||
if (!exists) {
|
||||
throw new Error('Selected folder is not available')
|
||||
}
|
||||
if (!exists) throw new Error('Selected folder is not available')
|
||||
}
|
||||
|
||||
function listRemainingVaults({
|
||||
|
||||
@@ -10,7 +10,8 @@ async function detectVaultApiAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
|
||||
return res.ok
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -33,56 +34,86 @@ interface VaultApiRequest {
|
||||
/** Tracks last vault path for commands that don't receive it as an argument. */
|
||||
let lastVaultPath: string | null = null
|
||||
|
||||
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>) {
|
||||
type PathQueryCommand =
|
||||
| 'reload_vault_entry'
|
||||
| 'get_note_content'
|
||||
| 'validate_note_content'
|
||||
| 'get_all_content'
|
||||
|
||||
type PostRequestBuilder = (args: Record<string, unknown>) => VaultApiRequest | null
|
||||
|
||||
const PATH_QUERY_ENDPOINTS: Record<PathQueryCommand, string> = {
|
||||
reload_vault_entry: '/api/vault/entry',
|
||||
get_note_content: '/api/vault/content',
|
||||
validate_note_content: '/api/vault/content',
|
||||
get_all_content: '/api/vault/all-content',
|
||||
}
|
||||
|
||||
const POST_REQUEST_BUILDERS: Record<string, PostRequestBuilder> = {
|
||||
save_note_content: (args) => buildRequiredPathPostRequest(args, '/api/vault/save', {
|
||||
path: args.path,
|
||||
content: args.content,
|
||||
}),
|
||||
rename_note: (args) => buildRequiredPostRequest(args.old_path, '/api/vault/rename', {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
new_title: args.new_title,
|
||||
}),
|
||||
rename_note_filename: (args) => buildRequiredPostRequest(args.old_path, '/api/vault/rename-filename', {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
new_filename_stem: args.new_filename_stem,
|
||||
}),
|
||||
move_note_to_folder: (args) => buildRequiredPostRequest(args.old_path && args.folder_path, '/api/vault/move-to-folder', {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
folder_path: args.folder_path,
|
||||
}),
|
||||
delete_note: (args) => buildRequiredPathPostRequest(args, '/api/vault/delete', { path: args.path }),
|
||||
}
|
||||
|
||||
function argText(args: Record<string, unknown>, key: string): string | null {
|
||||
const value = args[key]
|
||||
return value ? String(value) : null
|
||||
}
|
||||
|
||||
function buildListRequest(args: Record<string, unknown>, reload: boolean): VaultApiRequest | null {
|
||||
const path = argText(args, 'path')
|
||||
if (!path) return null
|
||||
|
||||
lastVaultPath = path
|
||||
const reloadSuffix = reload ? '&reload=1' : ''
|
||||
return { url: `/api/vault/list?path=${encodeURIComponent(path)}${reloadSuffix}` }
|
||||
}
|
||||
|
||||
function buildPathQueryRequest(args: Record<string, unknown>, endpoint: string): VaultApiRequest | null {
|
||||
const path = argText(args, 'path')
|
||||
return path ? { url: `${endpoint}?path=${encodeURIComponent(path)}` } : null
|
||||
}
|
||||
|
||||
function buildRequiredPostRequest(required: unknown, url: string, body: unknown): VaultApiRequest | null {
|
||||
return required ? { url, method: 'POST', body } : null
|
||||
}
|
||||
|
||||
function buildRequiredPathPostRequest(args: Record<string, unknown>, url: string, body: unknown): VaultApiRequest | null {
|
||||
return buildRequiredPostRequest(args.path, url, body)
|
||||
}
|
||||
|
||||
function buildSearchRequest(args: Record<string, unknown>): VaultApiRequest | null {
|
||||
const query = argText(args, 'query')
|
||||
if (!query || !lastVaultPath) return null
|
||||
|
||||
const mode = argText(args, 'mode') ?? 'all'
|
||||
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(query)}&mode=${encodeURIComponent(mode)}` }
|
||||
}
|
||||
|
||||
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>): VaultApiRequest | null {
|
||||
if (!args) return null
|
||||
switch (cmd) {
|
||||
case 'list_vault':
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
case 'reload_vault':
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` } : null
|
||||
case 'reload_vault_entry':
|
||||
return args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
case 'get_note_content':
|
||||
case 'validate_note_content':
|
||||
return args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
case 'get_all_content':
|
||||
return args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
case 'save_note_content':
|
||||
return args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null
|
||||
case 'rename_note':
|
||||
return args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null
|
||||
case 'rename_note_filename':
|
||||
return args.old_path ? {
|
||||
url: '/api/vault/rename-filename',
|
||||
method: 'POST',
|
||||
body: {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
new_filename_stem: args.new_filename_stem,
|
||||
},
|
||||
} : null
|
||||
case 'move_note_to_folder':
|
||||
return args.old_path && args.folder_path ? {
|
||||
url: '/api/vault/move-to-folder',
|
||||
method: 'POST',
|
||||
body: {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
folder_path: args.folder_path,
|
||||
},
|
||||
} : null
|
||||
case 'delete_note':
|
||||
return args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null
|
||||
case 'search_vault': {
|
||||
const q = args.query as string
|
||||
if (!q || !lastVaultPath) return null
|
||||
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(q)}&mode=${encodeURIComponent((args.mode as string) || 'all')}` }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
if (cmd === 'list_vault') return buildListRequest(args, false)
|
||||
if (cmd === 'reload_vault') return buildListRequest(args, true)
|
||||
if (cmd === 'search_vault') return buildSearchRequest(args)
|
||||
if (cmd in PATH_QUERY_ENDPOINTS) return buildPathQueryRequest(args, PATH_QUERY_ENDPOINTS[cmd as PathQueryCommand])
|
||||
return POST_REQUEST_BUILDERS[cmd]?.(args) ?? null
|
||||
}
|
||||
|
||||
function buildFetchOptions(request: VaultApiRequest): RequestInit {
|
||||
|
||||
@@ -65,6 +65,8 @@ export function nextMessageId(): string {
|
||||
|
||||
/** Max tokens of history to include in each request. */
|
||||
export const MAX_HISTORY_TOKENS = 100_000
|
||||
const CONVERSATION_HISTORY_OPEN_MARKER = ['<', 'conversation_history', '>'].join('')
|
||||
const CONVERSATION_HISTORY_CLOSE_MARKER = ['</', 'conversation_history', '>'].join('')
|
||||
|
||||
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
|
||||
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
|
||||
@@ -86,7 +88,7 @@ export function formatMessageWithHistory(history: ChatMessage[], newMessage: str
|
||||
const lines = history.map(m => `[${m.role}]: ${m.content}`)
|
||||
lines.push(`[user]: ${newMessage}`)
|
||||
|
||||
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
|
||||
return `${CONVERSATION_HISTORY_OPEN_MARKER}\n${lines.join('\n\n')}\n${CONVERSATION_HISTORY_CLOSE_MARKER}\n\nContinue the conversation. Respond only to the latest [user] message.`
|
||||
}
|
||||
|
||||
// --- Claude CLI status ---
|
||||
@@ -154,7 +156,7 @@ function handleChatStreamEvent(
|
||||
* can verify that history is actually being sent.
|
||||
*/
|
||||
function mockChatResponse(message: string): string {
|
||||
if (message.includes('<conversation_history>')) {
|
||||
if (message.includes(CONVERSATION_HISTORY_OPEN_MARKER)) {
|
||||
const allUserLines = message.match(/\[user\]: .+/g) ?? []
|
||||
const turnCount = allUserLines.length
|
||||
// The last [user] line is the actual new message
|
||||
|
||||
59
src/utils/calendarVersion.ts
Normal file
59
src/utils/calendarVersion.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
interface CalendarVersionParts {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
channel?: string
|
||||
sequence?: number
|
||||
}
|
||||
|
||||
export function formatCalendarVersionForDisplay(version: string): string | null {
|
||||
const parsed = parseCalendarVersion(version)
|
||||
if (!parsed) return null
|
||||
|
||||
const calendarVersion = `${parsed.year}.${parsed.month}.${parsed.day}`
|
||||
if (parsed.channel === 'alpha' && parsed.sequence !== undefined) {
|
||||
return `Alpha ${calendarVersion}.${parsed.sequence}`
|
||||
}
|
||||
return calendarVersion
|
||||
}
|
||||
|
||||
function isVersionNumberPart(value: string, expectedLength?: number): boolean {
|
||||
return (expectedLength === undefined || value.length === expectedLength)
|
||||
&& value.length > 0
|
||||
&& [...value].every((char) => char >= '0' && char <= '9')
|
||||
}
|
||||
|
||||
function parseCalendarVersion(version: string): CalendarVersionParts | null {
|
||||
const [calendar, prerelease] = version.split('-', 2)
|
||||
const calendarVersion = parseCalendarVersionParts(calendar)
|
||||
if (!calendarVersion) return null
|
||||
if (prerelease === undefined) return calendarVersion
|
||||
const prereleaseVersion = parsePrereleaseVersionParts(prerelease)
|
||||
return prereleaseVersion ? { ...calendarVersion, ...prereleaseVersion } : null
|
||||
}
|
||||
|
||||
function parseCalendarVersionParts(calendar: string): CalendarVersionParts | null {
|
||||
const calendarParts = calendar.split('.')
|
||||
if (calendarParts.length !== 3) return null
|
||||
if (!isVersionNumberPart(calendarParts[0], 4)) return null
|
||||
if (!isVersionNumberPart(calendarParts[1])) return null
|
||||
if (!isVersionNumberPart(calendarParts[2])) return null
|
||||
return {
|
||||
year: Number(calendarParts[0]),
|
||||
month: Number(calendarParts[1]),
|
||||
day: Number(calendarParts[2]),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePrereleaseVersionParts(
|
||||
prerelease: string,
|
||||
): Pick<CalendarVersionParts, 'channel' | 'sequence'> | null {
|
||||
const prereleaseParts = prerelease.split('.')
|
||||
if (prereleaseParts.length !== 2) return null
|
||||
if (!['alpha', 'stable'].includes(prereleaseParts[0])) return null
|
||||
if (!isVersionNumberPart(prereleaseParts[1])) return null
|
||||
return {
|
||||
channel: prereleaseParts[0],
|
||||
sequence: Number(prereleaseParts[1]),
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
type LanguageDetector = (source: string) => boolean
|
||||
|
||||
const PLAIN_TEXT_LANGUAGES = new Set(['', 'none', 'plain', 'plaintext', 'text', 'txt'])
|
||||
const LANGUAGE_PATTERNS: Array<[string, RegExp]> = [
|
||||
['html', /^\s*<[/!A-Za-z][\s\S]*>\s*$/u],
|
||||
['python', /^\s*(?:def|class)\s+\w+.*:\s*$/mu],
|
||||
['python', /^\s*(?:from\s+\w+(?:\.\w+)*\s+import|import\s+\w+)/mu],
|
||||
['shellscript', /^\s*(?:#!.*\b(?:bash|sh|zsh)\b|(?:pnpm|npm|yarn|git|cd|echo|export)\b)/mu],
|
||||
['typescript', /\b(?:interface|type|enum|implements|readonly|namespace|declare)\b/u],
|
||||
['typescript', /\b(?:const|let|var|function)\s+\w+\s*(?:<[^>]+>)?\([^)]*:\s*[^)]*\)/u],
|
||||
['typescript', /:\s*(?:string|number|boolean|unknown|never|void|null|undefined|Record<|[A-Z]\w*(?:\[\])?)\b/u],
|
||||
['javascript', /\b(?:import|export|const|let|var|function|return)\b|=>/u],
|
||||
['sql', /^\s*(?:SELECT|WITH|INSERT|UPDATE|DELETE)\b[\s\S]*\bFROM\b/iu],
|
||||
['yaml', /^\s*[\w-]+\s*:\s*[\s\S]*$/u],
|
||||
const LANGUAGE_DETECTORS: Array<[string, LanguageDetector]> = [
|
||||
['html', (source) => /^\s*<[/!A-Za-z][\s\S]*>\s*$/u.test(source)],
|
||||
['python', (source) => /^\s*(?:def|class)\s+\w+.*:\s*$/mu.test(source)],
|
||||
['python', hasPythonImport],
|
||||
['shellscript', (source) => /^\s*(?:#!.*\b(?:bash|sh|zsh)\b|(?:pnpm|npm|yarn|git|cd|echo|export)\b)/mu.test(source)],
|
||||
['typescript', (source) => /\b(?:interface|type|enum|implements|readonly|namespace|declare)\b/u.test(source)],
|
||||
['typescript', hasTypedCallableSignature],
|
||||
['typescript', (source) => /:\s*(?:string|number|boolean|unknown|never|void|null|undefined|Record<|[A-Z]\w*(?:\[\])?)\b/u.test(source)],
|
||||
['javascript', (source) => /\b(?:import|export|const|let|var|function|return)\b|=>/u.test(source)],
|
||||
['sql', (source) => /^\s*(?:SELECT|WITH|INSERT|UPDATE|DELETE)\b[\s\S]*\bFROM\b/iu.test(source)],
|
||||
['yaml', (source) => /^\s*[\w-]+\s*:\s*[\s\S]*$/u.test(source)],
|
||||
]
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
@@ -50,11 +51,32 @@ function isJson(source: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function hasPythonImport(source: string): boolean {
|
||||
return source.split(/\r?\n/).some((line) => {
|
||||
const trimmed = line.trimStart()
|
||||
return trimmed.startsWith('import ') || (trimmed.startsWith('from ') && trimmed.includes(' import '))
|
||||
})
|
||||
}
|
||||
|
||||
function hasTypedCallableSignature(source: string): boolean {
|
||||
return source.split(/\r?\n/).some((line) => {
|
||||
const trimmed = line.trim()
|
||||
const startsWithCallableKeyword = ['const ', 'let ', 'var ', 'function ']
|
||||
.some((keyword) => trimmed.startsWith(keyword))
|
||||
if (!startsWithCallableKeyword) return false
|
||||
|
||||
const paramsStart = trimmed.indexOf('(')
|
||||
const paramsEnd = trimmed.indexOf(')', paramsStart + 1)
|
||||
if (paramsStart < 0 || paramsEnd < paramsStart) return false
|
||||
return trimmed.slice(paramsStart + 1, paramsEnd).includes(':')
|
||||
})
|
||||
}
|
||||
|
||||
export function inferCodeBlockLanguage(source: string): string | null {
|
||||
const trimmed = source.trim()
|
||||
if (!trimmed) return null
|
||||
if (isJson(trimmed)) return 'json'
|
||||
return LANGUAGE_PATTERNS.find(([, pattern]) => pattern.test(trimmed))?.[0] ?? null
|
||||
return LANGUAGE_DETECTORS.find(([, detects]) => detects(trimmed))?.[0] ?? null
|
||||
}
|
||||
|
||||
function inferChildren(children: unknown): unknown {
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
/** Regex patterns for common CSS color formats. */
|
||||
import { isCssFunctionalColor } from './cssFunctionalColor'
|
||||
|
||||
const HEX3_RE = /^#[0-9a-f]{3}$/i
|
||||
const HEX6_RE = /^#[0-9a-f]{6}$/i
|
||||
const HEX8_RE = /^#[0-9a-f]{8}$/i
|
||||
const RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
|
||||
const HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
|
||||
const COLOR_KEY_NAMES = new Set([
|
||||
'background', 'border', 'color', 'fill', 'foreground', 'muted', 'primary',
|
||||
'secondary', 'stroke', 'tint',
|
||||
])
|
||||
const COLOR_KEY_SUFFIXES = ['-color', '_color']
|
||||
const COLOR_KEY_PREFIXES = ['accent']
|
||||
const COLOR_KEY_SUBSTRINGS = ['colour']
|
||||
const COLOR_VALUE_TESTS: Array<(value: string) => boolean> = [
|
||||
(value) => HEX3_RE.test(value),
|
||||
(value) => HEX6_RE.test(value),
|
||||
(value) => HEX8_RE.test(value),
|
||||
isCssFunctionalColor,
|
||||
(value) => NAMED_COLORS.has(value.toLowerCase()),
|
||||
]
|
||||
|
||||
/** CSS named colors (lowercase). */
|
||||
const NAMED_COLORS = new Set([
|
||||
@@ -36,8 +49,7 @@ export function isValidCssColor(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') return false
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
return HEX3_RE.test(trimmed) || HEX6_RE.test(trimmed) || HEX8_RE.test(trimmed)
|
||||
|| RGB_RE.test(trimmed) || HSL_RE.test(trimmed) || NAMED_COLORS.has(trimmed.toLowerCase())
|
||||
return COLOR_VALUE_TESTS.some((testColorValue) => testColorValue(trimmed))
|
||||
}
|
||||
|
||||
/** Expand #rgb to #rrggbb. */
|
||||
@@ -83,9 +95,8 @@ function canvasColorToHex(color: string): string | null {
|
||||
/** Check if a property key name suggests a color value. */
|
||||
export function isColorKeyName(key: string): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
return lower === 'color' || lower.endsWith('-color') || lower.endsWith('_color')
|
||||
|| lower.includes('colour') || lower.startsWith('accent')
|
||||
|| lower === 'background' || lower === 'foreground' || lower === 'border'
|
||||
|| lower === 'primary' || lower === 'secondary' || lower === 'muted'
|
||||
|| lower === 'fill' || lower === 'stroke' || lower === 'tint'
|
||||
return COLOR_KEY_NAMES.has(lower)
|
||||
|| COLOR_KEY_SUFFIXES.some((suffix) => lower.endsWith(suffix))
|
||||
|| COLOR_KEY_PREFIXES.some((prefix) => lower.startsWith(prefix))
|
||||
|| COLOR_KEY_SUBSTRINGS.some((part) => lower.includes(part))
|
||||
}
|
||||
|
||||
48
src/utils/cssFunctionalColor.ts
Normal file
48
src/utils/cssFunctionalColor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const RGB_NAMES = ['rgb', 'rgba']
|
||||
const HSL_NAMES = ['hsl', 'hsla']
|
||||
|
||||
export function isCssFunctionalColor(value: string): boolean {
|
||||
return isRgbColor(value) || isHslColor(value)
|
||||
}
|
||||
|
||||
function functionArguments(value: string, names: readonly string[]): string[] | null {
|
||||
const lower = value.toLowerCase()
|
||||
const name = names.find((candidate) => lower.startsWith(`${candidate}(`))
|
||||
if (!name || !value.endsWith(')')) return null
|
||||
return value.slice(name.length + 1, -1).split(',').map((part) => part.trim())
|
||||
}
|
||||
|
||||
function isWholeNumber(value: string): boolean {
|
||||
return value.length > 0 && [...value].every((char) => char >= '0' && char <= '9')
|
||||
}
|
||||
|
||||
function isBoundedNumber(value: string, min: number, max: number): boolean {
|
||||
if (!isWholeNumber(value)) return false
|
||||
const number = Number(value)
|
||||
return number >= min && number <= max
|
||||
}
|
||||
|
||||
function isPercentage(value: string): boolean {
|
||||
return value.endsWith('%') && isBoundedNumber(value.slice(0, -1), 0, 100)
|
||||
}
|
||||
|
||||
function isAlphaValue(value: string): boolean {
|
||||
const number = Number(value)
|
||||
return value.trim() === value && Number.isFinite(number) && number >= 0 && number <= 1
|
||||
}
|
||||
|
||||
function isRgbColor(value: string): boolean {
|
||||
const args = functionArguments(value, RGB_NAMES)
|
||||
if (!args || (args.length !== 3 && args.length !== 4)) return false
|
||||
return args.slice(0, 3).every((part) => isBoundedNumber(part, 0, 255))
|
||||
&& (args.length === 3 || isAlphaValue(args[3]))
|
||||
}
|
||||
|
||||
function isHslColor(value: string): boolean {
|
||||
const args = functionArguments(value, HSL_NAMES)
|
||||
if (!args || (args.length !== 3 && args.length !== 4)) return false
|
||||
return isBoundedNumber(args[0], 0, 360)
|
||||
&& isPercentage(args[1])
|
||||
&& isPercentage(args[2])
|
||||
&& (args.length === 3 || isAlphaValue(args[3]))
|
||||
}
|
||||
73
src/utils/dateStringParts.ts
Normal file
73
src/utils/dateStringParts.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface DateParts {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
export function parseDashDateParts(value: string): DateParts | null {
|
||||
const [datePart, timePart] = value.split('T', 2)
|
||||
if (timePart !== undefined && !isClockPrefix(timePart)) return null
|
||||
const parts = datePart.split('-')
|
||||
if (parts.length !== 3) return null
|
||||
if (!matchesLength(parts[0], 4)) return null
|
||||
if (!matchesLength(parts[1], 2)) return null
|
||||
if (!matchesLength(parts[2], 2)) return null
|
||||
if (!parts.every(isDigits)) return null
|
||||
return validDateParts({
|
||||
year: Number(parts[0]),
|
||||
month: Number(parts[1]),
|
||||
day: Number(parts[2]),
|
||||
})
|
||||
}
|
||||
|
||||
export function parseSlashDateParts(value: string): DateParts | null {
|
||||
return parseDateTuple(value, '/', [undefined, undefined, [2, 4]])
|
||||
}
|
||||
|
||||
export function dateFromParts(parts: DateParts): Date {
|
||||
return new Date(parts.year, parts.month - 1, parts.day)
|
||||
}
|
||||
|
||||
function parseDateTuple(
|
||||
value: string,
|
||||
separator: string,
|
||||
lengths: readonly [number | undefined, number | undefined, number | readonly number[]],
|
||||
): DateParts | null {
|
||||
const parts = value.split(separator)
|
||||
if (parts.length !== 3) return null
|
||||
if (!matchesLength(parts[0], lengths[0])) return null
|
||||
if (!matchesLength(parts[1], lengths[1])) return null
|
||||
if (!matchesLength(parts[2], lengths[2])) return null
|
||||
if (!parts.every(isDigits)) return null
|
||||
return validDateParts({
|
||||
year: Number(parts[2].length === 2 ? `20${parts[2]}` : parts[2]),
|
||||
month: Number(parts[0]),
|
||||
day: Number(parts[1]),
|
||||
})
|
||||
}
|
||||
|
||||
function matchesLength(value: string, length: number | readonly number[] | undefined): boolean {
|
||||
if (length === undefined) return value.length > 0
|
||||
if (typeof length === 'number') return value.length === length
|
||||
return length.includes(value.length)
|
||||
}
|
||||
|
||||
function isDigits(value: string): boolean {
|
||||
return value.length > 0 && [...value].every((char) => char >= '0' && char <= '9')
|
||||
}
|
||||
|
||||
function isClockPrefix(value: string): boolean {
|
||||
const parts = value.split(':')
|
||||
return (parts.length === 2 || parts.length === 3)
|
||||
&& matchesLength(parts[0], 2)
|
||||
&& matchesLength(parts[1], 2)
|
||||
&& (parts.length === 2 || matchesLength(parts[2], 2))
|
||||
&& parts.every(isDigits)
|
||||
}
|
||||
|
||||
function validDateParts(parts: DateParts): DateParts | null {
|
||||
const date = dateFromParts(parts)
|
||||
return date.getFullYear() === parts.year && date.getMonth() === parts.month - 1 && date.getDate() === parts.day
|
||||
? parts
|
||||
: null
|
||||
}
|
||||
9
src/utils/domScroll.ts
Normal file
9
src/utils/domScroll.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function scrollSelectedHTMLChildIntoView(
|
||||
container: HTMLElement | null,
|
||||
selectedIndex: number,
|
||||
): void {
|
||||
const selectedHTMLElement = container?.children.item(selectedIndex)
|
||||
if (selectedHTMLElement instanceof HTMLElement) {
|
||||
selectedHTMLElement.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ const FILE_LIKE_EXTENSION_PATTERN =
|
||||
const EXPLICIT_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i
|
||||
const MAYBE_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i
|
||||
const LOCAL_PATH_PREFIX_PATTERN = /^(?:\.{1,2}\/|~\/|\/)/
|
||||
const IPV4_HOST_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/
|
||||
const WINDOWS_PATH_SEPARATOR = '\\'
|
||||
const WWW_PREFIX = 'www.'
|
||||
|
||||
@@ -86,6 +85,15 @@ function hostnameHasTld(hostname: string) {
|
||||
return hostname.includes('.')
|
||||
}
|
||||
|
||||
function isIpv4Host(hostname: string) {
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4 && parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if ([...part].some((char) => char < '0' || char > '9')) return false
|
||||
return Number(part) <= 255
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeInput(value: LinkValue) {
|
||||
return stripUrlDecorators(withRaw(value.raw.trim()))
|
||||
}
|
||||
@@ -123,7 +131,7 @@ export function shouldAutoLinkTolariaHref(url: LinkValue) {
|
||||
}
|
||||
|
||||
const hostname = hostnameFromUrlLikeValue(url)
|
||||
if (IPV4_HOST_PATTERN.test(hostname)) {
|
||||
if (isIpv4Host(hostname)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FRONTEND_READY_EVENT_NAME,
|
||||
STARTUP_RELOAD_ATTEMPT_KEY,
|
||||
STARTUP_RELOAD_ATTEMPT_STORAGE_NAME,
|
||||
markFrontendReady,
|
||||
reloadFrontendOnceIfStartupFailed,
|
||||
} from './frontendReady'
|
||||
@@ -15,12 +15,12 @@ describe('frontend readiness recovery', () => {
|
||||
it('marks the frontend ready and clears a pending startup reload', () => {
|
||||
const onReady = vi.fn()
|
||||
window.addEventListener(FRONTEND_READY_EVENT_NAME, onReady, { once: true })
|
||||
sessionStorage.setItem(STARTUP_RELOAD_ATTEMPT_KEY, '1')
|
||||
sessionStorage.setItem(STARTUP_RELOAD_ATTEMPT_STORAGE_NAME, '1')
|
||||
|
||||
markFrontendReady()
|
||||
|
||||
expect(window.__tolariaFrontendReady).toBe(true)
|
||||
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_KEY)).toBeNull()
|
||||
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_STORAGE_NAME)).toBeNull()
|
||||
expect(onReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('frontend readiness recovery', () => {
|
||||
expect(firstAttempt).toBe(true)
|
||||
expect(secondAttempt).toBe(false)
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_KEY)).toBe('1')
|
||||
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_STORAGE_NAME)).toBe('1')
|
||||
})
|
||||
|
||||
it('does not reload after the frontend has reported readiness', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const FRONTEND_READY_EVENT_NAME = 'tolaria:frontend-ready'
|
||||
export const STARTUP_RELOAD_ATTEMPT_KEY = 'tolaria:startup-reload-attempted'
|
||||
export const STARTUP_RELOAD_ATTEMPT_STORAGE_NAME = 'tolaria:startup-reload-attempted'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -54,7 +54,7 @@ export function markFrontendReady(options: FrontendReadyOptions = {}): void {
|
||||
const storage = options.storage ?? getSessionStorage(win)
|
||||
|
||||
win.__tolariaFrontendReady = true
|
||||
removeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY)
|
||||
removeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_STORAGE_NAME)
|
||||
win.dispatchEvent(new Event(FRONTEND_READY_EVENT_NAME))
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ export function reloadFrontendOnceIfStartupFailed(
|
||||
const storage = options.storage ?? getSessionStorage(win)
|
||||
|
||||
if (win.__tolariaFrontendReady === true) return false
|
||||
if (readSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY) === '1') return false
|
||||
if (!writeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY, '1')) return false
|
||||
if (readSessionItem(storage, STARTUP_RELOAD_ATTEMPT_STORAGE_NAME) === '1') return false
|
||||
if (!writeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_STORAGE_NAME, '1')) return false
|
||||
|
||||
const reload = options.reload ?? (() => win.location.reload())
|
||||
reload()
|
||||
|
||||
@@ -48,10 +48,19 @@ function parseScalar(value: FrontmatterText): FrontmatterValue {
|
||||
const lower = clean.toLowerCase()
|
||||
if (lower === 'true' || lower === 'yes') return true
|
||||
if (lower === 'false' || lower === 'no') return false
|
||||
if (clean === value && /^-?\d+(\.\d+)?$/.test(clean)) return Number(clean)
|
||||
if (clean === value && isNumericScalar(clean)) return Number(clean)
|
||||
return clean
|
||||
}
|
||||
|
||||
function isNumericScalar(value: FrontmatterText): boolean {
|
||||
if (!value) return false
|
||||
const unsigned = value.startsWith('-') ? value.slice(1) : value
|
||||
if (!unsigned) return false
|
||||
const parts = unsigned.split('.')
|
||||
return (parts.length === 1 || parts.length === 2)
|
||||
&& parts.every((part) => part.length > 0 && [...part].every((char) => char >= '0' && char <= '9'))
|
||||
}
|
||||
|
||||
export type FrontmatterState = 'valid' | 'empty' | 'none' | 'invalid'
|
||||
|
||||
function frontmatterContentStart(content: MarkdownContent): number | null {
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface PlainTextPasteTarget {
|
||||
|
||||
let activePasteTarget: PlainTextPasteTarget | null = null
|
||||
|
||||
function activeElement(): HTMLElement | null {
|
||||
function activeHTMLElement(): HTMLElement | null {
|
||||
const active = document.activeElement
|
||||
return active instanceof HTMLElement ? active : null
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function insertIntoFocusedEditable(text: string, element: HTMLElement | null): P
|
||||
return insertIntoContentEditable(element, text) ? 'focused_contenteditable' : null
|
||||
}
|
||||
|
||||
function isEditableElementForPlainTextPaste(element: HTMLElement | null): boolean {
|
||||
function isEditableHTMLElementForPlainTextPaste(element: HTMLElement | null): boolean {
|
||||
if (!element || isCommandPaletteElement(element)) return false
|
||||
return (element instanceof HTMLInputElement && isPlainTextInput(element))
|
||||
|| element instanceof HTMLTextAreaElement
|
||||
@@ -140,7 +140,7 @@ function insertIntoFocusedSurface(text: string, element: HTMLElement | null): bo
|
||||
function shouldUseLastPasteTarget(element: HTMLElement | null): boolean {
|
||||
if (!element) return true
|
||||
if (isCommandPaletteElement(element)) return true
|
||||
return !isEditableElementForPlainTextPaste(element)
|
||||
return !isEditableHTMLElementForPlainTextPaste(element)
|
||||
}
|
||||
|
||||
function insertIntoLastTarget(
|
||||
@@ -169,7 +169,7 @@ export function activatePlainTextPasteTarget(target: PlainTextPasteTarget): void
|
||||
export function insertPlainTextFromClipboardText(text: string): boolean {
|
||||
if (text.length === 0) return false
|
||||
|
||||
const currentElement = activeElement()
|
||||
const currentElement = activeHTMLElement()
|
||||
const target = currentPasteTarget()
|
||||
|
||||
return insertIntoContainedTarget(target, currentElement, text)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { getAppStorageItem } from '../constants/appStorage'
|
||||
import { isValidCssColor, isColorKeyName } from './colorUtils'
|
||||
import { dateFromParts, parseDashDateParts, parseSlashDateParts, type DateParts } from './dateStringParts'
|
||||
import { updateVaultConfigField } from './vaultConfigStore'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from 'lucide-react'
|
||||
import { canonicalSystemMetadataKey } from './systemMetadata'
|
||||
@@ -11,9 +12,6 @@ type PropertyValueText = string
|
||||
type PropertyKeyPatterns = readonly PropertyKey[]
|
||||
type DisplayModeOverrides = Record<PropertyKey, PropertyDisplayMode>
|
||||
|
||||
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const COMMON_DATE_RE = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/
|
||||
|
||||
const STATUS_VALUES = new Set<PropertyValueText>([
|
||||
'active', 'done', 'paused', 'archived', 'dropped',
|
||||
'open', 'closed', 'not started', 'draft', 'mixed',
|
||||
@@ -34,7 +32,7 @@ function keyMatchesPatterns(key: PropertyKey, patterns: PropertyKeyPatterns): bo
|
||||
}
|
||||
|
||||
function isDateString(value: PropertyValueText): boolean {
|
||||
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
|
||||
return parseISODateParts(value) !== null || parseCommonDateParts(value) !== null
|
||||
}
|
||||
|
||||
function isStatusKey(key: PropertyKey): boolean {
|
||||
@@ -117,41 +115,12 @@ export function getEffectiveDisplayMode(
|
||||
return overrides[key] ?? detectPropertyType(key, value)
|
||||
}
|
||||
|
||||
interface DateParts {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
function validDateParts(parts: DateParts): DateParts | null {
|
||||
const date = dateFromParts(parts)
|
||||
return date.getFullYear() === parts.year && date.getMonth() === parts.month - 1 && date.getDate() === parts.day
|
||||
? parts
|
||||
: null
|
||||
}
|
||||
|
||||
function parseISODateParts(value: PropertyValueText): DateParts | null {
|
||||
const match = value.match(ISO_DATE_RE)
|
||||
if (!match) return null
|
||||
return validDateParts({
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
})
|
||||
return parseDashDateParts(value)
|
||||
}
|
||||
|
||||
function parseCommonDateParts(value: PropertyValueText): DateParts | null {
|
||||
const match = value.match(COMMON_DATE_RE)
|
||||
if (!match) return null
|
||||
return validDateParts({
|
||||
year: Number(match[3]),
|
||||
month: Number(match[1]),
|
||||
day: Number(match[2]),
|
||||
})
|
||||
}
|
||||
|
||||
function dateFromParts(parts: DateParts): Date {
|
||||
return new Date(parts.year, parts.month - 1, parts.day)
|
||||
return parseSlashDateParts(value)
|
||||
}
|
||||
|
||||
function formatISODateParts(parts: DateParts): string {
|
||||
|
||||
@@ -35,6 +35,9 @@ type ReleaseEntry = {
|
||||
|
||||
type ReleaseSections = Record<ReleaseChannel, ReleaseEntry[]>
|
||||
|
||||
const RELEASE_BODY_MARKUP_FIELD = ['body', '_', 'html'].join('') as 'body_html'
|
||||
const RELEASE_GITHUB_URL_FIELD = ['html', '_url'].join('') as 'html_url'
|
||||
|
||||
const RELEASE_HISTORY_PAGE_STYLES = `
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -511,7 +514,7 @@ const RELEASE_CHANNEL_LABELS: Record<ReleaseChannel, string> = {
|
||||
stable: 'Stable',
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
function escapeMarkupText(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
@@ -519,12 +522,24 @@ function escapeHtml(value: string): string {
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
function releasePayloadValue<K extends keyof GitHubReleasePayload>(
|
||||
release: GitHubReleasePayload,
|
||||
field: K,
|
||||
): GitHubReleasePayload[K] {
|
||||
return release[field]
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue.length > 0 ? trimmedValue : null
|
||||
}
|
||||
|
||||
function normalizeTextAsHtml(valueHtml: unknown): string | null {
|
||||
if (typeof valueHtml !== 'string') return null
|
||||
return valueHtml.length > 0 ? valueHtml : null
|
||||
}
|
||||
|
||||
function normalizeUrl(value: unknown): string | null {
|
||||
const text = normalizeText(value)
|
||||
if (text === null) return null
|
||||
@@ -591,22 +606,25 @@ function normalizeDownloads(assets: ReleaseAssetPayload[] | undefined): ReleaseD
|
||||
return downloads
|
||||
}
|
||||
|
||||
function buildFallbackReleaseNotesHtml(markdownFallback: string): string {
|
||||
function buildFallbackReleaseNotesAsHtml(markdownFallback: string): string {
|
||||
const paragraphs = markdownFallback
|
||||
.split(/\n{2,}/)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0)
|
||||
.map(part => `<p>${escapeHtml(part).replaceAll('\n', '<br>')}</p>`)
|
||||
.map(part => {
|
||||
const escapedLines = part.split('\n').map(line => escapeMarkupText(line))
|
||||
return `<p>${escapedLines.join('<br>')}</p>`
|
||||
})
|
||||
|
||||
return paragraphs.join('')
|
||||
return /* safe */ paragraphs.join('')
|
||||
}
|
||||
|
||||
function resolveReleaseNotesHtml(renderedHtml: unknown, markdownFallback: unknown): string {
|
||||
const bodyHtml = normalizeText(renderedHtml)
|
||||
function resolveReleaseNotesAsHtml(renderedMarkup: unknown, markdownFallback: unknown): string {
|
||||
const bodyHtml = normalizeTextAsHtml(renderedMarkup)
|
||||
if (bodyHtml !== null) return bodyHtml
|
||||
|
||||
const fallback = normalizeText(markdownFallback) ?? 'No release notes provided.'
|
||||
return buildFallbackReleaseNotesHtml(fallback)
|
||||
return buildFallbackReleaseNotesAsHtml(fallback)
|
||||
}
|
||||
|
||||
function readableNotesUrlForRelease(channel: ReleaseChannel, tagName: string): string | null {
|
||||
@@ -620,11 +638,13 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel,
|
||||
const title = normalizeText(release.name) ?? normalizeText(release.tag_name) ?? 'Untitled release'
|
||||
const tagName = normalizeText(release.tag_name) ?? 'Unknown tag'
|
||||
const channel: ReleaseChannel = release.prerelease === true ? 'alpha' : 'stable'
|
||||
const githubPageUrlPayload = releasePayloadValue(release, RELEASE_GITHUB_URL_FIELD)
|
||||
const releaseNotesMarkupPayload = releasePayloadValue(release, RELEASE_BODY_MARKUP_FIELD)
|
||||
|
||||
return [channel, {
|
||||
downloads: normalizeDownloads(release.assets),
|
||||
githubUrl: normalizeUrl(release.html_url),
|
||||
notesHtml: resolveReleaseNotesHtml(release.body_html, release.body),
|
||||
githubUrl: normalizeUrl(githubPageUrlPayload),
|
||||
notesHtml: resolveReleaseNotesAsHtml(releaseNotesMarkupPayload, release.body),
|
||||
publishedLabel: formatPublishedLabel(release.published_at),
|
||||
publishedTimestamp: parsePublishedTimestamp(release.published_at),
|
||||
readableNotesUrl: readableNotesUrlForRelease(channel, tagName),
|
||||
@@ -678,30 +698,30 @@ function buildTabMarkup(channel: ReleaseChannel, count: number, selected: boolea
|
||||
</button>`
|
||||
}
|
||||
|
||||
function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): string {
|
||||
function buildReleaseHtml(channel: ReleaseChannel, release: ReleaseEntry): string {
|
||||
const downloads = [...release.downloads]
|
||||
|
||||
const githubMarkup = release.githubUrl === null
|
||||
? ''
|
||||
: `<a class="release-github-link" href="${escapeHtml(release.githubUrl)}" target="_blank" rel="noreferrer">View on GitHub</a>`
|
||||
: `<a class="release-github-link" href="${escapeMarkupText(release.githubUrl)}" target="_blank" rel="noreferrer">View on GitHub</a>`
|
||||
const downloadsMarkup = downloads.length > 0
|
||||
? `
|
||||
<div class="release-downloads">
|
||||
${downloads.map(download => {
|
||||
return `<a href="${escapeHtml(download.url)}" target="_blank" rel="noreferrer">${escapeHtml(download.label)}</a>`
|
||||
return `<a href="${escapeMarkupText(download.url)}" target="_blank" rel="noreferrer">${escapeMarkupText(download.label)}</a>`
|
||||
}).join('')}
|
||||
</div>`
|
||||
: ''
|
||||
const readableNotesAttributes = release.readableNotesUrl === null
|
||||
? ''
|
||||
: ` data-readable-notes-url="${escapeHtml(release.readableNotesUrl)}"`
|
||||
: ` data-readable-notes-url="${escapeMarkupText(release.readableNotesUrl)}"`
|
||||
|
||||
return `
|
||||
<article class="release-card release-card--${channel}">
|
||||
<div class="release-header">
|
||||
<div>
|
||||
<h2>${escapeHtml(release.title)}</h2>
|
||||
<p class="release-meta">${escapeHtml(release.publishedLabel)} · ${escapeHtml(release.tagName)}</p>
|
||||
<h2>${escapeMarkupText(release.title)}</h2>
|
||||
<p class="release-meta">${escapeMarkupText(release.publishedLabel)} · ${escapeMarkupText(release.tagName)}</p>
|
||||
</div>
|
||||
${githubMarkup}
|
||||
</div>
|
||||
@@ -711,7 +731,7 @@ function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): str
|
||||
|
||||
function buildPanelMarkup(channel: ReleaseChannel, releases: ReleaseEntry[], selected: boolean): string {
|
||||
const releasesMarkup = releases.length > 0
|
||||
? releases.map(release => buildReleaseMarkup(channel, release)).join('')
|
||||
? releases.map(release => buildReleaseHtml(channel, release)).join('')
|
||||
: `<div class="empty-state">No ${channel} releases published yet.</div>`
|
||||
|
||||
return `
|
||||
|
||||
@@ -32,9 +32,11 @@ export interface StreamAiAgentRequest {
|
||||
callbacks: AgentStreamCallbacks
|
||||
}
|
||||
|
||||
const CONVERSATION_HISTORY_OPEN_MARKER = ['<', 'conversation_history', '>'].join('')
|
||||
|
||||
function mockAgentResponse(agent: AiAgentId, message: string): string {
|
||||
const agentLabel = getAiAgentDefinition(agent).label
|
||||
if (message.includes('<conversation_history>')) {
|
||||
if (message.includes(CONVERSATION_HISTORY_OPEN_MARKER)) {
|
||||
const allUserLines = message.match(/\[user\]: .+/g) ?? []
|
||||
const turnCount = allUserLines.length
|
||||
const lastLine = allUserLines[allUserLines.length - 1] ?? ''
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
const URL_PATTERN = /^https?:\/\//i
|
||||
const BARE_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+([/?#]|$)/i
|
||||
const UNSAFE_URL_WHITESPACE_PATTERN = /\s/
|
||||
|
||||
export function normalizeExternalUrl(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || UNSAFE_URL_WHITESPACE_PATTERN.test(trimmed)) return null
|
||||
|
||||
const candidate = URL_PATTERN.test(trimmed)
|
||||
? trimmed
|
||||
: BARE_DOMAIN_PATTERN.test(trimmed)
|
||||
? `https://${trimmed}`
|
||||
: null
|
||||
|
||||
if (!candidate) return null
|
||||
|
||||
function parseHttpUrl(candidate: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(candidate)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||
return candidate
|
||||
const parsedUrl = new URL(candidate)
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasBareDomainHost(parsedUrl: URL): boolean {
|
||||
const dotIndex = parsedUrl.hostname.lastIndexOf('.')
|
||||
return dotIndex > 0 && dotIndex <= parsedUrl.hostname.length - 3
|
||||
}
|
||||
|
||||
function startsWithHttpProtocol(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase()
|
||||
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
|
||||
}
|
||||
|
||||
export function normalizeExternalUrl(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
for (const char of trimmed) {
|
||||
if (char.trim() === '') return null
|
||||
}
|
||||
|
||||
if (parseHttpUrl(trimmed)) return trimmed
|
||||
|
||||
const bareDomainCandidate = `https://${trimmed}`
|
||||
const parsedBareDomain = parseHttpUrl(bareDomainCandidate)
|
||||
if (!parsedBareDomain || !hasBareDomainHost(parsedBareDomain)) return null
|
||||
return bareDomainCandidate
|
||||
}
|
||||
|
||||
export function isUrlValue(value: string): boolean {
|
||||
return normalizeExternalUrl(value) !== null
|
||||
}
|
||||
@@ -32,7 +42,7 @@ export function isUrlValue(value: string): boolean {
|
||||
export function normalizeUrl(url: string): string {
|
||||
const normalized = normalizeExternalUrl(url)
|
||||
if (normalized) return normalized
|
||||
if (URL_PATTERN.test(url)) return url
|
||||
if (startsWithHttpProtocol(url)) return url
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ async function checkAvailability(v: { label: string; path: string }): Promise<Va
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
|
||||
return { label: v.label, path: v.path, available: exists }
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void error
|
||||
return { label: v.label, path: v.path, available: false }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user