@dsh-workbench/shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/blocker-table.d.ts +9 -0
- package/dist/api/blocker-table.js +130 -0
- package/dist/api/blocker.d.ts +30 -0
- package/dist/api/blocker.js +1 -0
- package/dist/api/context-ref.d.ts +10 -0
- package/dist/api/context-ref.js +40 -0
- package/dist/api/dsh-client.d.ts +170 -0
- package/dist/api/dsh-client.js +1 -0
- package/dist/api/dsh.d.ts +153 -0
- package/dist/api/dsh.js +1 -0
- package/dist/api/facts.d.ts +27 -0
- package/dist/api/facts.js +146 -0
- package/dist/api/host-write.d.ts +12 -0
- package/dist/api/host-write.js +17 -0
- package/dist/api/long-task.d.ts +18 -0
- package/dist/api/long-task.js +22 -0
- package/dist/api/unit-board.d.ts +14 -0
- package/dist/api/unit-board.js +29 -0
- package/dist/api/unit.d.ts +54 -0
- package/dist/api/unit.js +1 -0
- package/dist/api/workbench-client.d.ts +19 -0
- package/dist/api/workbench-client.js +33 -0
- package/dist/api/workbench-contract.d.ts +32 -0
- package/dist/api/workbench-contract.js +1 -0
- package/dist/components/ApprovalDetail.d.ts +27 -0
- package/dist/components/ApprovalDetail.js +33 -0
- package/dist/components/CardHead.d.ts +16 -0
- package/dist/components/CardHead.js +14 -0
- package/dist/components/ChoiceGroup.d.ts +14 -0
- package/dist/components/ChoiceGroup.js +7 -0
- package/dist/components/LongTaskRow.d.ts +16 -0
- package/dist/components/LongTaskRow.js +28 -0
- package/dist/components/MockStrip.d.ts +14 -0
- package/dist/components/MockStrip.js +7 -0
- package/dist/components/ResourceStates.d.ts +18 -0
- package/dist/components/ResourceStates.js +17 -0
- package/dist/components/SaveBar.d.ts +12 -0
- package/dist/components/SaveBar.js +6 -0
- package/dist/components/StateDot.d.ts +5 -0
- package/dist/components/StateDot.js +10 -0
- package/dist/components/board-module.d.ts +67 -0
- package/dist/components/board-module.js +1 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/icons.js +81 -0
- package/dist/config/constants.d.ts +27 -0
- package/dist/config/constants.js +27 -0
- package/dist/config/storage.d.ts +4 -0
- package/dist/config/storage.js +27 -0
- package/dist/host/__tests__/fakes.d.ts +41 -0
- package/dist/host/__tests__/fakes.js +76 -0
- package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
- package/dist/host/__tests__/preflight-fixtures.js +47 -0
- package/dist/host/cli-error.d.ts +8 -0
- package/dist/host/cli-error.js +70 -0
- package/dist/host/context-inject.d.ts +24 -0
- package/dist/host/context-inject.js +74 -0
- package/dist/host/data-root.d.ts +3 -0
- package/dist/host/data-root.js +12 -0
- package/dist/host/events.d.ts +9 -0
- package/dist/host/events.js +70 -0
- package/dist/host/facts.d.ts +24 -0
- package/dist/host/facts.js +44 -0
- package/dist/host/git.d.ts +55 -0
- package/dist/host/git.js +64 -0
- package/dist/host/http.d.ts +36 -0
- package/dist/host/http.js +242 -0
- package/dist/host/long-task.d.ts +58 -0
- package/dist/host/long-task.js +148 -0
- package/dist/host/mount-units.d.ts +52 -0
- package/dist/host/mount-units.js +186 -0
- package/dist/host/opencli-runtime.d.ts +22 -0
- package/dist/host/opencli-runtime.js +46 -0
- package/dist/host/opencli.d.ts +37 -0
- package/dist/host/opencli.js +152 -0
- package/dist/host/plugin-source.d.ts +31 -0
- package/dist/host/plugin-source.js +81 -0
- package/dist/host/preflight.d.ts +58 -0
- package/dist/host/preflight.js +233 -0
- package/dist/host/probe-cache.d.ts +6 -0
- package/dist/host/probe-cache.js +19 -0
- package/dist/host/probe-libs.d.ts +45 -0
- package/dist/host/probe-libs.js +61 -0
- package/dist/host/probe-units.d.ts +17 -0
- package/dist/host/probe-units.js +102 -0
- package/dist/host/probe-verdict.d.ts +7 -0
- package/dist/host/probe-verdict.js +143 -0
- package/dist/host/provision.d.ts +14 -0
- package/dist/host/provision.js +12 -0
- package/dist/host/shell.d.ts +14 -0
- package/dist/host/shell.js +23 -0
- package/dist/host/tool-approval.d.ts +29 -0
- package/dist/host/tool-approval.js +190 -0
- package/dist/host/tool-kit.d.ts +19 -0
- package/dist/host/tool-kit.js +37 -0
- package/dist/host/tool-scope.d.ts +32 -0
- package/dist/host/tool-scope.js +84 -0
- package/dist/host/unit-face.d.ts +66 -0
- package/dist/host/unit-face.js +1 -0
- package/dist/host/unit-libs.d.ts +37 -0
- package/dist/host/unit-libs.js +1 -0
- package/dist/host/unit-readiness.d.ts +7 -0
- package/dist/host/unit-readiness.js +5 -0
- package/dist/host/unit-registry.d.ts +9 -0
- package/dist/host/unit-registry.js +33 -0
- package/dist/host/value-handoff.d.ts +16 -0
- package/dist/host/value-handoff.js +21 -0
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host/workspace-store.d.ts +19 -0
- package/dist/host/workspace-store.js +133 -0
- package/dist/host/write-result.d.ts +1 -0
- package/dist/host/write-result.js +18 -0
- package/dist/host.d.ts +19 -0
- package/dist/host.js +19 -0
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/en.js +162 -0
- package/dist/i18n/index.d.ts +172 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/merge.d.ts +6 -0
- package/dist/i18n/merge.js +40 -0
- package/dist/i18n/zh.d.ts +163 -0
- package/dist/i18n/zh.js +162 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/lib/blocker-action.d.ts +13 -0
- package/dist/lib/blocker-action.js +12 -0
- package/dist/lib/busy.d.ts +5 -0
- package/dist/lib/busy.js +7 -0
- package/dist/lib/react.d.ts +10 -0
- package/dist/lib/react.js +9 -0
- package/dist/lib/text.d.ts +4 -0
- package/dist/lib/text.js +48 -0
- package/dist/lib/ui.d.ts +11 -0
- package/dist/lib/ui.js +37 -0
- package/package.json +44 -0
- package/src/api/__tests__/blocker-table.test.ts +253 -0
- package/src/api/__tests__/facts.test.ts +274 -0
- package/src/api/__tests__/host-write.test.ts +55 -0
- package/src/api/__tests__/long-task.test.ts +52 -0
- package/src/api/__tests__/unit-board.test.ts +76 -0
- package/src/api/blocker-table.ts +155 -0
- package/src/api/blocker.ts +25 -0
- package/src/api/context-ref.ts +47 -0
- package/src/api/dsh-client.ts +176 -0
- package/src/api/dsh.ts +156 -0
- package/src/api/facts.ts +166 -0
- package/src/api/host-write.ts +27 -0
- package/src/api/long-task.ts +38 -0
- package/src/api/unit-board.ts +44 -0
- package/src/api/unit.ts +59 -0
- package/src/api/workbench-client.test.ts +92 -0
- package/src/api/workbench-client.ts +59 -0
- package/src/api/workbench-contract.ts +40 -0
- package/src/components/ApprovalDetail.tsx +57 -0
- package/src/components/CardHead.tsx +74 -0
- package/src/components/ChoiceGroup.tsx +38 -0
- package/src/components/LongTaskRow.tsx +65 -0
- package/src/components/MockStrip.tsx +40 -0
- package/src/components/ResourceStates.tsx +44 -0
- package/src/components/SaveBar.tsx +31 -0
- package/src/components/StateDot.tsx +31 -0
- package/src/components/board-module.ts +73 -0
- package/src/components/icons.tsx +232 -0
- package/src/config/constants.test.ts +41 -0
- package/src/config/constants.ts +52 -0
- package/src/config/storage.test.ts +82 -0
- package/src/config/storage.ts +25 -0
- package/src/host/__tests__/fakes.ts +116 -0
- package/src/host/__tests__/opencli-runtime.test.ts +46 -0
- package/src/host/cli-error.ts +77 -0
- package/src/host/data-root.ts +13 -0
- package/src/host/facts.ts +69 -0
- package/src/host/git.ts +99 -0
- package/src/host/http.ts +311 -0
- package/src/host/long-task.ts +235 -0
- package/src/host/opencli-runtime.ts +66 -0
- package/src/host/opencli.ts +210 -0
- package/src/host/plugin-source.ts +145 -0
- package/src/host/provision.ts +34 -0
- package/src/host/shell.ts +42 -0
- package/src/host/tool-approval.ts +243 -0
- package/src/host/tool-kit.ts +56 -0
- package/src/host/unit-face.ts +65 -0
- package/src/host/unit-libs.ts +41 -0
- package/src/host/value-handoff.ts +38 -0
- package/src/host/workspace-store.ts +161 -0
- package/src/host/write-result.ts +16 -0
- package/src/host.ts +19 -0
- package/src/i18n/en.ts +170 -0
- package/src/i18n/index.test.ts +15 -0
- package/src/i18n/index.ts +12 -0
- package/src/i18n/merge.test.ts +60 -0
- package/src/i18n/merge.ts +54 -0
- package/src/i18n/zh.test.ts +40 -0
- package/src/i18n/zh.ts +170 -0
- package/src/index.ts +33 -0
- package/src/lib/blocker-action.test.ts +40 -0
- package/src/lib/blocker-action.ts +28 -0
- package/src/lib/busy.test.ts +27 -0
- package/src/lib/busy.ts +11 -0
- package/src/lib/react.test.ts +26 -0
- package/src/lib/react.ts +16 -0
- package/src/lib/text.test.ts +92 -0
- package/src/lib/text.ts +46 -0
- package/src/lib/ui.test.ts +93 -0
- package/src/lib/ui.ts +38 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Dictionaries } from './index';
|
|
2
|
+
|
|
3
|
+
export interface MessagesContribution {
|
|
4
|
+
readonly unit: string;
|
|
5
|
+
readonly messages?: Dictionaries | undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function assertSelfConsistent(unit: string, messages: Dictionaries): void {
|
|
9
|
+
const zhKeys = Object.keys(messages.zh).sort();
|
|
10
|
+
const enKeys = Object.keys(messages.en).sort();
|
|
11
|
+
if (zhKeys.length !== enKeys.length || zhKeys.some((key, index) => key !== enKeys[index])) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`套件 ${unit} 的文案两语言不同构:zh ${zhKeys.length} 键 / en ${enKeys.length} 键`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
for (const [language, dictionary] of [
|
|
17
|
+
['zh', messages.zh],
|
|
18
|
+
['en', messages.en],
|
|
19
|
+
] as const) {
|
|
20
|
+
for (const [key, value] of Object.entries(dictionary)) {
|
|
21
|
+
if (value.trim() === '') throw new Error(`套件 ${unit} 的文案 ${language}.${key} 是空的`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mergeMessages(
|
|
27
|
+
base: Dictionaries,
|
|
28
|
+
contributions: readonly MessagesContribution[],
|
|
29
|
+
): Dictionaries {
|
|
30
|
+
const zh: Record<string, string> = { ...base.zh };
|
|
31
|
+
const en: Record<string, string> = { ...base.en };
|
|
32
|
+
const owner = new Map<string, string>(Object.keys(base.zh).map((key) => [key, '外壳']));
|
|
33
|
+
|
|
34
|
+
for (const item of contributions) {
|
|
35
|
+
const messages = item.messages;
|
|
36
|
+
if (messages === undefined) continue;
|
|
37
|
+
assertSelfConsistent(item.unit, messages);
|
|
38
|
+
|
|
39
|
+
for (const [key, value] of Object.entries(messages.zh)) {
|
|
40
|
+
if (!key.startsWith(`${item.unit}.`)) {
|
|
41
|
+
throw new Error(`套件 ${item.unit} 的文案键必须以 "${item.unit}." 开头:${key}`);
|
|
42
|
+
}
|
|
43
|
+
const previous = owner.get(key);
|
|
44
|
+
if (previous !== undefined) {
|
|
45
|
+
throw new Error(`文案键冲突:${key} 由 ${previous} 与 ${item.unit} 同时声明`);
|
|
46
|
+
}
|
|
47
|
+
owner.set(key, item.unit);
|
|
48
|
+
zh[key] = value;
|
|
49
|
+
en[key] = messages.en[key] ?? '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { zh, en };
|
|
54
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { en } from './en';
|
|
3
|
+
import { zh } from './zh';
|
|
4
|
+
|
|
5
|
+
function placeholders(text: string): string[] {
|
|
6
|
+
return [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? '').sort();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const zhKeys = Object.keys(zh).sort();
|
|
10
|
+
const enKeys = Object.keys(en).sort();
|
|
11
|
+
|
|
12
|
+
describe('dictionaries', () => {
|
|
13
|
+
it('两种语言的键集合完全一致', () => {
|
|
14
|
+
expect(enKeys).toEqual(zhKeys);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('没有空文案(空值会静默显示成空白)', () => {
|
|
18
|
+
for (const [language, dictionary] of [['zh', zh], ['en', en]] as const) {
|
|
19
|
+
for (const [key, value] of Object.entries(dictionary)) {
|
|
20
|
+
expect(value.trim(), `${language}.${key}`).not.toBe('');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('同一个键在两语言里的占位符一致', () => {
|
|
26
|
+
for (const key of zhKeys) {
|
|
27
|
+
const zhText = (zh as Record<string, string>)[key] ?? '';
|
|
28
|
+
const enText = (en as Record<string, string>)[key] ?? '';
|
|
29
|
+
expect(placeholders(enText), key).toEqual(placeholders(zhText));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('复数键成对出现(`.one` 必须有 `.other`,否则英文会缺一档)', () => {
|
|
34
|
+
const oneKeys = zhKeys.filter((key) => key.endsWith('.one'));
|
|
35
|
+
expect(oneKeys.length).toBeGreaterThan(0);
|
|
36
|
+
for (const key of oneKeys) {
|
|
37
|
+
expect(zhKeys, key).toContain(`${key.slice(0, -'.one'.length)}.other`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/i18n/zh.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export const zh = {
|
|
2
|
+
'brand.name': '工作台',
|
|
3
|
+
|
|
4
|
+
'toggle.open': '打开侧边栏',
|
|
5
|
+
'toggle.collapse': '收起侧边栏',
|
|
6
|
+
'project.label': '切换项目',
|
|
7
|
+
'workspace.section': '工作区',
|
|
8
|
+
'project.none': '未选择项目',
|
|
9
|
+
'project.empty': '还没有项目。用下面的「添加项目」选一个目录。',
|
|
10
|
+
'project.add': '添加项目…',
|
|
11
|
+
'session.new': '新会话',
|
|
12
|
+
'session.new.label': '在当前项目新建会话',
|
|
13
|
+
'session.empty': '这个项目还没有会话。',
|
|
14
|
+
'session.noProject': '先添加一个项目。',
|
|
15
|
+
'session.pinned': '已置顶',
|
|
16
|
+
'session.actions': '{name} 的操作',
|
|
17
|
+
'status.idle': '空闲',
|
|
18
|
+
'status.running': '运行中',
|
|
19
|
+
'status.waitingApproval': '等待授权',
|
|
20
|
+
'status.planReview': '等待计划确认',
|
|
21
|
+
'status.waitingAnswer': '等待回答',
|
|
22
|
+
'status.completed': '已完成',
|
|
23
|
+
'status.subagentsRunning.one': '{n} 个子代理运行中',
|
|
24
|
+
'status.subagentsRunning.other': '{n} 个子代理运行中',
|
|
25
|
+
|
|
26
|
+
'time.now': '刚刚',
|
|
27
|
+
'time.minutes': '{n} 分钟前',
|
|
28
|
+
'time.hours': '{n} 小时前',
|
|
29
|
+
'time.days': '{n} 天前',
|
|
30
|
+
|
|
31
|
+
'panels.title': '面板模块',
|
|
32
|
+
'panels.reset': '恢复默认',
|
|
33
|
+
'panels.enabled': '这个项目已启用',
|
|
34
|
+
'panels.disabled': '这个项目未启用',
|
|
35
|
+
'panels.choose': '选择模块',
|
|
36
|
+
'panels.failed': '改配置失败了,再试一次。',
|
|
37
|
+
'panels.hintEffect': '停用 = agent 拿不到相关数据',
|
|
38
|
+
'panels.dragHint': '拖动调整顺序',
|
|
39
|
+
'panels.unknownStep': '这一层没有套件提供',
|
|
40
|
+
'panels.labelWithHint': '{label}({hint})',
|
|
41
|
+
'panels.dep.missingPlugin': '⚠ 还没装好',
|
|
42
|
+
'panels.dep.missingCommands': '⚠ 需要更新',
|
|
43
|
+
'panels.dep.unchecked': '⚠ 检测不了',
|
|
44
|
+
'panels.tab.modules': '面板配置',
|
|
45
|
+
'panels.tab.market': '工作台模块市场',
|
|
46
|
+
'panels.browseMarket': '浏览模块市场',
|
|
47
|
+
'panels.noneInstalled': '这些模块要的插件都还没装',
|
|
48
|
+
'panels.noneInstalledHint': '装好之后它们会回到这里,再由你决定放哪几个上板。',
|
|
49
|
+
'panels.market.installed': '已装',
|
|
50
|
+
'panels.market.outdated': '要更新',
|
|
51
|
+
'panels.market.hasNew': '有新版本',
|
|
52
|
+
'panels.market.missing': '未装',
|
|
53
|
+
'panels.market.unchecked': '状态未知',
|
|
54
|
+
'panels.market.checking': '检测中',
|
|
55
|
+
'panels.market.sectionInstalled': '已安装',
|
|
56
|
+
'panels.market.sectionPending': '未安装',
|
|
57
|
+
'panels.market.sectionShared': '共用依赖',
|
|
58
|
+
'panels.market.sharedUsedBy': '供 {modules} 使用',
|
|
59
|
+
'panels.market.listSeparator': '、',
|
|
60
|
+
'panels.market.sharedMissing': '未装',
|
|
61
|
+
'panels.market.sharedOutdated': '有新版',
|
|
62
|
+
'panels.market.sharedUnsatisfied': '版本不对',
|
|
63
|
+
'panels.market.sharedOk': '已装',
|
|
64
|
+
'panels.market.versionArrow': '{from} → {to}',
|
|
65
|
+
'panels.market.lead': '装在这台机器上,所有项目共用。',
|
|
66
|
+
'panels.market.install': '安装',
|
|
67
|
+
'panels.market.update': '更新',
|
|
68
|
+
'panels.market.uninstall': '卸载',
|
|
69
|
+
'panels.market.installing': '正在安装…',
|
|
70
|
+
'panels.market.updating': '正在更新…',
|
|
71
|
+
'panels.market.uninstalling': '正在卸载…',
|
|
72
|
+
'panels.market.confirmUninstall': '要卸载吗?',
|
|
73
|
+
'panels.market.confirmUninstallDetail':
|
|
74
|
+
'卸载后这台机器上就没有它了,所有项目的面板也会把它一起拿掉(绑定与设置不会丢)。',
|
|
75
|
+
'panels.market.confirmYes': '确定卸载',
|
|
76
|
+
'panels.market.confirmNo': '取消',
|
|
77
|
+
'panels.market.failed': '没成功:{reason}',
|
|
78
|
+
'panels.market.needEnv': '先在启动工作台的终端里设 {env}',
|
|
79
|
+
'panels.market.noSource': '这个模块没声明怎么装,只能自己动手',
|
|
80
|
+
'panels.market.badSource': '安装方式没写全,只能自己动手',
|
|
81
|
+
'panels.market.empty': '暂无可用模块',
|
|
82
|
+
'panels.market.foot': '装好后到那个项目的面板页勾选它,模块才会出现。',
|
|
83
|
+
'panels.market.recheck': '重新检查更新',
|
|
84
|
+
'panels.market.checkUpdates': '检查更新',
|
|
85
|
+
'panels.market.checkedAt': '最近检查:{time}',
|
|
86
|
+
'panels.market.justNow': '刚刚',
|
|
87
|
+
'panels.market.minutesAgo': '{n} 分钟前',
|
|
88
|
+
'panels.market.hoursAgo': '{n} 小时前',
|
|
89
|
+
'blocker.fix.install': '帮我安装',
|
|
90
|
+
'blocker.fix.update': '帮我升级',
|
|
91
|
+
'blocker.fix.running': '正在执行…',
|
|
92
|
+
'blocker.fix.failed': '执行失败:',
|
|
93
|
+
'blocker.retry': '重试',
|
|
94
|
+
'blocker.retry.running': '重试中…',
|
|
95
|
+
'blocker.copy': '复制命令',
|
|
96
|
+
'blocker.market.open': '去模块市场',
|
|
97
|
+
'blocker.login.title': '需要登录 {platform}',
|
|
98
|
+
'blocker.login.titleGeneric': '需要登录',
|
|
99
|
+
'blocker.login.hint': '工作台用你的浏览器登录态;登录一次就行,之后不用再登。',
|
|
100
|
+
'blocker.login.go': '去登录',
|
|
101
|
+
'blocker.login.manual': '在终端执行 `opencli coding team-current`,按提示完成登录后重试。',
|
|
102
|
+
'blocker.forbidden.title': '没有权限访问 {platform}',
|
|
103
|
+
'blocker.forbidden.titleGeneric': '没有权限',
|
|
104
|
+
'blocker.forbidden.hint': '账号已登录,但没有这个项目/仓库的权限;申请之后再试。',
|
|
105
|
+
'blocker.forbidden.open': '打开权限页',
|
|
106
|
+
'blocker.timeout.hint': '无头浏览器可能没起来,重试一次通常就好。',
|
|
107
|
+
'blocker.exec.hint': '确认 opencli 已安装且在 PATH 里(which opencli)。',
|
|
108
|
+
'blocker.unknown.title': '出了点问题',
|
|
109
|
+
'panels.reading': '正在读取面板配置…',
|
|
110
|
+
'panels.empty': '面板里还没有模块',
|
|
111
|
+
'panels.emptyHint': '也可以直接跟 agent 说:「把某个模块加到面板」。',
|
|
112
|
+
'env.title': '准备工作台',
|
|
113
|
+
'env.checking': '正在检测运行环境…',
|
|
114
|
+
'env.installing': '正在安装缺失的依赖…',
|
|
115
|
+
'env.failed': '还差一步就能用了',
|
|
116
|
+
'env.elapsed': '已等待 {seconds} 秒',
|
|
117
|
+
'env.stepFailed.install': '装不上',
|
|
118
|
+
'env.stepFailed.path': '装了但还找不到',
|
|
119
|
+
'env.stepFailed.shell': '执行通道不可用',
|
|
120
|
+
'env.bridge.ok': '已连接',
|
|
121
|
+
'env.bridge.short': '没连上',
|
|
122
|
+
'env.bridge.why': '面板的数据要走你浏览器里的登录态:这个通道没连上,模块就取不到数据(不拦路,但先修好它)。',
|
|
123
|
+
'env.bridge.daemon': '后台服务没起来:正常它会自己起,先在终端跑下面这条命令。',
|
|
124
|
+
'env.bridge.extension': '浏览器扩展没连上:在 Chrome 应用商店装一次就好(装完回来点「重试」)。已经装过的话,先跑下面这条命令。',
|
|
125
|
+
'env.bridge.extensionLink': '去 Chrome 应用商店装扩展',
|
|
126
|
+
'env.bridge.profile': '连上了多个浏览器 profile,但没选默认的那个:先看有哪些(下面这条命令),再选一个。',
|
|
127
|
+
'env.bridge.stale': '默认的浏览器 profile 已经失效(它记的是上一次的扩展实例):重新选一个。',
|
|
128
|
+
'env.bridge.unknown': '没认出是哪种问题:下面原始输出里有 opencli 自己给的步骤。',
|
|
129
|
+
'env.fix.title': '怎么解决',
|
|
130
|
+
'env.fix.why': '工作台用它自己那份 opencli(与你在终端里那套分开装,互不影响),这台机器上还没装好它。',
|
|
131
|
+
'env.fix.whyPath': 'opencli 装上了,但当前环境还找不到它 —— 通常是 PATH 或终端会话的问题。',
|
|
132
|
+
'env.fix.whyShell': '工作台拿不到执行命令的通道,所以取不了数据 —— 这一步用户自己装不了。',
|
|
133
|
+
'env.fix.step': '在终端里运行下面这条命令,装好之后点「重试」:',
|
|
134
|
+
'env.fix.stepPath': '新开一个终端窗口(或确认 npm 的全局 bin 目录在 PATH 里),然后点「重试」。',
|
|
135
|
+
'env.fix.stepShell': '重启一次工作台(或 dsh);如果反复出现,说明执行通道没配好。',
|
|
136
|
+
'env.fix.permission': '权限不够:在命令前加 sudo 重跑,或先把 npm 的全局目录配到你的用户目录。',
|
|
137
|
+
'env.fix.network': '网络不通:检查网络或代理设置后重试。',
|
|
138
|
+
'env.fix.details': '详细输出(排障用)',
|
|
139
|
+
'env.retry': '重试',
|
|
140
|
+
'env.copyCommand': '复制命令',
|
|
141
|
+
'env.copied': '已复制',
|
|
142
|
+
'env.step.opencli': '检测工作台的 opencli',
|
|
143
|
+
'env.step.codingPlugin': '检测 coding 插件',
|
|
144
|
+
'env.step.bridge': '检测浏览器通道',
|
|
145
|
+
'env.step.warm': '预热站点登录态',
|
|
146
|
+
'env.warm.done': '已预热 {n} 个站点',
|
|
147
|
+
'env.warm.short': '这次没热上 —— 不影响用,面板第一次读取会自己补',
|
|
148
|
+
'env.unit.missingPlugin': '这个模块还没装好',
|
|
149
|
+
'env.unit.missingCommandsHint': '更新之后就能用了。',
|
|
150
|
+
'env.unit.unchecked': '检测不了:拿不到执行通道',
|
|
151
|
+
'env.unit.uncheckedHint': '不是这个模块的问题,是执行通道不可用 —— 重启一次工作台再试。',
|
|
152
|
+
'env.unit.missingCommands': '这个模块的版本旧了',
|
|
153
|
+
'env.unit.hint': '装好之后点重试;不影响其它模块。',
|
|
154
|
+
'rightbar.label': '数据面板',
|
|
155
|
+
'rightbar.expand': '展开数据面板',
|
|
156
|
+
'rightbar.collapse': '收起数据面板',
|
|
157
|
+
'rightbar.refresh': '刷新',
|
|
158
|
+
'rightbar.refreshing': '刷新中…',
|
|
159
|
+
'rightbar.updatedAt': '更新于 {time}',
|
|
160
|
+
'rightbar.noProject': '当前会话没有所属项目',
|
|
161
|
+
'coding.notARepo.title': '这个项目不是 Coding 仓库',
|
|
162
|
+
'error.unknown': '出错了',
|
|
163
|
+
|
|
164
|
+
'board.title': '看板',
|
|
165
|
+
|
|
166
|
+
'panel.conversation': '对话',
|
|
167
|
+
'panels.label': '全局面板',
|
|
168
|
+
} as const;
|
|
169
|
+
|
|
170
|
+
export type WorkbenchMessages = typeof zh;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export * from './api/blocker-table';
|
|
2
|
+
export * from './api/blocker';
|
|
3
|
+
export * from './api/context-ref';
|
|
4
|
+
export * from './api/dsh-client';
|
|
5
|
+
export * from './api/dsh';
|
|
6
|
+
export * from './api/facts';
|
|
7
|
+
export * from './api/host-write';
|
|
8
|
+
export * from './api/long-task';
|
|
9
|
+
export * from './api/unit-board';
|
|
10
|
+
export * from './api/unit';
|
|
11
|
+
export * from './api/workbench-client';
|
|
12
|
+
export * from './api/workbench-contract';
|
|
13
|
+
export * from './components/ApprovalDetail';
|
|
14
|
+
export * from './components/CardHead';
|
|
15
|
+
export * from './components/ChoiceGroup';
|
|
16
|
+
export * from './components/LongTaskRow';
|
|
17
|
+
export * from './components/MockStrip';
|
|
18
|
+
export * from './components/ResourceStates';
|
|
19
|
+
export * from './components/SaveBar';
|
|
20
|
+
export * from './components/StateDot';
|
|
21
|
+
export * from './components/board-module';
|
|
22
|
+
export * from './components/icons';
|
|
23
|
+
export * from './config/constants';
|
|
24
|
+
export * from './config/storage';
|
|
25
|
+
export * from './i18n/en';
|
|
26
|
+
export * from './i18n/index';
|
|
27
|
+
export * from './i18n/merge';
|
|
28
|
+
export * from './i18n/zh';
|
|
29
|
+
export * from './lib/blocker-action';
|
|
30
|
+
export * from './lib/busy';
|
|
31
|
+
export * from './lib/react';
|
|
32
|
+
export * from './lib/text';
|
|
33
|
+
export * from './lib/ui';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { blockerActionLabel, blockerPending } from './blocker-action';
|
|
3
|
+
|
|
4
|
+
const t = (key: string): string => `«${key}»`;
|
|
5
|
+
|
|
6
|
+
const RETRY = { kind: 'retry', label: '重试' } as const;
|
|
7
|
+
const LOGIN = { kind: 'open-url', label: '去登录', url: 'https://cmdb.example/login', inline: true } as const;
|
|
8
|
+
const COPY = { kind: 'copy', label: '复制命令', text: 'opencli cmdb envs' } as const;
|
|
9
|
+
const FIX = { kind: 'run-fix', label: '帮我安装', unit: 'cmdb', fix: 'install' } as const;
|
|
10
|
+
|
|
11
|
+
const idle = { retrying: false, running: null, copied: null } as const;
|
|
12
|
+
|
|
13
|
+
describe('阻断态那颗按钮说什么', () => {
|
|
14
|
+
it('★ 忙的时候「重试」自己说「重试中…」(用户点的就是它)', () => {
|
|
15
|
+
expect(blockerActionLabel(RETRY, { ...idle, retrying: true }, t)).toBe('«blocker.retry.running»');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('不忙就是原文(不假装忙)', () => {
|
|
19
|
+
expect(blockerActionLabel(RETRY, idle, t)).toBe('重试');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('★ 忙的不是重试本身(安装在路上)→ 「重试」不许跟着说「重试中…」', () => {
|
|
23
|
+
expect(blockerActionLabel(RETRY, { ...idle, running: '帮我安装' }, t)).toBe('重试');
|
|
24
|
+
expect(blockerActionLabel(LOGIN, { ...idle, retrying: true }, t)).toBe('去登录');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('安装在路上 → 那颗说「正在执行…」', () => {
|
|
28
|
+
expect(blockerActionLabel(FIX, { ...idle, running: '帮我安装' }, t)).toBe('«blocker.fix.running»');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('复制成功那一下说「已复制」', () => {
|
|
32
|
+
expect(blockerActionLabel(COPY, { ...idle, copied: 'opencli cmdb envs' }, t)).toBe('«env.copied»');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('★ 忙 = 卡片在刷 || 本地动作在跑(少算一半,按钮就又哑了)', () => {
|
|
36
|
+
expect(blockerPending(true, null)).toBe(true);
|
|
37
|
+
expect(blockerPending(false, '帮我安装')).toBe(true);
|
|
38
|
+
expect(blockerPending(false, null)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type BlockerLabelTranslate = (key: string) => string;
|
|
2
|
+
|
|
3
|
+
export interface BlockerActionShape {
|
|
4
|
+
readonly kind: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly text?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BlockerLabelState {
|
|
10
|
+
readonly retrying: boolean;
|
|
11
|
+
readonly running: string | null;
|
|
12
|
+
readonly copied: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function blockerPending(busy: boolean, running: string | null): boolean {
|
|
16
|
+
return busy || running !== null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function blockerActionLabel(
|
|
20
|
+
action: BlockerActionShape,
|
|
21
|
+
state: BlockerLabelState,
|
|
22
|
+
t: BlockerLabelTranslate,
|
|
23
|
+
): string {
|
|
24
|
+
if (action.kind === 'retry' && state.retrying) return t('blocker.retry.running');
|
|
25
|
+
if (state.running === action.label) return t('blocker.fix.running');
|
|
26
|
+
if (action.kind === 'copy' && state.copied === action.text) return t('env.copied');
|
|
27
|
+
return action.label;
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { holdBusy } from './busy';
|
|
3
|
+
|
|
4
|
+
describe('holdBusy', () => {
|
|
5
|
+
it('这一轮忙刚开了头:还要按住,剩下的时长报出来(计时器按它收尾)', () => {
|
|
6
|
+
expect(holdBusy(1000, 1000, 500)).toEqual({ visible: true, releaseInMs: 500 });
|
|
7
|
+
expect(holdBusy(1000, 1200, 500)).toEqual({ visible: true, releaseInMs: 300 });
|
|
8
|
+
expect(holdBusy(1000, 1499, 500)).toEqual({ visible: true, releaseInMs: 1 });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('★ 差一毫秒还按住,正好到点就放 —— 下限是"至少看这么久"', () => {
|
|
12
|
+
expect(holdBusy(1000, 1499, 500).visible).toBe(true);
|
|
13
|
+
expect(holdBusy(1000, 1500, 500)).toEqual({ visible: false, releaseInMs: 0 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('慢读(本来就超过下限)一下都不多按:真实时长说了算', () => {
|
|
17
|
+
expect(holdBusy(1000, 7000, 500)).toEqual({ visible: false, releaseInMs: 0 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('没开过这一轮忙 → 什么都不按(挂载第一帧、enabled=false 的那些读者)', () => {
|
|
21
|
+
expect(holdBusy(null, 1000, 500)).toEqual({ visible: false, releaseInMs: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('时钟倒退(now 比开头还早)按"刚开头"算,不出现负的剩余时长', () => {
|
|
25
|
+
expect(holdBusy(1000, 900, 500)).toEqual({ visible: true, releaseInMs: 500 });
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/lib/busy.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface BusyHold {
|
|
2
|
+
readonly visible: boolean;
|
|
3
|
+
readonly releaseInMs: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function holdBusy(since: number | null, now: number, floorMs: number): BusyHold {
|
|
7
|
+
if (since === null) return { visible: false, releaseInMs: 0 };
|
|
8
|
+
const elapsed = Math.max(0, now - since);
|
|
9
|
+
const left = floorMs - elapsed;
|
|
10
|
+
return left <= 0 ? { visible: false, releaseInMs: 0 } : { visible: true, releaseInMs: left };
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { Fragment, h, useCallback, useEffect, useMemo, useRef, useState } from './react';
|
|
4
|
+
|
|
5
|
+
describe('react 入口', () => {
|
|
6
|
+
it('h 就是 React.createElement(同一个引用,不是包装)', () => {
|
|
7
|
+
expect(h).toBe(React.createElement);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('h 造出真正的 React 元素', () => {
|
|
11
|
+
const element = h('div', { className: 'x' }, 'hi') as { type: string; props: Record<string, unknown> };
|
|
12
|
+
expect(element.type).toBe('div');
|
|
13
|
+
expect(element.props.className).toBe('x');
|
|
14
|
+
expect(element.props.children).toBe('hi');
|
|
15
|
+
expect(React.isValidElement(element)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('Fragment 与 hooks 都是 React 自己的', () => {
|
|
19
|
+
expect(Fragment).toBe(React.Fragment);
|
|
20
|
+
expect(useState).toBe(React.useState);
|
|
21
|
+
expect(useEffect).toBe(React.useEffect);
|
|
22
|
+
expect(useMemo).toBe(React.useMemo);
|
|
23
|
+
expect(useCallback).toBe(React.useCallback);
|
|
24
|
+
expect(useRef).toBe(React.useRef);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/lib/react.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type * as ReactTypes from 'react';
|
|
2
|
+
|
|
3
|
+
const react = require('react') as typeof ReactTypes;
|
|
4
|
+
|
|
5
|
+
export const h = react.createElement;
|
|
6
|
+
|
|
7
|
+
export const Fragment = react.Fragment;
|
|
8
|
+
|
|
9
|
+
export const useState = react.useState;
|
|
10
|
+
export const useEffect = react.useEffect;
|
|
11
|
+
export const useMemo = react.useMemo;
|
|
12
|
+
export const useCallback = react.useCallback;
|
|
13
|
+
export const useRef = react.useRef;
|
|
14
|
+
export const useSyncExternalStore = react.useSyncExternalStore;
|
|
15
|
+
|
|
16
|
+
export type { ReactNode, CSSProperties, ChangeEvent } from 'react';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { excerpt, parseJsonLoose, quoteShellArg, tailLines } from './text';
|
|
4
|
+
|
|
5
|
+
function shellSees(value: string): string {
|
|
6
|
+
return execFileSync('/bin/sh', ['-c', `printf '%s' ${quoteShellArg(value)}`], {
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('quoteShellArg', () => {
|
|
12
|
+
it('普通值原样往返', () => {
|
|
13
|
+
expect(shellSees('example-monorepo')).toBe('example-monorepo');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('含空格的路径不被拆成两个参数', () => {
|
|
17
|
+
expect(shellSees('/Volumes/My Projects/app')).toBe('/Volumes/My Projects/app');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('单引号被正确转义(否则就是注入面)', () => {
|
|
21
|
+
expect(shellSees("it's")).toBe("it's");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('shell 元字符按字面量传递', () => {
|
|
25
|
+
for (const raw of ['$(whoami)', '`id`', 'a; rm -rf /', 'a && b', '*', '"double"', '\\n']) {
|
|
26
|
+
expect(shellSees(raw), raw).toBe(raw);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('tailLines', () => {
|
|
32
|
+
it('取末尾若干行并去掉首尾空行', () => {
|
|
33
|
+
expect(tailLines('\n a \n b \n', 2)).toBe('a \n b');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('行数不够时全给', () => {
|
|
37
|
+
expect(tailLines('only', 6)).toBe('only');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('空输入给空串', () => {
|
|
41
|
+
expect(tailLines(undefined, 3)).toBe('');
|
|
42
|
+
expect(tailLines(' ', 3)).toBe('');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('parseJsonLoose', () => {
|
|
47
|
+
it('干净 JSON 直接解析', () => {
|
|
48
|
+
expect(parseJsonLoose('{"a":1}')).toEqual({ a: 1 });
|
|
49
|
+
expect(parseJsonLoose('[1,2]')).toEqual([1, 2]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('前面有噪音时从第一个结构符开始解析', () => {
|
|
53
|
+
expect(parseJsonLoose('正在检查更新…\n[{"code":2133}]')).toEqual([{ code: 2133 }]);
|
|
54
|
+
expect(parseJsonLoose('warn: x\n{"ok":true}')).toEqual({ ok: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('尾部有噪音时截到最后闭合符', () => {
|
|
58
|
+
expect(parseJsonLoose('[1,2]\n完成')).toEqual([1, 2]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('解析不出来给 undefined,让调用方报 coding-bad-output', () => {
|
|
62
|
+
expect(parseJsonLoose('')).toBeUndefined();
|
|
63
|
+
expect(parseJsonLoose('not json at all')).toBeUndefined();
|
|
64
|
+
expect(parseJsonLoose('[1,2')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('excerpt', () => {
|
|
69
|
+
it('剥掉图片语法(它没有文字信息)', () => {
|
|
70
|
+
expect(excerpt('', 80)).toBe('');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('链接只留可见文字,标记符号压平', () => {
|
|
74
|
+
expect(excerpt('见 [设计稿](https://x/y) 与 **重点**', 80)).toBe('见 设计稿 与 重点');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('压平换行与多余空白,模板正文变成一行', () => {
|
|
78
|
+
expect(excerpt('\n【复现步骤】\n\n\n【预期结果】\n', 80)).toBe('【复现步骤】 【预期结果】');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('超长时按字符截断并加省略号', () => {
|
|
82
|
+
const long = '一二三四五六七八九十'.repeat(10);
|
|
83
|
+
const out = excerpt(long, 12);
|
|
84
|
+
expect(out.endsWith('…')).toBe(true);
|
|
85
|
+
expect(out.length).toBe(13);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('空输入给空串', () => {
|
|
89
|
+
expect(excerpt(undefined, 20)).toBe('');
|
|
90
|
+
expect(excerpt(' ', 20)).toBe('');
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/lib/text.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function quoteShellArg(value: unknown): string {
|
|
2
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function tailLines(text: string | null | undefined, lines = 6): string {
|
|
6
|
+
const all = String(text ?? '')
|
|
7
|
+
.trim()
|
|
8
|
+
.split('\n');
|
|
9
|
+
return all.slice(-lines).join('\n');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseJsonLoose(text: string | null | undefined): unknown {
|
|
13
|
+
const raw = String(text ?? '').trim();
|
|
14
|
+
if (!raw) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw) as unknown;
|
|
17
|
+
} catch {
|
|
18
|
+
const starts = [raw.indexOf('['), raw.indexOf('{')].filter((index) => index >= 0);
|
|
19
|
+
if (starts.length === 0) return undefined;
|
|
20
|
+
const from = Math.min(...starts);
|
|
21
|
+
const open = raw[from];
|
|
22
|
+
const close = open === '[' ? ']' : '}';
|
|
23
|
+
const to = raw.lastIndexOf(close);
|
|
24
|
+
if (to <= from) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(raw.slice(from, to + 1)) as unknown;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MARKDOWN_IMAGE = /!\[[^\]]*\]\([^)]*\)/g;
|
|
34
|
+
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
|
35
|
+
const MARKDOWN_MARKS = /[*`_>#]/g;
|
|
36
|
+
|
|
37
|
+
export function excerpt(text: string | null | undefined, limit = 80): string {
|
|
38
|
+
const flat = String(text ?? '')
|
|
39
|
+
.replace(MARKDOWN_IMAGE, ' ')
|
|
40
|
+
.replace(MARKDOWN_LINK, '$1')
|
|
41
|
+
.replace(MARKDOWN_MARKS, ' ')
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
if (flat.length <= limit) return flat;
|
|
45
|
+
return `${flat.slice(0, limit).trimEnd()}…`;
|
|
46
|
+
}
|