dsh-config-manager 0.1.86 → 0.2.1

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.
Files changed (65) hide show
  1. package/lib/adapters/self.d.ts +28 -0
  2. package/lib/adapters/self.js +14 -3
  3. package/lib/cli/web/page.js +2 -2
  4. package/lib/client.d.ts +86 -1
  5. package/lib/client.js +2368 -775
  6. package/lib/core/native-path-picker.d.ts +49 -0
  7. package/lib/core/native-path-picker.js +475 -0
  8. package/lib/core/path-browse.d.ts +42 -0
  9. package/lib/core/path-browse.js +134 -0
  10. package/lib/foreign/claude-sessions.d.ts +1 -1
  11. package/lib/foreign/claude-sessions.js +1 -1
  12. package/lib/index.d.ts +2 -2
  13. package/lib/index.js +3 -3
  14. package/lib/market/security.js +1 -1
  15. package/lib/market/types.d.ts +1 -1
  16. package/lib/market/types.js +1 -1
  17. package/lib/routes/index.d.ts +1 -1
  18. package/lib/routes/index.js +2 -0
  19. package/lib/routes/kit.d.ts +2 -2
  20. package/lib/routes/path-browse.d.ts +3 -0
  21. package/lib/routes/path-browse.js +63 -0
  22. package/lib/security/secret-scanner.js +1 -1
  23. package/lib/ui/sync-settings-view.d.ts +18 -0
  24. package/lib/ui/sync-settings-view.js +20 -0
  25. package/package.json +2 -2
  26. package/src/adapters/self.test.ts +54 -0
  27. package/src/adapters/self.ts +40 -3
  28. package/src/cli/web/page.ts +2 -2
  29. package/src/client/ConfigManagerSection.tsx +20 -17
  30. package/src/client/api.ts +60 -1
  31. package/src/client/bundle-selfcontained.test.ts +9 -0
  32. package/src/client/chat/chat-entry-view.test.ts +1 -1
  33. package/src/client/chat/rollback-entry-view.test.ts +14 -0
  34. package/src/client/chat/rollback-entry-view.ts +2 -1
  35. package/src/client/common/CopyButton.tsx +21 -2
  36. package/src/client/common/Icon.tsx +7 -0
  37. package/src/client/common/routes.ts +5 -0
  38. package/src/client/config-manager.module.css +70 -0
  39. package/src/client/import/ImportWizardView.tsx +1 -0
  40. package/src/client/import/PathMappingForm.tsx +62 -8
  41. package/src/client/import/PathPickerDialog.tsx +172 -0
  42. package/src/client/import/dsh-directory-pick.test.ts +55 -0
  43. package/src/client/import/dsh-directory-pick.ts +55 -0
  44. package/src/client/import/import-wizard-steps.tsx +5 -2
  45. package/src/client/index.ts +12 -1
  46. package/src/client/locales.ts +25 -0
  47. package/src/client/lucide-icons.d.ts +2 -0
  48. package/src/client/sync/SyncPanel.tsx +4 -4
  49. package/src/client/sync/sync-view.ts +1 -1
  50. package/src/core/native-path-picker.test.ts +118 -0
  51. package/src/core/native-path-picker.ts +517 -0
  52. package/src/core/path-browse.test.ts +88 -0
  53. package/src/core/path-browse.ts +166 -0
  54. package/src/foreign/claude-sessions.ts +3 -3
  55. package/src/index.ts +3 -3
  56. package/src/market/security.ts +1 -1
  57. package/src/market/types.ts +1 -1
  58. package/src/routes/index.ts +3 -1
  59. package/src/routes/kit.ts +2 -2
  60. package/src/routes/path-browse.ts +70 -0
  61. package/src/security/secret-scanner.test.ts +1 -1
  62. package/src/security/secret-scanner.ts +1 -1
  63. package/src/ui/session-issue-labels.ts +1 -1
  64. package/src/ui/sync-settings-view.test.ts +18 -1
  65. package/src/ui/sync-settings-view.ts +21 -0
@@ -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'); setForeignImportOpen(true); return
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' && (foreignImportOpen ? (
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
- setForeignImportOpen(false)
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
- onOpenForeignImport={() => { setForeignImportOpen(true) }}
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/spec/known-gaps.md 的「G-39 增量(八)」)。这张用例是那条口径的源级护栏:
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
- workspaces: asArchiveSource(safeGet('workspaces')),
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 type="button" className={css.copyBtn} aria-label={label} title={label} onClick={onClick}>
68
- <CopyStateIcon done={copied} size={12} />
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;
@@ -648,6 +648,7 @@ function ImportWizardBody({ api, t, uiT, onOpenForeignImport }: ImportWizardView
648
648
  onBack={() => { setPhase('preview') }}
649
649
  onNext={finishPathMapping}
650
650
  t={t}
651
+ api={api}
651
652
  />
652
653
  )
653
654
  }
@@ -10,7 +10,10 @@ import { useMemo, useState } from 'react'
10
10
  import type { ChangeEvent } from 'react'
11
11
  import type { PathIssue, PathMapping } from '../../core/types.ts'
12
12
  import type { TranslateNS } from '../client-types.ts'
13
- import { Banner } from '../common/ui.tsx'
13
+ import { Banner, IconButton } from '../common/ui.tsx'
14
+ import { FolderOpenIcon } from '../common/Icon.tsx'
15
+ import { PathPickerDialog } from './PathPickerDialog.tsx'
16
+ import type { PathBrowsePort } from './PathPickerDialog.tsx'
14
17
  import css from '../config-manager.module.css'
