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)])