diff --git a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx index eafb81c8..137491e6 100644 --- a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx +++ b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx @@ -58,6 +58,24 @@ describe('MermaidRenderer Mermaid integration', () => { expect(surface.innerHTML).not.toContain('onerror') }) + it('auto-quotes flowchart labels containing forward slashes to avoid lexical errors', async () => { + render( + D2[直接调用 dclService]', + ' D2 --> R[返回结果]', + ].join('\n')} + />, + ) + + const surface = await screen.findByTestId('mermaid-diagram-surface') + + expect(surface).toHaveTextContent('/api/dcl') + expect(surface).toHaveTextContent('直接调用 dclService') + expect(screen.queryByText('Mermaid Error')).not.toBeInTheDocument() + }) + it('renders generated flowchart labels with HTML breaks and structural characters', async () => { render( |[{}[\]*]/i +const UNQUOTED_FLOWCHART_LABEL_UNSAFE = /|[{}[\]*\/]/i function isFlowchartDiagram(code: string) { const firstMeaningfulLine = code diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 6c936f5f..edfda0c1 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -5,6 +5,7 @@ import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' import { GlobalSearchModal } from '../search/GlobalSearchModal' +import { FindInPageModal } from '../search/FindInPageModal' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' @@ -1221,6 +1222,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { /> + ) } diff --git a/desktop/src/components/search/FindInPageModal.tsx b/desktop/src/components/search/FindInPageModal.tsx new file mode 100644 index 00000000..f38b1718 --- /dev/null +++ b/desktop/src/components/search/FindInPageModal.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown, ChevronUp, Search, X } from 'lucide-react' + +// JS-based scoped find-in-page. We walk text nodes in the document body EXCLUDING the +// sidebar (.sidebar-panel) and this find bar ([data-find-bar]), then highlight matches +// with the CSS Custom Highlight API (Range-based, no DOM mutation → React-safe). +// Why not native webContents.findInPage: it scans the whole document incl. the +// value, so the search box matches itself and steals focus/caret. Scoping sidesteps that. + +const FIND_DEBOUNCE_MS = 250 +const RESULTS_HL = 'cc-find-results' +const ACTIVE_HL = 'cc-find-active' +// Subtrees never searched: sidebar, tab bar, this find bar, non-content tags. +const SKIP_CLOSEST = '.sidebar-panel, [data-testid="tab-bar"], [data-find-bar], script, style, noscript, .material-symbols-outlined' + +type Props = { + open: boolean + onClose: () => void +} + +export function FindInPageModal({ open, onClose }: Props) { + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [count, setCount] = useState(0) + const [activeIndex, setActiveIndex] = useState(0) + const inputRef = useRef(null) + const rangesRef = useRef([]) + + // Focus + reset whenever the bar opens; clear highlights when it closes. + useEffect(() => { + if (!open) { + clearHighlights() + return + } + setQuery('') + setDebouncedQuery('') + setCount(0) + setActiveIndex(0) + rangesRef.current = [] + const id = requestAnimationFrame(() => inputRef.current?.focus()) + return () => cancelAnimationFrame(id) + }, [open]) + + // Debounce the typed query. + useEffect(() => { + const id = setTimeout(() => setDebouncedQuery(query), FIND_DEBOUNCE_MS) + return () => clearTimeout(id) + }, [query]) + + // Clear highlights on unmount. + useEffect(() => () => clearHighlights(), []) + + // Run (or clear) the search once the query settles. + useEffect(() => { + const q = debouncedQuery.trim() + if (!q) { + clearHighlights() + rangesRef.current = [] + setCount(0) + setActiveIndex(0) + return + } + const ranges = collectRanges(q) + rangesRef.current = ranges + setCount(ranges.length) + setActiveIndex(0) + paint(ranges, 0) + }, [debouncedQuery]) + + // Next/previous — immediate, uses live state. + function step(forward: boolean) { + const ranges = rangesRef.current + if (ranges.length === 0) return + const next = forward ? (activeIndex + 1) % ranges.length : (activeIndex - 1 + ranges.length) % ranges.length + setActiveIndex(next) + paint(ranges, next) + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault() + step(!e.shiftKey) // Enter = next, Shift+Enter = previous + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + if (!open) return null + + return createPortal( +
+ +
+
+
, + document.body, + ) +} + +// ---- search core (module scope, no React state) ---- + +/** Walk visible text nodes outside skipped subtrees; return a Range per case-insensitive match. */ +function collectRanges(q: string): Range[] { + const ranges: Range[] = [] + const needle = q.toLowerCase() + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement + if (!parent || !node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT + if (parent.closest(SKIP_CLOSEST)) return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_ACCEPT + }, + }) + let textNode = walker.nextNode() as Text | null + while (textNode) { + const text = textNode.nodeValue!.toLowerCase() + let idx = text.indexOf(needle) + while (idx !== -1) { + const range = document.createRange() + range.setStart(textNode, idx) + range.setEnd(textNode, idx + needle.length) + ranges.push(range) + idx = text.indexOf(needle, idx + needle.length) + } + textNode = walker.nextNode() as Text | null + } + return ranges +} + +/** Register CSS highlights for all matches + the active one, and scroll the active into view. */ +function paint(ranges: Range[], activeIndex: number) { + const highlights = (CSS as any).highlights as Map | undefined + const HighlightCtor = (globalThis as any).Highlight + if (highlights && HighlightCtor) { + const results = new HighlightCtor() + for (const r of ranges) results.add(r) + highlights.set(RESULTS_HL, results) + const active = ranges[activeIndex] + if (active) { + const activeHl = new HighlightCtor() + activeHl.add(active) + activeHl.priority = 1 // paint over the results highlight + highlights.set(ACTIVE_HL, activeHl) + } else { + highlights.delete(ACTIVE_HL) + } + } + ranges[activeIndex]?.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' }) +} + +function clearHighlights() { + const highlights = (CSS as any).highlights as Map | undefined + highlights?.delete(RESULTS_HL) + highlights?.delete(ACTIVE_HL) +} diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index 8a299e78..5382c3bc 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -56,6 +56,12 @@ export function useKeyboardShortcuts() { openModal('globalSearch') } + // Ctrl+F — Open find-in-page bar + if (meta && e.key === 'f') { + e.preventDefault() + openModal('findInPage') + } + // Escape — Close modal or clear state if (e.key === 'Escape') { if (activeModalRef.current) { diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 91709e59..1db149ab 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1153,7 +1153,7 @@ export const zh: Record = { // ─── Empty Session ────────────────────────────────────── 'empty.title': '新建會話', - 'empty.subtitle': '開始一個新的編碼會話。Claude 已準備好幫你構建、除錯和架構你的專案。', + 'empty.subtitle': '開始一個新的編碼會話。Claude 已準備好幫你構建、除錯和設計你的專案。', 'empty.placeholder': '隨便問點什麼...', 'empty.addFiles': '新增檔案或圖片', 'empty.slashCommands': '斜槓命令', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index af49ac0a..50319e07 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1153,7 +1153,7 @@ export const zh: Record = { // ─── Empty Session ────────────────────────────────────── 'empty.title': '新建会话', - 'empty.subtitle': '开始一个新的编码会话。Claude 已准备好帮你构建、调试和架构你的项目。', + 'empty.subtitle': '开始一个新的编码会话。Claude 已准备好帮你构建、调试和设计你的项目。', 'empty.placeholder': '随便问点什么...', 'empty.addFiles': '添加文件或图片', 'empty.slashCommands': '斜杠命令', diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index bd473316..334e6925 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -772,9 +772,9 @@ export function ActivitySettings() { style={{ animationDelay: `${index * 45}ms` }} >
-
+ }`} style={{ overflow: 'visible', whiteSpace: 'normal', textOverflow: 'clip' }}> {metric.value}