From 1d1b92e6243b5aeb4faec66b9f4e382d7f30d639 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Mon, 17 Aug 2026 22:49:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.3.2=20=E6=8A=A5=E5=91=8A=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E4=B8=8E=E6=80=A7=E8=83=BD=E6=94=B6=E5=B0=BE=20-=20?= =?UTF-8?q?=E7=BA=AF=E6=96=87=E6=9C=AC=E6=8A=A5=E5=91=8A=E8=A1=8C=E5=8F=B7?= =?UTF-8?q?=E5=8F=B3=E5=AF=B9=E9=BD=90=E3=80=81=E8=B6=85=E9=95=BF=E8=A1=8C?= =?UTF-8?q?=E5=88=97=E5=AE=BD=E5=B0=81=E9=A1=B6=E9=98=B2=E5=8D=A1=E9=A1=BF?= =?UTF-8?q?=E3=80=81=E5=AF=B9=E8=AF=9D=E6=A1=86=E4=B8=8E=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=90=8D=E6=B8=85=E5=8D=95=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E3=80=81=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=97=B6=E5=AF=BC=E8=88=AA=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E3=80=81=E6=B8=85=E7=90=86=E5=AF=BC=E5=87=BA=E6=AD=BB?= =?UTF-8?q?=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/应用开发与版本迭代规范.md | 5 ++-- docs/测试策略与方案.md | 6 ++--- package.json | 2 +- src/main/index.ts | 5 ++-- src/renderer/src/App.tsx | 7 +++-- src/renderer/src/__tests__/App.test.tsx | 11 ++++++++ src/renderer/src/components/DiffView.test.tsx | 26 +++++++++++++++++++ src/renderer/src/components/DiffView.tsx | 4 ++- src/renderer/src/components/Toolbar.test.tsx | 3 +-- src/renderer/src/components/Toolbar.tsx | 8 +----- src/renderer/src/diff/report.test.ts | 16 ++++++++++++ src/renderer/src/diff/report.ts | 23 +++++++++++----- src/renderer/src/diff/textUtils.test.ts | 13 ++++++++++ src/renderer/src/diff/textUtils.ts | 4 ++- 15 files changed, 105 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ea13b67..4811203 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.3.1 +- 版本:0.3.2 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index c206cbf..ba86198 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.3.1`** +当前基线版本:**`0.3.2`** --- @@ -64,7 +64,8 @@ z = 补丁版本号(Patch) 0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 -0.3.1 ← 稳健性与测试补强(当前):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 +0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 +0.3.2 ← 报告对齐与性能收尾(当前):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性 0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 7ca653b..c216b89 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -76,19 +76,19 @@ src/renderer/src/ ### 5.2 报告生成(`report.ts`) - HTML:输出合法文档、含文件名/统计/类型高亮语义 -- 纯文本:含增删改标记(`+ -`) +- 纯文本:含增删改标记(`+ -`)、行号按最大位数右对齐 - Markdown:表格化、管道符转义保持表格结构 - 格式分发与扩展名映射(`REPORT_EXT`) ### 5.3 文本工具(`textUtils.ts`) - 文本扩展名白名单放行 / 拒绝 / 大小写不敏感 - `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF) -- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 +- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 / 传 cap 时达上限提前返回 ### 5.4 对比视图(`DiffView.tsx`) - 渲染:行内容、行号、词级高亮段、行尾空白标注 - 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 -- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽);探针未测得时不设置 +- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置 - 折叠提示行:默认文案与自定义文案(仅看差异视图) - 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo` diff --git a/package.json b/package.json index b5834e9..ab3a362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.3.1", + "version": "0.3.2", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 4c8eed4..35bcf23 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -94,8 +94,9 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, properties: ['openFile'], filters: [ - { name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'swift', 'kt', 'rb', 'php', 'sh', 'bat', 'ps1', 'log', 'csv', 'sql', 'vue', 'graphql', 'gradle'] }, - // 兜底入口:拖拽白名单比对话框筛选项更全(svelte/tsv/lock 等),补“所有文件”消除两种入口的不一致 + // 与渲染进程 TEXT_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步 + { name: '文本文件', extensions: ['txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log', 'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock'] }, + // 兜底入口:允许打开任意扩展名文件 { name: '所有文件', extensions: ['*'] } ] }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 416d7ce..2a0b714 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -266,17 +266,17 @@ export default function App(): ReactElement { [changedRows, navIndex] ) - // 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗打开时不抢占 + // 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗或右键菜单打开时不抢占 useEffect(() => { const onKey = (e: KeyboardEvent): void => { if (e.key !== 'F7') return - if (pasteSide !== null) return + if (pasteSide !== null || menu !== null) return e.preventDefault() go(e.shiftKey ? -1 : 1) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [go, pasteSide]) + }, [go, pasteSide, menu]) // 行级右键菜单 const onRowContext = useCallback( @@ -438,7 +438,6 @@ export default function App(): ReactElement { navCount={changedRows.length} onNav={go} onExport={(fmt) => void exportReport(fmt)} - canExport={anyPane} onlyDiff={onlyDiff} onOnlyDiffChange={setOnlyDiff} onSwap={swapPanes} diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 30b0d9b..102e3b3 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -491,6 +491,17 @@ describe('App - F7 / Shift+F7 差异导航快捷键', () => { // 导航未被触发,计数保持 1 / 2 expect(screen.getByText('1 / 2')).toBeInTheDocument() }) + + it('右键菜单打开时 F7 不触发导航', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('1 / 2') + fireEvent.contextMenu((await screen.findAllByText(/line1/))[0]) + expect(await screen.findByText('复制左侧此行内容')).toBeInTheDocument() + fireEvent.keyDown(window, { key: 'F7' }) + // 导航未被触发,计数保持 1 / 2 + expect(screen.getByText('1 / 2')).toBeInTheDocument() + }) }) describe('App - 交换左右侧', () => { diff --git a/src/renderer/src/components/DiffView.test.tsx b/src/renderer/src/components/DiffView.test.tsx index 37d51cd..c72ffe2 100644 --- a/src/renderer/src/components/DiffView.test.tsx +++ b/src/renderer/src/components/DiffView.test.tsx @@ -254,6 +254,32 @@ describe('DiffView - 虚拟滚动', () => { expect(columns[0].style.minWidth).toBe('') expect(columns[1].style.minWidth).toBe('') }) + + it('超长行列宽封顶,minWidth 不随行长无限增长', () => { + // 探针 mock 为 10 字符 70px → 每字符 7px;列宽封顶 5000 → 5000×7+86 + const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 70, + height: 21, + top: 0, + left: 0, + right: 70, + bottom: 21, + x: 0, + y: 0, + toJSON: () => ({}) + } as DOMRect) + const longRow: DiffRow = { + id: 'long0', + rowKind: 'unchanged', + isChanged: false, + left: { lineNo: 1, text: 'a'.repeat(200000), segs: null }, + right: { lineNo: 1, text: 'b', segs: null } + } + const { container } = renderDiff([longRow]) + const columns = container.querySelectorAll('.diff-columns') as NodeListOf + expect(columns[0].style.minWidth).toBe('35086px') + rectSpy.mockRestore() + }) }) describe('DiffView - 折叠提示行', () => { diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index c054877..a529af8 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -213,13 +213,15 @@ export default function DiffView({ if (w > 0) setCharW(w / 10) }, []) + /** 列宽计算封顶:超长行(base64/压缩产物可达数 MB 单行)达到上限即停,避免逐字符全量遍历;与词级防护阈值同源 */ + const MAX_COLS_CAP = 5000 const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => { let max = 40 for (const it of rows) { if (isFold(it)) continue const t = pick(it).text if (t) { - const c = displayCols(t) + const c = displayCols(t, MAX_COLS_CAP) if (c > max) max = c } } diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 17331be..92883d2 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine' const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } -function setup(extra?: { canExport?: boolean }) { +function setup() { const onOptionsChange = vi.fn() const onNav = vi.fn() const onExport = vi.fn() @@ -20,7 +20,6 @@ function setup(extra?: { canExport?: boolean }) { navCount={3} onNav={onNav} onExport={onExport} - canExport={extra?.canExport ?? true} onlyDiff={false} onOnlyDiffChange={onOnlyDiffChange} onSwap={onSwap} diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 5bfad14..6853d28 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -17,7 +17,6 @@ interface ToolbarProps { navCount: number onNav: (dir: 1 | -1) => void onExport: (fmt: ReportFormat) => void - canExport: boolean /** 仅看差异视图开关 */ onlyDiff: boolean onOnlyDiffChange: (v: boolean) => void @@ -33,7 +32,6 @@ export default function Toolbar({ navCount, onNav, onExport, - canExport, onlyDiff, onOnlyDiffChange, onSwap @@ -115,11 +113,7 @@ export default function Toolbar({
- {exportOpen && ( diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index 1fd9f18..2e9d37b 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -29,6 +29,22 @@ describe('buildTxtReport', () => { expect(txt).toContain('DiffLens') expect(txt).toContain('+') }) + + it('行号按最大位数右对齐填充', () => { + const left = Array.from({ length: 12 }, (_, i) => `l${i}`).join('\n') + const right = left.replace('l1', 'changed') + const { rows, summary } = computeDiff(left, right) + const txt = buildTxtReport(rows, ctx, summary) + // 最大行号 12 → 宽度 2:行号 2 左补空格,行号 12 原样(修改行左右行号同样填充) + expect(txt).toContain('~ 2 | l1 ==> 2 | changed') + expect(txt).toContain(' 12 | l11') + }) + + it('行号位数一致时无额外填充', () => { + const { rows, summary } = computeDiff('a\nc', 'a\nb') + const txt = buildTxtReport(rows, ctx, summary) + expect(txt).toContain('~ 2 | c ==> 2 | b') + }) }) describe('buildMarkdownReport', () => { diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 1c23e16..8a90694 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -20,9 +20,11 @@ export interface ReportContext { const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -/** 格式化为固定宽行号(右对齐) */ -function padNo(n: number | null): string { - return n === null ? '' : String(n) +/** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */ +function padNo(n: number | null, width?: number): string { + if (n === null) return '' + const s = String(n) + return width !== undefined ? s.padStart(width) : s } /** 单元格文本(词级高亮段在 HTML 里渲染为 span) */ @@ -140,16 +142,25 @@ export function buildTxtReport( const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======') const lines = [...head, '----------------------------------'] + // 行号列宽取全部行号的最大位数,右对齐填充保证各行文本起始列一致 + let maxNo = 0 + for (const row of rows) { + if (row.left.lineNo !== null && row.left.lineNo > maxNo) maxNo = row.left.lineNo + if (row.right.lineNo !== null && row.right.lineNo > maxNo) maxNo = row.right.lineNo + } + const width = String(maxNo).length + for (const row of rows) { const tag = typeTag(row.rowKind) if (row.rowKind === 'modified') { // 修改行:左/右并置 - const l = padNo(row.left.lineNo) || ' ' - const r = padNo(row.right.lineNo) || ' ' + const l = padNo(row.left.lineNo, width) + const r = padNo(row.right.lineNo, width) lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`) } else { const no = padNo( - row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo + row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo, + width ) const text = row.rowKind === 'added' ? row.right.text : row.left.text lines.push(`${tag} ${no} | ${text ?? ''}`) diff --git a/src/renderer/src/diff/textUtils.test.ts b/src/renderer/src/diff/textUtils.test.ts index 0abf4a0..1070874 100644 --- a/src/renderer/src/diff/textUtils.test.ts +++ b/src/renderer/src/diff/textUtils.test.ts @@ -68,4 +68,17 @@ describe('displayCols 列宽估算', () => { it('中英混排按叠加列宽计', () => { expect(displayCols('a中b')).toBe(4) }) + + it('传入 cap 时达到上限提前返回', () => { + expect(displayCols('a'.repeat(9999), 5000)).toBe(5000) + }) + + it('未达 cap 时返回真实列宽', () => { + expect(displayCols('abc', 5000)).toBe(3) + }) + + it('全角字符跨过 cap 时返回实际累计列宽', () => { + // 2499 个半角后接全角:2499 + 2 = 2501 ≥ 2500 + expect(displayCols('a'.repeat(2499) + '中', 2500)).toBe(2501) + }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/textUtils.ts b/src/renderer/src/diff/textUtils.ts index 072fd6d..d24eaf2 100644 --- a/src/renderer/src/diff/textUtils.ts +++ b/src/renderer/src/diff/textUtils.ts @@ -102,11 +102,13 @@ function isWideCodePoint(cp: number): boolean { /** * 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。 * 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。 + * cap 为列宽累计上限:达到即提前返回,供调用方跳过超长行的全量逐字符遍历。 */ -export function displayCols(text: string): number { +export function displayCols(text: string, cap?: number): number { let n = 0 for (const ch of text) { n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1 + if (cap !== undefined && n >= cap) return n } return n } \ No newline at end of file