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
+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;