From 521aaf4cce842a02c5bd42d5f0f062c245b70475 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 16:46:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(diff):=20DiffSearchPanel=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=E6=90=9C=E7=B4=A2=E7=AE=97=E6=B3=95=20/=20?= =?UTF-8?q?=E7=9D=80=E8=89=B2=20/=20=E7=8A=B6=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiffSearchPanel.tsx 576 → ~190 行,退化为瘦渲染组件。新增 search/ 子目录: - search/diff-search.ts:纯跨文件搜索逻辑(runSearch + findChangedIndices / expandToContext / stripLeadingIndent / findMatchSpan / countLines / loadContent) + 类型 LineMatch / FileResults + 常量,无 React 依赖 - search/colorize.ts:colorizeAll(Monaco editor.colorize 异步语法着色) - search/useDiffSearch.ts:搜索状态机 hook(query / 大小写持久化 / 结果 / loading / 折叠态 + 去抖搜索 + 内容缓存 + 自动聚焦) 纯结构拆分,行为不变;DiffSearchPanel 导出名与路径保持不变(DiffView 引用零波及)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/diff/DiffSearchPanel.tsx | 428 +----------------- .../features/pr/tabs/diff/search/colorize.ts | 44 ++ .../pr/tabs/diff/search/diff-search.ts | 263 +++++++++++ .../pr/tabs/diff/search/useDiffSearch.ts | 123 +++++ 4 files changed, 454 insertions(+), 404 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx index 30827df..e4204e8 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx @@ -1,10 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TFunction } from 'i18next'; -import { editor as MonacoEditorNs } from 'monaco-editor'; import type { DiffChangedFile } from '@meebox/ipc'; -import { invoke } from '../../../../../api'; -import { languageFor } from '../../../../../utils/language'; +import { PER_FILE_MATCH_CAP, basename, dirname } from './search/diff-search'; +import { useDiffSearch } from './search/useDiffSearch'; /** * 搜索 PR diff 全部变更文件内容。仿 Bitbucket "Search code" 入口的行为: @@ -18,8 +16,8 @@ import { languageFor } from '../../../../../utils/language'; * - 文件级 expand/collapse,默认全展开 * - 点击某条结果 → 调 onJumpToMatch 切换文件 + scroll 到对应行 * - * 性能:每个文件并行 invoke 两次 diff:getFileContent (base + head)。常规 PR 几十 - * 文件 × 几百行毫秒级;> 200 行结果时折叠"显示更多"避免一次渲染过载 + * 搜索算法见 [search/diff-search](./search/diff-search.ts),状态机见 + * [search/useDiffSearch](./search/useDiffSearch.ts);本组件只负责渲染。 */ interface DiffSearchPanelProps { @@ -33,90 +31,21 @@ interface DiffSearchPanelProps { onExit?: () => void; } -const CASE_SENSITIVE_LS_KEY = 'meebox.diffSearch.caseSensitive'; - -interface LineMatch { - /** 1-based 行号 (在 srcSide 端的行号) */ - line: number; - content: string; - /** 该行在 diff 中的角色 */ - diffRole: 'added' | 'removed' | 'context'; - /** 该行属于 head 还是 base 文件 — 决定 onJumpToMatch 的 side 参数 */ - srcSide: 'old' | 'new'; - /** 匹配子串在 content 中的起止位置 (用于高亮渲染) */ - matchStart: number; - matchEnd: number; - /** - * Monaco colorize 后的 HTML (含 inline style 语法着色)。第一波结果回来时为 - * undefined,后台 colorize 完成后异步填上,UI 再 update 一次。 - * 未着色时 fallback 到 renderHighlight 走纯文本 + 关键词 `` - */ - colorizedHtml?: string; -} - -interface FileResults { - file: DiffChangedFile; - matches: LineMatch[]; -} - -/** 大文件保护:单文件超过 N 条命中只展示前 N + "更多" */ -const PER_FILE_MATCH_CAP = 200; - -const SEARCH_DEBOUNCE_MS = 220; - -/** - * 搜索范围限制:仅扫"变更行 + 上下 N 行 context",跟 Monaco DiffEditor 视觉上 - * 用户能看到的"diff 周围若干行 context"对齐。N=10 比 git diff 默认 unified=3 - * 更宽松,让搜索能命中变更附近稍远的引用 (常见场景:改了某个函数实现,调用方 - * 在其上下 5-10 行能被搜到) - */ -const CONTEXT_LINES = 10; - -export function DiffSearchPanel({ - files, - prLocalId, - onJumpToMatch, - onExit, -}: DiffSearchPanelProps) { +export function DiffSearchPanel({ files, prLocalId, onJumpToMatch, onExit }: DiffSearchPanelProps) { const { t } = useTranslation(); - const [query, setQuery] = useState(''); - // 大小写敏感跨 session 持久化 — 用户习惯一旦定下来 (一般是关或开),每次 - // 进搜索面板都得重新切一次很烦。localStorage 写一次就记住 - const [caseSensitive, setCaseSensitive] = useState(() => { - try { - return localStorage.getItem(CASE_SENSITIVE_LS_KEY) === '1'; - } catch { - return false; - } - }); - useEffect(() => { - try { - localStorage.setItem(CASE_SENSITIVE_LS_KEY, caseSensitive ? '1' : '0'); - } catch { - // 隐私模式 / 配额满 等失败静默,不影响搜索功能 - } - }, [caseSensitive]); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - // 默认全部展开 — 用户已经主动搜索,不需要再点一次才看结果 - const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); - - // 当前 search session 的 token,让旧的异步任务发现自己被取消 - const sessionRef = useRef(0); - // 内容缓存:同一个 PR 搜不同关键字时 invoke diff:getFileContent 拿过的不重复拉 - // key: `${side}:${path}` → text content - const contentCacheRef = useRef>(new Map()); - // PR 切换时清缓存 - useEffect(() => { - contentCacheRef.current = new Map(); - }, [prLocalId]); - - // mount 时自动聚焦输入框,省一次点击 - const inputRef = useRef(null); - useEffect(() => { - inputRef.current?.focus(); - }, []); + const { + query, + setQuery, + caseSensitive, + setCaseSensitive, + results, + loading, + error, + collapsedFiles, + toggleFile, + totalMatches, + inputRef, + } = useDiffSearch(files, prLocalId); // Esc 退出搜索:用 window capture-stage listener 让焦点在 input / 结果按钮 / // 任何子元素都生效。子元素的 input 自带 Esc 清空行为浏览器不一定有 (type=text) @@ -133,58 +62,6 @@ export function DiffSearchPanel({ return () => window.removeEventListener('keydown', onKey); }, [onExit]); - useEffect(() => { - const q = query.trim(); - if (!q) { - setResults([]); - setError(null); - setLoading(false); - return; - } - const token = ++sessionRef.current; - setLoading(true); - setError(null); - const timer = window.setTimeout(() => { - void runSearch(token, q, caseSensitive, files, prLocalId, contentCacheRef.current, t) - .then(({ results: r, partialError }) => { - if (token !== sessionRef.current) return; - // 先显示带 关键词高亮的纯文本结果 — 用户立刻能看到命中 - setResults(r); - setError(partialError); - // 异步着色:Monaco colorize 按文件 language 串行执行;token 跟 session - // 关联,过期 session 不再 update state - void colorizeAll(r, token, sessionRef).then((colorized) => { - if (token === sessionRef.current) setResults(colorized); - }); - }) - .catch((e: unknown) => { - if (token !== sessionRef.current) return; - setError(e instanceof Error ? e.message : String(e)); - setResults([]); - }) - .finally(() => { - if (token === sessionRef.current) setLoading(false); - }); - }, SEARCH_DEBOUNCE_MS); - return () => { - window.clearTimeout(timer); - }; - }, [query, caseSensitive, files, prLocalId, t]); - - const totalMatches = useMemo( - () => results.reduce((n, fr) => n + fr.matches.length, 0), - [results], - ); - - const toggleFile = (path: string): void => { - setCollapsedFiles((prev) => { - const next = new Set(prev); - if (next.has(path)) next.delete(path); - else next.add(path); - return next; - }); - }; - return (
@@ -201,7 +78,11 @@ export function DiffSearchPanel({ type="button" className={`diff-search-case-toggle${caseSensitive ? ' active' : ''}`} onClick={() => setCaseSensitive((c) => !c)} - title={caseSensitive ? t('diffSearchPanel.caseSensitiveOn') : t('diffSearchPanel.caseSensitiveOff')} + title={ + caseSensitive + ? t('diffSearchPanel.caseSensitiveOn') + : t('diffSearchPanel.caseSensitiveOff') + } aria-pressed={caseSensitive} > Aa @@ -313,264 +194,3 @@ function renderHighlight(content: string, start: number, end: number): React.Rea ); } - -function basename(p: string): string { - const i = p.lastIndexOf('/'); - return i < 0 ? p : p.slice(i + 1); -} -function dirname(p: string): string { - const i = p.lastIndexOf('/'); - return i <= 0 ? '' : p.slice(0, i); -} - -/** - * 跨文件搜索主逻辑: - * - * 对每个 file: - * 1. 拉 head + base 内容 (有缓存命中即跳过 IPC) - * 2. 用 multiset consume 算"变更行"集合,扩展 ±CONTEXT_LINES 得到"可见范围" - * (跟用户在 Diff 视图能看到的 hunk 周围 context 对齐) - * 3. 仅在可见范围内扫 line-by-line 找匹配 - * 4. head 命中:base 端也有 → context;否则 added - * 5. base 命中且 head 端无:removed (排除已被 context 命中的) - * 6. 同文件结果按 line 升序整理 - * - * 单个文件失败 (binary / 拉取错误) 静默跳过,最后返回 partialError 提示有几个 - * 文件没搜成 - */ -async function runSearch( - token: number, - rawQuery: string, - caseSensitive: boolean, - files: DiffChangedFile[], - prLocalId: string, - cache: Map, - t: TFunction, -): Promise<{ results: FileResults[]; partialError: string | null }> { - const out: FileResults[] = []; - let failedCount = 0; - const probe = caseSensitive ? rawQuery : rawQuery.toLowerCase(); - - await Promise.all( - files.map(async (f) => { - try { - const headPath = f.path; - const basePath = f.oldPath ?? f.path; - const [headText, baseText] = await Promise.all([ - loadContent(cache, prLocalId, 'head', headPath), - loadContent(cache, prLocalId, 'base', basePath), - ]); - // 中途用户切到别的 query — 当前 promise 还没 return 就被取消,直接放弃 - if (token === -1) return; - - const headLines = headText === null ? [] : headText.split('\n'); - const baseLines = baseText === null ? [] : baseText.split('\n'); - // 用 Multiset 而非 Set — 同内容行可能出现多次 (空行 / 大括号 / 同名变量 - // 声明)。Set 会让"基础侧某次 instance 仍存在"误判为 context 跳过 added - const baseLineCount = countLines(baseLines); - const headLineCount = countLines(headLines); - - // 变更行集合 + ±CONTEXT_LINES context = 用户在 Diff 视图看得到的范围 - const changedHead = findChangedIndices(headLines, baseLineCount); - const changedBase = findChangedIndices(baseLines, headLineCount); - const visibleHead = expandToContext(changedHead, headLines.length); - const visibleBase = expandToContext(changedBase, baseLines.length); - - const matches: LineMatch[] = []; - // head 端扫 - for (let i = 0; i < headLines.length; i++) { - if (!visibleHead.has(i)) continue; - const line = headLines[i]!; - const span = findMatchSpan(line, rawQuery, caseSensitive, probe); - if (span === null) continue; - // base 端有同样内容 → context;否则 added - const stillInBase = (baseLineCount.get(line) ?? 0) > 0; - const stripped = stripLeadingIndent(line, span); - matches.push({ - line: i + 1, - content: stripped.content, - diffRole: stillInBase ? 'context' : 'added', - srcSide: 'new', - matchStart: stripped.matchStart, - matchEnd: stripped.matchEnd, - }); - } - // base 端扫,仅收集"仅在 base 出现"的 removed 行 (避免跟 context 重复) - for (let i = 0; i < baseLines.length; i++) { - if (!visibleBase.has(i)) continue; - const line = baseLines[i]!; - const span = findMatchSpan(line, rawQuery, caseSensitive, probe); - if (span === null) continue; - if ((headLineCount.get(line) ?? 0) > 0) continue; - const stripped = stripLeadingIndent(line, span); - matches.push({ - line: i + 1, - content: stripped.content, - diffRole: 'removed', - srcSide: 'old', - matchStart: stripped.matchStart, - matchEnd: stripped.matchEnd, - }); - } - if (matches.length === 0) return; - // 排序:先按 srcSide (head 优先) 再按 line,让结果列表有可预测顺序 - matches.sort((a, b) => { - if (a.srcSide !== b.srcSide) return a.srcSide === 'new' ? -1 : 1; - return a.line - b.line; - }); - out.push({ file: f, matches }); - } catch { - failedCount++; - } - }), - ); - - // 文件级排序按 path 字典序,跟文件树视觉对齐 - out.sort((a, b) => a.file.path.localeCompare(b.file.path)); - return { - results: out, - partialError: - failedCount > 0 ? t('diffSearchPanel.partialError', { count: failedCount }) : null, - }; -} - -/** - * 用 multiset consume 算"变更行"行号集合:other 端有同内容行就配对消费 (从 - * count 表里扣 1),配不上的就是"仅在本侧出现" — 即变更行。 - * - * 比简单 set 含 / 不含判定更准 — 同内容行 (空行 / `}` / 同名 import) 在 PR 一 - * 般是匹配存在,不是变更 - */ -function findChangedIndices( - ownLines: string[], - otherCount: Map, -): Set { - const remaining = new Map(otherCount); - const changed = new Set(); - for (let i = 0; i < ownLines.length; i++) { - const l = ownLines[i]!; - const c = remaining.get(l) ?? 0; - if (c > 0) remaining.set(l, c - 1); - else changed.add(i); - } - return changed; -} - -/** 把变更行索引集合按 ±CONTEXT_LINES 扩展,并合并相邻区间 */ -function expandToContext(changed: Set, total: number): Set { - const out = new Set(); - for (const idx of changed) { - const lo = Math.max(0, idx - CONTEXT_LINES); - const hi = Math.min(total - 1, idx + CONTEXT_LINES); - for (let j = lo; j <= hi; j++) out.add(j); - } - return out; -} - -/** - * 异步着色:对每个 file 的每条 match.content 用 Monaco colorize 加语法高亮。 - * - * Monaco colorize 返回带 inline style 的 HTML — 不依赖 monaco theme CSS, - * dangerouslySetInnerHTML 即可用。串行 file 但并发 line 平衡 throughput vs - * 启动开销 (一个文件的 language 加载只一次)。 - * - * session token 检查:search session 已经被新 query 取代时立即放弃,避免 - * setState 到过期结果上 - */ -async function colorizeAll( - results: FileResults[], - token: number, - sessionRef: React.RefObject, -): Promise { - const out: FileResults[] = []; - for (const fr of results) { - if (token !== sessionRef.current) return results; - const langId = languageFor(fr.file.path); - // plaintext 文件没意义着色 — 直接复用原 matches - if (langId === 'plaintext') { - out.push(fr); - continue; - } - const colorized = await Promise.all( - fr.matches.map(async (m) => { - try { - const html = await MonacoEditorNs.colorize(m.content, langId, { tabSize: 2 }); - // colorize 输出末尾会加 `
`;裁掉避免行高跳一档 - return { ...m, colorizedHtml: html.replace(/$/i, '') }; - } catch { - return m; - } - }), - ); - out.push({ ...fr, matches: colorized }); - } - return out; -} - -async function loadContent( - cache: Map, - prLocalId: string, - side: 'head' | 'base', - path: string, -): Promise { - const k = `${side}:${path}`; - if (cache.has(k)) return cache.get(k)!; - try { - const c = await invoke('diff:getFileContent', { - localId: prLocalId, - side, - path, - }); - // DiffFileContent 联合:{binary:false, content:string} 或 {binary:true}。 - // binary 文件跳过搜索 (没有可比对的文本);non-binary 取 content 字段 - const text = c.binary === false ? c.content : null; - cache.set(k, text); - return text; - } catch { - cache.set(k, null); - return null; - } -} - -/** - * 剥行首缩进 (tab / 空格) — 搜索面板宽度有限,对齐到代码原缩进会浪费横向空间 - * 也让 match 看起来不显眼。剥掉后视觉上左对齐,关键词高亮起止点也按剥后内容 - * 重算。命中**在**缩进里 (query 包含 leading 空白) → 不剥,保持高亮位置正确 - */ -function stripLeadingIndent( - line: string, - span: [number, number], -): { content: string; matchStart: number; matchEnd: number } { - const m = /^[\t ]+/.exec(line); - if (!m) return { content: line, matchStart: span[0], matchEnd: span[1] }; - const offset = m[0].length; - if (span[0] < offset) { - // 命中在缩进区,剥了就高亮就错位 — 保留原内容 - return { content: line, matchStart: span[0], matchEnd: span[1] }; - } - return { - content: line.slice(offset), - matchStart: span[0] - offset, - matchEnd: span[1] - offset, - }; -} - -function countLines(lines: string[]): Map { - const m = new Map(); - for (const l of lines) m.set(l, (m.get(l) ?? 0) + 1); - return m; -} - -function findMatchSpan( - line: string, - query: string, - caseSensitive: boolean, - probeLower: string, -): [number, number] | null { - if (caseSensitive) { - const idx = line.indexOf(query); - return idx < 0 ? null : [idx, idx + query.length]; - } - const idx = line.toLowerCase().indexOf(probeLower); - return idx < 0 ? null : [idx, idx + query.length]; -} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts new file mode 100644 index 0000000..5c41298 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts @@ -0,0 +1,44 @@ +import type { RefObject } from 'react'; +import { editor as MonacoEditorNs } from 'monaco-editor'; +import { languageFor } from '../../../../../../utils/language'; +import type { FileResults } from './diff-search'; + +/** + * 异步着色:对每个 file 的每条 match.content 用 Monaco colorize 加语法高亮。 + * + * Monaco colorize 返回带 inline style 的 HTML — 不依赖 monaco theme CSS, + * dangerouslySetInnerHTML 即可用。串行 file 但并发 line 平衡 throughput vs + * 启动开销 (一个文件的 language 加载只一次)。 + * + * session token 检查:search session 已经被新 query 取代时立即放弃,避免 + * setState 到过期结果上 + */ +export async function colorizeAll( + results: FileResults[], + token: number, + sessionRef: RefObject, +): Promise { + const out: FileResults[] = []; + for (const fr of results) { + if (token !== sessionRef.current) return results; + const langId = languageFor(fr.file.path); + // plaintext 文件没意义着色 — 直接复用原 matches + if (langId === 'plaintext') { + out.push(fr); + continue; + } + const colorized = await Promise.all( + fr.matches.map(async (m) => { + try { + const html = await MonacoEditorNs.colorize(m.content, langId, { tabSize: 2 }); + // colorize 输出末尾会加 `
`;裁掉避免行高跳一档 + return { ...m, colorizedHtml: html.replace(/$/i, '') }; + } catch { + return m; + } + }), + ); + out.push({ ...fr, matches: colorized }); + } + return out; +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts new file mode 100644 index 0000000..d6e3ee4 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts @@ -0,0 +1,263 @@ +import type { TFunction } from 'i18next'; +import type { DiffChangedFile } from '@meebox/ipc'; +import { invoke } from '../../../../../../api'; + +export const CASE_SENSITIVE_LS_KEY = 'meebox.diffSearch.caseSensitive'; + +/** 大文件保护:单文件超过 N 条命中只展示前 N + "更多" */ +export const PER_FILE_MATCH_CAP = 200; + +export const SEARCH_DEBOUNCE_MS = 220; + +/** + * 搜索范围限制:仅扫"变更行 + 上下 N 行 context",跟 Monaco DiffEditor 视觉上 + * 用户能看到的"diff 周围若干行 context"对齐。N=10 比 git diff 默认 unified=3 + * 更宽松,让搜索能命中变更附近稍远的引用 (常见场景:改了某个函数实现,调用方 + * 在其上下 5-10 行能被搜到) + */ +const CONTEXT_LINES = 10; + +export interface LineMatch { + /** 1-based 行号 (在 srcSide 端的行号) */ + line: number; + content: string; + /** 该行在 diff 中的角色 */ + diffRole: 'added' | 'removed' | 'context'; + /** 该行属于 head 还是 base 文件 — 决定 onJumpToMatch 的 side 参数 */ + srcSide: 'old' | 'new'; + /** 匹配子串在 content 中的起止位置 (用于高亮渲染) */ + matchStart: number; + matchEnd: number; + /** + * Monaco colorize 后的 HTML (含 inline style 语法着色)。第一波结果回来时为 + * undefined,后台 colorize 完成后异步填上,UI 再 update 一次。 + * 未着色时 fallback 到 renderHighlight 走纯文本 + 关键词 `` + */ + colorizedHtml?: string; +} + +export interface FileResults { + file: DiffChangedFile; + matches: LineMatch[]; +} + +export function basename(p: string): string { + const i = p.lastIndexOf('/'); + return i < 0 ? p : p.slice(i + 1); +} +export function dirname(p: string): string { + const i = p.lastIndexOf('/'); + return i <= 0 ? '' : p.slice(0, i); +} + +/** + * 跨文件搜索主逻辑: + * + * 对每个 file: + * 1. 拉 head + base 内容 (有缓存命中即跳过 IPC) + * 2. 用 multiset consume 算"变更行"集合,扩展 ±CONTEXT_LINES 得到"可见范围" + * (跟用户在 Diff 视图能看到的 hunk 周围 context 对齐) + * 3. 仅在可见范围内扫 line-by-line 找匹配 + * 4. head 命中:base 端也有 → context;否则 added + * 5. base 命中且 head 端无:removed (排除已被 context 命中的) + * 6. 同文件结果按 line 升序整理 + * + * 单个文件失败 (binary / 拉取错误) 静默跳过,最后返回 partialError 提示有几个 + * 文件没搜成 + */ +export async function runSearch( + token: number, + rawQuery: string, + caseSensitive: boolean, + files: DiffChangedFile[], + prLocalId: string, + cache: Map, + t: TFunction, +): Promise<{ results: FileResults[]; partialError: string | null }> { + const out: FileResults[] = []; + let failedCount = 0; + const probe = caseSensitive ? rawQuery : rawQuery.toLowerCase(); + + await Promise.all( + files.map(async (f) => { + try { + const headPath = f.path; + const basePath = f.oldPath ?? f.path; + const [headText, baseText] = await Promise.all([ + loadContent(cache, prLocalId, 'head', headPath), + loadContent(cache, prLocalId, 'base', basePath), + ]); + // 中途用户切到别的 query — 当前 promise 还没 return 就被取消,直接放弃 + if (token === -1) return; + + const headLines = headText === null ? [] : headText.split('\n'); + const baseLines = baseText === null ? [] : baseText.split('\n'); + // 用 Multiset 而非 Set — 同内容行可能出现多次 (空行 / 大括号 / 同名变量 + // 声明)。Set 会让"基础侧某次 instance 仍存在"误判为 context 跳过 added + const baseLineCount = countLines(baseLines); + const headLineCount = countLines(headLines); + + // 变更行集合 + ±CONTEXT_LINES context = 用户在 Diff 视图看得到的范围 + const changedHead = findChangedIndices(headLines, baseLineCount); + const changedBase = findChangedIndices(baseLines, headLineCount); + const visibleHead = expandToContext(changedHead, headLines.length); + const visibleBase = expandToContext(changedBase, baseLines.length); + + const matches: LineMatch[] = []; + // head 端扫 + for (let i = 0; i < headLines.length; i++) { + if (!visibleHead.has(i)) continue; + const line = headLines[i]!; + const span = findMatchSpan(line, rawQuery, caseSensitive, probe); + if (span === null) continue; + // base 端有同样内容 → context;否则 added + const stillInBase = (baseLineCount.get(line) ?? 0) > 0; + const stripped = stripLeadingIndent(line, span); + matches.push({ + line: i + 1, + content: stripped.content, + diffRole: stillInBase ? 'context' : 'added', + srcSide: 'new', + matchStart: stripped.matchStart, + matchEnd: stripped.matchEnd, + }); + } + // base 端扫,仅收集"仅在 base 出现"的 removed 行 (避免跟 context 重复) + for (let i = 0; i < baseLines.length; i++) { + if (!visibleBase.has(i)) continue; + const line = baseLines[i]!; + const span = findMatchSpan(line, rawQuery, caseSensitive, probe); + if (span === null) continue; + if ((headLineCount.get(line) ?? 0) > 0) continue; + const stripped = stripLeadingIndent(line, span); + matches.push({ + line: i + 1, + content: stripped.content, + diffRole: 'removed', + srcSide: 'old', + matchStart: stripped.matchStart, + matchEnd: stripped.matchEnd, + }); + } + if (matches.length === 0) return; + // 排序:先按 srcSide (head 优先) 再按 line,让结果列表有可预测顺序 + matches.sort((a, b) => { + if (a.srcSide !== b.srcSide) return a.srcSide === 'new' ? -1 : 1; + return a.line - b.line; + }); + out.push({ file: f, matches }); + } catch { + failedCount++; + } + }), + ); + + // 文件级排序按 path 字典序,跟文件树视觉对齐 + out.sort((a, b) => a.file.path.localeCompare(b.file.path)); + return { + results: out, + partialError: + failedCount > 0 ? t('diffSearchPanel.partialError', { count: failedCount }) : null, + }; +} + +/** + * 用 multiset consume 算"变更行"行号集合:other 端有同内容行就配对消费 (从 + * count 表里扣 1),配不上的就是"仅在本侧出现" — 即变更行。 + * + * 比简单 set 含 / 不含判定更准 — 同内容行 (空行 / `}` / 同名 import) 在 PR 一 + * 般是匹配存在,不是变更 + */ +export function findChangedIndices( + ownLines: string[], + otherCount: Map, +): Set { + const remaining = new Map(otherCount); + const changed = new Set(); + for (let i = 0; i < ownLines.length; i++) { + const l = ownLines[i]!; + const c = remaining.get(l) ?? 0; + if (c > 0) remaining.set(l, c - 1); + else changed.add(i); + } + return changed; +} + +/** 把变更行索引集合按 ±CONTEXT_LINES 扩展,并合并相邻区间 */ +export function expandToContext(changed: Set, total: number): Set { + const out = new Set(); + for (const idx of changed) { + const lo = Math.max(0, idx - CONTEXT_LINES); + const hi = Math.min(total - 1, idx + CONTEXT_LINES); + for (let j = lo; j <= hi; j++) out.add(j); + } + return out; +} + +export async function loadContent( + cache: Map, + prLocalId: string, + side: 'head' | 'base', + path: string, +): Promise { + const k = `${side}:${path}`; + if (cache.has(k)) return cache.get(k)!; + try { + const c = await invoke('diff:getFileContent', { + localId: prLocalId, + side, + path, + }); + // DiffFileContent 联合:{binary:false, content:string} 或 {binary:true}。 + // binary 文件跳过搜索 (没有可比对的文本);non-binary 取 content 字段 + const text = c.binary === false ? c.content : null; + cache.set(k, text); + return text; + } catch { + cache.set(k, null); + return null; + } +} + +/** + * 剥行首缩进 (tab / 空格) — 搜索面板宽度有限,对齐到代码原缩进会浪费横向空间 + * 也让 match 看起来不显眼。剥掉后视觉上左对齐,关键词高亮起止点也按剥后内容 + * 重算。命中**在**缩进里 (query 包含 leading 空白) → 不剥,保持高亮位置正确 + */ +export function stripLeadingIndent( + line: string, + span: [number, number], +): { content: string; matchStart: number; matchEnd: number } { + const m = /^[\t ]+/.exec(line); + if (!m) return { content: line, matchStart: span[0], matchEnd: span[1] }; + const offset = m[0].length; + if (span[0] < offset) { + // 命中在缩进区,剥了就高亮就错位 — 保留原内容 + return { content: line, matchStart: span[0], matchEnd: span[1] }; + } + return { + content: line.slice(offset), + matchStart: span[0] - offset, + matchEnd: span[1] - offset, + }; +} + +export function countLines(lines: string[]): Map { + const m = new Map(); + for (const l of lines) m.set(l, (m.get(l) ?? 0) + 1); + return m; +} + +export function findMatchSpan( + line: string, + query: string, + caseSensitive: boolean, + probeLower: string, +): [number, number] | null { + if (caseSensitive) { + const idx = line.indexOf(query); + return idx < 0 ? null : [idx, idx + query.length]; + } + const idx = line.toLowerCase().indexOf(probeLower); + return idx < 0 ? null : [idx, idx + query.length]; +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts new file mode 100644 index 0000000..ede7069 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { DiffChangedFile } from '@meebox/ipc'; +import { colorizeAll } from './colorize'; +import { + CASE_SENSITIVE_LS_KEY, + SEARCH_DEBOUNCE_MS, + runSearch, + type FileResults, +} from './diff-search'; + +/** + * 跨文件搜索状态机:query / 大小写敏感(localStorage 持久化) / 结果 / loading / error / + * 文件折叠态 + 去抖搜索 + 异步着色 + 内容缓存(PR 切换清)。mount 自动聚焦输入框。 + * 纯算法见 ./diff-search;着色见 ./colorize。 + */ +export function useDiffSearch(files: DiffChangedFile[], prLocalId: string) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + // 大小写敏感跨 session 持久化 — 用户习惯一旦定下来 (一般是关或开),每次 + // 进搜索面板都得重新切一次很烦。localStorage 写一次就记住 + const [caseSensitive, setCaseSensitive] = useState(() => { + try { + return localStorage.getItem(CASE_SENSITIVE_LS_KEY) === '1'; + } catch { + return false; + } + }); + useEffect(() => { + try { + localStorage.setItem(CASE_SENSITIVE_LS_KEY, caseSensitive ? '1' : '0'); + } catch { + // 隐私模式 / 配额满 等失败静默,不影响搜索功能 + } + }, [caseSensitive]); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // 默认全部展开 — 用户已经主动搜索,不需要再点一次才看结果 + const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); + + // 当前 search session 的 token,让旧的异步任务发现自己被取消 + const sessionRef = useRef(0); + // 内容缓存:同一个 PR 搜不同关键字时 invoke diff:getFileContent 拿过的不重复拉 + // key: `${side}:${path}` → text content + const contentCacheRef = useRef>(new Map()); + // PR 切换时清缓存 + useEffect(() => { + contentCacheRef.current = new Map(); + }, [prLocalId]); + + // mount 时自动聚焦输入框,省一次点击 + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const q = query.trim(); + if (!q) { + setResults([]); + setError(null); + setLoading(false); + return; + } + const token = ++sessionRef.current; + setLoading(true); + setError(null); + const timer = window.setTimeout(() => { + void runSearch(token, q, caseSensitive, files, prLocalId, contentCacheRef.current, t) + .then(({ results: r, partialError }) => { + if (token !== sessionRef.current) return; + // 先显示带 关键词高亮的纯文本结果 — 用户立刻能看到命中 + setResults(r); + setError(partialError); + // 异步着色:Monaco colorize 按文件 language 串行执行;token 跟 session + // 关联,过期 session 不再 update state + void colorizeAll(r, token, sessionRef).then((colorized) => { + if (token === sessionRef.current) setResults(colorized); + }); + }) + .catch((e: unknown) => { + if (token !== sessionRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + setResults([]); + }) + .finally(() => { + if (token === sessionRef.current) setLoading(false); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + }; + }, [query, caseSensitive, files, prLocalId, t]); + + const totalMatches = useMemo( + () => results.reduce((n, fr) => n + fr.matches.length, 0), + [results], + ); + + const toggleFile = (path: string): void => { + setCollapsedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return { + query, + setQuery, + caseSensitive, + setCaseSensitive, + results, + loading, + error, + collapsedFiles, + toggleFile, + totalMatches, + inputRef, + }; +} From 81988d3a823b00b3f50ab9bbe18002f481b1c407 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 16:50:27 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(drafts):=20DraftZone=20=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=20read/edit/publish=20=E7=8A=B6=E6=80=81=E6=9C=BA=20h?= =?UTF-8?q?ook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DraftZone.tsx 531 → ~230 行,退化为瘦渲染组件。 - useDraftZone.ts:read/edit/publish 状态机 hook —— 全部 state(isEditing / editingBody / saving / publishing / publishError / confirmDelete)+ ref(textarea + 4 个同步 ref + isMutating 锁)+ effect(ref 同步 / mutate 锁 / unmount cleanup / registerEditTrigger / draft.body 同步 / focus / Esc window listener)+ handler (runCancelLogic / handleSave / handlePublish / handlePublishFromEdit / handleCancel / handleDelete / handleConfirmDelete / onKeyDown)+ 派生(canEdit / canSave / isStash) - DraftZone.tsx:消费 hook 做渲染(head / edit / read / foot / 错误 / ConfirmModal) 取消四档逻辑(取消按钮 / unmount cleanup / Esc 共用 runCancelLogic 读 ref 最新值)整体 搬进 hook,行为不变;eslint-disable 兜底原样保留。DraftZone 导出名与路径不变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/drafts/DraftZone.tsx | 357 ++---------------- .../features/pr/tabs/drafts/useDraftZone.ts | 329 ++++++++++++++++ 2 files changed, 365 insertions(+), 321 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx index 1172f2d..ad641b3 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx @@ -1,10 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { ReviewDraft } from '@meebox/shared'; import { ConfirmModal, TrashIcon } from '../../../../common'; +import { useDraftZone } from './useDraftZone'; interface DraftZoneProps { draft: ReviewDraft; @@ -12,63 +12,29 @@ interface DraftZoneProps { hardBreaks: boolean; /** * 注册 "进入编辑模式" 触发函数到外部 ref map。DiffView 调用注册的 fn 时本组件 - * setIsEditing(true)。 - * - * 用 ref-based fn 而不是 props token,避免之前的 bug:trigger token 变化 → - * DiffView re-render → useEffect 重跑 → DraftZone unmount/mount → 新 instance - * 看到 token 非 undefined 又触发 setIsEditing(true)。结果:用户点取消走 auto - * save → re-mount → 自动又进 edit,看起来"没退出"。 - * - * ref-fn 调用不引发任何 React state change,零副作用,调一次只 enter edit 一次 + * setIsEditing(true)。用 ref-based fn 而不是 props token,避免 trigger token 变化引发的 + * unmount/mount 循环误触(详见 useDraftZone)。 */ registerEditTrigger?: (draftId: string, fn: (() => void) | null) => void; - /** - * 保存编辑后的 body。调用方走 IPC drafts:update。成功后由 drafts-store 事件流 - * 重渲染本组件 (draft prop 更新) - */ + /** 保存编辑后的 body。调用方走 IPC drafts:update。 */ onSave: (newBody: string) => void | Promise; /** 删除本草稿。调用方走 IPC drafts:delete */ onDelete: () => void | Promise; /** - * 单条直接发布到远端。调用方走 drafts:publishBatch 传单元素 draftIds (跟批量 - * 路径共用 handler — main 端串行 POST + 失败收集 + 发完 force-refresh 评论)。 - * 返回单条的发布结果:ok=false 时 error 填人读错因 (Bitbucket 4xx),本组件渲染 inline - * 错误提示但不卸载 zone (用户可改完 body 再点发布重试)。 - * 不传 = read 模式不渲染"发布"按钮 (e.g., 未来某些只读场景) + * 单条直接发布到远端。调用方走 drafts:publishBatch 传单元素 draftIds。 + * 返回 ok=false 时 error 填人读错因,本组件渲染 inline 错误但不卸载 zone。 + * 不传 = read 模式不渲染"发布"按钮。 */ onPublish?: () => Promise<{ ok: boolean; error?: string }>; } /** * Diff 视图内联草稿编辑 zone。挂在 Monaco editor 的 view zone 里,由 - * `createRoot.render()` 渲染。 - * - * 视觉跟现有 CommentZone (远端评论 read-only) 区分: - * - CommentZone 黄底 (Bitbucket 远端) → 这里**蓝底 + DRAFT chip** - * - posted 状态切回绿底跟远端评论形态对齐 (含远端 comment id 链接) - * - rejected 状态默认 css 隐藏 (DiffView 端 .monaco-draft-zone-rejected 上设) - * - * 状态机:内部 isEditing 控 read / edit 模式 - * read → 显示 markdown body + chips + [发布] [编辑] [🗑] 按钮 - * edit → textarea + [发布] [取消] [🗑] 按钮;Cmd/Ctrl+Enter = 发布,Esc = 取消 + * `createRoot.render()` 渲染。read/edit/publish 状态机见 [useDraftZone](./useDraftZone.ts); + * 本组件只负责渲染。 * - * 关键交互约定: - * - 取消 = 1) editing+persisted 都空 → 删除真空草稿避免占位; - * 2) editing 空 + persisted 非空 → revert 退出 (用户清空 textarea 可能误操作); - * 3) editing 跟 persisted 一样 → 直接退出 edit; - * 4) editing 跟 persisted 不同 → **自动保存** (不弹 confirm),最贴近用户直觉 - * - 发布 (edit 模式) = 先 auto-save 当前 editingBody,再走 onPublish。设计动机: - * 取消既然已经 auto-save dirty,独立的"保存"按钮就冗余了 —— 用户在 edit 编辑完 - * 最自然的下一步是发布,合并成一次点击。失败 (Bitbucket 4xx) 不退 edit,让用户改后重试 - * - 发布 (read 模式) = 直接 onPublish,body 取盘上 persisted 值 - * - 删除 = 独立 [🗑] 按钮,body 非空时弹 ConfirmModal 二次确认;空草稿直接删 - * - * 没传 onPublish 时 (only happens in hypothetical read-only context) edit 按钮组 - * 退回老的"保存"按钮 - * - * onSave 触发后等 drafts-store 事件流推回新 draft prop,useEffect 把 isEditing - * 收回 read 模式。乐观更新 UI:onSave 调用后立即把本地 editingBody 同步到下次 - * draft.body 比较,保证 UI 不闪烁 + * 视觉跟 CommentZone (远端评论 read-only) 区分:CommentZone 黄底 → 这里**蓝底 + DRAFT chip**; + * posted 切绿底跟远端评论对齐;rejected 默认 css 隐藏(DiffView 端 .monaco-draft-zone-rejected)。 */ export function DraftZone({ draft, @@ -79,282 +45,31 @@ export function DraftZone({ onPublish, }: DraftZoneProps) { const { t } = useTranslation(); - const [isEditing, setIsEditing] = useState(false); - const [editingBody, setEditingBody] = useState(draft.body); - const [saving, setSaving] = useState(false); - const [publishing, setPublishing] = useState(false); - const [publishError, setPublishError] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const textareaRef = useRef(null); - - // Ref 跟踪最新 state / props,供 unmount cleanup 闭包同步读 - const editingBodyRef = useRef(editingBody); - const isEditingRef = useRef(isEditing); - const draftBodyRef = useRef(draft.body); - useEffect(() => { - editingBodyRef.current = editingBody; - }, [editingBody]); - useEffect(() => { - isEditingRef.current = isEditing; - }, [isEditing]); - useEffect(() => { - draftBodyRef.current = draft.body; - }, [draft.body]); - - // 显式 mutate 标记 — 区分 "用户主动操作触发 unmount" vs "切换文件触发 unmount"。 - // 设计:mutate 入口设 true (handleSave/Cancel/Delete 各种路径);进 edit 时重置 - // false (new editing session 没显式意图)。**不在 finally 清** — 因为 mutate IPC - // 完成跟 drafts:changed 引发的 unmount 时序不可控,清得太早会让 cleanup 误判。 - // 锁的生命周期跟随 component instance:mutate 后保留 true 直到 unmount 或下次进 - // edit 时被重置 - const isMutatingRef = useRef(false); - useEffect(() => { - if (isEditing) isMutatingRef.current = false; - }, [isEditing]); - - // 统一的 cancel 四档逻辑 — handleCancel / unmount cleanup / Esc 共用 - // (避免三个地方分别实现引起行为漂移)。读 ref 拿最新值,支持 fire-and-forget - const runCancelLogic = (): void => { - const editing = editingBodyRef.current.trim(); - const persisted = draftBodyRef.current.trim(); - if (!editing && !persisted) { - void onDelete(); - return; - } - if (!editing) { - // 空 + 有 → revert (unmount 时 no-op,state 已销毁;handleCancel 时设 state) - setEditingBody(draftBodyRef.current); - setIsEditing(false); - return; - } - if (editingBodyRef.current === draftBodyRef.current) { - setIsEditing(false); - return; - } - // dirty → auto save - void onSave(editingBodyRef.current); - setIsEditing(false); - }; - - // unmount cleanup — 切换文件 / PR / tab 触发。跟取消按钮共用 runCancelLogic 让 - // 行为完全一致。isMutating=true 时跳过 (用户已经显式 mutate 接管,不需要 cleanup - // 兜底,避免跟 IPC 形成 race) - useEffect(() => { - return () => { - if (isMutatingRef.current) return; - if (!isEditingRef.current) return; - runCancelLogic(); - }; - // 空 deps:mount/unmount 跑;runCancelLogic 内部全 ref 读,无 closure 失效问题 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // 把 "进入编辑模式" 触发器注册到 DiffView 的 ref map。register 是稳定函数引用 - // (DiffView 用 useCallback 包),draft.id 不变 → effect 只在 mount/unmount 跑。 - // 调用 fn 不引发 React state change in DiffView side → 没有 re-render → 没有 - // unmount/mount 循环,进 edit 一次只一次 - useEffect(() => { - registerEditTrigger?.(draft.id, () => { - setIsEditing(true); - setEditingBody(draftBodyRef.current); - }); - return () => registerEditTrigger?.(draft.id, null); - }, [draft.id, registerEditTrigger]); - - // draft.body 外部变化 (e.g., 队列写回) + 当前非 editing → 同步 editingBody - useEffect(() => { - if (!isEditing) setEditingBody(draft.body); - }, [draft.body, isEditing]); - - // 进入 edit 时 focus + 光标到末尾 - useEffect(() => { - if (isEditing && textareaRef.current) { - const el = textareaRef.current; - el.focus(); - el.setSelectionRange(el.value.length, el.value.length); - } - }, [isEditing]); - - // textarea 的 React onKeyDown 在 React 18 root delegation 下走 bubble 阶段, - // monaco 在 editor container 上的 capture-stage keydown listener 可能在事件 - // 冒泡前就 stopPropagation 吞掉 Esc,导致 textarea 的 onKeyDown 永远不触发。 - // 兜底:window 顶层 capture listener,textarea focus 时按 Esc 走 runCancelLogic - // (跟取消按钮 / unmount cleanup 共用) + 设 isMutatingRef=true 让 cleanup 跳过 - useEffect(() => { - if (!isEditing) return; - const onKey = (e: KeyboardEvent): void => { - if (e.key !== 'Escape') return; - if (document.activeElement !== textareaRef.current) return; - e.preventDefault(); - e.stopPropagation(); - isMutatingRef.current = true; - runCancelLogic(); - }; - window.addEventListener('keydown', onKey, true); - return () => window.removeEventListener('keydown', onKey, true); - // runCancelLogic 是局部 fn,不放 deps;isEditing 切回时移除 listener - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isEditing]); - - const status = draft.status; - // posted 不应该再被编辑;UI 上隐藏编辑/删除按钮,整体灰显 - const canEdit = status !== 'posted'; - // rejected 草稿默认隐藏(DiffView 端样式控制);本组件渲染时也不挂 zone - - const trimmedEditing = editingBody.trim(); - // 评论不能为空:trim 后无字符不允许保存。提交按钮 disabled + 按钮 title 解释 - const canSave = trimmedEditing.length > 0; - - const handleSave = async (): Promise => { - if (saving) return; - if (!canSave) return; // 防御:除按钮 disabled 外双保险 - if (trimmedEditing === draft.body.trim()) { - // 没改 → 退回 read 不调 IPC - setIsEditing(false); - return; - } - isMutatingRef.current = true; - setSaving(true); - try { - await onSave(editingBody); - // 等 store 事件回来后 draft.body 更新,但乐观先退出 edit;体感更顺 - setIsEditing(false); - } finally { - setSaving(false); - } - }; - - // 单条直接发布。仅 pending/edited 可发;body 空也不发 (Bitbucket 拒绝空评论)。 - // 不弹 confirm — 单条评论的"发布"心智跟"批量评审"不同,是即时操作;要二次 - // 确认放在批量入口的 PublishReviewModal 那一层做就够了 - const handlePublish = async (): Promise => { - if (!onPublish || publishing) return; - if (status === 'posted' || status === 'rejected') return; - if (!draft.body.trim()) return; - isMutatingRef.current = true; - setPublishError(null); - setPublishing(true); - try { - const res = await onPublish(); - if (!res.ok) { - setPublishError(res.error ?? t('draftZone.publishFailed')); - } - // 成功 → drafts-store 广播让 status 切到 'posted',组件自动 re-render 显示 - // posted chip + 远端 id。无需手动 setIsEditing 之类,draft prop 流推回 - } catch (e) { - setPublishError(e instanceof Error ? e.message : String(e)); - } finally { - setPublishing(false); - } - }; - - // edit 模式的"发布" — 先 save 当前 textarea 内容到本地,再调 publish。 - // 设计动机:取消按钮已经有 dirty auto-save 语义,独立的"保存"按钮反而冗余; - // 用户在 edit 改完最自然的下一步是发布,合并一次点击。 - // - 保存失败 (本地写盘几乎不会) → 仍尝试发布;publish 端读盘拿到的是 main 上 - // 一次成功的 body,对用户没有静默错误风险 - // - 发布失败 (Bitbucket 4xx) → 留在 edit 让用户改 body 重试,inline error 提示 - const handlePublishFromEdit = async (): Promise => { - if (!onPublish || publishing) return; - if (!canSave) return; - isMutatingRef.current = true; - setPublishError(null); - // 先持久化本次 editing 内容 — onPublish 通过 main store 读盘拿 body,必须 - // 保证盘上是用户刚改的最新版本 - if (editingBody !== draft.body) { - setSaving(true); - try { - await onSave(editingBody); - } finally { - setSaving(false); - } - } - setPublishing(true); - try { - const res = await onPublish(); - if (res.ok) { - setIsEditing(false); - } else { - setPublishError(res.error ?? t('draftZone.publishFailed')); - } - } catch (e) { - setPublishError(e instanceof Error ? e.message : String(e)); - } finally { - setPublishing(false); - } - }; - - const handleCancel = async (): Promise => { - // 取消复用 runCancelLogic (跟 unmount cleanup 共用)。区别:handleCancel 主动 - // 设 isMutatingRef=true (用户显式操作),让 cleanup 知道意图已被接管 - isMutatingRef.current = true; - // 内部 dirty 分支需要 await save 才退 edit,单独走一遍含 await 的版本: - const editingTrim = editingBody.trim(); - const persistedTrim = draft.body.trim(); - if (!editingTrim && !persistedTrim) { - void onDelete(); - return; - } - if (!editingTrim) { - setEditingBody(draft.body); - setIsEditing(false); - return; - } - if (editingBody === draft.body) { - setIsEditing(false); - return; - } - setSaving(true); - try { - await onSave(editingBody); - setIsEditing(false); - } finally { - setSaving(false); - } - }; - - const handleDelete = (): void => { - // body 非空(含编辑中的草稿)→ 弹 ConfirmModal 二次确认;空草稿直接删。 - // edit 模式下用 editingBody 判(用户可能在 textarea 输入很多还没保存就点删除) - const currentBody = (isEditing ? editingBody : draft.body).trim(); - if (currentBody) { - setConfirmDelete(true); - return; - } - isMutatingRef.current = true; - void onDelete(); - }; - - const handleConfirmDelete = async (): Promise => { - setConfirmDelete(false); - isMutatingRef.current = true; - await onDelete(); - }; - - const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.nativeEvent.isComposing) return; - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - // Cmd/Ctrl+Enter 优先走"发布" (跟新的主按钮一致);onPublish 缺失场景退回保存 - e.preventDefault(); - if (onPublish) { - void handlePublishFromEdit(); - } else { - void handleSave(); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - void handleCancel(); - } - }; - - // 取消按钮动态文案:textarea 有内容 → "暂存",明示点击不丢内容;textarea - // 完全空 → "取消",对应"退出 edit 不留草稿"的语义 (runCancelLogic 此时会删 - // 真空草稿 / revert)。 - // 不再用"dirty (editing != persisted)"作判据 — 用户进 edit 看到已存的旧内容 - // 没改也"有文字",按用户心智应该是暂存 (即使内部走 no-op 直接退出 edit) - // textarea 有内容 → 暂存语义;空 → 取消语义。stash 标志驱动按钮文案与 title - const isStash = trimmedEditing.length > 0; - const cancelLabel = isStash ? t('draftZone.stash') : t('common.cancel'); + const { + status, + canEdit, + canSave, + isStash, + cancelLabel, + isEditing, + setIsEditing, + editingBody, + setEditingBody, + saving, + publishing, + publishError, + setPublishError, + confirmDelete, + setConfirmDelete, + textareaRef, + handleSave, + handlePublish, + handlePublishFromEdit, + handleCancel, + handleDelete, + handleConfirmDelete, + onKeyDown, + } = useDraftZone({ draft, registerEditTrigger, onSave, onDelete, onPublish }); const statusLabel: Record = { pending: t('draftZone.statusPending'), diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts new file mode 100644 index 0000000..58ca2f1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts @@ -0,0 +1,329 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ReviewDraft } from '@meebox/shared'; + +export interface UseDraftZoneParams { + draft: ReviewDraft; + registerEditTrigger?: (draftId: string, fn: (() => void) | null) => void; + onSave: (newBody: string) => void | Promise; + onDelete: () => void | Promise; + onPublish?: () => Promise<{ ok: boolean; error?: string }>; +} + +/** + * Diff 内联草稿 zone 的 read/edit/publish 状态机。把全部 state / ref / effect / handler 收敛于此, + * DraftZone 组件只消费返回值做渲染。 + * + * 取消四档逻辑(runCancelLogic)被取消按钮 / unmount cleanup / Esc 三处共用,全靠 ref 读最新值, + * 保持「三处行为完全一致」的现有约定 —— 整体留在 hook 内,调用点都指向同一函数。 + */ +export function useDraftZone({ + draft, + registerEditTrigger, + onSave, + onDelete, + onPublish, +}: UseDraftZoneParams) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const [editingBody, setEditingBody] = useState(draft.body); + const [saving, setSaving] = useState(false); + const [publishing, setPublishing] = useState(false); + const [publishError, setPublishError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const textareaRef = useRef(null); + + // Ref 跟踪最新 state / props,供 unmount cleanup 闭包同步读 + const editingBodyRef = useRef(editingBody); + const isEditingRef = useRef(isEditing); + const draftBodyRef = useRef(draft.body); + useEffect(() => { + editingBodyRef.current = editingBody; + }, [editingBody]); + useEffect(() => { + isEditingRef.current = isEditing; + }, [isEditing]); + useEffect(() => { + draftBodyRef.current = draft.body; + }, [draft.body]); + + // 显式 mutate 标记 — 区分 "用户主动操作触发 unmount" vs "切换文件触发 unmount"。 + // 设计:mutate 入口设 true (handleSave/Cancel/Delete 各种路径);进 edit 时重置 + // false (new editing session 没显式意图)。**不在 finally 清** — 因为 mutate IPC + // 完成跟 drafts:changed 引发的 unmount 时序不可控,清得太早会让 cleanup 误判。 + // 锁的生命周期跟随 component instance:mutate 后保留 true 直到 unmount 或下次进 + // edit 时被重置 + const isMutatingRef = useRef(false); + useEffect(() => { + if (isEditing) isMutatingRef.current = false; + }, [isEditing]); + + // 统一的 cancel 四档逻辑 — handleCancel / unmount cleanup / Esc 共用 + // (避免三个地方分别实现引起行为漂移)。读 ref 拿最新值,支持 fire-and-forget + const runCancelLogic = (): void => { + const editing = editingBodyRef.current.trim(); + const persisted = draftBodyRef.current.trim(); + if (!editing && !persisted) { + void onDelete(); + return; + } + if (!editing) { + // 空 + 有 → revert (unmount 时 no-op,state 已销毁;handleCancel 时设 state) + setEditingBody(draftBodyRef.current); + setIsEditing(false); + return; + } + if (editingBodyRef.current === draftBodyRef.current) { + setIsEditing(false); + return; + } + // dirty → auto save + void onSave(editingBodyRef.current); + setIsEditing(false); + }; + + // unmount cleanup — 切换文件 / PR / tab 触发。跟取消按钮共用 runCancelLogic 让 + // 行为完全一致。isMutating=true 时跳过 (用户已经显式 mutate 接管,不需要 cleanup + // 兜底,避免跟 IPC 形成 race) + useEffect(() => { + return () => { + if (isMutatingRef.current) return; + if (!isEditingRef.current) return; + runCancelLogic(); + }; + // 空 deps:mount/unmount 跑;runCancelLogic 内部全 ref 读,无 closure 失效问题 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 把 "进入编辑模式" 触发器注册到 DiffView 的 ref map。register 是稳定函数引用 + // (DiffView 用 useCallback 包),draft.id 不变 → effect 只在 mount/unmount 跑。 + // 调用 fn 不引发 React state change in DiffView side → 没有 re-render → 没有 + // unmount/mount 循环,进 edit 一次只一次 + useEffect(() => { + registerEditTrigger?.(draft.id, () => { + setIsEditing(true); + setEditingBody(draftBodyRef.current); + }); + return () => registerEditTrigger?.(draft.id, null); + }, [draft.id, registerEditTrigger]); + + // draft.body 外部变化 (e.g., 队列写回) + 当前非 editing → 同步 editingBody + useEffect(() => { + if (!isEditing) setEditingBody(draft.body); + }, [draft.body, isEditing]); + + // 进入 edit 时 focus + 光标到末尾 + useEffect(() => { + if (isEditing && textareaRef.current) { + const el = textareaRef.current; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + } + }, [isEditing]); + + // textarea 的 React onKeyDown 在 React 18 root delegation 下走 bubble 阶段, + // monaco 在 editor container 上的 capture-stage keydown listener 可能在事件 + // 冒泡前就 stopPropagation 吞掉 Esc,导致 textarea 的 onKeyDown 永远不触发。 + // 兜底:window 顶层 capture listener,textarea focus 时按 Esc 走 runCancelLogic + // (跟取消按钮 / unmount cleanup 共用) + 设 isMutatingRef=true 让 cleanup 跳过 + useEffect(() => { + if (!isEditing) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key !== 'Escape') return; + if (document.activeElement !== textareaRef.current) return; + e.preventDefault(); + e.stopPropagation(); + isMutatingRef.current = true; + runCancelLogic(); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + // runCancelLogic 是局部 fn,不放 deps;isEditing 切回时移除 listener + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing]); + + const status = draft.status; + // posted 不应该再被编辑;UI 上隐藏编辑/删除按钮,整体灰显 + const canEdit = status !== 'posted'; + + const trimmedEditing = editingBody.trim(); + // 评论不能为空:trim 后无字符不允许保存。提交按钮 disabled + 按钮 title 解释 + const canSave = trimmedEditing.length > 0; + + const handleSave = async (): Promise => { + if (saving) return; + if (!canSave) return; // 防御:除按钮 disabled 外双保险 + if (trimmedEditing === draft.body.trim()) { + // 没改 → 退回 read 不调 IPC + setIsEditing(false); + return; + } + isMutatingRef.current = true; + setSaving(true); + try { + await onSave(editingBody); + // 等 store 事件回来后 draft.body 更新,但乐观先退出 edit;体感更顺 + setIsEditing(false); + } finally { + setSaving(false); + } + }; + + // 单条直接发布。仅 pending/edited 可发;body 空也不发 (Bitbucket 拒绝空评论)。 + // 不弹 confirm — 单条评论的"发布"心智跟"批量评审"不同,是即时操作;要二次 + // 确认放在批量入口的 PublishReviewModal 那一层做就够了 + const handlePublish = async (): Promise => { + if (!onPublish || publishing) return; + if (status === 'posted' || status === 'rejected') return; + if (!draft.body.trim()) return; + isMutatingRef.current = true; + setPublishError(null); + setPublishing(true); + try { + const res = await onPublish(); + if (!res.ok) { + setPublishError(res.error ?? t('draftZone.publishFailed')); + } + // 成功 → drafts-store 广播让 status 切到 'posted',组件自动 re-render 显示 + // posted chip + 远端 id。无需手动 setIsEditing 之类,draft prop 流推回 + } catch (e) { + setPublishError(e instanceof Error ? e.message : String(e)); + } finally { + setPublishing(false); + } + }; + + // edit 模式的"发布" — 先 save 当前 textarea 内容到本地,再调 publish。 + // 设计动机:取消按钮已经有 dirty auto-save 语义,独立的"保存"按钮反而冗余; + // 用户在 edit 改完最自然的下一步是发布,合并一次点击。 + // - 保存失败 (本地写盘几乎不会) → 仍尝试发布;publish 端读盘拿到的是 main 上 + // 一次成功的 body,对用户没有静默错误风险 + // - 发布失败 (Bitbucket 4xx) → 留在 edit 让用户改 body 重试,inline error 提示 + const handlePublishFromEdit = async (): Promise => { + if (!onPublish || publishing) return; + if (!canSave) return; + isMutatingRef.current = true; + setPublishError(null); + // 先持久化本次 editing 内容 — onPublish 通过 main store 读盘拿 body,必须 + // 保证盘上是用户刚改的最新版本 + if (editingBody !== draft.body) { + setSaving(true); + try { + await onSave(editingBody); + } finally { + setSaving(false); + } + } + setPublishing(true); + try { + const res = await onPublish(); + if (res.ok) { + setIsEditing(false); + } else { + setPublishError(res.error ?? t('draftZone.publishFailed')); + } + } catch (e) { + setPublishError(e instanceof Error ? e.message : String(e)); + } finally { + setPublishing(false); + } + }; + + const handleCancel = async (): Promise => { + // 取消复用 runCancelLogic (跟 unmount cleanup 共用)。区别:handleCancel 主动 + // 设 isMutatingRef=true (用户显式操作),让 cleanup 知道意图已被接管 + isMutatingRef.current = true; + // 内部 dirty 分支需要 await save 才退 edit,单独走一遍含 await 的版本: + const editingTrim = editingBody.trim(); + const persistedTrim = draft.body.trim(); + if (!editingTrim && !persistedTrim) { + void onDelete(); + return; + } + if (!editingTrim) { + setEditingBody(draft.body); + setIsEditing(false); + return; + } + if (editingBody === draft.body) { + setIsEditing(false); + return; + } + setSaving(true); + try { + await onSave(editingBody); + setIsEditing(false); + } finally { + setSaving(false); + } + }; + + const handleDelete = (): void => { + // body 非空(含编辑中的草稿)→ 弹 ConfirmModal 二次确认;空草稿直接删。 + // edit 模式下用 editingBody 判(用户可能在 textarea 输入很多还没保存就点删除) + const currentBody = (isEditing ? editingBody : draft.body).trim(); + if (currentBody) { + setConfirmDelete(true); + return; + } + isMutatingRef.current = true; + void onDelete(); + }; + + const handleConfirmDelete = async (): Promise => { + setConfirmDelete(false); + isMutatingRef.current = true; + await onDelete(); + }; + + const onKeyDown = (e: React.KeyboardEvent): void => { + if (e.nativeEvent.isComposing) return; + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + // Cmd/Ctrl+Enter 优先走"发布" (跟新的主按钮一致);onPublish 缺失场景退回保存 + e.preventDefault(); + if (onPublish) { + void handlePublishFromEdit(); + } else { + void handleSave(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + void handleCancel(); + } + }; + + // 取消按钮动态文案:textarea 有内容 → "暂存",明示点击不丢内容;textarea + // 完全空 → "取消",对应"退出 edit 不留草稿"的语义 (runCancelLogic 此时会删 + // 真空草稿 / revert)。 + // 不再用"dirty (editing != persisted)"作判据 — 用户进 edit 看到已存的旧内容 + // 没改也"有文字",按用户心智应该是暂存 (即使内部走 no-op 直接退出 edit) + // textarea 有内容 → 暂存语义;空 → 取消语义。stash 标志驱动按钮文案与 title + const isStash = trimmedEditing.length > 0; + const cancelLabel = isStash ? t('draftZone.stash') : t('common.cancel'); + + return { + status, + canEdit, + canSave, + isStash, + cancelLabel, + isEditing, + setIsEditing, + editingBody, + setEditingBody, + saving, + publishing, + publishError, + setPublishError, + confirmDelete, + setConfirmDelete, + textareaRef, + handleSave, + handlePublish, + handlePublishFromEdit, + handleCancel, + handleDelete, + handleConfirmDelete, + onKeyDown, + }; +} From cf6e0c9b432a474fe712c8a2a46b01fe07fad15c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 16:54:21 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(chat):=20ChatInputBar=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E5=91=BD=E4=BB=A4=E8=A7=A3=E6=9E=90=20/=20=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E7=8A=B6=E6=80=81=E6=9C=BA=20/=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatInputBar.tsx 479 → ~240 行,退化为瘦渲染组件。 - utils/parse-command.ts:纯命令解析 parseChatCommand → 判别式结果 (unknown / commandNoArgs / askNeedsQuestion / reviewAction / run / agentAsk),无 i18n / 副作用 - hooks/useChatInput.ts:输入栏状态机 hook —— 输入 / 提交(调 parseChatCommand 映射回调)/ 自动补全浮层 / 历史回放(shell 式 Up/Down)/ 停止请求 + 相关 effect - hooks/useTextareaAutosizeDrag.ts:textarea 顶边拖拽调高 - ChatInputBar.tsx:消费两个 hook 做渲染(补全列表 / textarea / 命令栏 / 自动评审 / 发送停止) 纯结构拆分,行为不变;ChatInputBar 导出名与路径不变(ChatPane 引用零波及)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/chat/components/ChatInputBar.tsx | 324 +++--------------- .../features/chat/hooks/useChatInput.ts | 280 +++++++++++++++ .../chat/hooks/useTextareaAutosizeDrag.ts | 40 +++ .../features/chat/utils/parse-command.ts | 43 +++ 4 files changed, 404 insertions(+), 283 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/chat/hooks/useChatInput.ts create mode 100644 apps/desktop/src/renderer/src/components/features/chat/hooks/useTextareaAutosizeDrag.ts create mode 100644 apps/desktop/src/renderer/src/components/features/chat/utils/parse-command.ts diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index 820c16c..7fb8a3b 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { LocalPrStatus, @@ -7,8 +6,9 @@ import type { StoredPullRequest, } from '@meebox/shared'; import { AutoReviewIcon, SendIcon, StopIcon } from '../../../common'; -import { COMMANDS, type CommandSpec } from '../commands'; -import { loadChatHistory, pushChatHistory } from '../utils/chat-history'; +import { COMMANDS } from '../commands'; +import { useChatInput } from '../hooks/useChatInput'; +import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag'; interface ChatInputBarProps { pr: StoredPullRequest | null; @@ -37,16 +37,11 @@ interface ChatInputBarProps { } /** - * 输入栏:textarea + 命令按钮 + `/` 触发的自动补全。 + * 输入栏:textarea + 命令按钮 + `/` 触发的自动补全。状态机(输入 / 命令解析 / 补全 / 历史回放 / + * 停止)见 [useChatInput](../hooks/useChatInput.ts);命令解析纯逻辑见 ../utils/parse-command。 * - * 提交语义 (按 Enter 或点发送): - * - 空输入 → 不提交 - * - `/describe` / `/review` 开头 → 触发对应工具,忽略后面文字 - * - `/ask <文本>` 开头 → 触发 ask,rest 作 question - * - `/xxx` 但 xxx 未知 → 报错提示 - * - 不以 `/` 开头 → 等价于 `/ask <整段>` - * - * Shift+Enter 换行,Enter 提交。textarea 高度 1→5 行自适应,超过 5 行内部滚动。 + * 提交语义:空不提交;`/describe` `/review` 等触发对应工具;`/ask <文本>` 触发 ask;未知 `/xxx` 报错; + * 不以 `/` 开头 = 自然语言委派给自由规划 Agent。Shift+Enter 换行,Enter 提交。 */ export function ChatInputBar({ pr, @@ -61,271 +56,38 @@ export function ChatInputBar({ onAgentReview, }: ChatInputBarProps) { const { t } = useTranslation(); - const [input, setInput] = useState(''); - const [parseError, setParseError] = useState(null); - // PR 切换时清掉异常提示 + 输入框残留 (避免跨 PR 显示陈旧的错误"未知命令" 等) - useEffect(() => { - setParseError(null); - setInput(''); - }, [pr?.localId]); - const [cmdMenuOpen, setCmdMenuOpen] = useState(false); - // 自动补全菜单选中项索引 (textarea 输入 / 时显示的浮层) - const [autocompleteIdx, setAutocompleteIdx] = useState(0); - // 已经为某个特定输入值关闭过菜单 (Esc / 选中后插入)。input 一变就失效 - // → 用户继续打字时菜单会自然重新出现,但选中 / Esc 后不会立刻重弹 - const [dismissedFor, setDismissedFor] = useState(null); - // 历史回放:从最新到最老的栈;historyIdx 表示当前正在浏览的位置 (-1 = 不在浏览态) - const [history, setHistory] = useState(() => loadChatHistory()); - const [historyIdx, setHistoryIdx] = useState(-1); - // 进入历史浏览前用户正在编辑的内容;按 Down 回到底端时还原回去,模仿 shell 行为 - const draftBeforeHistoryRef = useRef(''); - const textareaRef = useRef(null); - const cmdMenuRef = useRef(null); - - // 队列模型:仅 !pr / pr-agent 未就绪 时禁用 input。activeRun / busyOnOtherPr - // 不再阻塞新提交 (会排队 by main)。running 决定是否渲染 stop 按钮:除活动工具 run 外, - // Agent 自身执行阶段(思考 / 编排,无工具 run 占用)也算「运行中」,以便随时取消。 - const running = runningTool !== null || agentRunningHere; - // LLM 未配置时一并禁用:即便 pr-agent 运行时就绪,没有模型也无法发起调用 - const disabled = !pr || !prAgent.available || !llmConfigured; - // stop 按钮点过后等 main 回 queueChanged 才会改变状态;中间这段时间二次点击 - // 应失效,避免反复 spam abort - const [stopRequested, setStopRequested] = useState(false); - // running → false 时 (run 结束了) 重置 stopRequested,下次起 run 又能取消 - useEffect(() => { - if (!running) setStopRequested(false); - }, [running]); - const trimmed = input.trim(); - // `/` 开头 + 命令名还没敲完整 (没空格) → 显示候选;已为当前 input dismiss 过则隐藏 - const showAutocomplete = - !disabled && dismissedFor !== input && input.startsWith('/') && !input.includes(' '); - const filtered = showAutocomplete - ? COMMANDS.filter((c) => c.label.startsWith(input.split(' ')[0] ?? '')) - : []; - - // 输入变化时重置选中项到首条 (候选集变了) - useEffect(() => { - setAutocompleteIdx(0); - }, [input]); - - // `/` 命令按钮触发的弹出菜单:点击外部 / Esc / 选中命令时关闭 - useEffect(() => { - if (!cmdMenuOpen) return; - const onDown = (e: MouseEvent): void => { - if (!cmdMenuRef.current?.contains(e.target as Node)) { - setCmdMenuOpen(false); - } - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') setCmdMenuOpen(false); - }; - document.addEventListener('mousedown', onDown); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDown); - document.removeEventListener('keydown', onKey); - }; - }, [cmdMenuOpen]); - - // textarea 高度:用户拖顶边 handle 调整。 - // - // 不用 CSS `resize: vertical` 因为它的 handle 在右下角、向下拖才放大 —— - // 但 input 整体被钉在 chat 面板底部,视觉上 textarea 是"向上扩展",跟操作方向 - // 反直觉。改成顶边自绘 handle (类似 chat-pane-resize-handle 模式),向上拖 = 放大, - // 视觉操作直觉一致。 - // - // 边界跟 css 里 min-height (2 行) / max-height (5 行) 一致;state null 时不写 - // inline style,由 css 默认值起手 - const [textareaHeightPx, setTextareaHeightPx] = useState(null); - const handleTextareaResizeStart = (e: React.MouseEvent): void => { - e.preventDefault(); - const el = textareaRef.current; - if (!el) return; - const startY = e.clientY; - const startHeight = el.getBoundingClientRect().height; - // 跟 css token: $fs-md=13 * $lh-normal=1.4 = 18.2 px/line;$space-3=6 px padding 上下 = 12 px - const MIN = Math.round(13 * 1.4 * 2 + 12); - const MAX = Math.round(13 * 1.4 * 5 + 12); - const onMove = (ev: MouseEvent): void => { - // 上拖 dy < 0 → 高度增加;下拖反之 - const dy = ev.clientY - startY; - const next = Math.min(MAX, Math.max(MIN, startHeight - dy)); - setTextareaHeightPx(next); - }; - const onUp = (): void => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - }; - - const handleInsertCommand = (cmd: CommandSpec): void => { - setInput(cmd.insertAs); - setParseError(null); - setCmdMenuOpen(false); - // 选中后立即关掉补全菜单 (insertAs 可能 "/describe" 没空格,否则会一直撑着)。 - // dismissedFor 绑当前 input 值,用户继续打字 input 变了菜单会重新打开 - setDismissedFor(cmd.insertAs); - const el = textareaRef.current; - if (el) { - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(cmd.insertAs.length, cmd.insertAs.length); - }); - } - }; - - const submit = (): void => { - if (disabled || !trimmed) return; - setParseError(null); - // 解析命令头:'/' 起手 → COMMANDS 表里找;无 '/' → 等价 /ask <整段> - let cmd: CommandSpec; - let rest = ''; - if (trimmed.startsWith('/')) { - const spaceIdx = trimmed.indexOf(' '); - const head = spaceIdx < 0 ? trimmed : trimmed.slice(0, spaceIdx); - rest = spaceIdx < 0 ? '' : trimmed.slice(spaceIdx + 1).trim(); - const found = COMMANDS.find((c) => c.label === head); - if (!found) { - setParseError( - t('chatPane.unknownCommand', { - head, - cmds: COMMANDS.map((c) => c.label).join(' / '), - }), - ); - return; - } - cmd = found; - } else { - // 无 '/' → 自然语言「对话即委派」:交给自由规划 Agent(而非 /ask)。 - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onAgentAsk(trimmed); - return; - } - // review-action:/approve /needswork 没有参数,多余文本拒绝以免误用 - if (cmd.kind === 'review-action') { - if (rest) { - setParseError(t('chatPane.commandNoArgs', { cmd: cmd.label })); - return; - } - if (!onSetReviewStatus) return; // 没装回调直接忽略 (保护性) - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onSetReviewStatus(cmd.reviewStatus); - return; - } - // pragent:/ask 必须带问题,其他工具空 question - let question: string | undefined; - if (cmd.name === 'ask') { - if (!rest) { - setParseError(t('chatPane.askNeedsQuestion')); - return; - } - question = rest; - } - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onRun(cmd.name, question); - }; - - // 历史回放工具:根据 idx 设 textarea 内容;idx = -1 表示退出浏览态,恢复 draft - const applyHistoryIdx = (nextIdx: number): void => { - setHistoryIdx(nextIdx); - setInput(nextIdx < 0 ? draftBeforeHistoryRef.current : (history[nextIdx] ?? '')); - // 光标移到末尾,下一次 Up/Down 行为可预期 - const el = textareaRef.current; - if (el) { - requestAnimationFrame(() => { - el.focus(); - const len = el.value.length; - el.setSelectionRange(len, len); - }); - } - }; - - // 判断是否应让 Up/Down 触发历史回放:textarea 光标必须在首行 / 末行边缘, - // 否则让 Up/Down 走原生光标移动 (多行编辑时还在行内导航不能被劫持) - const atFirstLine = (): boolean => { - const el = textareaRef.current; - if (!el) return false; - return el.value.slice(0, el.selectionStart).indexOf('\n') < 0; - }; - const atLastLine = (): boolean => { - const el = textareaRef.current; - if (!el) return false; - return el.value.slice(el.selectionEnd).indexOf('\n') < 0; - }; - - const onKeyDown = (e: React.KeyboardEvent): void => { - // 输入法 composing 中:所有快捷键都不拦截,交给 IME 处理 - if (e.nativeEvent.isComposing) return; - - // 自动补全菜单打开时:拦截 Up/Down/Enter/Tab/Esc 用于菜单导航,避免落到 textarea - if (showAutocomplete && filtered.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setAutocompleteIdx((i) => (i + 1) % filtered.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setAutocompleteIdx((i) => (i - 1 + filtered.length) % filtered.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const cmd = filtered[Math.min(autocompleteIdx, filtered.length - 1)]; - if (cmd) handleInsertCommand(cmd); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setDismissedFor(input); - return; - } - } - - // 历史回放:菜单未打开时,Up/Down 在边缘行 → 翻历史。中间行让原生光标移动接管 - if (e.key === 'ArrowUp' && history.length > 0 && atFirstLine()) { - e.preventDefault(); - if (historyIdx < 0) { - // 首次进浏览态:把当前编辑内容存为 draft,方便 Down 回到底端时复原 - draftBeforeHistoryRef.current = input; - } - applyHistoryIdx(Math.min(historyIdx + 1, history.length - 1)); - return; - } - if (e.key === 'ArrowDown' && historyIdx >= 0 && atLastLine()) { - e.preventDefault(); - applyHistoryIdx(historyIdx - 1); - return; - } - - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - submit(); - } - }; - - const placeholder = !prAgent.available - ? t('chatPane.placeholderNotReady') - : !llmConfigured - ? t('chatPane.placeholderNeedLlm') - : !pr - ? t('chatPane.placeholderNoPr') - : t('chatPane.placeholderReady'); + const { + input, + setInput, + parseError, + disabled, + running, + stopRequested, + requestStop, + showAutocomplete, + filtered, + autocompleteIdx, + setAutocompleteIdx, + cmdMenuOpen, + setCmdMenuOpen, + handleInsertCommand, + onKeyDown, + submit, + placeholder, + textareaRef, + cmdMenuRef, + } = useChatInput({ + pr, + prAgent, + llmConfigured, + runningTool, + agentRunningHere, + onRun, + onAgentAsk, + onCancel, + onSetReviewStatus, + }); + const { textareaHeightPx, handleTextareaResizeStart } = useTextareaAutosizeDrag(textareaRef); return (
{ - if (stopRequested) return; - setStopRequested(true); - onCancel(); - }} + onClick={requestStop} disabled={stopRequested} title={t('chatPane.stopTitle')} aria-label={t('chatPane.stopAria')} @@ -466,7 +224,7 @@ export function ChatInputBar({