diff --git a/.codacy.yaml b/.codacy.yaml new file mode 100644 index 00000000..29c3ee5e --- /dev/null +++ b/.codacy.yaml @@ -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" diff --git a/src/App.test.tsx b/src/App.test.tsx index 935409c4..4a888766 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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' }, diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ad07469e..a8d137c8 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -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(() => { diff --git a/src/components/NoteAutocomplete.tsx b/src/components/NoteAutocomplete.tsx index 7be22f64..4588e4a2 100644 --- a/src/components/NoteAutocomplete.tsx +++ b/src/components/NoteAutocomplete.tsx @@ -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 @@ -29,26 +32,106 @@ interface MatchedEntry { TypeIcon?: ComponentType> } +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): 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, 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 ( +
onSelect(item.title)} + onMouseEnter={onHover} + > + + {item.TypeIcon && } + + {item.title} + + {item.noteType && ( + + {item.noteType} + + )} +
) - 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 && (
{matches.map((item, index) => ( -
e.preventDefault()} - onClick={() => handleSelect(item.title)} - onMouseEnter={() => setSelectedIndex(index)} - > - - {item.TypeIcon && } - - {item.title} - - {item.noteType && ( - - {item.noteType} - - )} -
+ item={item} + selected={index === selectedIndex} + onSelect={handleSelect} + onHover={() => setSelectedIndex(index)} + /> ))}
)} diff --git a/src/components/NoteSearchList.tsx b/src/components/NoteSearchList.tsx index 3c2883df..e34b0894 100644 --- a/src/components/NoteSearchList.tsx +++ b/src/components/NoteSearchList.tsx @@ -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 { className?: string } +interface NoteSearchListItemProps { + item: T + index: number + selected: boolean + onItemClick: (item: T, index: number) => void + onItemHover?: (index: number) => void +} + +function NoteSearchListItem({ + item, + index, + selected, + onItemClick, + onItemHover, +}: NoteSearchListItemProps) { + return ( +
onItemClick(item, index)} + onMouseEnter={() => onItemHover?.(index)} + > + + {item.TypeIcon && ( + + )} + + {item.title} + + {item.noteType && ( + + {item.noteType} + + )} +
+ ) +} + export function NoteSearchList({ items, selectedIndex, @@ -34,9 +84,7 @@ export function NoteSearchList({ const listRef = useRef(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({ return (
{items.map((item, i) => ( -
onItemClick(item, i)} - onMouseEnter={() => onItemHover?.(i)} - > - - {item.TypeIcon && ( - - )} - - {item.title} - - {item.noteType && ( - - {item.noteType} - - )} -
+ item={item} + index={i} + selected={i === selectedIndex} + onItemClick={onItemClick} + onItemHover={onItemHover} + /> ))}
) diff --git a/src/components/SafeMarkup.tsx b/src/components/SafeMarkup.tsx index 800318bf..27fcc587 100644 --- a/src/components/SafeMarkup.tsx +++ b/src/components/SafeMarkup.tsx @@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, type HTMLAttributes } from 'react' import { RUNTIME_STYLE_NONCE } from '@/lib/runtimeStyleNonce' interface SafeHtmlSpanProps extends HTMLAttributes { - html: string + markup: string } interface SafeSvgDivProps extends HTMLAttributes { @@ -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(null) useLayoutEffect(() => { - ref.current?.replaceChildren(...importSanitizedHtmlNodes(html)) - }, [html]) + ref.current?.replaceChildren(...importSanitizedMarkupNodes(markup)) + }, [markup]) return } diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 4a543cc2..21fe0b5c 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -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, + 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 + 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 ( +
onSelect(result)} + onMouseEnter={() => onHover(index)} + > +
+ {createElement(TypeIcon, { + width: 14, + height: 14, + className: 'shrink-0', + style: { color: typeColor ?? 'var(--muted-foreground)' }, + })} + + + {entry?.title ?? result.title} + + {noteType && ( + {noteType} + )} +
+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ) +} + function SearchContent({ query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover, }: SearchContentProps) { @@ -204,42 +284,18 @@ function SearchContent({
- {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 ( -
onSelect(result)} - onMouseEnter={() => onHover(i)} - > -
- - - - {entry?.title ?? result.title} - - {noteType && ( - {noteType} - )} -
- {subtitle && ( -

- {subtitle} -

- )} -
- ) - })} + {results.map((result, i) => ( + + ))}
)} diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index 640d3272..d5af26f8 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -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 diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index 6b1a0aa7..75a8251f 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -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} /> diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index cd227d5b..79522c89 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -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) { diff --git a/src/components/note-list/useMultiSelectKeyboard.ts b/src/components/note-list/useMultiSelectKeyboard.ts index 4af24479..d99d431a 100644 --- a/src/components/note-list/useMultiSelectKeyboard.ts +++ b/src/components/note-list/useMultiSelectKeyboard.ts @@ -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 { diff --git a/src/hooks/useAiAgentsOnboarding.ts b/src/hooks/useAiAgentsOnboarding.ts index 19b90c1b..7a85fd1b 100644 --- a/src/hooks/useAiAgentsOnboarding.ts +++ b/src/hooks/useAiAgentsOnboarding.ts @@ -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 } diff --git a/src/hooks/useClaudeCodeOnboarding.test.ts b/src/hooks/useClaudeCodeOnboarding.test.ts index cbc3ff3f..ee1846f0 100644 --- a/src/hooks/useClaudeCodeOnboarding.test.ts +++ b/src/hooks/useClaudeCodeOnboarding.test.ts @@ -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') }) }) diff --git a/src/hooks/useClaudeCodeOnboarding.ts b/src/hooks/useClaudeCodeOnboarding.ts index d0856599..21a90f83 100644 --- a/src/hooks/useClaudeCodeOnboarding.ts +++ b/src/hooks/useClaudeCodeOnboarding.ts @@ -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 } diff --git a/src/hooks/useNoteWidthMode.ts b/src/hooks/useNoteWidthMode.ts index 3aff46e7..fd561798 100644 --- a/src/hooks/useNoteWidthMode.ts +++ b/src/hooks/useNoteWidthMode.ts @@ -76,7 +76,8 @@ async function readNoteContentForWidthPersistence({ return isTauri() ? await invoke('get_note_content', { path }) : await mockInvoke('get_note_content', { path }) - } catch { + } catch (error) { + void error return fallbackContent } } diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts index 3802553b..1c0e059f 100644 --- a/src/hooks/useOnboarding.ts +++ b/src/hooks/useOnboarding.ts @@ -90,7 +90,8 @@ async function clearMissingActiveVault(missingPath: string): Promise { }, }) 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 }) } diff --git a/src/hooks/useStatusBarAddRemote.ts b/src/hooks/useStatusBarAddRemote.ts index f155facd..65877ca0 100644 --- a/src/hooks/useStatusBarAddRemote.ts +++ b/src/hooks/useStatusBarAddRemote.ts @@ -11,7 +11,8 @@ function tauriCall(command: string, args: Record): Promise { try { return await tauriCall('git_remote_status', { vaultPath }) - } catch { + } catch (error) { + void error return null } } diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index 537a2fde..e5fcd6f9 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -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 { try { const { relaunch } = await import('@tauri-apps/plugin-process') await relaunch() - } catch { + } catch (error) { + void error console.warn('[updater] Failed to relaunch') } } diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts index 15d14efc..8f3d0bc3 100644 --- a/src/hooks/useVaultSwitcher.ts +++ b/src/hooks/useVaultSwitcher.ts @@ -717,9 +717,7 @@ function switchVaultPath({ async function ensureVaultCanBeRegistered(path: string): Promise { 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({ diff --git a/src/mock-tauri/vault-api.ts b/src/mock-tauri/vault-api.ts index e1c79eb2..54deafde 100644 --- a/src/mock-tauri/vault-api.ts +++ b/src/mock-tauri/vault-api.ts @@ -10,7 +10,8 @@ async function detectVaultApiAvailability(): Promise { 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) { +type PathQueryCommand = + | 'reload_vault_entry' + | 'get_note_content' + | 'validate_note_content' + | 'get_all_content' + +type PostRequestBuilder = (args: Record) => VaultApiRequest | null + +const PATH_QUERY_ENDPOINTS: Record = { + 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 = { + 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, key: string): string | null { + const value = args[key] + return value ? String(value) : null +} + +function buildListRequest(args: Record, 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, 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, url: string, body: unknown): VaultApiRequest | null { + return buildRequiredPostRequest(args.path, url, body) +} + +function buildSearchRequest(args: Record): 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): 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 { diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index fa6ffefa..4a4cd81f 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -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 = [''].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 `\n${lines.join('\n\n')}\n\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('')) { + 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 diff --git a/src/utils/calendarVersion.ts b/src/utils/calendarVersion.ts new file mode 100644 index 00000000..c9462271 --- /dev/null +++ b/src/utils/calendarVersion.ts @@ -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 | 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]), + } +} diff --git a/src/utils/codeBlockLanguage.ts b/src/utils/codeBlockLanguage.ts index 13436985..23216164 100644 --- a/src/utils/codeBlockLanguage.ts +++ b/src/utils/codeBlockLanguage.ts @@ -1,17 +1,18 @@ type UnknownRecord = Record +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 { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 8f03b903..16088023 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -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)) } diff --git a/src/utils/cssFunctionalColor.ts b/src/utils/cssFunctionalColor.ts new file mode 100644 index 00000000..70db8343 --- /dev/null +++ b/src/utils/cssFunctionalColor.ts @@ -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])) +} diff --git a/src/utils/dateStringParts.ts b/src/utils/dateStringParts.ts new file mode 100644 index 00000000..84bfd3b1 --- /dev/null +++ b/src/utils/dateStringParts.ts @@ -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 +} diff --git a/src/utils/domScroll.ts b/src/utils/domScroll.ts new file mode 100644 index 00000000..6d86a6d6 --- /dev/null +++ b/src/utils/domScroll.ts @@ -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' }) + } +} diff --git a/src/utils/editorLinkAutolink.ts b/src/utils/editorLinkAutolink.ts index 4baa7e83..b709949a 100644 --- a/src/utils/editorLinkAutolink.ts +++ b/src/utils/editorLinkAutolink.ts @@ -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 } diff --git a/src/utils/frontendReady.test.ts b/src/utils/frontendReady.test.ts index 442090ce..5d3848d0 100644 --- a/src/utils/frontendReady.test.ts +++ b/src/utils/frontendReady.test.ts @@ -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', () => { diff --git a/src/utils/frontendReady.ts b/src/utils/frontendReady.ts index 0178e88b..015869fb 100644 --- a/src/utils/frontendReady.ts +++ b/src/utils/frontendReady.ts @@ -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() diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index 4845f129..f740d105 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -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 { diff --git a/src/utils/plainTextPaste.ts b/src/utils/plainTextPaste.ts index 77fdf724..1152e4d4 100644 --- a/src/utils/plainTextPaste.ts +++ b/src/utils/plainTextPaste.ts @@ -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) diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index 1792fdca..a8c6a0c4 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -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 -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([ '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 { diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts index 4fe4bfb0..8282b158 100644 --- a/src/utils/releaseHistoryPage.ts +++ b/src/utils/releaseHistoryPage.ts @@ -35,6 +35,9 @@ type ReleaseEntry = { type ReleaseSections = Record +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 = { 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( + 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 => `

${escapeHtml(part).replaceAll('\n', '
')}

`) + .map(part => { + const escapedLines = part.split('\n').map(line => escapeMarkupText(line)) + return `

${escapedLines.join('
')}

` + }) - 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 ` } -function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): string { +function buildReleaseHtml(channel: ReleaseChannel, release: ReleaseEntry): string { const downloads = [...release.downloads] const githubMarkup = release.githubUrl === null ? '' - : `View on GitHub` + : `View on GitHub` const downloadsMarkup = downloads.length > 0 ? `
${downloads.map(download => { - return `${escapeHtml(download.label)}` + return `${escapeMarkupText(download.label)}` }).join('')}
` : '' const readableNotesAttributes = release.readableNotesUrl === null ? '' - : ` data-readable-notes-url="${escapeHtml(release.readableNotesUrl)}"` + : ` data-readable-notes-url="${escapeMarkupText(release.readableNotesUrl)}"` return `
-

${escapeHtml(release.title)}

-

${escapeHtml(release.publishedLabel)} · ${escapeHtml(release.tagName)}

+

${escapeMarkupText(release.title)}

+

${escapeMarkupText(release.publishedLabel)} · ${escapeMarkupText(release.tagName)}

${githubMarkup}
@@ -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('') : `
No ${channel} releases published yet.
` return ` diff --git a/src/utils/streamAiAgent.ts b/src/utils/streamAiAgent.ts index e89c887c..e64411c0 100644 --- a/src/utils/streamAiAgent.ts +++ b/src/utils/streamAiAgent.ts @@ -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('')) { + if (message.includes(CONVERSATION_HISTORY_OPEN_MARKER)) { const allUserLines = message.match(/\[user\]: .+/g) ?? [] const turnCount = allUserLines.length const lastLine = allUserLines[allUserLines.length - 1] ?? '' diff --git a/src/utils/url.ts b/src/utils/url.ts index d1f922c3..83f63cc6 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -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}` } diff --git a/src/utils/vaultListStore.ts b/src/utils/vaultListStore.ts index 5842c4d1..7a504475 100644 --- a/src/utils/vaultListStore.ts +++ b/src/utils/vaultListStore.ts @@ -16,7 +16,8 @@ async function checkAvailability(v: { label: string; path: string }): Promise('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 } } }