dsh-config-manager 0.1.85 → 0.2.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/lib/cli/web/page.js +2 -2
- package/lib/client.d.ts +86 -1
- package/lib/client.js +2365 -772
- package/lib/core/native-path-picker.d.ts +49 -0
- package/lib/core/native-path-picker.js +475 -0
- package/lib/core/path-browse.d.ts +42 -0
- package/lib/core/path-browse.js +134 -0
- package/lib/foreign/claude-sessions.d.ts +1 -1
- package/lib/foreign/claude-sessions.js +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +3 -3
- package/lib/market/security.js +1 -1
- package/lib/market/types.d.ts +1 -1
- package/lib/market/types.js +1 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/index.js +2 -0
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/path-browse.d.ts +3 -0
- package/lib/routes/path-browse.js +63 -0
- package/lib/security/secret-scanner.js +1 -1
- package/lib/sync/git/git-transport.js +18 -3
- package/lib/ui/sync-settings-view.d.ts +18 -0
- package/lib/ui/sync-settings-view.js +20 -0
- package/lib/utils/workdir-lock.d.ts +26 -2
- package/lib/utils/workdir-lock.js +133 -5
- package/package.json +2 -2
- package/src/cli/web/page.ts +2 -2
- package/src/client/ConfigManagerSection.tsx +20 -17
- package/src/client/api.ts +60 -1
- package/src/client/bundle-selfcontained.test.ts +9 -0
- package/src/client/chat/chat-entry-view.test.ts +1 -1
- package/src/client/chat/rollback-entry-view.test.ts +14 -0
- package/src/client/chat/rollback-entry-view.ts +2 -1
- package/src/client/common/CopyButton.tsx +21 -2
- package/src/client/common/Icon.tsx +7 -0
- package/src/client/common/routes.ts +5 -0
- package/src/client/config-manager.module.css +70 -0
- package/src/client/import/ImportWizardView.tsx +1 -0
- package/src/client/import/PathMappingForm.tsx +62 -8
- package/src/client/import/PathPickerDialog.tsx +172 -0
- package/src/client/import/dsh-directory-pick.test.ts +55 -0
- package/src/client/import/dsh-directory-pick.ts +55 -0
- package/src/client/import/import-wizard-steps.tsx +5 -2
- package/src/client/index.ts +12 -1
- package/src/client/locales.ts +25 -0
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/sync/SyncPanel.tsx +4 -4
- package/src/client/sync/sync-view.ts +1 -1
- package/src/core/native-path-picker.test.ts +118 -0
- package/src/core/native-path-picker.ts +517 -0
- package/src/core/path-browse.test.ts +88 -0
- package/src/core/path-browse.ts +166 -0
- package/src/foreign/claude-sessions.ts +3 -3
- package/src/index.ts +3 -3
- package/src/market/security.ts +1 -1
- package/src/market/types.ts +1 -1
- package/src/routes/index.ts +3 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/path-browse.ts +70 -0
- package/src/security/secret-scanner.test.ts +1 -1
- package/src/security/secret-scanner.ts +1 -1
- package/src/sync/git/git-transport.test.ts +32 -0
- package/src/sync/git/git-transport.ts +20 -4
- package/src/ui/session-issue-labels.ts +1 -1
- package/src/ui/sync-settings-view.test.ts +18 -1
- package/src/ui/sync-settings-view.ts +21 -0
- package/src/utils/workdir-lock.test.ts +77 -4
- package/src/utils/workdir-lock.ts +122 -5
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* - 锁文件放 `os.tmpdir()`(按工作目录绝对路径哈希命名),**不写进工作副本**:
|
|
20
20
|
* 工作副本还没 clone 出来也能先锁住,且绝不让 `git status` 看到脏文件;
|
|
21
21
|
* - 跨进程独占用 `open(path,'wx')`(O_EXCL,原子);等待方轮询,超时抛 WorkdirLockError;
|
|
22
|
-
* - holder
|
|
22
|
+
* - holder 崩溃留下的陈旧锁:先问 holder pid 是否还存在(ESRCH ⇒ 已被杀,立即回收),
|
|
23
|
+
* 读不出 pid 或进程仍存活时才按 mtime 超过 staleMs 回收(避免误抢仍在跑的长时间任务);
|
|
24
|
+
* - 等待方是**有界**的:等文件锁与进程内链上排队共用同一个 timeoutMs 上限,超时即抛,
|
|
25
|
+
* 绝不在界面早已放弃之后仍去驱动工作副本;
|
|
23
26
|
* - 进程内再串一条 Promise 链:同进程两个 GitTransport 实例(autosync / 路由)共用同一条链,
|
|
24
27
|
* 不依赖文件锁的轮询延迟。
|
|
25
28
|
*/
|
|
@@ -56,6 +59,102 @@ export function workdirLockKey(workDir) {
|
|
|
56
59
|
export function workdirLockPath(workDir) {
|
|
57
60
|
return path.join(os.tmpdir(), `dshcm-workdir-${workdirLockKey(workDir)}.lock`);
|
|
58
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* holder 进程是否还活着(三态:true = 存活、false = 确定已死、null = 判不了)。
|
|
64
|
+
*
|
|
65
|
+
* 为什么必须问:holder 被强杀时(DSH 重启 / 崩溃 / 任务管理器结束进程)`finally` 不会执行,
|
|
66
|
+
* 锁文件就留在 tmpdir 里;只按 mtime 判定要等满 staleMs(15 分钟)才接管。这段窗口里**所有**
|
|
67
|
+
* 同步与只读自检都排在锁上 —— 真机 2026-10-27:DSH 重启踩在一次 push 中间,pid 28888 的锁留在
|
|
68
|
+
* %TEMP%,之后每次打开同步页 `GET /sync/status` 都要等满 10 s 预算再 503。
|
|
69
|
+
*
|
|
70
|
+
* 误差方向:只有 `ESRCH`(pid 完全不存在)才判死;`EPERM`(进程存在但无权发信号,如 Linux 跨用户)
|
|
71
|
+
* 判活;pid 复用也会把「别人的锁」判成活 —— 两种误判都偏向保守(最多退化成 mtime 规则),
|
|
72
|
+
* 绝不会把仍在跑的 holder 的锁抢走。
|
|
73
|
+
*/
|
|
74
|
+
export function workdirLockHolderAlive(pid) {
|
|
75
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const code = err.code;
|
|
83
|
+
if (code === 'ESRCH')
|
|
84
|
+
return false;
|
|
85
|
+
if (code === 'EPERM')
|
|
86
|
+
return true;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** 读锁文件里记录的 holder(读不出 / 半写入 / 不是对象 ⇒ null,一律退回 mtime 规则) */
|
|
91
|
+
async function readLockHolder(lockPath) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
|
94
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
95
|
+
return null;
|
|
96
|
+
const holder = parsed;
|
|
97
|
+
return { pid: holder.pid, workDir: holder.workDir };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const LOCK_FILE_PREFIX = 'dshcm-workdir-';
|
|
104
|
+
let pruneScheduled = false;
|
|
105
|
+
/**
|
|
106
|
+
* 一次性清扫本机所有「holder 已死」的工作副本锁(每进程只跑一次)。
|
|
107
|
+
*
|
|
108
|
+
* 为什么需要:pid 回收只在**自己被这把锁挡住时**生效,而残留锁可能属于早已不再使用的工作目录
|
|
109
|
+
* (换过仓库、换过数据目录、临时实例)—— 它们永远不会再被查询,却一直躺在 %TEMP%(真机 2026-10-27
|
|
110
|
+
* 见到 8 个,最久 3.4 天)。只删 pid 确定不存在的锁:读不出 holder 的不动(保守,宁可留下)。
|
|
111
|
+
*/
|
|
112
|
+
export async function pruneDeadWorkdirLocksOnce() {
|
|
113
|
+
if (pruneScheduled)
|
|
114
|
+
return;
|
|
115
|
+
pruneScheduled = true;
|
|
116
|
+
const dir = os.tmpdir();
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = await fs.readdir(dir);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const remove = async (lockPath) => {
|
|
125
|
+
try {
|
|
126
|
+
await fs.unlink(lockPath);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// 别人同时也在清扫 / 已被释放:无事可做
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
await Promise.all(names
|
|
133
|
+
.filter((name) => name.startsWith(LOCK_FILE_PREFIX) && name.endsWith('.lock'))
|
|
134
|
+
.map(async (name) => {
|
|
135
|
+
const lockPath = path.join(dir, name);
|
|
136
|
+
const holder = await readLockHolder(lockPath);
|
|
137
|
+
if (holder === null)
|
|
138
|
+
return; // 读不出 holder:保守留下
|
|
139
|
+
const alive = workdirLockHolderAlive(holder.pid);
|
|
140
|
+
if (alive === false)
|
|
141
|
+
return await remove(lockPath); // holder 确定已死
|
|
142
|
+
if (alive !== true)
|
|
143
|
+
return; // 判不了:留下
|
|
144
|
+
// pid 复用陷阱:holder pid 此刻可能属于一个毫不相干的进程(本机实测 2026-10-27:一条测试残留锁的
|
|
145
|
+
// pid 9704 已被 `NoiseCancelingEngine.exe` 复用)。只有「它声明的工作目录确实不存在 + 锁已过
|
|
146
|
+
// staleMs」才清 —— 那说明 holder 早就不在这把锁上了,而不是正在干活。
|
|
147
|
+
const st = await fs.stat(lockPath).catch(() => null);
|
|
148
|
+
if (st === null || Date.now() - st.mtimeMs <= DEFAULT_WORKDIR_LOCK_STALE_MS)
|
|
149
|
+
return;
|
|
150
|
+
const workDir = typeof holder.workDir === 'string' ? holder.workDir : '';
|
|
151
|
+
if (workDir === '')
|
|
152
|
+
return;
|
|
153
|
+
if (await fs.stat(workDir).then(() => true, () => false))
|
|
154
|
+
return;
|
|
155
|
+
await remove(lockPath);
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
59
158
|
async function tryAcquire(lockPath, workDir, staleMs, now) {
|
|
60
159
|
const token = randomUUID();
|
|
61
160
|
try {
|
|
@@ -84,7 +183,18 @@ async function tryAcquire(lockPath, workDir, staleMs, now) {
|
|
|
84
183
|
const code = err.code;
|
|
85
184
|
if (code !== 'EEXIST')
|
|
86
185
|
throw err;
|
|
87
|
-
//
|
|
186
|
+
// ① holder pid 已经不存在 ⇒ 这把锁必然陈旧,立即回收,不等 staleMs(重启/崩溃后的现场)
|
|
187
|
+
const holder = await readLockHolder(lockPath);
|
|
188
|
+
if (workdirLockHolderAlive(holder?.pid) === false) {
|
|
189
|
+
try {
|
|
190
|
+
await fs.unlink(lockPath);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// 别人刚好也判定并回收、或 holder 的 finally 刚好跑到:下一轮重试即可
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
// ② 读不出 holder / holder 仍存活 ⇒ 只有超过 staleMs 才视为崩溃残留(绝不抢正在跑的长任务)
|
|
88
198
|
try {
|
|
89
199
|
const st = await fs.stat(lockPath);
|
|
90
200
|
if (now() - st.mtimeMs > staleMs)
|
|
@@ -109,8 +219,15 @@ export async function withWorkdirLock(workDir, fn, options = {}) {
|
|
|
109
219
|
const now = options.now ?? Date.now;
|
|
110
220
|
const key = workdirLockKey(workDir);
|
|
111
221
|
const lockPath = workdirLockPath(workDir);
|
|
222
|
+
// 顺带清扫本机别的死 holder 残留(每进程一次,失败不影响本次取锁)
|
|
223
|
+
void pruneDeadWorkdirLocksOnce().catch(() => undefined);
|
|
224
|
+
// 计时必须在进入串行链**之前**取:链上排队同样属于「在等这把锁」。否则一个只读调用排在
|
|
225
|
+
// 上一个 300 s 的同步后面时,计时从轮到它那一刻才清零,超时上限形同失效(2026-10-27 现场)。
|
|
226
|
+
const started = now();
|
|
112
227
|
const run = async () => {
|
|
113
|
-
|
|
228
|
+
// 排队期间调用方已超时放弃:轮到它时直接失败,**不执行 fn**,免得被遗弃的请求稍后仍去驱动工作副本
|
|
229
|
+
if (now() - started >= timeoutMs)
|
|
230
|
+
throw new WorkdirLockError(workDir, now() - started);
|
|
114
231
|
// 先同步做一次尝试,再进入轮询:无竞争的常见情形零额外延迟
|
|
115
232
|
let release = await tryAcquire(lockPath, workDir, staleMs, now);
|
|
116
233
|
while (release === null) {
|
|
@@ -132,16 +249,27 @@ export async function withWorkdirLock(workDir, fn, options = {}) {
|
|
|
132
249
|
// 链里只保留「尾巴」的完成状态,异常不向下游传染
|
|
133
250
|
const tail = current.then(() => undefined, () => undefined);
|
|
134
251
|
chains.set(key, tail);
|
|
252
|
+
// 硬上界:timeoutMs 是「拿到锁」的上限,不是「开始计时后」的上限。只用 run 内的计时不够 ——
|
|
253
|
+
// 排在长任务后面的调用永远轮不到自己计时,界面就被历史任务拖着走。这里直接对链头 race,
|
|
254
|
+
// 超时 reject;被放弃的 run 之后还会被链唤醒,但入口的过期检查让它立刻抛(不碰工作副本)。
|
|
255
|
+
let rejectDeadline;
|
|
256
|
+
const deadline = new Promise((_, reject) => { rejectDeadline = reject; });
|
|
257
|
+
const timer = setTimeout(() => { rejectDeadline?.(new WorkdirLockError(workDir, timeoutMs)); }, timeoutMs);
|
|
258
|
+
// unref:这个计时器不该拖住进程退出(缺省 300 s)
|
|
259
|
+
if (typeof timer.unref === 'function')
|
|
260
|
+
timer.unref();
|
|
135
261
|
try {
|
|
136
|
-
return await current;
|
|
262
|
+
return await Promise.race([current, deadline]);
|
|
137
263
|
}
|
|
138
264
|
finally {
|
|
265
|
+
clearTimeout(timer);
|
|
139
266
|
if (chains.get(key) === tail)
|
|
140
267
|
chains.delete(key);
|
|
141
268
|
}
|
|
142
269
|
}
|
|
143
|
-
/**
|
|
270
|
+
/** 仅供测试:清空进程内串行链与一次性清扫标记(不影响磁盘上的锁文件) */
|
|
144
271
|
export function resetWorkdirLockChainsForTest() {
|
|
145
272
|
chains.clear();
|
|
273
|
+
pruneScheduled = false;
|
|
146
274
|
}
|
|
147
275
|
//# sourceMappingURL=workdir-lock.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-config-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
|
|
6
6
|
"icon": "./icon.svg",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"lightningcss": "1.33.0",
|
|
200
200
|
"lucide": "^1.61.0",
|
|
201
201
|
"lucide-react": "^1.61.0",
|
|
202
|
-
"morphicons": "^
|
|
202
|
+
"morphicons": "^2.0.0",
|
|
203
203
|
"react": "^18.3.1",
|
|
204
204
|
"react-dom": "^18.3.1",
|
|
205
205
|
"schemastery": "^3.18.0",
|
package/src/cli/web/page.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* HTML:**零脚本、零外部资源、零构建产物**,配合 http.ts 里收紧的 CSP。
|
|
6
6
|
* 样式内联在 <style> 里(独立于插件 UI 的 CSS Modules;该新 pattern 已登记进 DESIGN.md)。
|
|
7
7
|
* 这套样式表**有一份外部出处**(亮色 IBM Carbon / 暗色 Raycast 阶梯)与逐条对照表:
|
|
8
|
-
* docs/
|
|
8
|
+
* docs/components/cli-rescue-console.md —— 改 STYLE 前先读它,改完跑 web.test.ts 的 R3-02。
|
|
9
9
|
*
|
|
10
10
|
* 呈现纪律:每个数字都要能回答「你怎么知道的」—— 离线拿不到的(结构化分区/凭据/同步令牌)
|
|
11
11
|
* 一律显式列出并说明原因,绝不显示为 0 或留空。
|
|
@@ -32,7 +32,7 @@ export interface PageChrome {
|
|
|
32
32
|
|
|
33
33
|
const STYLE = [
|
|
34
34
|
// ---- 设计 token:IBM Carbon(亮色语义四色)+ Raycast(暗色 surface 阶梯)----
|
|
35
|
-
// 映射表、取的章节与全部偏离记录:docs/
|
|
35
|
+
// 映射表、取的章节与全部偏离记录:docs/components/cli-rescue-console.md
|
|
36
36
|
// 文字色一律取「能读」的那一档:Carbon 的 #24a148 / #f1c21b 只做填充,正文用 #198038 / #8e6a00
|
|
37
37
|
// (AA 对比度由 web.test.ts 的 R3-02 逐对钉住,改色先过那条测试)。
|
|
38
38
|
':root{color-scheme:light dark;--font:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--fg:#161616;--muted:#525252;--bg:#f4f4f4;--card:#fff;--surface:#f4f4f4;--line:#e0e0e0;--accent:#0f62fe;--accent-hover:#0043ce;--on-accent:#fff;--ok:#198038;--warn:#8e6a00;--bad:#da1e28;--scrim:rgba(22,22,22,.45)}',
|
|
@@ -165,15 +165,6 @@ const navLayoutResult = navWidths === null
|
|
|
165
165
|
*/
|
|
166
166
|
const [libraryPending, setLibraryPending] = useState<ArtifactPendingAction | null>(null)
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
* 导入面板当前是否停在「从其它 agent 导入」这一页(t17)。
|
|
170
|
-
*
|
|
171
|
-
* 为什么是壳层 state 而不是 runStore:它与「导入面板」同生命周期,且**不该被持久化** ——
|
|
172
|
-
* 刷新后重放一个「正在选来源」的界面只会骗人(来源检测结果要重新拉)。
|
|
173
|
-
* 关面板 / 切走即随组件卸载归零,与 libraryAction 同一档语义。
|
|
174
|
-
*/
|
|
175
|
-
const [foreignImportOpen, setForeignImportOpen] = useState(false)
|
|
176
|
-
|
|
177
168
|
/* ---------------- m-star-prompt:Star 引导弹窗(保持既有能力) ---------------- */
|
|
178
169
|
const [starPromptOpen, setStarPromptOpen] = useState(false)
|
|
179
170
|
/** 弹窗展示的 GitHub 仓库地址(GET /star-prompt 返回;不落 store) */
|
|
@@ -441,7 +432,7 @@ const navLayoutResult = navWidths === null
|
|
|
441
432
|
case 'market.publish.open': openTask('market', { subView: 'myconfigs' }); return
|
|
442
433
|
// 维护与诊断是全屏子视图:命令直达它,而不是只把用户送到环境页
|
|
443
434
|
// 从其它 agent 导入:开导入面板并停在来源选择(与首页工具栏同一路径)
|
|
444
|
-
case 'import.foreign': openTask('import'
|
|
435
|
+
case 'import.foreign': openTask('import', { foreign: true }); return
|
|
445
436
|
case 'maintenance.open': openMaintenance(); return
|
|
446
437
|
case 'activity.open': openTask('runs'); return
|
|
447
438
|
case 'history.open': openTask('history'); return
|
|
@@ -468,10 +459,7 @@ const navLayoutResult = navWidths === null
|
|
|
468
459
|
t={t}
|
|
469
460
|
syncT={syncT}
|
|
470
461
|
// 直达来源选择:开导入面板 + 把面板切到「从其它 agent 导入」那一步
|
|
471
|
-
openForeignImport={() => {
|
|
472
|
-
openTask('import')
|
|
473
|
-
setForeignImportOpen(true)
|
|
474
|
-
}}
|
|
462
|
+
openForeignImport={() => { openTask('import', { foreign: true }) }}
|
|
475
463
|
/>
|
|
476
464
|
)
|
|
477
465
|
break
|
|
@@ -638,7 +626,7 @@ const navLayoutResult = navWidths === null
|
|
|
638
626
|
{/* 导入与逛市场都是多阶段流程(§1),同样由本面板承载。
|
|
639
627
|
此前只渲染了 export / runs / history / about —— 从产物库点「导入」会开到**一个空面板**,
|
|
640
628
|
而导入其实在页面那条路径里跑(返回后正好看到它),两处状态分叉。 */}
|
|
641
|
-
{activeTask.kind === 'import' && (
|
|
629
|
+
{activeTask.kind === 'import' && (importTaskForeign(activeTask.payload) ? (
|
|
642
630
|
/* 「从其它 agent 导入」(t17):页面级步骤,复用同一块面板 ——
|
|
643
631
|
与既有导入向导并列,避免「弹窗套向导」。产包成功后把 zipPath 写进
|
|
644
632
|
runStore.library.pendingZip(产物库「一键导入」那条已验证的通道),
|
|
@@ -648,8 +636,9 @@ const navLayoutResult = navWidths === null
|
|
|
648
636
|
t={t}
|
|
649
637
|
uiT={uiT}
|
|
650
638
|
onReady={(result) => {
|
|
651
|
-
|
|
639
|
+
// 产包成功 = 这一页的使命结束:入参改回「向导」,并把 zipPath 交给既有导入通道
|
|
652
640
|
runStore.patch({
|
|
641
|
+
task: { ...activeTask, payload: { foreign: false } },
|
|
653
642
|
library: {
|
|
654
643
|
pendingZip: { zipPath: result.zipPath, name: result.name },
|
|
655
644
|
},
|
|
@@ -661,7 +650,8 @@ const navLayoutResult = navWidths === null
|
|
|
661
650
|
api={api}
|
|
662
651
|
t={t}
|
|
663
652
|
uiT={uiT}
|
|
664
|
-
|
|
653
|
+
// 向导 → 来源选择:同样是改 Task 入参(面板状态只有一个事实源)
|
|
654
|
+
onOpenForeignImport={() => { runStore.patch({ task: { ...activeTask, payload: { foreign: true } } }) }}
|
|
665
655
|
/>
|
|
666
656
|
))}
|
|
667
657
|
{activeTask.kind === 'market' && (
|
|
@@ -783,6 +773,19 @@ function marketTaskSubView(payload: unknown): 'browse' | 'myconfigs' | undefined
|
|
|
783
773
|
return v === 'myconfigs' || v === 'browse' ? v : undefined
|
|
784
774
|
}
|
|
785
775
|
|
|
776
|
+
/**
|
|
777
|
+
* Task 入参里取「导入面板停在来源选择」(t17「从其它 agent 导入」)。
|
|
778
|
+
*
|
|
779
|
+
* 为什么放进 Task 入参而不是壳层 useState(原 bug 的根因):壳层 state 与「导入面板」**不同**
|
|
780
|
+
* 生命周期 —— 关面板只是 `task: null`,壳层不重挂,于是「先点从其它 agent 导入 → 关掉 → 再点导入」
|
|
781
|
+
* 会带着旧的 true 进来,看到的是来源选择页而不是导入向导。入参随 Task 一起生灭(且 Task 本身
|
|
782
|
+
* 不持久化,见 run-store 的 `task: null`),开一个**新的**导入面板自然就是向导。
|
|
783
|
+
*/
|
|
784
|
+
function importTaskForeign(payload: unknown): boolean {
|
|
785
|
+
if (typeof payload !== 'object' || payload === null) return false
|
|
786
|
+
return (payload as { foreign?: unknown }).foreign === true
|
|
787
|
+
}
|
|
788
|
+
|
|
786
789
|
function marketTaskItemId(payload: unknown): string | undefined {
|
|
787
790
|
if (typeof payload !== 'object' || payload === null) return undefined
|
|
788
791
|
const id: unknown = (payload as { marketId?: unknown }).marketId
|
package/src/client/api.ts
CHANGED
|
@@ -42,6 +42,8 @@ import type { SessionExportProbe } from './session-export/session-export-view.ts
|
|
|
42
42
|
import type { ForeignSkip } from '../foreign/types.ts';
|
|
43
43
|
import type { ForeignSourcesResponse as ForeignSourcesViewInput } from '../ui/foreign-view.ts';
|
|
44
44
|
import type { DiskUsageReport } from '../core/disk-usage.ts';
|
|
45
|
+
import type { BrowseResult } from '../core/path-browse.ts';
|
|
46
|
+
import type { NativePickResult } from '../core/native-path-picker.ts';
|
|
45
47
|
import type { UiT } from '../ui/i18n.ts';
|
|
46
48
|
import { failedSectionsFromResponse } from '../ui/export-flow.ts';
|
|
47
49
|
import type { SessionFormatDisposition } from '../ui/session-format-disposition.ts';
|
|
@@ -291,6 +293,8 @@ const LONG_OPTS: RequestOptions = { timeoutMs: LONG_REQUEST_TIMEOUT_MS };
|
|
|
291
293
|
|
|
292
294
|
/** 插件自更新请求的超时:宿主侧安装上限 15 分钟,客户端留出余量(16 分钟)避免误报「请求超时」。 */
|
|
293
295
|
const SELF_UPDATE_OPTS: RequestOptions = { timeoutMs: 16 * 60 * 1000 };
|
|
296
|
+
/** 原生目录选择器:宿主侧 10 分钟上限,这里留出传输余量。 */
|
|
297
|
+
const NATIVE_PICK_OPTS: RequestOptions = { timeoutMs: 11 * 60 * 1000 };
|
|
294
298
|
|
|
295
299
|
/** query-string 辅助(跳过 undefined/空串,与 dsh-ssh 一致) */
|
|
296
300
|
function query(params: Record<string, string | number | undefined>): string {
|
|
@@ -337,6 +341,14 @@ function triggerBlobDownload(blob: Blob, filename: string): void {
|
|
|
337
341
|
setTimeout(() => { URL.revokeObjectURL(url) }, 15_000)
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
/**
|
|
345
|
+
* DSH 宿主自带目录选择器的结果。
|
|
346
|
+
* - `ok`:用户选中(`path` 非空);
|
|
347
|
+
* - `canceled`:用户点了取消,调用方静默收手;
|
|
348
|
+
* - `unavailable`:宿主没有这个能力(非桌面宿主 / 版本较旧 / 调用抛错),调用方回落下一层。
|
|
349
|
+
*/
|
|
350
|
+
export type DshPickResult = { ok: true; path: string } | { ok: false; reason: 'canceled' | 'unavailable' }
|
|
351
|
+
|
|
340
352
|
/**
|
|
341
353
|
* Config Manager 浏览器半的唯一数据入口。
|
|
342
354
|
* 同时实现 `ExportPort`(export-flow 用)与 `ImportPort`(import-wizard 用)。
|
|
@@ -350,9 +362,16 @@ export class ConfigManagerApi {
|
|
|
350
362
|
exportPassword: string | null = null
|
|
351
363
|
/** 客户端展示层翻译器(zh 源 / en 镜像,见 ui/i18n.ts)。 */
|
|
352
364
|
readonly t: UiT
|
|
365
|
+
/**
|
|
366
|
+
* DSH 自带目录选择器(桌面壳全局 `__DSH_DIRECTORY_PICKER__.pick` 或客户端服务 `ctx.get('uiWorkspace').pickDirectory`),
|
|
367
|
+
* 由 `apply(ctx)` 注入的**惰取** thunk(解析顺序见 `import/dsh-directory-pick.ts`)。
|
|
368
|
+
* 宿主没提供该能力时保持 `undefined`。
|
|
369
|
+
*/
|
|
370
|
+
private readonly dshDirectoryPicker: (() => Promise<string | null>) | undefined
|
|
353
371
|
|
|
354
|
-
constructor(t: UiT) {
|
|
372
|
+
constructor(t: UiT, dshDirectoryPicker?: () => Promise<string | null>) {
|
|
355
373
|
this.t = t
|
|
374
|
+
this.dshDirectoryPicker = dshDirectoryPicker
|
|
356
375
|
}
|
|
357
376
|
|
|
358
377
|
// ------------------------------------------------------------- status
|
|
@@ -575,6 +594,46 @@ export class ConfigManagerApi {
|
|
|
575
594
|
);
|
|
576
595
|
}
|
|
577
596
|
|
|
597
|
+
/**
|
|
598
|
+
* 列出一层本机目录(只读;路径映射「选本机路径」弹窗的数据源)。
|
|
599
|
+
* `target` 缺省或空串 = 「此电脑」根列表(Windows 盘符 / POSIX `/`)。
|
|
600
|
+
* 失败不抛异常:宿主回 `error: 'not-found'|'denied'|'failed'|'not-absolute'`,弹窗照常可用。
|
|
601
|
+
*/
|
|
602
|
+
async browsePath(target?: string): Promise<BrowseResult> {
|
|
603
|
+
const suffix = target === undefined || target === '' ? '' : query({ path: target });
|
|
604
|
+
return getJson<BrowseResult>(CONFIG_MANAGER_API.pathBrowse + suffix, this.t);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 弹**系统原生**目录选择器(同一端点的 POST 形态)。
|
|
609
|
+
*
|
|
610
|
+
* 用户可能在系统对话框里逛几分钟,所以超时必须远大于常规请求:内核侧 10 分钟兜底,这里再加一道。
|
|
611
|
+
* 回 `reason: 'unsupported' | 'failed'` 时调用方回落到内置目录浏览器,不当错误处理。
|
|
612
|
+
*/
|
|
613
|
+
async pickNativePath(startPath?: string, title?: string): Promise<NativePickResult> {
|
|
614
|
+
const body: Record<string, string> = {};
|
|
615
|
+
if (startPath !== undefined && startPath !== '') body.startPath = startPath;
|
|
616
|
+
if (title !== undefined && title !== '') body.title = title;
|
|
617
|
+
return postJson<NativePickResult>(CONFIG_MANAGER_API.pathBrowse, body, this.t, NATIVE_PICK_OPTS);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 复用 **DSH 宿主自带**的原生目录选择器(就是「选择工作区目录」那个窗口)。
|
|
622
|
+
*
|
|
623
|
+
* 比插件自己起系统对话框更稳:窗口由 DSH 主进程弹,跟着宿主走,不受插件宿主的进程形态限制。
|
|
624
|
+
* 完全不设客户端超时 —— 用户可能在原生窗口里逛几分钟,宿主侧 `host.pickDirectory` 也是 caller-signal-only。
|
|
625
|
+
* 宿主没有该能力或调用抛错一律归成 `unavailable`,由调用方回落插件自己的原生对话框,不当错误处理。
|
|
626
|
+
*/
|
|
627
|
+
async pickDshPath(): Promise<DshPickResult> {
|
|
628
|
+
if (this.dshDirectoryPicker === undefined) return { ok: false, reason: 'unavailable' }
|
|
629
|
+
try {
|
|
630
|
+
const path = await this.dshDirectoryPicker()
|
|
631
|
+
return typeof path === 'string' && path !== '' ? { ok: true, path } : { ok: false, reason: 'canceled' }
|
|
632
|
+
} catch {
|
|
633
|
+
return { ok: false, reason: 'unavailable' }
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
578
637
|
/** ImportPort.decryptArchive:解锁整体加密备份容器 → 明文 ZIP 路径 + 解密覆盖的凭据 ref 名 */
|
|
579
638
|
async decryptArchive(zipPath: string, password: string): Promise<DecryptArchiveResponse> {
|
|
580
639
|
return postJson<DecryptArchiveResponse>(CONFIG_MANAGER_API.decryptArchive, { zipPath, password }, this.t, LONG_OPTS);
|
|
@@ -73,6 +73,15 @@ test('lib/client.js 自包含:只 require 运行时白名单内的外部包',
|
|
|
73
73
|
`修复方向:把该依赖加回 tsdown.config.ts 的 deps.alwaysBundle(并保留在 devDependencies)。`,
|
|
74
74
|
)
|
|
75
75
|
|
|
76
|
+
// 相对 require = 依赖同目录 chunk:loader 只取 lib/client.js,不会请求 ./client2.js。
|
|
77
|
+
const relativeSpecifiers = result.specifiers.filter((s) => s.startsWith('.'))
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
relativeSpecifiers,
|
|
80
|
+
[],
|
|
81
|
+
`lib/client.js 依赖了同目录 chunk(loader 不会请求它们):${relativeSpecifiers.join(', ')}\n` +
|
|
82
|
+
'修复方向:tsdown.config.ts 的 outputOptions.inlineDynamicImports = true。',
|
|
83
|
+
)
|
|
84
|
+
|
|
76
85
|
// node:* 内置模块不属于本护栏判定范围(浏览器半不应依赖它们,但那是另一条不变量)。
|
|
77
86
|
if (result.nodeBuiltins.length > 0) {
|
|
78
87
|
t.diagnostic(`注意:bundle 内出现 Node 内置 require:${result.nodeBuiltins.join(', ')}`)
|
|
@@ -475,7 +475,7 @@ test('动作行图标走宿主原生(复制 → 对勾 → 回退),拿不
|
|
|
475
475
|
/**
|
|
476
476
|
* 2026-10-17(用户要求「与 DSH 原生气泡完全一致」):§15b 的几何/字号必须与宿主
|
|
477
477
|
* MessageItem + MessageIconActions 的**同一条取值**逐字同式(宿主产物抽出的取值清单见
|
|
478
|
-
* docs/
|
|
478
|
+
* docs/maintenance/known-gaps.md 的「G-39 增量(八)」)。这张用例是那条口径的源级护栏:
|
|
479
479
|
* 谁把圆角/内边距/动作行/文件卡改回「近似档」,这里立刻红。
|
|
480
480
|
*/
|
|
481
481
|
test('气泡与动作行逐值对齐宿主:圆角 xl(20px) / 内边距 10·16 / 动作行 28+delta / 文件卡 240·64', () => {
|
|
@@ -443,6 +443,20 @@ test('A 真机回归:workspaces.archiveSession 依赖 this —— 抽方法时
|
|
|
443
443
|
assert.deepEqual(service.archived, ['c1'])
|
|
444
444
|
})
|
|
445
445
|
|
|
446
|
+
test('A 真机回归(0.2.x 服务名):归档能力只在 uiWorkspace 上,也必须取得到', async () => {
|
|
447
|
+
const service = {
|
|
448
|
+
archived: [] as string[],
|
|
449
|
+
async archiveSession(this: { archived: string[] }, id: string): Promise<unknown> {
|
|
450
|
+
this.archived.push(id)
|
|
451
|
+
return { ok: true, value: { archivedSessionIds: [id] } }
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
// 老版本才有 workspaces 服务;只照这个名字取 = 真机上永远拿不到(本轮修的就是这个)。
|
|
455
|
+
const sources = navigationSourcesOf({ get: (key: string) => (key === 'uiWorkspace' ? service : undefined) })
|
|
456
|
+
assert.deepEqual(await archiveParentSession(sources.workspaces, 'c2'), { ok: true })
|
|
457
|
+
assert.deepEqual(service.archived, ['c2'])
|
|
458
|
+
})
|
|
459
|
+
|
|
446
460
|
test('A:归档失败码 → 文案键(子串映射;成功不提示;界面绝不裸渲染机器码)', () => {
|
|
447
461
|
assert.equal(archiveHintKey({ ok: true }), null)
|
|
448
462
|
assert.equal(archiveHintKey({ ok: false, code: 'archive-unavailable' }), 'rollback.rewind.archive.unavailable')
|
|
@@ -839,7 +839,8 @@ export function navigationSourcesOf(ctx: unknown): NavigationSources {
|
|
|
839
839
|
return {
|
|
840
840
|
workspace: asWorkspaceSource(safeGet('uiWorkspace')),
|
|
841
841
|
sessions: asSessionListSource(safeGet('sessions')),
|
|
842
|
-
|
|
842
|
+
// 归档能力挂在 uiWorkspace 上(真机 DSH 0.2.x 只有这个服务名;老版本才有 workspaces)——两个都试,不猜。
|
|
843
|
+
workspaces: asArchiveSource(safeGet('uiWorkspace')) ?? asArchiveSource(safeGet('workspaces')),
|
|
843
844
|
}
|
|
844
845
|
}
|
|
845
846
|
|
|
@@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
21
21
|
import type { TranslateNS } from '../client-types.ts'
|
|
22
22
|
import { toast } from './toast-store.ts'
|
|
23
23
|
import { CopyStateIcon } from './Icon.tsx'
|
|
24
|
+
import { hostIcon } from './host-primitives.ts'
|
|
24
25
|
import { copyTextToClipboard } from './clipboard.ts'
|
|
25
26
|
import css from '../config-manager.module.css'
|
|
26
27
|
|
|
@@ -63,9 +64,27 @@ export function CopyButton({ text, label, t }: CopyButtonProps) {
|
|
|
63
64
|
})
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 图标 = **对话(聊天)回退条用的那两颗宿主原生图**(2026-10-28 用户要求「复制图标统一替换成
|
|
69
|
+
* 对话回退的复制 icon」):`IconCopyOutlineRegular → IconCheckOutlineRegular` 的**瞬时换图**,
|
|
70
|
+
* 与宿主 MessageIconActions / ChatEntryCopyAction 同一对、同一字形与描边重量。
|
|
71
|
+
* 拿不到宿主图标的老宿主回落 morphicons 的 `CopyStateIcon`(绝不能变成空白按钮)。
|
|
72
|
+
*/
|
|
73
|
+
const HostCopyIcon = hostIcon('IconCopyOutlineRegular')
|
|
74
|
+
const HostCheckIcon = hostIcon('IconCheckOutlineRegular')
|
|
75
|
+
|
|
66
76
|
return (
|
|
67
|
-
<button
|
|
68
|
-
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className={css.copyBtn}
|
|
80
|
+
aria-label={label}
|
|
81
|
+
title={label}
|
|
82
|
+
onClick={onClick}
|
|
83
|
+
data-copy-state={copied ? 'copied' : 'idle'}
|
|
84
|
+
>
|
|
85
|
+
{HostCopyIcon !== null && HostCheckIcon !== null
|
|
86
|
+
? (copied ? <HostCheckIcon size={12} /> : <HostCopyIcon size={12} />)
|
|
87
|
+
: <CopyStateIcon done={copied} size={12} />}
|
|
69
88
|
</button>
|
|
70
89
|
)
|
|
71
90
|
}
|
|
@@ -30,7 +30,9 @@ import DatabaseBackup from 'lucide-react/dist/esm/icons/database-backup.mjs'
|
|
|
30
30
|
import Download from 'lucide-react/dist/esm/icons/download.mjs'
|
|
31
31
|
import Eye from 'lucide-react/dist/esm/icons/eye.mjs'
|
|
32
32
|
import File from 'lucide-react/dist/esm/icons/file.mjs'
|
|
33
|
+
import Folder from 'lucide-react/dist/esm/icons/folder.mjs'
|
|
33
34
|
import FolderInput from 'lucide-react/dist/esm/icons/folder-input.mjs'
|
|
35
|
+
import FolderOpen from 'lucide-react/dist/esm/icons/folder-open.mjs'
|
|
34
36
|
import HardDriveDownload from 'lucide-react/dist/esm/icons/hard-drive-download.mjs'
|
|
35
37
|
import History from 'lucide-react/dist/esm/icons/history.mjs'
|
|
36
38
|
import Info from 'lucide-react/dist/esm/icons/info.mjs'
|
|
@@ -68,6 +70,8 @@ const ICONS = {
|
|
|
68
70
|
close: X,
|
|
69
71
|
delete: Trash2,
|
|
70
72
|
file: File,
|
|
73
|
+
folder: Folder,
|
|
74
|
+
folderOpen: FolderOpen,
|
|
71
75
|
download: Download,
|
|
72
76
|
inspect: Search,
|
|
73
77
|
view: Eye,
|
|
@@ -238,6 +242,9 @@ export const CloseIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name
|
|
|
238
242
|
export const DeleteIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="delete" {...p} />
|
|
239
243
|
export const DownloadIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="download" {...p} />
|
|
240
244
|
export const InspectIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="inspect" {...p} />
|
|
245
|
+
export const FolderIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="folder" {...p} />
|
|
246
|
+
export const FolderOpenIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="folderOpen" {...p} />
|
|
247
|
+
export const FileIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="file" {...p} />
|
|
241
248
|
export const ArrowRightIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="arrowRight" {...p} />
|
|
242
249
|
export const ChevronDownIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="chevronDown" {...p} />
|
|
243
250
|
export const PreviewIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="preview" {...p} />
|
|
@@ -106,6 +106,11 @@ export const CONFIG_MANAGER_API = {
|
|
|
106
106
|
* 带 `sessionId` = 流式代理 DSH 自己的 `/api/session.export`(原始日志 ZIP,含子会话与附件)。
|
|
107
107
|
*/
|
|
108
108
|
sessionExport: `${API_BASE}/session-export`,
|
|
109
|
+
/**
|
|
110
|
+
* 本机目录列举(**只读**):`?path=` 缺省 = 「此电脑」根列表。
|
|
111
|
+
* 导入向导「路径映射 → 选本机路径」弹窗的唯一数据源(浏览器拿不到真实目录树)。
|
|
112
|
+
*/
|
|
113
|
+
pathBrowse: `${API_BASE}/path-browse`,
|
|
109
114
|
} as const;
|
|
110
115
|
|
|
111
116
|
/** 远程同步路由族(git / webdav 通道、GitHub device flow、历史快照、自动同步、分区选择)。 */
|
|
@@ -3972,6 +3972,11 @@ body {
|
|
|
3972
3972
|
|
|
3973
3973
|
.pathOld,
|
|
3974
3974
|
.pathNew {
|
|
3975
|
+
/* 黑块溢出面板的根因:本仓库**没有**全局 `*{box-sizing:border-box}` 复位,而这块是
|
|
3976
|
+
width:100% + padding 4/8px + border 1px 的 content-box ⇒ 实际外框比父内容盒宽 18px,
|
|
3977
|
+
深色底块右缘冲出 .pathRow 约 7px(实测卡片 39..570 vs 内部块 51..577)。
|
|
3978
|
+
声明式修法:本块自己算 border-box(与 .input 的既有做法一致)。 */
|
|
3979
|
+
box-sizing: border-box;
|
|
3975
3980
|
min-width: 0;
|
|
3976
3981
|
width: 100%;
|
|
3977
3982
|
/* 纵向列容器:让内部 input 拉满块宽(等价于旧版全局 .input{width:100%} 的效果,
|
|
@@ -3998,6 +4003,71 @@ body {
|
|
|
3998
4003
|
flex: none;
|
|
3999
4004
|
}
|
|
4000
4005
|
|
|
4006
|
+
/* 新路径输入行:手打输入 + 浏览按钮同一行(窄画布下按钮 flex:none 不换行,输入可收缩) */
|
|
4007
|
+
.pathInputRow {
|
|
4008
|
+
display: flex;
|
|
4009
|
+
align-items: center;
|
|
4010
|
+
gap: 4px;
|
|
4011
|
+
min-width: 0;
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
.pathInputRow > .input {
|
|
4015
|
+
flex: 1 1 auto;
|
|
4016
|
+
min-width: 0;
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
/* 路径选择弹窗:当前目录条 + 一层条目列表(长列表由 Modal.Body scroll 限高内滚) */
|
|
4020
|
+
.pickerPath {
|
|
4021
|
+
box-sizing: border-box;
|
|
4022
|
+
width: 100%;
|
|
4023
|
+
padding: 4px 8px;
|
|
4024
|
+
border-radius: var(--cm-r-2);
|
|
4025
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
4026
|
+
color: var(--dsw-alias-label-secondary);
|
|
4027
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
4028
|
+
font-size: var(--cm-fs-meta);
|
|
4029
|
+
overflow-wrap: anywhere;
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
.pickerList {
|
|
4033
|
+
display: flex;
|
|
4034
|
+
flex-direction: column;
|
|
4035
|
+
gap: 2px;
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
.pickerRow {
|
|
4039
|
+
box-sizing: border-box;
|
|
4040
|
+
width: 100%;
|
|
4041
|
+
min-width: 0;
|
|
4042
|
+
display: flex;
|
|
4043
|
+
align-items: center;
|
|
4044
|
+
gap: 6px;
|
|
4045
|
+
padding: 4px 8px;
|
|
4046
|
+
border: 1px solid transparent;
|
|
4047
|
+
border-radius: var(--cm-r-2);
|
|
4048
|
+
background: transparent;
|
|
4049
|
+
color: inherit;
|
|
4050
|
+
font: inherit;
|
|
4051
|
+
font-size: var(--cm-fs-meta);
|
|
4052
|
+
text-align: left;
|
|
4053
|
+
cursor: pointer;
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
.pickerRow:hover {
|
|
4057
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
.pickerRow:focus-visible {
|
|
4061
|
+
outline: 2px solid var(--cm-accent);
|
|
4062
|
+
outline-offset: -1px;
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
/* 文件名可任意折行,绝不横向撑破弹窗 */
|
|
4066
|
+
.pickerName {
|
|
4067
|
+
min-width: 0;
|
|
4068
|
+
overflow-wrap: anywhere;
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4001
4071
|
/* 密钥补录 */
|
|
4002
4072
|
.secretsList {
|
|
4003
4073
|
display: flex;
|