qwen-code/packages/cli/src/ui/commands/diffCommand.test.ts
克竟 e9a27210d7 feat(cli): colorize /diff output via a themed Ink component
The /diff stats used to come back as a plain-text MessageActionReturn.
Pipes and ACP still get that, but in interactive terminals we now dispatch
a structured history item so the numbers can carry theme colors.

- packages/cli/src/ui/types.ts — new DiffRenderRow / DiffRenderModel /
  HistoryItemDiffStats, MessageType.DIFF_STATS.
- packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx — renders
  +N in theme.status.success (green), -M in theme.status.error (red), and
  the (new) / (binary) / (new, partial) markers in theme.text.secondary
  (dim). Column alignment matches the plain-text fallback.
- packages/cli/src/ui/components/HistoryItemDisplay.tsx — routes the new
  item type.
- packages/cli/src/ui/commands/diffCommand.ts — builds a DiffRenderModel
  once and fans out: interactive calls context.ui.addItem; other modes
  fall through to renderDiffModelText() for the plain-text path. Error
  and "clean tree" branches keep the existing info/error
  MessageActionReturn in every mode.
- Tests: existing diffCommand suite moved to an explicit non_interactive
  context (it was asserting text content); new interactive suite covers
  addItem dispatch and model shape; DiffStatsDisplay component tests
  cover the four row variants and the "…and N more" note.
2026-04-24 17:45:42 +08:00

