Skip to content

Commit 74e4ca9

Browse files
committed
feat(config-generator): copy displayed configuration from detail view
Add a copy button to the config-desc document header that writes the currently visible lines of the selected config, format, and selector branch as plain text, with success and failure status feedback. The status resets when the config, format, or selector changes. Cover the clipboard round trip in the TUIC browser regression and assert the button in the reuse example regression. Assisted-by: opencode:deepseek-v4-flash
1 parent 641f6ba commit 74e4ca9

5 files changed

Lines changed: 32 additions & 4 deletions

File tree

‎config-generator/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Svelte 通过 WASM `Engine` 提交 `set`、`set-row`、`add`、`remove`、`gener
5353

5454
## 配置详解
5555

56-
可选的 `config-desc` 区块为独立的“配置详解”视图提供结构化示例和逐项说明。它不读取表单状态,也不参与配置投影、校验或导出;产品字段、示例值和说明仍全部留在 XML 中。页面左侧先选择 `config`、YAML/TOML 格式和该配置声明的任意 `selector`;右侧只显示 `when` 匹配的配置项。每行可用鼠标悬浮或键盘聚焦查看 `description`。前端 `ui/prism.ts` 用 Prism 仅注册 YAML/TOML 语法逐行高亮,颜色由 `--tok-*` 变量在明暗主题下定义;高亮只影响展示,XML 与 Rust 契约不变。
56+
可选的 `config-desc` 区块为独立的“配置详解”视图提供结构化示例和逐项说明。它不读取表单状态,也不参与配置投影、校验或导出;产品字段、示例值和说明仍全部留在 XML 中。页面左侧先选择 `config`、YAML/TOML 格式和该配置声明的任意 `selector`;右侧只显示 `when` 匹配的配置项。每行可用鼠标悬浮或键盘聚焦查看 `description`。前端 `ui/prism.ts` 用 Prism 仅注册 YAML/TOML 语法逐行高亮,颜色由 `--tok-*` 变量在明暗主题下定义;高亮只影响展示,XML 与 Rust 契约不变。右侧头部提供“复制配置”,复制当前 config、格式与 selector 过滤后可见的纯文本。
5757

5858
页面级方案来自 `schema/schemas.txt`,与单份 XML 内的输出和 `config-desc/config` 名称无关。页面可通过 `?schema=<schema-id>&mode=generate|detail` 直接选择应用 schema 和生成/详解视图;选择器与页签会同步这些参数并保留其他查询参数和片段。切换 schema 会创建全新会话,不复用上一应用的输入或凭据。
5959

‎config-generator/styles.css‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ footer { display: flex; justify-content: space-between; border-top: 1px solid va
9999
.cg-desc-document { min-width: 0; overflow: visible; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 4px 18px #14223404; }
100100
.cg-desc-document > header { display: flex; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--border); }
101101
.cg-desc-document > header span { color: var(--muted); font-size: 11px; }
102+
.cg-desc-meta { display: flex; align-items: center; gap: 12px; }
103+
.cg-desc-document header button { min-height: 30px; padding: 4px 10px; font-size: 12px; }
104+
.cg-desc-status { margin: 0; padding: 8px 20px 0; color: var(--muted); font-size: 11px; }
105+
.cg-desc-status:empty { display: none; }
102106
.cg-desc-code { padding: 14px 0; background: var(--subtle); border-radius: 0 0 12px 12px; }
103107
.cg-desc-line { position: relative; display: grid; width: 100%; grid-template-columns: 42px minmax(0, 1fr); min-height: 28px; align-items: center; padding: 1px 20px 1px 0; border: 0; border-radius: 0; background: transparent; color: var(--text); text-align: left; font-weight: 400; outline: 0; }
104108
.cg-desc-line:hover, .cg-desc-line:focus-visible { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); }
@@ -120,5 +124,5 @@ footer { display: flex; justify-content: space-between; border-top: 1px solid va
120124
.cg-header { padding: 16px 20px; align-items: flex-start; } .cg-brand-sub, .cg-header nav a, .cg-schema > span { display: none; } .cg-header nav { gap: 3px; font-size: 12px; flex-wrap: wrap; justify-content: flex-end; }
121125
#config-generator { padding: 28px 20px 20px; } h1 { font-size: 27px; } .cg-heading > p { font-size: 13px; } .cg-heading .cg-privacy { font-size: 10px; }
122126
.cg-modes button { min-width: 0; flex: 1; font-size: 12px; padding: 8px; } .cg-output { padding: 16px; } .cg-row-title { flex-wrap: wrap; }
123-
.cg-desc-document > header { display: block; } .cg-desc-document > header span { display: block; margin-top: 4px; } .cg-tooltip { left: 12px; width: calc(100vw - 64px); }
127+
.cg-desc-document > header { display: block; } .cg-desc-document > header span { display: block; margin-top: 4px; } .cg-desc-meta { display: block; } .cg-desc-meta button { margin-top: 8px; } .cg-tooltip { left: 12px; width: calc(100vw - 64px); }
124128
}

‎config-generator/ui/ConfigDescription.svelte‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,32 @@
66
let selected = $state('');
77
let selectedFormat = $state('yaml');
88
let selections = $state<Record<string, string>>({});
9+
let status = $state('');
910
const config = $derived(description.configs.find(item => item.name === selected) ?? description.configs[0]);
1011
const format = $derived(config?.formats.find(item => item.name === selectedFormat) ?? config?.formats[0]);
1112
const lines = $derived(format?.lines.filter(line => line.conditions.every(([name, value]) => selections[name] === value)) ?? []);
1213
const rendered = $derived(highlightLines(lines.map(line => line.text), format?.name ?? 'yaml'));
1314
1415
function selectConfig(name: string) {
1516
selected = name;
17+
status = '';
1618
const next = description.configs.find(item => item.name === name);
1719
selections = Object.fromEntries(next?.selectors.map(selector => [selector.name, selector.default]) ?? []);
1820
if (next && !next.formats.some(item => item.name === selectedFormat)) selectedFormat = next.formats[0]?.name ?? '';
1921
}
2022
2123
function setSelection(name: string, value: string) {
2224
selections = { ...selections, [name]: value };
25+
status = '';
26+
}
27+
28+
async function copy() {
29+
try {
30+
await navigator.clipboard.writeText(`${lines.map(line => line.text).join('\n')}\n`);
31+
status = '已复制当前显示的配置。';
32+
} catch {
33+
status = '浏览器未允许复制,请手动选择文本。';
34+
}
2335
}
2436
2537
$effect(() => {
@@ -47,7 +59,7 @@
4759
{#if config.formats.length > 1}
4860
<label class="cg-desc-selector" for="cg-desc-format">
4961
<span>配置格式</span>
50-
<select id="cg-desc-format" value={selectedFormat} onchange={(event) => selectedFormat = event.currentTarget.value}>
62+
<select id="cg-desc-format" value={selectedFormat} onchange={(event) => { selectedFormat = event.currentTarget.value; status = ''; }}>
5163
{#each config.formats as item (item.name)}<option value={item.name}>{item.label}</option>{/each}
5264
</select>
5365
</label>
@@ -66,7 +78,14 @@
6678
</aside>
6779
{#if config && format}
6880
<article class="cg-desc-document" aria-label={`${config.label} ${format.label} 配置详解`}>
69-
<header><strong>{config.filename}.{format.extension}</strong><span>悬浮或聚焦任意配置项查看说明</span></header>
81+
<header>
82+
<strong>{config.filename}.{format.extension}</strong>
83+
<div class="cg-desc-meta">
84+
<span>悬浮或聚焦任意配置项查看说明</span>
85+
<button type="button" onclick={copy}>复制配置</button>
86+
</div>
87+
</header>
88+
<p class="cg-desc-status" role="status" aria-live="polite">{status}</p>
7089
<div class="cg-desc-code">
7190
{#each lines as line, index (`${line.text}-${index}`)}
7291
<button type="button" class="cg-desc-line" aria-label={`${line.text}。${line.description}`}>

‎tests/config-generator/browser-generic.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ try {
3030
assert.equal(await page.getByRole('heading', { name: '任务清单生成器' }).count(), 1);
3131
await click('配置详解');
3232
assert.equal(await page.getByRole('heading', { name: '清单格式详解' }).count(), 1);
33+
assert.equal(await page.getByRole('button', { name: '复制配置', exact: true }).count(), 1);
3334
await id('desc-visibility').selectOption('private');
3435
assert.ok((await page.locator('.cg-desc-line').allTextContents()).some(line => line.includes('visibility: "private"')));
3536
assert.ok(await page.locator('.cg-desc-line code .token.atrule').count() > 0, 'YAML keys are highlighted');

‎tests/config-generator/browser.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ try {
3737
assert.ok(await page.locator('.cg-desc-line code .token.atrule').count() > 0, 'YAML keys are highlighted');
3838
await page.locator('#cg-desc-forward').selectOption('udp');
3939
assert.ok((await page.locator('.cg-desc-line').allTextContents()).some(line => line.includes('udp_forward:')));
40+
const visible = (await page.locator('.cg-desc-line code').allTextContents()).join('\n');
41+
await click('复制配置');
42+
const detailCopy = (await page.evaluate(() => navigator.clipboard.readText())).replace(/\r\n/g, '\n');
43+
assert.equal(detailCopy, `${visible}\n`);
4044
await id('desc-format').selectOption('toml');
4145
assert.equal(await page.locator('article[aria-label="客户端配置 TOML 配置详解"]').count(), 1);
4246
assert.equal(await page.locator('.cg-desc-document header strong').textContent(), 'client.toml');

0 commit comments

Comments
 (0)