fix: 比较选项联动可视化——禁用开关灰显样式、已包含徽标与点击解释、字符级降级标记(0.5.4)

This commit is contained in:
2026-08-18 13:14:58 +08:00
parent 32a4bdf24d
commit 1c34f386af
9 changed files with 182 additions and 21 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ src/
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.5.3
- 版本:0.5.4
---
+3 -2
View File
@@ -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:disabledcheckbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明)
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
...
0.y.z ← 长期停留,永不进入 1.x
+2 -2
View File
@@ -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 环境去抖到期后回退同步计算,完成后无计算中遮罩
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.5.3",
"version": "0.5.4",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+4
View File
@@ -571,6 +571,10 @@ export default function App(): ReactElement {
onlyDiff={onlyDiff}
onOnlyDiffChange={setOnlyDiff}
onSwap={swapPanes}
onOptionBlocked={(label) =>
showToast(`${label}」已包含在字符级对比中,关闭字符级后可单独设置`)
}
charModeDowngraded={diff?.charModeDowngraded ?? false}
/>
</>
) : (
+37 -3
View File
@@ -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(<App />)
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(<App />)
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 () => {
+71 -5
View File
@@ -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<import('../diff/diffEngine').DiffOptions>
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(
<Toolbar
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: extra?.charMode ?? false }}
options={{
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: false,
...extra?.baseOptions,
charMode: extra?.charMode ?? false
}}
onOptionsChange={onOptionsChange}
summary={summary}
navIndex={0}
@@ -24,6 +37,8 @@ function setup(extra?: { computing?: boolean; charMode?: boolean }) {
onlyDiff={false}
onOnlyDiffChange={onOnlyDiffChange}
onSwap={onSwap}
onOptionBlocked={extra?.onOptionBlocked}
charModeDowngraded={extra?.charModeDowngraded ?? false}
/>
)
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('字符级对比开启时忽略大小写仍可用', () => {
+34 -7
View File
@@ -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<DiffOptions>): 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 (
<div className="toolbar">
<div className="tool-group">
<span className="tool-label"></span>
<label
className="switch"
className={'switch' + (options.charMode ? ' disabled' : '')}
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行首尾空白差异'
}
onClick={blockedLabel('忽略行首尾空白')}
>
<input
type="checkbox"
checked={options.trimWhitespace}
checked={options.charMode ? true : options.trimWhitespace}
disabled={options.charMode}
onChange={(e) => set({ trimWhitespace: e.target.checked })}
/>
{options.charMode && <span className="opt-badge"></span>}
</label>
<label className="switch">
<input
@@ -77,36 +95,40 @@ export default function Toolbar({
</label>
<label
className="switch"
className={'switch' + (options.charMode ? ' disabled' : '')}
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略空行差异(两侧空白行互相判等)'
}
onClick={blockedLabel('忽略空行')}
>
<input
type="checkbox"
checked={options.ignoreBlankLines ?? false}
checked={options.charMode ? true : (options.ignoreBlankLines ?? false)}
disabled={options.charMode}
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
/>
{options.charMode && <span className="opt-badge"></span>}
</label>
<label
className="switch"
className={'switch' + (options.charMode ? ' disabled' : '')}
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比'
}
onClick={blockedLabel('忽略所有空白')}
>
<input
type="checkbox"
checked={options.ignoreAllWhitespace ?? false}
checked={options.charMode ? true : (options.ignoreAllWhitespace ?? false)}
disabled={options.charMode}
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
/>
{options.charMode && <span className="opt-badge"></span>}
</label>
<label
className="switch"
@@ -118,6 +140,11 @@ export default function Toolbar({
onChange={(e) => set({ charMode: e.target.checked })}
/>
{options.charMode && charModeDowngraded && (
<span className="opt-badge warn" title="内容较大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
</span>
)}
</label>
</div>
+29
View File
@@ -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;