✨ feat(vscode): React 文件照源码一块一张卡片,组件卡片只留名称、说明、构造参数

- 组件体的语句不再并进组件卡片,照源码顺序是它的子块;状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出各成一张卡片,回调和函数里的语句是这些卡片下面的子卡片
- 普通变量、提前返回的 if、注释照语言默认的卡片;表格格子里只写名字和位置,去掉「改它的」「来自」格子里单独打开源码的那条路
- 用词改成书面叫法(更新位置、使用位置、计算表达式、依赖项、触发时机、清理函数、绑定事件、样式取决于……),新增 toggle-right、anchor、download、sigma、layout-template 五个种类图标
- 夹具加上 luffy 的 QueuedBubble.tsx、PlanChecklist.tsx,规格的 React 一节照新样子改写
This commit is contained in:
cruldra committed 2026-10-08 07:17:25 +08:00
1 parent 194544b4ba
commit 6ce4ef7ee2
20 files changed
+1327 -989

No files matched your search

@@ -0,0 +1,148 @@
"use client";
import { Circle, CircleDot, Pause } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CARD_SURFACE } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleChevron, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible";
import { FLUSH_ROW_INTERACTIVE } from "@/components/ui/interactive";
import { StatusMark } from "@/components/ui/status-mark";
import type { PlanTodo } from "./events";
interface PlanChecklistProps {
/** 执行期计划清单 (覆盖式刷新); 空数组时不渲染. */
todos: PlanTodo[];
/**
* 本轮是否仍在流式执行中. ``in_progress`` 仅在 ``active`` 时转圈;
* turn 结束/报错后转 ``false``, 当前步停转 (显示"停在此步"), 不再空转误导.
*/
active: boolean;
}
/**
* 执行期"计划清单"实时勾选 (TodoWrite 式): code-agent 批准计划后边执行边刷新各步
* 状态, 用户看到"做到哪一步了". 渲染在 composer 上方, 由 planTodos 驱动.
*
* 进度叙事三件套: 顶部分段进度条 (一段一步) · 左侧轨道串起各步 · 当前步整行铺中性底,
* 不看文字也知道走到哪. 设计稿见 docs/prototype/todolist/openpencil.
*
* 可折叠: 七八步的清单会把对话区挤成一小截, 点标题栏折成一行 (分段条 + 当前步 + 计数).
* 偏好全局粘性 (localStorage): 折过一次以后所有会话默认折叠, 展开同理.
* SSR / 首屏恒为展开, 水合后 effect 读偏好校正 (与 ComposerSizeProvider 同款).
*/
export const PlanChecklist = ({ todos, active }: PlanChecklistProps) => {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
try {
if (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) === "1") {
queueMicrotask(() => setCollapsed(true));
}
} catch {
/* localStorage 不可用 (隐私模式等) 时静默用默认 */
}
}, []);
const toggle = useCallback(() => {
const next = !collapsed;
setCollapsed(next);
try {
localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, next ? "1" : "0");
} catch {
/* 忽略写入失败 */
}
}, [collapsed]);
if (todos.length === 0) return null;
const whole = wholeStatusOf(todos, active);
const doneCount = todos.filter((t) => t.status === "done").length;
const counter = `${doneCount} / ${todos.length}`;
const current = todos.find((t) => t.status === "in_progress");
// 折叠行永远带一步的名字: 跑着是当前步, 做完了是最后一步, 不留光秃秃的计数
const headline = (current ?? todos[todos.length - 1]).title;
return (
<Collapsible
open={!collapsed}
onOpenChange={toggle}
className={cn(CARD_SURFACE, "overflow-hidden")}
>
<CollapsibleTrigger
data-testid="plan-checklist-toggle"
className={cn(FLUSH_ROW_INTERACTIVE, "block w-full text-left")}
>
<div data-testid="plan-progress" className="flex gap-1 px-3.5 pt-3">
{todos.map((todo) => (
<span
key={todo.title}
className={cn("h-[3px] flex-1 rounded-full", segmentClass(todo.status, whole))}
/>
))}
</div>
{collapsed ? (
<div className="flex items-center gap-2.5 px-3.5 pt-2.5 pb-3">
<StatusIcon status={current ? "in_progress" : "done"} active={active} />
<span
className={cn(
"min-w-0 flex-1 truncate text-ui leading-[22px] font-medium",
whole === "done" ? "text-muted-foreground" : "text-foreground",
)}
>
{headline}
</span>
<span className="text-caption text-ink-4 tabular-nums">{counter}</span>
<span className="flex items-center gap-0.5 text-caption text-ink-4">
展开
<CollapsibleChevron strokeWidth={1.8} />
</span>
</div>
) : (
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-2">
<span className="text-caption font-medium tracking-wide text-ink-2">计划清单</span>
<span className="text-caption text-ink-4 tabular-nums">{counter}</span>
<span className="flex-1" />
<WholeStatusLabel whole={whole} />
<span className="flex items-center gap-0.5 text-caption text-ink-4">
收起
<CollapsibleChevron strokeWidth={1.8} />
</span>
</div>
)}
</CollapsibleTrigger>
<CollapsiblePanel>
<ol className="max-h-[40vh] overflow-y-auto border-t border-border px-2 pt-1.5 pb-2">
{todos.map((todo, i) => {
const focus = todo.status === "in_progress";
const last = i === todos.length - 1;
return (
<li
key={todo.title}
className={cn("flex gap-2.5 rounded-md px-2", focus && "bg-surface-2")}
>
{/* 轨道: 首行顶端留空, 末行不画下段; 9px 上段让 16px 图标对齐 22px 行高的第一行 */}
<span className="flex w-4 shrink-0 flex-col items-center">
<span
className={cn("h-[9px] w-0.5", i > 0 && railClass(todos[i - 1].status === "done"))}
/>
<StatusIcon status={todo.status} active={active} />
{!last && (
<span className={cn("w-0.5 flex-1", railClass(todo.status === "done"))} />
)}
</span>
<span
className={cn(
"min-w-0 flex-1 py-1.5 text-ui leading-[22px]",
titleClass(todo.status),
)}
>
{todo.title}
</span>
</li>
);
})}
</ol>
</CollapsiblePanel>
</Collapsible>
);
};
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { Clock, FileText, Pause, Pencil, X } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { FIELD_TEXT } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { QueuedMessage } from "../events";
import { USER_BUBBLE_SHAPE_CLASS, USER_FILE_CHIP_CLASS } from "./UserBubble";
const QUEUE_ACTION_CLASS = buttonVariants({ variant: "ghost", size: "icon-xs", className: "text-ink-4" });
/**
* 消息流底部的排队消息组: 上方一行说明, 下面每条一个描边气泡, 靠右排.
* 有条目在原地修改时说明换成「修改中」, 保存之前整队先不发出.
*/
export const QueuedMessages = ({
queue,
editingId,
onCancel,
onEdit,
onSaveEdit,
onCancelEdit,
}: {
queue: QueuedMessage[];
/** 正在原地修改的那条; null 表示没有. */
editingId: string | null;
onCancel?: (id: string) => void;
onEdit?: (id: string) => void;
onSaveEdit?: (id: string, text: string) => void;
onCancelEdit?: () => void;
}) => {
if (queue.length === 0) return null;
const editing = editingId !== null;
return (
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-1 text-caption text-muted-foreground">
{editing ? <Pause size={12} aria-hidden="true" /> : <Clock size={12} aria-hidden="true" />}
<span>{editing ? "修改中 · 保存之前先不发出" : "排队中 · 点停止可立即发出第 1 条"}</span>
</div>
{queue.map((q) => (
<QueuedBubble
key={q.id}
item={q}
editing={q.id === editingId}
onCancel={onCancel}
onEdit={onEdit}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
/>
))}
</div>
);
};
/**
* 一条排队消息: 描边气泡, 字用弱色, 气泡内右侧是编辑 (铅笔) 与取消排队 (×).
* 不给 onEdit / onCancel 时对应按钮不显示. editing 时气泡原地变成修改框, 附件照常显示.
*/
export const QueuedBubble = ({
item,
editing = false,
onCancel,
onEdit,
onSaveEdit,
onCancelEdit,
}: {
item: QueuedMessage;
editing?: boolean;
onCancel?: (id: string) => void;
onEdit?: (id: string) => void;
onSaveEdit?: (id: string, text: string) => void;
onCancelEdit?: () => void;
}) => {
const images = item.attachments.filter((a) => a.kind === "image");
const files = item.attachments.filter((a) => a.kind === "file");
const hasText = item.text.trim().length > 0;
const hasActions = onEdit != null || onCancel != null;
return (
<div className={cn("flex max-w-[85%] flex-col items-end gap-2 md:max-w-[75%]", editing && "w-full")}>
{files.length > 0 && (
<div className="flex flex-wrap justify-end gap-2 w-full">
{files.map((f) => (
<span key={f.url} className={USER_FILE_CHIP_CLASS}>
<FileText size={16} className="shrink-0 text-muted-foreground" />
<span className="max-w-[200px] truncate">{f.name ?? "文件附件"}</span>
</span>
))}
</div>
)}
{images.length > 0 && (
<div className="flex flex-wrap justify-end gap-2 w-full">
{images.map((img) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={img.url}
src={img.url}
alt={img.name ?? "图片附件"}
className="block max-w-[160px] max-h-[160px] w-auto h-auto object-cover rounded-xl border border-border"
/>
))}
</div>
)}
{editing ? (
<QueuedEditor item={item} onSave={onSaveEdit} onCancel={onCancelEdit} />
) : (
(hasText || hasActions) && (
<div
className={cn(
USER_BUBBLE_SHAPE_CLASS,
"flex items-start gap-1.5 border border-border-strong text-muted-foreground",
hasActions && "pr-1.5",
)}
>
{hasText && <span className="min-w-0">{item.text}</span>}
{hasActions && (
<div className="flex shrink-0 items-center">
{onEdit && (
<Tooltip>
<TooltipTrigger
type="button"
onClick={() => onEdit(item.id)}
aria-label="编辑这条排队消息"
className={QUEUE_ACTION_CLASS}
>
<Pencil className="size-3.5" />
</TooltipTrigger>
<TooltipContent>编辑</TooltipContent>
</Tooltip>
)}
{onCancel && (
<Tooltip>
<TooltipTrigger
type="button"
onClick={() => onCancel(item.id)}
aria-label="取消这条排队消息"
className={QUEUE_ACTION_CLASS}
>
<X className="size-3.5" />
</TooltipTrigger>
<TooltipContent>取消排队</TooltipContent>
</Tooltip>
)}
</div>
)}
</div>
)
)}
</div>
);
};
/**
* 排队消息的原地修改框: 只改文字, 附件不动. 文本框随内容长高.
* Cmd/Ctrl+Enter 保存, Esc 取消; 文字为空且没有附件时不能保存.
*/
const QueuedEditor = ({
item,
onSave,
onCancel,
}: {
item: QueuedMessage;
onSave?: (id: string, text: string) => void;
onCancel?: () => void;
}) => {
const [draft, setDraft] = useState(item.text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSave = draft.trim().length > 0 || item.attachments.length > 0;
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}, []);
const save = () => {
if (canSave) onSave?.(item.id, draft);
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Escape") {
// 修改框里的 Esc 只用来取消修改, 不再往外传: 页面上「连按两次 Esc 停止这一轮」不该把它算进去
e.preventDefault();
e.stopPropagation();
onCancel?.();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
}
};
return (
<div
role="group"
aria-label="修改排队消息"
onKeyDown={handleKeyDown}
className={cn(USER_BUBBLE_SHAPE_CLASS, "flex w-full flex-col gap-2.5 border border-primary bg-card text-foreground")}
>
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
aria-label="排队消息内容"
className={cn(FIELD_TEXT, "block w-full resize-none bg-transparent outline-none field-sizing-content")}
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>
取消
</Button>
<Button size="sm" disabled={!canSave} onClick={save}>
保存
</Button>
</div>
</div>
);
};