diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 2efa3082ea..77039bc2a1 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -22,7 +22,7 @@ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ArtifactRecord } from '@maka/core/artifacts'; import type { BrowserState } from '@maka/core/browser'; -import type { GitReviewSnapshot } from '@maka/core/git-review'; +import type { GitReviewReadResult, GitReviewSnapshot } from '@maka/core/git-review'; import type { SessionSummary } from '@maka/core/session'; import type { Task } from '@maka/core/task-ledger'; import type { SessionTrace } from '@maka/core/session-trace'; @@ -289,6 +289,169 @@ const gitReviewSnapshot: GitReviewSnapshot = { files: gitReviewFiles, }; +// A session whose branch matches its base — the panel's own empty state, not an +// error and not a spinner. +const emptyGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-empty', + additions: 0, + deletions: 0, + truncated: false, + files: [], +}; + +// Two independent truncation authorities, kept in separate stories: the +// snapshot-level `truncated` flag (the source dropped files from a huge +// changeset) and the per-file 500-line `boundedDiff` cap (one file's body is +// clipped). A single large file only trips the latter, so they are not merged. +const largeAddedFilePath = 'src/generated/catalog.ts'; +const largeGitReviewFile: GitReviewSnapshot['files'][number] = { + path: largeAddedFilePath, + status: 'added', + additions: 620, + deletions: 0, + diff: [ + `diff --git a/${largeAddedFilePath} b/${largeAddedFilePath}`, + 'new file mode 100644', + 'index 0000000..a1b2c3d', + '--- /dev/null', + `+++ b/${largeAddedFilePath}`, + '@@ -0,0 +1,620 @@', + ...Array.from({ length: 620 }, (_, index) => `+ export const ENTRY_${index} = ${index};`), + ].join('\n'), +}; +// Per-file cap only: one 620-line file, snapshot NOT truncated. +const fileLineCapGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-file-cap', + additions: largeGitReviewFile.additions, + deletions: 0, + truncated: false, + files: [largeGitReviewFile], +}; +// Source-level truncation: a large changeset whose file list the source capped, +// so `truncated` is set and the panel shows its 变化过多 banner. Each file is +// ordinary — no single file trips the per-file cap here. +const sourceTruncatedFiles: GitReviewSnapshot['files'] = Array.from({ length: 24 }, (_, index) => { + const path = `src/feature-${String(index).padStart(2, '0')}.ts`; + return { + path, + status: 'modified', + additions: 3, + deletions: 1, + diff: [ + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, + '@@ -1,2 +1,4 @@', + ' import { register } from "./registry";', + `+export const FLAG_${index} = true;`, + `-const legacy${index} = null;`, + ].join('\n'), + }; +}); +const sourceTruncatedGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-truncated', + additions: sourceTruncatedFiles.reduce((total, file) => total + file.additions, 0), + deletions: sourceTruncatedFiles.reduce((total, file) => total + file.deletions, 0), + truncated: true, + files: sourceTruncatedFiles, +}; + +// The diff shapes that never appear in a routine "a few lines changed" review: +// a binary blob, a rename with no body, a deletion, a no-newline-at-EOF marker, +// and a single pathologically long minified line. Each is just a different +// unified-diff string handed to the same DiffCodePreview. +const edgeGitReviewFiles: GitReviewSnapshot['files'] = [ + { + path: 'assets/logo.png', + status: 'modified', + additions: 0, + deletions: 0, + diff: [ + 'diff --git a/assets/logo.png b/assets/logo.png', + 'index e69de29..d95f3ad 100644', + 'Binary files a/assets/logo.png and b/assets/logo.png differ', + ].join('\n'), + }, + { + path: 'src/renamed-module.ts', + previousPath: 'src/old-module.ts', + status: 'renamed', + additions: 1, + deletions: 1, + diff: [ + 'diff --git a/src/old-module.ts b/src/renamed-module.ts', + 'similarity index 92%', + 'rename from src/old-module.ts', + 'rename to src/renamed-module.ts', + '--- a/src/old-module.ts', + '+++ b/src/renamed-module.ts', + '@@ -1,3 +1,3 @@', + ' import { foo } from "./foo";', + '-export const NAME = "old";', + '+export const NAME = "renamed";', + ' export default NAME;', + ].join('\n'), + }, + { + path: 'src/deprecated.ts', + status: 'deleted', + additions: 0, + deletions: 3, + diff: [ + 'diff --git a/src/deprecated.ts b/src/deprecated.ts', + 'deleted file mode 100644', + 'index 1a2b3c4..0000000', + '--- a/src/deprecated.ts', + '+++ /dev/null', + '@@ -1,3 +0,0 @@', + '-export function legacy() {', + '- return true;', + '-}', + ].join('\n'), + }, + { + path: 'config/version', + status: 'modified', + additions: 1, + deletions: 1, + diff: [ + 'diff --git a/config/version b/config/version', + '--- a/config/version', + '+++ b/config/version', + '@@ -1 +1 @@', + '-1.2.3', + '\\ No newline at end of file', + '+1.2.4', + '\\ No newline at end of file', + ].join('\n'), + }, + { + path: 'src/minified.bundle.js', + status: 'modified', + additions: 1, + deletions: 0, + diff: [ + 'diff --git a/src/minified.bundle.js b/src/minified.bundle.js', + '--- a/src/minified.bundle.js', + '+++ b/src/minified.bundle.js', + '@@ -1,1 +1,2 @@', + ' /* build output */', + `+const PAYLOAD="${'a'.repeat(1800)}";`, + ].join('\n'), + }, +]; +const edgeGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-edge', + additions: edgeGitReviewFiles.reduce((total, file) => total + file.additions, 0), + deletions: edgeGitReviewFiles.reduce((total, file) => total + file.deletions, 0), + truncated: false, + files: edgeGitReviewFiles, +}; + const populatedTrace: SessionTrace = { schemaVersion: 1, sessionId: SESSION_ID, @@ -634,6 +797,10 @@ function bridge(options: { browserState?: BrowserState; /** Make `browser.navigate` reject, so a valid address surfaces the navigation-failed toast. */ browserNavigateFails?: boolean; + /** The git-review read result the 变更 panel receives (empty / source error / truncated / edge diffs). */ + review?: GitReviewReadResult; + /** Make `review.read` reject, so the panel shows its load-error banner. */ + reviewFail?: boolean; } = {}): Decorator { const browserState = options.browserState ?? EMPTY_BROWSER_STATE; const services = createFakeWorkbarServices({ @@ -684,10 +851,10 @@ function bridge(options: { subscribeSessionEvents: unsubscribe, }, review: { - read: async () => ({ - ok: true, - snapshot: gitReviewSnapshot, - }), + read: async () => { + if (options.reviewFail) throw new Error('读取变更失败:无法运行 git diff'); + return options.review ?? { ok: true, snapshot: gitReviewSnapshot }; + }, subscribeSessionEvents: unsubscribe, }, terminal: { @@ -862,6 +1029,96 @@ export const Changes: Story = { render: () => , }; +// Real path: 任务工作栏 → 变更 on a session whose branch matches its base. The +// panel's own empty state (icon + help), not a spinner and not an error. +export const ChangesEmpty: Story = { + decorators: [bridge({ review: { ok: true, snapshot: emptyGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + await within(canvasElement).findByText('当前 Git 工作区没有变化'); + }, +}; + +// Real path: 任务工作栏 → 变更 when `review.read` rejects (the git command +// failed); the error takes a Banner with 重试, the same shape as +// TasksLoadFailed — not an empty state. +export const ChangesLoadFailed: Story = { + decorators: [bridge({ reviewFail: true })], + render: () => , + play: async ({ canvasElement }) => { + await within(canvasElement).findByRole('button', { name: '重试' }); + }, +}; + +// Real path: 任务工作栏 → 变更 when the session cwd is not a Git repository. A +// source that cannot be read is a failure (error Banner + 重试), not an +// absence — the other read reasons (workspace unavailable, unborn repo, +// invalid base branch, git failed) share this branch. +export const ChangesSourceNotGit: Story = { + decorators: [bridge({ review: { ok: false, reason: 'not_git_repository' } })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('当前任务目录不是 Git 仓库'); + await canvas.findByRole('button', { name: '重试' }); + }, +}; + +// Real path: 任务工作栏 → 变更 on a changeset so large the source capped its +// file list — the snapshot-level 变化过多 banner. The per-file line cap is a +// separate authority (see ChangesFileLineCap), not combined here. +export const ChangesTruncated: Story = { + decorators: [bridge({ review: { ok: true, snapshot: sourceTruncatedGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + await within(canvasElement).findByText('变化过多,仅显示前一部分文件'); + }, +}; + +// Real path: 任务工作栏 → 变更 with one file whose body runs past the 500-line +// cap — expanding it clips the body and notes the hidden remainder. The +// snapshot is NOT truncated, so only the per-file authority fires (no 变化过多 +// banner). +export const ChangesFileLineCap: Story = { + decorators: [bridge({ review: { ok: true, snapshot: fileLineCapGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const file = await canvas.findByText('src/generated/catalog.ts'); + expect(canvas.queryByText('变化过多,仅显示前一部分文件')).toBeNull(); + await userEvent.click(file); + await canvas.findByText(/另有 \d+ 行未显示/); + }, +}; + +// Real path: 任务工作栏 → 变更 across the diff shapes a routine review never +// shows — binary, rename, deletion, no-newline-at-EOF, and a very long minified +// line — each rendered by the same DiffCodePreview. +export const ChangesEdgeContent: Story = { + decorators: [bridge({ review: { ok: true, snapshot: edgeGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Expand each edge file in turn (the group is single-open) and confirm its + // OWN body renders through the diff surface — scoped to that file's row, + // since collapsed rows keep their diff mounted. + const expectDiffBody = async (path: string, marker: string) => { + await userEvent.click(await canvas.findByText(path)); + await waitFor(() => { + const row = Array.from( + canvasElement.querySelectorAll('.maka-session-review-file'), + ).find((el) => el.textContent?.includes(path)); + expect(row?.querySelector('.maka-session-review-diff')?.textContent ?? '').toContain(marker); + }); + }; + await expectDiffBody('assets/logo.png', 'Binary files a/assets/logo.png and b/assets/logo.png differ'); + await expectDiffBody('src/renamed-module.ts', 'rename from src/old-module.ts'); + await expectDiffBody('src/deprecated.ts', 'export function legacy'); + await expectDiffBody('config/version', 'No newline at end of file'); + await expectDiffBody('src/minified.bundle.js', 'PAYLOAD'); + }, +}; + // Real path: 任务工作栏 → 终端 after Desktop has created a PTY resource. The // service fake hydrates the real xterm surface without an Electron bridge. export const Terminal: Story = {