mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
62be2e6fc3
@ -58,6 +58,24 @@ describe('MermaidRenderer Mermaid integration', () => {
|
|||||||
expect(surface.innerHTML).not.toContain('onerror')
|
expect(surface.innerHTML).not.toContain('onerror')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('auto-quotes flowchart labels containing forward slashes to avoid lexical errors', async () => {
|
||||||
|
render(
|
||||||
|
<MermaidRenderer
|
||||||
|
code={[
|
||||||
|
'flowchart TD',
|
||||||
|
' D1[/api/dcl] --> 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 () => {
|
it('renders generated flowchart labels with HTML breaks and structural characters', async () => {
|
||||||
render(
|
render(
|
||||||
<MermaidRenderer
|
<MermaidRenderer
|
||||||
|
|||||||
@ -40,7 +40,7 @@ type MermaidThemeColors = {
|
|||||||
|
|
||||||
const FLOWCHART_START = /^\s*(?:graph|flowchart)\b/i
|
const FLOWCHART_START = /^\s*(?:graph|flowchart)\b/i
|
||||||
const FLOWCHART_NODE_START = /^([A-Za-z][\w-]*)\[/
|
const FLOWCHART_NODE_START = /^([A-Za-z][\w-]*)\[/
|
||||||
const UNQUOTED_FLOWCHART_LABEL_UNSAFE = /<br\s*\/?>|[{}[\]*]/i
|
const UNQUOTED_FLOWCHART_LABEL_UNSAFE = /<br\s*\/?>|[{}[\]*\/]/i
|
||||||
|
|
||||||
function isFlowchartDiagram(code: string) {
|
function isFlowchartDiagram(code: string) {
|
||||||
const firstMeaningfulLine = code
|
const firstMeaningfulLine = code
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useUIStore } from '../../stores/uiStore'
|
|||||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||||
import { GlobalSearchModal } from '../search/GlobalSearchModal'
|
import { GlobalSearchModal } from '../search/GlobalSearchModal'
|
||||||
|
import { FindInPageModal } from '../search/FindInPageModal'
|
||||||
import type { SessionListItem } from '../../types/session'
|
import type { SessionListItem } from '../../types/session'
|
||||||
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
|
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
@ -1221,6 +1222,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
|
<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
|
||||||
|
<FindInPageModal open={activeModal === 'findInPage'} onClose={closeModal} />
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
203
desktop/src/components/search/FindInPageModal.tsx
Normal file
203
desktop/src/components/search/FindInPageModal.tsx
Normal file
@ -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 <input>
|
||||||
|
// 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<HTMLInputElement>(null)
|
||||||
|
const rangesRef = useRef<Range[]>([])
|
||||||
|
|
||||||
|
// 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<HTMLInputElement>) {
|
||||||
|
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(
|
||||||
|
<div className="fixed right-3 top-3 z-50" data-find-bar>
|
||||||
|
<style>{`
|
||||||
|
::highlight(${RESULTS_HL}) { background-color: rgba(250, 204, 21, 0.45); color: inherit; }
|
||||||
|
::highlight(${ACTIVE_HL}) { background-color: rgba(249, 115, 22, 0.9); color: #fff; }
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
className="glass-panel flex items-center gap-1 rounded-lg border border-[var(--color-border)] px-2 py-1.5 shadow-lg"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Find in page"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Find"
|
||||||
|
className="w-52 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-[48px] shrink-0 px-1 text-center text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||||
|
{count > 0 ? `${activeIndex + 1} / ${count}` : (debouncedQuery.trim() ? '0' : '')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(false)}
|
||||||
|
disabled={count === 0}
|
||||||
|
aria-label="Previous match"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(true)}
|
||||||
|
disabled={count === 0}
|
||||||
|
aria-label="Next match"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close find bar"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
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<string, unknown> | 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<string, unknown> | undefined
|
||||||
|
highlights?.delete(RESULTS_HL)
|
||||||
|
highlights?.delete(ACTIVE_HL)
|
||||||
|
}
|
||||||
@ -56,6 +56,12 @@ export function useKeyboardShortcuts() {
|
|||||||
openModal('globalSearch')
|
openModal('globalSearch')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+F — Open find-in-page bar
|
||||||
|
if (meta && e.key === 'f') {
|
||||||
|
e.preventDefault()
|
||||||
|
openModal('findInPage')
|
||||||
|
}
|
||||||
|
|
||||||
// Escape — Close modal or clear state
|
// Escape — Close modal or clear state
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (activeModalRef.current) {
|
if (activeModalRef.current) {
|
||||||
|
|||||||
@ -1153,7 +1153,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// ─── Empty Session ──────────────────────────────────────
|
// ─── Empty Session ──────────────────────────────────────
|
||||||
'empty.title': '新建會話',
|
'empty.title': '新建會話',
|
||||||
'empty.subtitle': '開始一個新的編碼會話。Claude 已準備好幫你構建、除錯和架構你的專案。',
|
'empty.subtitle': '開始一個新的編碼會話。Claude 已準備好幫你構建、除錯和設計你的專案。',
|
||||||
'empty.placeholder': '隨便問點什麼...',
|
'empty.placeholder': '隨便問點什麼...',
|
||||||
'empty.addFiles': '新增檔案或圖片',
|
'empty.addFiles': '新增檔案或圖片',
|
||||||
'empty.slashCommands': '斜槓命令',
|
'empty.slashCommands': '斜槓命令',
|
||||||
|
|||||||
@ -1153,7 +1153,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// ─── Empty Session ──────────────────────────────────────
|
// ─── Empty Session ──────────────────────────────────────
|
||||||
'empty.title': '新建会话',
|
'empty.title': '新建会话',
|
||||||
'empty.subtitle': '开始一个新的编码会话。Claude 已准备好帮你构建、调试和架构你的项目。',
|
'empty.subtitle': '开始一个新的编码会话。Claude 已准备好帮你构建、调试和设计你的项目。',
|
||||||
'empty.placeholder': '随便问点什么...',
|
'empty.placeholder': '随便问点什么...',
|
||||||
'empty.addFiles': '添加文件或图片',
|
'empty.addFiles': '添加文件或图片',
|
||||||
'empty.slashCommands': '斜杠命令',
|
'empty.slashCommands': '斜杠命令',
|
||||||
|
|||||||
@ -772,9 +772,9 @@ export function ActivitySettings() {
|
|||||||
style={{ animationDelay: `${index * 45}ms` }}
|
style={{ animationDelay: `${index * 45}ms` }}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-[68px] flex-col items-center justify-center gap-1.5">
|
<div className="flex min-h-[68px] flex-col items-center justify-center gap-1.5">
|
||||||
<div className={`activity-summary-value max-w-full truncate font-semibold leading-none tracking-tight text-[var(--color-text-primary)] tabular-nums ${
|
<div className={`activity-summary-value max-w-full min-w-0 font-semibold leading-none tracking-tight text-[var(--color-text-primary)] tabular-nums ${
|
||||||
isPrimary ? 'text-[23px]' : 'text-[22px]'
|
isPrimary ? 'text-[23px]' : 'text-[22px]'
|
||||||
}`}>
|
}`} style={{ overflow: 'visible', whiteSpace: 'normal', textOverflow: 'clip' }}>
|
||||||
{metric.value}
|
{metric.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 truncate text-[13px] font-medium leading-tight text-[var(--color-text-secondary)]">
|
<div className="min-w-0 truncate text-[13px] font-medium leading-tight text-[var(--color-text-secondary)]">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user