Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 262 additions & 5 deletions apps/desktop/stories/session-workbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -862,6 +1029,96 @@ export const Changes: Story = {
render: () => <Workbar tab="review" />,
};

// 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: () => <Workbar tab="review" />,
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: () => <Workbar tab="review" />,
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: () => <Workbar tab="review" />,
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: () => <Workbar tab="review" />,
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: () => <Workbar tab="review" />,
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: () => <Workbar tab="review" />,
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<HTMLElement>('.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 = {
Expand Down