|
| 1 | +// webapp/components/edited-files-card.tsx — the 「已编辑 N 个文件」 summary card. |
| 2 | +// |
| 3 | +// Ticket 77 (G3 of ticket 61). Structure follows the reference implementation |
| 4 | +// (`other-minimax-code/.../DiffCard.tsx`): a header row and a file list, driven |
| 5 | +// by a pure state machine kept beside the component so the transitions are |
| 6 | +// testable without a DOM. |
| 7 | +// |
| 8 | +// The reference's card is fed by `getTurnDiff` / `revertTurnDiff` / |
| 9 | +// `reapplyTurnDiff` — a per-turn changeset the runtime owns. This repo has no |
| 10 | +// reachable equivalent (the audit is in |
| 11 | +// `.tickets/webui-parity/77-edited-files-card.md`), so the card renders only |
| 12 | +// what `collectEditedFiles` can prove: the paths the turn's edit tools named. |
| 13 | +// Consequences, all deliberate and none cosmetic: |
| 14 | +// |
| 15 | +// * no green/red added-deleted badges — no line counts exist to show; |
| 16 | +// * no 撤销 button — there is no per-turn revert to call, and a button that |
| 17 | +// does nothing is worse than an absent one; |
| 18 | +// * no Review button — clicking a file row opens the real file preview |
| 19 | +// instead, which is the affordance this codebase can actually honour. |
| 20 | +// |
| 21 | +// The header reuses the existing `activity.editedFiles` key rather than adding |
| 22 | +// a near-duplicate: the activity-group summary and this card are the same |
| 23 | +// sentence, so they must stay the same sentence. |
| 24 | + |
| 25 | +"use client"; |
| 26 | + |
| 27 | +import { useState } from "react"; |
| 28 | +import { Icon } from "./icons"; |
| 29 | +import type { EditedFile } from "../lib/edited-files"; |
| 30 | +import type { MessageKey } from "../lib/i18n"; |
| 31 | + |
| 32 | +/** Rows shown before the card needs the expand toggle. */ |
| 33 | +export const COLLAPSED_FILE_ROWS = 3; |
| 34 | + |
| 35 | +export interface EditedFilesCardState { |
| 36 | + /** Whether the file list is showing all rows rather than the first few. */ |
| 37 | + readonly expanded: boolean; |
| 38 | +} |
| 39 | + |
| 40 | +export type EditedFilesCardAction = { readonly type: "toggle-expanded" }; |
| 41 | + |
| 42 | +export function initialEditedFilesCardState(): EditedFilesCardState { |
| 43 | + return { expanded: false }; |
| 44 | +} |
| 45 | + |
| 46 | +export function reduceEditedFilesCardState( |
| 47 | + state: EditedFilesCardState, |
| 48 | + action: EditedFilesCardAction, |
| 49 | +): EditedFilesCardState { |
| 50 | + switch (action.type) { |
| 51 | + case "toggle-expanded": |
| 52 | + return { ...state, expanded: !state.expanded }; |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +export function EditedFilesCard({ |
| 57 | + files, |
| 58 | + t, |
| 59 | + onOpenFile, |
| 60 | +}: { |
| 61 | + readonly files: readonly EditedFile[]; |
| 62 | + readonly t: (key: MessageKey) => string; |
| 63 | + readonly onOpenFile?: (path: string) => void; |
| 64 | +}) { |
| 65 | + const [state, setState] = useState<EditedFilesCardState>(initialEditedFilesCardState); |
| 66 | + // A1: with no files there is nothing truthful to say, so the card is not in |
| 67 | + // the tree at all — not an empty shell, not a 「0 个文件」 placeholder. |
| 68 | + if (files.length === 0) return null; |
| 69 | + |
| 70 | + const shown = state.expanded ? files : files.slice(0, COLLAPSED_FILE_ROWS); |
| 71 | + const hidden = files.length - shown.length; |
| 72 | + const title = t("activity.editedFiles").replace("{{count}}", String(files.length)); |
| 73 | + |
| 74 | + return ( |
| 75 | + <section |
| 76 | + data-testid="edited-files-card" |
| 77 | + data-edited-files-count={files.length} |
| 78 | + aria-label={title} |
| 79 | + className="mb-3 rounded-[10px] border border-border_default bg-bg_default_primary px-3 py-2.5" |
| 80 | + > |
| 81 | + <header className="flex items-center gap-2"> |
| 82 | + <span className="text-icon_default_tertiary" data-testid="edited-files-card-icon"> |
| 83 | + <Icon name="pencil" size={16} /> |
| 84 | + </span> |
| 85 | + <span className="text-body-small-strong text-text_default_primary">{title}</span> |
| 86 | + </header> |
| 87 | + <ul className="mt-1.5 flex flex-col" data-testid="edited-files-card-list"> |
| 88 | + {shown.map((file) => ( |
| 89 | + <li |
| 90 | + key={file.path} |
| 91 | + data-testid="edited-files-card-row" |
| 92 | + data-file-path={file.path} |
| 93 | + className="flex min-h-[28px] items-center gap-2" |
| 94 | + > |
| 95 | + <span className="text-icon_default_tertiary"> |
| 96 | + <Icon name="file" size={14} /> |
| 97 | + </span> |
| 98 | + {onOpenFile ? ( |
| 99 | + <button |
| 100 | + type="button" |
| 101 | + onClick={() => onOpenFile(file.path)} |
| 102 | + title={file.path} |
| 103 | + data-testid="edited-files-card-file" |
| 104 | + className="truncate text-left font-mono text-caption-small text-text_default_primary underline-offset-2 hover:underline" |
| 105 | + > |
| 106 | + {file.name} |
| 107 | + </button> |
| 108 | + ) : ( |
| 109 | + <span |
| 110 | + title={file.path} |
| 111 | + data-testid="edited-files-card-file" |
| 112 | + className="truncate font-mono text-caption-small text-text_default_primary" |
| 113 | + > |
| 114 | + {file.name} |
| 115 | + </span> |
| 116 | + )} |
| 117 | + </li> |
| 118 | + ))} |
| 119 | + </ul> |
| 120 | + {hidden > 0 || state.expanded ? ( |
| 121 | + <button |
| 122 | + type="button" |
| 123 | + data-testid="edited-files-card-toggle" |
| 124 | + data-expanded={state.expanded} |
| 125 | + onClick={() => setState((current) => reduceEditedFilesCardState(current, { type: "toggle-expanded" }))} |
| 126 | + className="mt-1 flex items-center gap-1 text-caption-small text-text_default_tertiary transition-colors hover:text-text_default_primary" |
| 127 | + > |
| 128 | + <Icon name={state.expanded ? "chevronUp" : "chevronDown"} size={12} /> |
| 129 | + {state.expanded ? t("activity.collapse") : t("activity.expand")} |
| 130 | + </button> |
| 131 | + ) : null} |
| 132 | + </section> |
| 133 | + ); |
| 134 | +} |
0 commit comments