diff --git a/vscode/src/codeReader/fixtures/overviewSection.tsx.txt b/vscode/src/codeReader/fixtures/overviewSection.tsx.txt
new file mode 100644
index 0000000..df251f9
--- /dev/null
+++ b/vscode/src/codeReader/fixtures/overviewSection.tsx.txt
@@ -0,0 +1,110 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { Button } from "@/components/ui/button";
+import { formatBytes } from "@/app/(main)/account/storage/format";
+import { getStatusView } from "@/app/(main)/apps/deployment-status";
+import { presentSessionCreateError } from "@/lib/api/balance-error";
+import { type AppDetail, createAppSession, refreshAppQueries } from "@/lib/apps";
+import { liveUrl as toLiveUrl } from "@/lib/deployments/live-url";
+import { formatRelativeTime } from "@/lib/format/relative-time";
+import { SESSION_ROUTE } from "@/lib/sessions";
+
+import { SectionCard, SettingsRow } from "../SettingsRow";
+import { sectionHref } from "../nav";
+import { DeployTabLink } from "./DeployTabLink";
+import { useResumeDeployment } from "./use-resume-deployment";
+
+/** 概览: 一行一件事, 值在左、说明在下、次要动作靠右. */
+export const OverviewSection = ({ detail }: { detail: AppDetail }) => {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const deployment = detail.deployment ?? null;
+ const liveUrl = toLiveUrl(detail.custom_domain, deployment?.url);
+
+ const resume = useResumeDeployment(detail.id);
+
+ const newSession = useMutation({
+ mutationFn: () => createAppSession(detail.id),
+ onSuccess: (session) => {
+ void refreshAppQueries(queryClient, detail.id);
+ router.push(`${SESSION_ROUTE}?session_id=${session.id}`);
+ },
+ onError: (err) => presentSessionCreateError(err, router),
+ });
+
+ return (
+
+
+ {liveUrl.replace(/^https?:\/\//, "")}
+
+ ) : (
+ "未发布"
+ )
+ }
+ note={detail.custom_domain ? null : "未绑自定义域名"}
+ action={
+ detail.custom_domain ? null : (
+ }>
+ 绑定域名
+
+ )
+ }
+ />
+ resume.mutate(deployment.deployment_id)} disabled={resume.isPending}>
+ 恢复部署
+
+ ) : deployment != null ? (
+
+ ) : null
+ }
+ />
+
+
+ 0 ? `已归档 ${detail.archived_session_count} 个` : null}
+ action={
+
+ }
+ />
+
+
+ );
+};
diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts
index 50a83d8..7d19881 100644
--- a/vscode/src/codeReader/rules/react.test.ts
+++ b/vscode/src/codeReader/rules/react.test.ts
@@ -320,16 +320,48 @@ describe('react 库规则:渲染大纲', () => {
' ⟦|否则⟧',
' Dialog open onOpenChange=(next) => 非 next 并且 closeSettings()',
])
- expect(rowLines(await card(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary'), '渲染').slice(0, 5)).toEqual([
+ expect(rowLines(await card(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary'), '渲染').slice(0, 7)).toEqual([
'渲染: 1 处返回',
' <> 片段,不产生元素',
- ' SettingHeader compact align="center" title="密钥与变量" description="按应用保存,这个应用的所有会话共用" right=',
+ ' SettingHeader compact align="center" title="密钥与变量" description="按应用保存,这个应用的所有会话共用"',
+ ' right',
+ ' Link ⟦样式 4 个类|⟧ href=sectionHref(appId, "vault") "管理"',
' ⟦|当 effective.length > 0 时⟧ ul ⟦样式 2 个类|⟧',
' ⟦|对 effective 的每一项 { item, label }⟧ key item.id',
])
expect(rowLines(await card(settings, 'RuntimeSection'), '渲染')).toContain(' p ⟦样式 3 个类|⟧ {w.subdir} · {STATE_NOTE[w.state]}')
})
+ it('luffy 的 OverviewSection:属性值里的条件和元素折到属性名下面,className 收成样式小标,短属性留在元素那一行', async () => {
+ const view = await card(readFixture('overviewSection.tsx.txt'), 'OverviewSection')
+ const lines = rowLines(view, '渲染')
+ const next = lines.indexOf(' SettingsRow label="状态"')
+ expect(lines.slice(0, next)).toEqual([
+ '渲染: 1 处返回',
+ ' SectionCard title="概览"',
+ ' SettingsRow label="线上地址"',
+ ' value',
+ ' ⟦|如果 liveUrl⟧',
+ ' ⟦|是⟧ a ⟦样式 3 个类|⟧ href=liveUrl target="_blank" rel="noreferrer" {liveUrl.replace(/^https?:\\/\\//, "")}',
+ ' ⟦|否⟧ "未发布"',
+ ' note',
+ ' ⟦|如果 detail.custom_domain⟧',
+ ' ⟦|是⟧ 不渲染',
+ ' ⟦|否⟧ "未绑自定义域名"',
+ ' action',
+ ' ⟦|如果 detail.custom_domain⟧',
+ ' ⟦|是⟧ 不渲染',
+ ' ⟦|否⟧ Button variant="outline" size="sm" "绑定域名"',
+ ' render',
+ ' Link href=sectionHref(detail.id, "domain")',
+ ])
+ expect(lines).toContain(' SettingsRow label="规格" value=deployment?.instance_type_code ?? "—" note="重新发布时可换档"')
+ expect(lines.join('\n')).not.toContain('text-primary underline')
+ const anchor = findItem(properties(view).find(item => item.label === '渲染')?.items, item => item.cells[0].some(piece => 'role' in piece && piece.text === 'a'))
+ const chip = anchor?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
+ expect(chip && !('role' in chip) && chip.detail).toBe('text-primary underline underline-offset-2')
+ })
+
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元写成是 / 否两支', async () => {
const lines = rowLines(await card(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'), '渲染')
const button = lines.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=非 ready 或者 pending')
diff --git a/vscode/src/codeReader/rules/react.ts b/vscode/src/codeReader/rules/react.ts
index 9303606..e839529 100644
--- a/vscode/src/codeReader/rules/react.ts
+++ b/vscode/src/codeReader/rules/react.ts
@@ -1,6 +1,6 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts } from './reactTree'
-import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, openTag, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
+import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
/**
@@ -11,7 +11,8 @@ import { hint, joined, name, plain, range } from './segments'
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
* 点名称展开它的子卡片)、渲染(返回的 JSX 画成缩进的结构大纲:原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」,
- * `cond &&` 写「当 … 时」,三元分「是 / 否」两支,`.map` 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时 不渲染」)。
+ * `cond &&` 写「当 … 时」,三元分「是 / 否」两支,`.map` 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时 不渲染」。
+ * 属性值是元素、条件或列表时不写进元素那一行:属性名单独成行,值照同一套大纲缩进在下面;字符串、数字这些短属性留在元素那一行)。
* 讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
@@ -814,22 +815,42 @@ function tagName(nameNode: Node | undefined, component: boolean): Inline[] {
return parts(nameNode)
}
-/** 一个属性写成「名字=值」:字符串、表达式照常翻,跨几行写的对象只写有哪些键,JSX 元素写成 ``;只写了名字的就是名字,`{...x}` 照写。 */
+/** 属性名,点它跳到源码里这个属性。 */
+function attributeHead(attribute: Node): Inline {
+ const nameNode = named(attribute)[0]
+ return { text: nameNode?.text ?? '', role: 'variable', ...(nameNode && { offset: nameNode.startIndex }) }
+}
+
+/**
+ * 属性值是一段 JSX(元素、条件、列表)时单独成一行:这一行只留属性名,值用同一套大纲缩进在下面。
+ * 字符串、数字、普通表达式留在元素那一行,不因为这一行长就折下去。
+ */
+function nestedAttribute(attribute: Node, env: Env): PropertyItem | undefined {
+ if (attribute.type !== 'jsx_attribute')
+ return undefined
+ const value = attributeValue(attribute)
+ const inner = value && (value.type === 'jsx_expression' ? unwrap(named(value).find(child => child.type !== 'comment')) : value)
+ if (!inner)
+ return undefined
+ const outlined = outlineOf(inner)
+ if (!outlined.some(node => node.kind === 'element' || node.kind === 'ternary' || node.kind === 'map'))
+ return undefined
+ return { cells: [[attributeHead(attribute)]], items: outlineItems(outlined, env) }
+}
+
+/** 一个属性写成「名字=值」:字符串、表达式照常翻,跨几行写的对象只写有哪些键;只写了名字的就是名字,`{...x}` 照写。值是元素、条件或列表的不走这里。 */
function attributeText(attribute: Node, env: Env): Inline[] {
const { context, words } = env
if (attribute.type === 'jsx_expression')
return [plain('{'), ...context.translate(named(attribute).find(child => child.type !== 'comment') ?? attribute), plain('}')]
- const nameNode = named(attribute)[0]
const value = attributeValue(attribute)
- const head: Inline = { text: nameNode?.text ?? '', role: 'variable', ...(nameNode && { offset: nameNode.startIndex }) }
+ const head = attributeHead(attribute)
if (!value)
return [head]
const inner = value.type === 'jsx_expression' ? unwrap(named(value).find(child => child.type !== 'comment')) : value
let shown: Inline[]
if (!inner)
shown = context.translate(value)
- else if (inner.type === 'jsx_element' || inner.type === 'jsx_self_closing_element')
- shown = elementRef(openTag(inner))
else if (inner.type === 'object' && inner.startPosition.row !== inner.endPosition.row)
shown = [hint(words.object), ...joined(named(inner).flatMap(pair => pair.childForFieldName('key') ? [[name(pair.childForFieldName('key') as Node, 'variable')]] : []), hint(words.listSeparator))]
else
@@ -871,7 +892,7 @@ function outlineItems(nodes: readonly OutlineNode[], env: Env, prefix: Inline[]
return nodes.flatMap((node, index) => outlineNode(node, env, index === 0 ? prefix : []))
}
-/** 大纲的一个节点:元素一行(子元素在下一层)、文字、JSX 注释、三元(下面是 / 否两支)、列表(下面是每一项渲染的)、空。 */
+/** 大纲的一个节点:元素一行(子元素、以及值是结构的属性,在下一层)、文字、JSX 注释、三元(下面是 / 否两支)、列表(下面是每一项渲染的)、空。 */
function outlineNode(node: OutlineNode, env: Env, prefix: Inline[]): PropertyItem[] {
const { context, words } = env
const when = 'when' in node && node.when ? [pill('condition', whenText(node.when, env))] : []
@@ -880,13 +901,22 @@ function outlineNode(node: OutlineNode, env: Env, prefix: Inline[]): PropertyIte
switch (node.kind) {
case 'element': {
const chip = node.classes && classesChip(node.classes, env)
+ const inline: Inline[][] = []
+ const folded: PropertyItem[] = []
+ for (const attribute of node.attributes) {
+ const nested = nestedAttribute(attribute, env)
+ if (nested)
+ folded.push(nested)
+ else
+ inline.push(attributeText(attribute, env))
+ }
return line([
tagName(node.name, node.component),
node.name ? [] : [hint(words.fragment)],
chip ? [chip] : [],
- joined(node.attributes.map(attribute => attributeText(attribute, env)), plain(' ')),
+ joined(inline, plain(' ')),
node.text.length > 0 ? inlineText(node.text, env) : [],
- ], outlineItems(node.children, env))
+ ], [...folded, ...outlineItems(node.children, env)])
}
case 'text':
return line([inlineText(node.nodes, env)])