✨ 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()',
|
' 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 处返回',
|
'渲染: 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.length > 0 时⟧ ul ⟦样式 2 个类|⟧',
|
||||||
' ⟦|对 effective 的每一项 { item, label }⟧ key item.id',
|
' ⟦|对 effective 的每一项 { item, label }⟧ key item.id',
|
||||||
])
|
])
|
||||||
expect(rowLines(await card(settings, 'RuntimeSection'), '渲染')).toContain(' p ⟦样式 3 个类|⟧ {w.subdir} · {STATE_NOTE[w.state]}')
|
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 () => {
|
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元写成是 / 否两支', async () => {
|
||||||
const lines = rowLines(await card(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'), '渲染')
|
const lines = rowLines(await card(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'), '渲染')
|
||||||
const button = lines.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=非 ready 或者 pending')
|
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 { 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 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'
|
import { hint, joined, name, plain, range } from './segments'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,7 +11,8 @@ import { hint, joined, name, plain, range } from './segments'
|
|||||||
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
|
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
|
||||||
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
|
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
|
||||||
* 点名称展开它的子卡片)、渲染(返回的 JSX 画成缩进的结构大纲:原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」,
|
* 点名称展开它的子卡片)、渲染(返回的 JSX 画成缩进的结构大纲:原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」,
|
||||||
* `cond &&` 写「当 … 时」,三元分「是 / 否」两支,`.map` 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时 不渲染」)。
|
* `cond &&` 写「当 … 时」,三元分「是 / 否」两支,`.map` 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时 不渲染」。
|
||||||
|
* 属性值是元素、条件或列表时不写进元素那一行:属性名单独成行,值照同一套大纲缩进在下面;字符串、数字这些短属性留在元素那一行)。
|
||||||
* 讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
|
* 讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
|
||||||
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
|
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
|
||||||
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
|
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
|
||||||
@@ -814,22 +815,42 @@ function tagName(nameNode: Node | undefined, component: boolean): Inline[] {
|
|||||||
return parts(nameNode)
|
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[] {
|
function attributeText(attribute: Node, env: Env): Inline[] {
|
||||||
const { context, words } = env
|
const { context, words } = env
|
||||||
if (attribute.type === 'jsx_expression')
|
if (attribute.type === 'jsx_expression')
|
||||||
return [plain('{'), ...context.translate(named(attribute).find(child => child.type !== 'comment') ?? attribute), plain('}')]
|
return [plain('{'), ...context.translate(named(attribute).find(child => child.type !== 'comment') ?? attribute), plain('}')]
|
||||||
const nameNode = named(attribute)[0]
|
|
||||||
const value = attributeValue(attribute)
|
const value = attributeValue(attribute)
|
||||||
const head: Inline = { text: nameNode?.text ?? '', role: 'variable', ...(nameNode && { offset: nameNode.startIndex }) }
|
const head = attributeHead(attribute)
|
||||||
if (!value)
|
if (!value)
|
||||||
return [head]
|
return [head]
|
||||||
const inner = value.type === 'jsx_expression' ? unwrap(named(value).find(child => child.type !== 'comment')) : value
|
const inner = value.type === 'jsx_expression' ? unwrap(named(value).find(child => child.type !== 'comment')) : value
|
||||||
let shown: Inline[]
|
let shown: Inline[]
|
||||||
if (!inner)
|
if (!inner)
|
||||||
shown = context.translate(value)
|
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)
|
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))]
|
shown = [hint(words.object), ...joined(named(inner).flatMap(pair => pair.childForFieldName('key') ? [[name(pair.childForFieldName('key') as Node, 'variable')]] : []), hint(words.listSeparator))]
|
||||||
else
|
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 : []))
|
return nodes.flatMap((node, index) => outlineNode(node, env, index === 0 ? prefix : []))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 大纲的一个节点:元素一行(子元素在下一层)、文字、JSX 注释、三元(下面是 / 否两支)、列表(下面是每一项渲染的)、空。 */
|
/** 大纲的一个节点:元素一行(子元素、以及值是结构的属性,在下一层)、文字、JSX 注释、三元(下面是 / 否两支)、列表(下面是每一项渲染的)、空。 */
|
||||||
function outlineNode(node: OutlineNode, env: Env, prefix: Inline[]): PropertyItem[] {
|
function outlineNode(node: OutlineNode, env: Env, prefix: Inline[]): PropertyItem[] {
|
||||||
const { context, words } = env
|
const { context, words } = env
|
||||||
const when = 'when' in node && node.when ? [pill('condition', whenText(node.when, 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) {
|
switch (node.kind) {
|
||||||
case 'element': {
|
case 'element': {
|
||||||
const chip = node.classes && classesChip(node.classes, env)
|
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([
|
return line([
|
||||||
tagName(node.name, node.component),
|
tagName(node.name, node.component),
|
||||||
node.name ? [] : [hint(words.fragment)],
|
node.name ? [] : [hint(words.fragment)],
|
||||||
chip ? [chip] : [],
|
chip ? [chip] : [],
|
||||||
joined(node.attributes.map(attribute => attributeText(attribute, env)), plain(' ')),
|
joined(inline, plain(' ')),
|
||||||
node.text.length > 0 ? inlineText(node.text, env) : [],
|
node.text.length > 0 ? inlineText(node.text, env) : [],
|
||||||
], outlineItems(node.children, env))
|
], [...folded, ...outlineItems(node.children, env)])
|
||||||
}
|
}
|
||||||
case 'text':
|
case 'text':
|
||||||
return line([inlineText(node.nodes, env)])
|
return line([inlineText(node.nodes, env)])
|
||||||
|
|||||||
Reference in new issue
Block a user