From 1c34f386af9929d8add65f9a3b9094bc9dee4ae7 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 18 Aug 2026 13:14:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=AF=94=E8=BE=83=E9=80=89=E9=A1=B9?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E5=8F=AF=E8=A7=86=E5=8C=96=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E5=BC=80=E5=85=B3=E7=81=B0=E6=98=BE=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E5=B7=B2=E5=8C=85=E5=90=AB=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E4=B8=8E=E7=82=B9=E5=87=BB=E8=A7=A3=E9=87=8A=E3=80=81=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E7=BA=A7=E9=99=8D=E7=BA=A7=E6=A0=87=E8=AE=B0=EF=BC=88?= =?UTF-8?q?0.5.4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/应用开发与版本迭代规范.md | 5 +- docs/测试策略与方案.md | 4 +- package.json | 2 +- src/renderer/src/App.tsx | 4 ++ src/renderer/src/__tests__/App.test.tsx | 40 ++++++++++- src/renderer/src/components/Toolbar.test.tsx | 76 ++++++++++++++++++-- src/renderer/src/components/Toolbar.tsx | 41 +++++++++-- src/renderer/src/styles/global.css | 29 ++++++++ 9 files changed, 182 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2db53b9..7486bd6 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.5.3 +- 版本:0.5.4 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index b0dd4fe..d5425b2 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.5.3`** +当前基线版本:**`0.5.4`** --- @@ -73,7 +73,8 @@ z = 补丁版本号(Patch) 0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含) 0.5.1 ← 字符级同行多块变更重复输出修复(已发布):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化) 0.5.2 ← 稳健性收尾与偏好记忆(已发布):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否) -0.5.3 ← 稳健性补漏与偏好记忆收尾(当前):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main) +0.5.3 ← 稳健性补漏与偏好记忆收尾(已发布):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main) +0.5.4 ← 比较选项联动可视化与状态透明(当前):修复禁用开关零视觉反馈的样式缺陷(原 CSS 仅有 button:disabled,checkbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明) 0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 5167649..d58d1f8 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -64,7 +64,7 @@ src/renderer/src/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 -│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动置灰) +│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记) └── __tests__/ ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退) └── main.test.ts # 入口挂载冒烟测试 @@ -117,7 +117,7 @@ src/renderer/src/ - 清空二次确认:有内容时第一次点击仅提示不清空 / 确认窗口内再点执行清空 / 超时自动复位(再点仍先提示)/ 空态点击不弹确认 - 状态栏行数:单侧/双侧加载显示对应行数(切分与引擎一致)/ 清空后随空态消失 - 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰) -- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 +- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 diff --git a/package.json b/package.json index a57a153..b688113 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.5.3", + "version": "0.5.4", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7ff598f..da73b39 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -571,6 +571,10 @@ export default function App(): ReactElement { onlyDiff={onlyDiff} onOnlyDiffChange={setOnlyDiff} onSwap={swapPanes} + onOptionBlocked={(label) => + showToast(`「${label}」已包含在字符级对比中,关闭字符级后可单独设置`) + } + charModeDowngraded={diff?.charModeDowngraded ?? false} /> ) : ( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 8b461ac..e5961b0 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -713,12 +713,44 @@ describe('App - 字符级对比', () => { await screen.findByText('导出报告') expect(screen.getByLabelText('忽略行首尾空白')).toBeEnabled() fireEvent.click(screen.getByLabelText('字符级对比')) - expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled() - expect(screen.getByLabelText('忽略空行')).toBeDisabled() - expect(screen.getByLabelText('忽略所有空白')).toBeDisabled() + expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeDisabled() + expect(screen.getByLabelText(/^忽略空行/)).toBeDisabled() + expect(screen.getByLabelText(/^忽略所有空白/)).toBeDisabled() expect(screen.getByLabelText('忽略大小写')).toBeEnabled() }) + // 字符级开启时空白选项 label 带“已包含”徽标,getByLabelText 需正则前缀匹配 + it('先勾选空白选项再开启字符级:灰显勾选+已包含徽标,关闭后恢复原状态', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + // 用户先勾选“忽略所有空白”与“忽略空行” + fireEvent.click(screen.getByLabelText('忽略所有空白')) + fireEvent.click(screen.getByLabelText('忽略空行')) + expect(screen.getByLabelText('忽略所有空白')).toBeChecked() + // 开启字符级:三个空白选项全部灰显勾选(语义已包含)并带徽标 + fireEvent.click(screen.getByLabelText('字符级对比')) + expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeChecked() + expect(screen.getByLabelText(/^忽略空行/)).toBeChecked() + expect(screen.getByLabelText(/^忽略所有空白/)).toBeChecked() + expect(screen.getAllByText('已包含')).toHaveLength(3) + // 关闭字符级:回到用户自己的勾选状态(忽略所有空白与忽略空行仍勾选,行首尾空白未勾选) + fireEvent.click(screen.getByLabelText('字符级对比')) + expect(screen.getByLabelText('忽略行首尾空白')).not.toBeChecked() + expect(screen.getByLabelText('忽略空行')).toBeChecked() + expect(screen.getByLabelText('忽略所有空白')).toBeChecked() + expect(screen.queryByText('已包含')).not.toBeInTheDocument() + }) + + it('点击被字符级包含的空白选项弹出解释提示', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByLabelText('字符级对比')) + fireEvent.click(screen.getByLabelText(/^忽略所有空白/)) + expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument() + }) + it('内容超限时自动降级行级并给出提示', async () => { // 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值); // 总行数 1901 未超过快路径阈值,同步计算即时降级 @@ -737,6 +769,8 @@ describe('App - 字符级对比', () => { await screen.findByText('导出报告') fireEvent.click(screen.getByLabelText('字符级对比')) expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument() + // 降级状态在工具栏可见:字符级开关旁出现“已降级”标记 + expect(await screen.findByText('已降级')).toBeInTheDocument() }) it('导出报告携带字符级对比说明', async () => { diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 4ba0630..a38d5aa 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -5,7 +5,13 @@ import { DiffSummary } from '../diff/diffEngine' const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } -function setup(extra?: { computing?: boolean; charMode?: boolean }) { +function setup(extra?: { + computing?: boolean + charMode?: boolean + charModeDowngraded?: boolean + baseOptions?: Partial + onOptionBlocked?: (label: string) => void +}) { const onOptionsChange = vi.fn() const onNav = vi.fn() const onExport = vi.fn() @@ -13,7 +19,14 @@ function setup(extra?: { computing?: boolean; charMode?: boolean }) { const onSwap = vi.fn() const utils = render( ) return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils } @@ -67,11 +82,62 @@ describe('Toolbar', () => { expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: true }) }) + // 字符级开启时空白选项 label 带有“已包含”徽标,getByLabelText 需正则前缀匹配 it('字符级对比开启时三个空白选项被禁用(语义已被包含)', () => { setup({ charMode: true }) - expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled() - expect(screen.getByLabelText('忽略空行')).toBeDisabled() - expect(screen.getByLabelText('忽略所有空白')).toBeDisabled() + expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeDisabled() + expect(screen.getByLabelText(/^忽略空行/)).toBeDisabled() + expect(screen.getByLabelText(/^忽略所有空白/)).toBeDisabled() + }) + + it('字符级对比开启时空白选项灰显勾选(语义已生效)并渲染“已包含”徽标', () => { + const { container } = setup({ charMode: true }) + // 灰显勾选:checked 仅为视觉呈现,表达语义已随字符级生效 + expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeChecked() + expect(screen.getByLabelText(/^忽略空行/)).toBeChecked() + expect(screen.getByLabelText(/^忽略所有空白/)).toBeChecked() + // 徽标常显(无需悬停 title) + expect(screen.getAllByText('已包含')).toHaveLength(3) + expect(container.querySelectorAll('.switch.disabled')).toHaveLength(3) + }) + + it('字符级对比关闭时空白选项恢复用户原状态且不渲染徽标', () => { + const { container } = setup({ baseOptions: { ignoreBlankLines: true } }) + // 用户原状态:仅忽略空行勾选 + expect(screen.getByLabelText('忽略行首尾空白')).not.toBeChecked() + expect(screen.getByLabelText('忽略空行')).toBeChecked() + expect(screen.getByLabelText('忽略所有空白')).not.toBeChecked() + expect(screen.queryByText('已包含')).not.toBeInTheDocument() + expect(container.querySelector('.switch.disabled')).toBeNull() + }) + + it('点击被包含的空白选项触发 onOptionBlocked 且不改写选项状态', () => { + const onOptionBlocked = vi.fn() + const { onOptionsChange } = setup({ charMode: true, onOptionBlocked }) + // 点击 label 本身(贴近真实用户点文字的行为;onClick preventDefault 阻止激活转发的干扰) + const label = screen.getByLabelText(/^忽略所有空白/).closest('label') as HTMLElement + fireEvent.click(label) + expect(onOptionBlocked).toHaveBeenCalledWith('忽略所有空白') + expect(onOptionsChange).not.toHaveBeenCalled() + }) + + it('字符级对比关闭时点击空白选项不触发 onOptionBlocked', () => { + const onOptionBlocked = vi.fn() + setup({ onOptionBlocked }) + fireEvent.click(screen.getByLabelText('忽略所有空白')) + expect(onOptionBlocked).not.toHaveBeenCalled() + }) + + it('字符级因内容超限降级时开关旁显示“已降级”标记', () => { + setup({ charMode: true, charModeDowngraded: true }) + expect(screen.getByText('已降级')).toBeInTheDocument() + }) + + it('未降级或字符级关闭时不显示“已降级”标记', () => { + setup({ charMode: true }) + expect(screen.queryByText('已降级')).not.toBeInTheDocument() + setup({ charModeDowngraded: true }) + expect(screen.queryByText('已降级')).not.toBeInTheDocument() }) it('字符级对比开启时忽略大小写仍可用', () => { diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index cca047b..0b7b2cd 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -24,6 +24,10 @@ interface ToolbarProps { onOnlyDiffChange: (v: boolean) => void /** 交换左右两侧 */ onSwap: () => void + /** 点击被字符级对比包含(禁用中)的空白选项时通知(App 弹 toast 解释) */ + onOptionBlocked?: (label: string) => void + /** 字符级对比因内容超限自动降级为行级(开关旁显示“已降级”标记) */ + charModeDowngraded?: boolean } export default function Toolbar({ @@ -37,7 +41,9 @@ export default function Toolbar({ computing, onlyDiff, onOnlyDiffChange, - onSwap + onSwap, + onOptionBlocked, + charModeDowngraded = false }: ToolbarProps): ReactElement { const set = (patch: Partial): void => onOptionsChange({ ...options, ...patch }) const [exportOpen, setExportOpen] = useState(false) @@ -48,25 +54,37 @@ export default function Toolbar({ const canNav = navCount > 0 + // 被包含选项的 label:灰显 + 点击解释(checkbox 本身 disabled 不响应,label 捕获点击给反馈) + const blockedLabel = (label: string): ((e: { preventDefault(): void }) => void) | undefined => + options.charMode + ? (e) => { + // 阻止 label 默认激活行为,统一走解释反馈 + e.preventDefault() + onOptionBlocked?.(label) + } + : undefined + return (
比较选项
diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index f50aefe..6507323 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -465,6 +465,35 @@ button:disabled { transform: rotate(45deg); } +/* 被字符级对比包含的选项:整行灰显 + 不可点光标(勾选仅为视觉呈现,语义已随字符级生效) */ +.switch.disabled { + opacity: 0.55; + cursor: not-allowed; +} +.switch.disabled input { + cursor: not-allowed; +} + +/* 选项状态徽标:已包含 / 已降级 */ +.opt-badge { + display: inline-block; + font-size: 10px; + line-height: 1.5; + padding: 0 6px; + border-radius: 999px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + letter-spacing: 0.5px; + user-select: none; + white-space: nowrap; +} +.opt-badge.warn { + color: var(--mod-fg); + background: var(--mod-bg); + border-color: rgba(245, 158, 11, 0.3); +} + /* 导航按钮 */ .nav-btn { width: 28px;