15
18
 
16
19
  /**
@@ -32,13 +35,17 @@ export interface PathMappingFormProps {
32
35
  t: TranslateNS<'config-manager'>
33
36
  /** 映射变化时上报(仅已填写 newPrefix 的条目进入 core) */
34
37
  onChange: (mappings: PathMapping[]) => void
38
+ /** 本机路径来源:原生选择器优先,内置目录浏览器兜底(同一条宿主端点 /path-browse) */
39
+ api: PathBrowsePort
35
40
  }
36
41
 
37
42
  /**
38
43
  * 路径映射表单:每条 PathIssue 一行(原路径 → 新路径输入框),
39
44
  * 留空 = 该路径不映射(unresolved);填写 = 输出 core PathMapping[]。
40
45
  */
41
- export function PathMappingForm({ issues, initial, t, onChange }: PathMappingFormProps) {
46
+ export function PathMappingForm({ issues, initial, t, onChange, api }: PathMappingFormProps) {
47
+ // 正在为哪条路径挑目录(oldPrefix 作 key;null = 弹窗关闭)
48
+ const [pickingFor, setPickingFor] = useState<string | null>(null)
42
49
  // draft 状态:issue.value(oldPrefix)→ newPrefix;预置映射合并进 initial 状态
43
50
  const [drafts, setDrafts] = useState<Record<string, string>>(() => {
44
51
  const out: Record<string, string> = {}
@@ -55,6 +62,34 @@ export function PathMappingForm({ issues, initial, t, onChange }: PathMappingFor
55
62
  onChange(mappings)
56
63
  }
57
64
 
65
+ /**
66
+ * 文件夹按钮的完整动作,三层降级,任一层拿到绝对路径就回填:
67
+ * ① **DSH 宿主自带**的目录选择器(就是「选择工作区目录」那个原生窗口,跟着宿主主进程走,最稳);
68
+ * ② 插件自己的系统对话框(Windows = 资源管理器同款、macOS = Finder、Linux = zenity/kdialog);
69
+ * ③ 内置目录浏览器(前两层都没有时才用)。
70
+ *
71
+ * 用户在任何一层点取消 ⇒ 什么都不做(不是错误,不弹红条)。
72
+ */
73
+ const pickFolder = (oldPrefix: string): void => {
74
+ const startPath = (drafts[oldPrefix] ?? '').trim()
75
+ void (async () => {
76
+ const viaHost = api.pickDshPath === undefined ? undefined : await api.pickDshPath()
77
+ if (viaHost?.ok === true) {
78
+ setNewPrefix(oldPrefix, viaHost.path)
79
+ return
80
+ }
81
+ if (viaHost?.reason === 'canceled') return
82
+ // 宿主没有该能力(非桌面宿主 / 版本较旧 / 调用抛错)⇒ 换插件自己的原生对话框
83
+ const native = await api.pickNativePath(startPath, t('import.paths.picker.title')).catch(() => null)
84
+ if (native !== null && native.ok) {
85
+ setNewPrefix(oldPrefix, native.path)
86
+ return
87
+ }
88
+ if (native !== null && native.reason === 'canceled') return
89
+ setPickingFor(oldPrefix) // unsupported / failed / 请求失败 → 内置浏览器兜底
90
+ })()
91
+ }
92
+
58
93
  const unresolved = useMemo(() => {
59
94
  return issues.filter((issue) => (drafts[issue.value] ?? '') === '')
60
95
  }, [issues, drafts])
@@ -78,15 +113,34 @@ export function PathMappingForm({ issues, initial, t, onChange }: PathMappingFor
78
113
  </div>
79
114
  <div className={css.pathNew}>
80
115
  <div className={css.fieldLabel}>{t('import.paths.new')}</div>
81
- <input
82
- className={css.input}
83
- value={drafts[issue.value] ?? ''}
84
- placeholder={issue.mappedTo ?? ''}
85
- onChange={(e: ChangeEvent<HTMLInputElement>) => { setNewPrefix(issue.value, e.target.value) }}
86
- />
116
+ {/* 输入行 = 手打 + 原生选择器(不可用时回落宿主的目录浏览器;浏览器本身拿不到本机路径) */}
117
+ <div className={css.pathInputRow}>
118
+ <input
119
+ className={css.input}
120
+ value={drafts[issue.value] ?? ''}
121
+ placeholder={issue.mappedTo ?? ''}
122
+ onChange={(e: ChangeEvent<HTMLInputElement>) => { setNewPrefix(issue.value, e.target.value) }}
123
+ />
124
+ <IconButton
125
+ icon={<FolderOpenIcon size={14} />}
126
+ label={t('import.paths.browse')}
127
+ onClick={() => { pickFolder(issue.value) }}
128
+ />
129
+ </div>
87
130
  </div>
88
131
  </div>
89
132
  ))}
133
+ <PathPickerDialog
134
+ open={pickingFor !== null}
135
+ api={api}
136
+ t={t}
137
+ startPath={pickingFor === null ? '' : (drafts[pickingFor] ?? '')}
138
+ onPick={(path) => {
139
+ if (pickingFor !== null) setNewPrefix(pickingFor, path)
140
+ setPickingFor(null)
141
+ }}
142
+ onClose={() => { setPickingFor(null) }}
143
+ />
90
144
  </div>
91
145
  )
92
146
  }