✨ feat(vscode): 渲染大纲的结构属性折到下一行
This commit is contained in:
1 parent
93889397e1
commit
57b2f2a115
3 files changed
+184
-12
No files matched your search
@@ -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 (
|
||||
<SectionCard title="概览">
|
||||
<SettingsRow
|
||||
label="线上地址"
|
||||
value={
|
||||
liveUrl ? (
|
||||
<a href={liveUrl} target="_blank" rel="noreferrer" className="text-primary underline underline-offset-2">
|
||||
{liveUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
) : (
|
||||
"未发布"
|
||||
)
|
||||
}
|
||||
note={detail.custom_domain ? null : "未绑自定义域名"}
|
||||
action={
|
||||
detail.custom_domain ? null : (
|
||||
<Button variant="outline" size="sm" render={<Link href={sectionHref(detail.id, "domain")} />}>
|
||||
绑定域名
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="状态"
|
||||
value={deployment ? getStatusView(deployment.status).label : "未发布"}
|
||||
note={deployment ? `${formatRelativeTime(deployment.updated_at)}发布` : null}
|
||||
action={
|
||||
deployment?.status === "suspended" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => resume.mutate(deployment.deployment_id)} disabled={resume.isPending}>
|
||||
恢复部署
|
||||
</Button>
|
||||
) : deployment != null ? (
|
||||
<DeployTabLink appId={detail.id} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="规格"
|
||||
value={deployment?.instance_type_code ?? "—"}
|
||||
note="重新发布时可换档"
|
||||
/>
|
||||
<SettingsRow
|
||||
label="托管库"
|
||||
value={
|
||||
detail.database
|
||||
? `线上库 ${formatBytes(detail.database.prod_bytes)} · 开发库 ${formatBytes(detail.database.dev_bytes)}`
|
||||
: "未创建"
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="会话"
|
||||
value={`${detail.session_count} 个`}
|
||||
note={detail.archived_session_count > 0 ? `已归档 ${detail.archived_session_count} 个` : null}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => newSession.mutate()}
|
||||
disabled={newSession.isPending}
|
||||
>
|
||||
新建会话
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="创建于"
|
||||
value={new Date(detail.created_at).toLocaleDateString("zh-CN")}
|
||||
note={detail.slug}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -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=<Link>',
|
||||
' 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')
|
||||
|
||||
@@ -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 元素写成 `<Tag>`;只写了名字的就是名字,`{...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)])
|
||||
|
||||
Reference in new issue
Block a user