351 lines
13 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { diffCommand } from './diffCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { fetchGitDiff, type GitDiffResult } from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual<
typeof import('@qwen-code/qwen-code-core')
>('@qwen-code/qwen-code-core');
return {
...actual,
fetchGitDiff: vi.fn(),
};
});
function makeContextWithCwd(cwd = '/tmp/repo'): CommandContext {
// Non-interactive by default here because these tests assert on the
// plain-text `MessageActionReturn`; interactive mode dispatches via
// `context.ui.addItem` and is covered in a separate describe block.
return createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getWorkingDir: () => cwd,
getProjectRoot: () => cwd,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
});
}
function makeInteractiveContext(cwd = '/tmp/repo'): CommandContext {
return createMockCommandContext({
executionMode: 'interactive',
services: {
config: {
getWorkingDir: () => cwd,
getProjectRoot: () => cwd,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
});
}
describe('diffCommand', () => {
let mockContext: CommandContext;
let mockFetchGitDiff: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockFetchGitDiff = vi.mocked(fetchGitDiff);
mockContext = makeContextWithCwd();
});
it('errors when config is unavailable', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const noConfigContext = createMockCommandContext();
const result = await diffCommand.action(noConfigContext, '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
});
it('errors when getWorkingDir and getProjectRoot both return empty', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const noCwdContext = createMockCommandContext({
services: {
config: {
getWorkingDir: () => '',
getProjectRoot: () => '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
});
const result = await diffCommand.action(noCwdContext, '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
});
it('surfaces an error when fetchGitDiff throws', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockRejectedValueOnce(new Error('permission denied'));
const result = await diffCommand.action(mockContext, '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toContain(
'permission denied',
);
});
it('reports when not in a git repo or transient state', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue(null);
const result = await diffCommand.action(mockContext, '');
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
});
expect((result as { content: string }).content).toMatch(
/not a git repository|merge|rebase/i,
);
});
it('reports clean working tree when stats show zero changes', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 },
perFileStats: new Map(),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
expect((result as { content: string }).content).toMatch(
/Clean working tree/i,
);
});
it('uses singular "file" when exactly one file changed', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 1, linesAdded: 3, linesRemoved: 1 },
perFileStats: new Map([
['src/a.ts', { added: 3, removed: 1, isBinary: false }],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
expect(content).toMatch(/\b1 file\b/);
expect(content).not.toMatch(/\b1 files\b/);
});
it('renders header and per-file rows with +added / -removed', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 },
perFileStats: new Map([
['src/a.ts', { added: 5, removed: 2, isBinary: false }],
['src/b.ts', { added: 2, removed: 1, isBinary: false }],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
expect(content).toContain('2 files changed');
expect(content).toContain('+7');
expect(content).toContain('-3');
expect(content).toContain('src/a.ts');
expect(content).toContain('src/b.ts');
});
it('shows untracked text files with their line count and a (new) marker', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 2, linesAdded: 12, linesRemoved: 2 },
perFileStats: new Map([
['src/a.ts', { added: 10, removed: 2, isBinary: false }],
[
'notes.md',
{ added: 2, removed: 0, isBinary: false, isUntracked: true },
],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
const lines = content.split('\n');
const aLine = lines.find((l) => l.endsWith('src/a.ts'))!;
const newLine = lines.find((l) => l.includes('notes.md'))!;
expect(newLine).toContain('+ 2');
expect(newLine).toContain('(new)');
// Stat columns stay aligned across tracked and new rows.
expect(aLine.indexOf('src/a.ts')).toBe(newLine.indexOf('notes.md'));
});
it('marks truncated untracked text files with (new, partial)', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 1, linesAdded: 10000, linesRemoved: 0 },
perFileStats: new Map([
[
'big.log',
{
added: 10000,
removed: 0,
isBinary: false,
isUntracked: true,
truncated: true,
},
],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
const row = content.split('\n').find((l) => l.includes('big.log'))!;
expect(row).toContain('(new, partial)');
expect(row).not.toContain(' (new)');
});
it('marks binary untracked files with (binary, new) and no line count', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 1, linesAdded: 0, linesRemoved: 0 },
perFileStats: new Map([
[
'blob.bin',
{ added: 0, removed: 0, isBinary: true, isUntracked: true },
],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
const binaryLine = content.split('\n').find((l) => l.includes('blob.bin'))!;
expect(binaryLine).toContain('(binary, new)');
expect(binaryLine).not.toMatch(/\+\d/);
expect(binaryLine.trimStart().startsWith('~')).toBe(true);
});
it('pads counts consistently for 4-digit values', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 2, linesAdded: 9999, linesRemoved: 1 },
perFileStats: new Map([
['big.ts', { added: 9999, removed: 0, isBinary: false }],
['tiny.ts', { added: 0, removed: 1, isBinary: false }],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
// Both rows must use the same prefix width so they align.
const bigLine = content.split('\n').find((l) => l.endsWith('big.ts'))!;
const tinyLine = content.split('\n').find((l) => l.endsWith('tiny.ts'))!;
expect(bigLine.indexOf('big.ts')).toBe(tinyLine.indexOf('tiny.ts'));
expect(content).toContain('+9999');
});
it('notes how many files were hidden beyond the per-file cap', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 },
perFileStats: new Map([
['src/a.ts', { added: 1, removed: 0, isBinary: false }],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
expect(content).toContain('60 files changed');
expect(content).toMatch(/59 more/);
});
it('shows header only when the shortstat fast path yields no per-file data', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 1000, linesAdded: 50_000, linesRemoved: 8_000 },
perFileStats: new Map(),
} satisfies GitDiffResult);
const result = await diffCommand.action(mockContext, '');
const content = (result as { content: string }).content;
expect(content).toContain('1000 files changed');
expect(content).not.toMatch(/more \(showing first/);
});
});
describe('diffCommand interactive mode', () => {
let mockFetchGitDiff: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockFetchGitDiff = vi.mocked(fetchGitDiff);
});
it('dispatches a diff_stats history item instead of returning text', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 },
perFileStats: new Map([
['src/a.ts', { added: 5, removed: 2, isBinary: false }],
['src/b.ts', { added: 2, removed: 1, isBinary: false }],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(ctx, '');
expect(result).toBeUndefined();
expect(ctx.ui.addItem).toHaveBeenCalledTimes(1);
const call = (ctx.ui.addItem as Mock).mock.calls[0][0];
expect(call.type).toBe('diff_stats');
expect(call.model).toMatchObject({
filesCount: 2,
linesAdded: 7,
linesRemoved: 3,
hiddenCount: 0,
});
expect(call.model.rows).toHaveLength(2);
expect(call.model.rows[0]).toMatchObject({
filename: 'src/a.ts',
added: 5,
removed: 2,
isBinary: false,
isUntracked: false,
});
});
it('still returns a plain-text info message for the "clean tree" case', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 },
perFileStats: new Map(),
} satisfies GitDiffResult);
const result = await diffCommand.action(ctx, '');
expect(result).toMatchObject({ type: 'message', messageType: 'info' });
expect(ctx.ui.addItem).not.toHaveBeenCalled();
});
it('still returns an error MessageActionReturn when fetchGitDiff throws', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockRejectedValueOnce(new Error('boom'));
const result = await diffCommand.action(ctx, '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect(ctx.ui.addItem).not.toHaveBeenCalled();
});
it('propagates hiddenCount to the history item for fast-path results', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 },
perFileStats: new Map([
['src/a.ts', { added: 1, removed: 0, isBinary: false }],
]),
} satisfies GitDiffResult);
await diffCommand.action(ctx, '');
const call = (ctx.ui.addItem as Mock).mock.calls[0][0];
expect(call.model.hiddenCount).toBe(59);
expect(call.model.rows).toHaveLength(1);
});
});
describe('diffCommand registration', () => {
it('declares all execution modes so it works in non-interactive and ACP', () => {
expect(diffCommand.supportedModes).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
});