dsh-config-manager 0.1.86 → 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.
Files changed (61) hide show
  1. package/lib/cli/web/page.js +2 -2
  2. package/lib/client.d.ts +86 -1
  3. package/lib/client.js +2371 -778
  4. package/lib/core/native-path-picker.d.ts +49 -0
  5. package/lib/core/native-path-picker.js +475 -0
  6. package/lib/core/path-browse.d.ts +42 -0
  7. package/lib/core/path-browse.js +134 -0
  8. package/lib/foreign/claude-sessions.d.ts +1 -1
  9. package/lib/foreign/claude-sessions.js +1 -1
  10. package/lib/index.d.ts +2 -2
  11. package/lib/index.js +3 -3
  12. package/lib/market/security.js +1 -1
  13. package/lib/market/types.d.ts +1 -1
  14. package/lib/market/types.js +1 -1
  15. package/lib/routes/index.d.ts +1 -1
  16. package/lib/routes/index.js +2 -0
  17. package/lib/routes/kit.d.ts +2 -2
  18. package/lib/routes/path-browse.d.ts +3 -0
  19. package/lib/routes/path-browse.js +63 -0
  20. package/lib/security/secret-scanner.js +1 -1
  21. package/lib/ui/sync-settings-view.d.ts +18 -0
  22. package/lib/ui/sync-settings-view.js +20 -0
  23. package/package.json +2 -2
  24. package/src/cli/web/page.ts +2 -2
  25. package/src/client/ConfigManagerSection.tsx +20 -17
  26. package/src/client/api.ts +60 -1
  27. package/src/client/bundle-selfcontained.test.ts +9 -0
  28. package/src/client/chat/chat-entry-view.test.ts +1 -1
  29. package/src/client/chat/rollback-entry-view.test.ts +14 -0
  30. package/src/client/chat/rollback-entry-view.ts +2 -1
  31. package/src/client/common/CopyButton.tsx +21 -2
  32. package/src/client/common/Icon.tsx +7 -0
  33. package/src/client/common/routes.ts +5 -0
  34. package/src/client/config-manager.module.css +70 -0
  35. package/src/client/import/ImportWizardView.tsx +1 -0
  36. package/src/client/import/PathMappingForm.tsx +62 -8
  37. package/src/client/import/PathPickerDialog.tsx +172 -0
  38. package/src/client/import/dsh-directory-pick.test.ts +55 -0
  39. package/src/client/import/dsh-directory-pick.ts +55 -0
  40. package/src/client/import/import-wizard-steps.tsx +5 -2
  41. package/src/client/index.ts +12 -1
  42. package/src/client/locales.ts +25 -0
  43. package/src/client/lucide-icons.d.ts +2 -0
  44. package/src/client/sync/SyncPanel.tsx +4 -4
  45. package/src/client/sync/sync-view.ts +1 -1
  46. package/src/core/native-path-picker.test.ts +118 -0
  47. package/src/core/native-path-picker.ts +517 -0
  48. package/src/core/path-browse.test.ts +88 -0
  49. package/src/core/path-browse.ts +166 -0
  50. package/src/foreign/claude-sessions.ts +3 -3
  51. package/src/index.ts +3 -3
  52. package/src/market/security.ts +1 -1
  53. package/src/market/types.ts +1 -1
  54. package/src/routes/index.ts +3 -1
  55. package/src/routes/kit.ts +2 -2
  56. package/src/routes/path-browse.ts +70 -0
  57. package/src/security/secret-scanner.test.ts +1 -1
  58. package/src/security/secret-scanner.ts +1 -1
  59. package/src/ui/session-issue-labels.ts +1 -1
  60. package/src/ui/sync-settings-view.test.ts +18 -1
  61. package/src/ui/sync-settings-view.ts +21 -0
@@ -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
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * 本机路径选择弹窗(导入 → 路径映射 → 每条「新路径」右侧的文件夹按钮)。
3
+ *
4
+ * 为什么需要它
5
+ * ------------
6
+ * 路径映射要求用户给一堆**绝对路径**指定本机新位置,而浏览器拿不到真实路径
7
+ * (File System Access API 只给 handle,`<input type="file">` 只给文件名)。此前用户只能凭记忆
8
+ * 手打 `D:\\Apps\\nodejs\\node_global\\gitnexus.cmd` 这类字符串 —— 打错一位就等于「导不进」。
9
+ * 目录列举由宿主 `GET /path-browse` 提供(src/core/path-browse.ts,只读、有界),本组件只画界面。
10
+ *
11
+ * 交互约定
12
+ * --------
13
+ * - 点目录 = 进入;点文件 = 直接选中(原路径可能指向文件,如 .cmd / .json);
14
+ * - 「使用当前目录」回填当前目录;「上一层」「主目录」是导航快捷入口;
15
+ * - 起点 `startPath` 若是文件或不存在,宿主会同时给出 `parent`,这里上浮一层再列(只自动上浮一次,
16
+ * 免得用户在空目录里被反复弹走)。
17
+ *
18
+ * 边界:组件不发请求做判断,不创建/改名/删除任何东西;错误码由字典翻译(引擎不产文案)。
19
+ */
20
+ import { useEffect, useRef, useState } from 'react'
21
+ import type { BrowseErrorCode, BrowseResult } from '../../core/path-browse.ts'
22
+ import type { ConfigManagerApi } from '../api.ts'
23
+ import type { TranslateNS } from '../client-types.ts'
24
+ import { Modal } from '../common/Modal.tsx'
25
+ import { Banner, Button, Spinner } from '../common/ui.tsx'
26
+ import { FileIcon, FolderIcon } from '../common/Icon.tsx'
27
+ import css from '../config-manager.module.css'
28
+
29
+ /** 列举失败原因码 → 字典键(与 core 的 `BrowseErrorCode` 一一对应)。 */
30
+ const BROWSE_ERROR_LABEL: Record<BrowseErrorCode, Parameters<TranslateNS<'config-manager'>>[0]> = {
31
+ 'not-found': 'import.paths.picker.notFound',
32
+ denied: 'import.paths.picker.denied',
33
+ failed: 'import.paths.picker.failed',
34
+ 'not-absolute': 'import.paths.picker.notAbsolute',
35
+ }
36
+
37
+ /** 列举状态机(加载中 / 有结果 / 传输失败)。 */
38
+ type BrowseState =
39
+ | { kind: 'loading' }
40
+ | { kind: 'ready'; data: BrowseResult }
41
+ | { kind: 'failed'; detail: string }
42
+
43
+ /**
44
+ * 只需要两个方法:内置浏览器列举 + 原生选择器(两者是**同一条**宿主端点的 GET / POST 形态)。
45
+ * 这样测试替身只碰这两个方法,其余 API 一概不进入组件视野。
46
+ * `pickDshPath`(DSH 宿主自带选择器)是可选能力:DSH 客户端总提供它,测试替身不必实现。
47
+ */
48
+ export type PathBrowsePort = Pick<ConfigManagerApi, 'browsePath' | 'pickNativePath'> & {
49
+ pickDshPath?: ConfigManagerApi['pickDshPath']
50
+ }
51
+
52
+ export interface PathPickerDialogProps {
53
+ open: boolean
54
+ api: PathBrowsePort
55
+ t: TranslateNS<'config-manager'>
56
+ /** 起点:输入框现值(可能是文件、可能是别的机器的路径、可能为空) */
57
+ startPath: string
58
+ /** 选中(目录或文件)—— 由调用方回填输入框;本组件不自行关闭弹窗 */
59
+ onPick: (path: string) => void
60
+ onClose: () => void
61
+ }
62
+
63
+ /**
64
+ * 目录浏览弹窗:列一层目录,点目录进入、点文件选中。
65
+ */
66
+ export function PathPickerDialog({ open, api, t, startPath, onPick, onClose }: PathPickerDialogProps) {
67
+ const [current, setCurrent] = useState<string | null>(null)
68
+ const [state, setState] = useState<BrowseState>({ kind: 'loading' })
69
+ // 自动上浮只做一次:记录已经为哪个起点上浮过(避免空目录/不存在路径来回弹)
70
+ const risenFor = useRef<string | null>(null)
71
+
72
+ const trimmedStart = startPath.trim()
73
+
74
+ // 打开时把起点落到当前目录(关闭状态下不动,避免清掉上次浏览位置造成闪烁)
75
+ useEffect(() => {
76
+ if (!open) return
77
+ risenFor.current = null
78
+ setCurrent(trimmedStart === '' ? null : trimmedStart)
79
+ }, [open, trimmedStart])
80
+
81
+ useEffect(() => {
82
+ if (!open) return
83
+ let alive = true
84
+ setState({ kind: 'loading' })
85
+ api.browsePath(current ?? undefined).then(
86
+ (data) => {
87
+ if (!alive) return
88
+ // 起点是文件或不存在:宿主给出的 parent 就是「该去哪」,上浮一层再列(一次性)
89
+ if (
90
+ data.error === 'not-found'
91
+ && data.path === trimmedStart
92
+ && data.parent !== null
93
+ && risenFor.current !== data.path
94
+ ) {
95
+ risenFor.current = data.path
96
+ setCurrent(data.parent)
97
+ return
98
+ }
99
+ setState({ kind: 'ready', data })
100
+ },
101
+ (error: unknown) => {
102
+ if (!alive) return
103
+ setState({ kind: 'failed', detail: error instanceof Error ? error.message : String(error) })
104
+ },
105
+ )
106
+ return () => { alive = false }
107
+ }, [open, current, trimmedStart, api])
108
+
109
+ const data = state.kind === 'ready' ? state.data : null
110
+ const atRoots = data !== null && data.path === ''
111
+ const canGoUp = data !== null && data.parent !== null
112
+ const pathLabel = atRoots ? t('import.paths.picker.thisPc') : (data?.path ?? trimmedStart)
113
+
114
+ return (
115
+ <Modal open={open} onClose={onClose} title={t('import.paths.picker.title')} wide>
116
+ <Modal.Header title={t('import.paths.picker.title')} onClose={onClose} closeLabel={t('common.close')} />
117
+ <Modal.Body scroll style={{ maxHeight: '56vh' }}>
118
+ <div className={css.pickerPath} title={pathLabel}>{pathLabel}</div>
119
+ {state.kind === 'loading' && <Spinner label={t('common.loading')} />}
120
+ {state.kind === 'failed' && <Banner kind="error">{state.detail}</Banner>}
121
+ {state.kind === 'ready' && (
122
+ <>
123
+ {state.data.error !== null && <Banner kind="warn">{t(BROWSE_ERROR_LABEL[state.data.error])}</Banner>}
124
+ {state.data.error === null && state.data.truncated && (
125
+ <Banner kind="info">{t('import.paths.picker.truncated', { count: String(state.data.entries.length) })}</Banner>
126
+ )}
127
+ {state.data.error === null && state.data.entries.length === 0 && (
128
+ <div className={css.empty}>{t('import.paths.picker.empty')}</div>
129
+ )}
130
+ {state.data.entries.length > 0 && (
131
+ <div className={css.pickerList}>
132
+ {state.data.entries.map((entry) => (
133
+ <button
134
+ key={entry.path}
135
+ type="button"
136
+ className={css.pickerRow}
137
+ data-kind={entry.kind}
138
+ title={entry.path}
139
+ onClick={() => {
140
+ if (entry.kind === 'dir') setCurrent(entry.path)
141
+ else onPick(entry.path)
142
+ }}
143
+ >
144
+ {entry.kind === 'dir' ? <FolderIcon size={14} /> : <FileIcon size={14} />}
145
+ <span className={css.pickerName}>{entry.name}</span>
146
+ </button>
147
+ ))}
148
+ </div>
149
+ )}
150
+ </>
151
+ )}
152
+ </Modal.Body>
153
+ <Modal.Footer>
154
+ <Button onClick={() => { if (data?.parent != null) setCurrent(data.parent) }} disabled={!canGoUp}>
155
+ {t('import.paths.picker.up')}
156
+ </Button>
157
+ <Button onClick={() => { if (data !== null) setCurrent(data.home) }} disabled={data === null}>
158
+ {t('import.paths.picker.home')}
159
+ </Button>
160
+ <Button onClick={onClose}>{t('common.cancel')}</Button>
161
+ {/* 根列表(「此电脑」)本身不是可映射目录:没有「当前目录」可回填 */}
162
+ <Button
163
+ variant="primary"
164
+ disabled={data === null || atRoots}
165
+ onClick={() => { if (data !== null) onPick(data.path) }}
166
+ >
167
+ {t('import.paths.picker.useCurrent')}
168
+ </Button>
169
+ </Modal.Footer>
170
+ </Modal>
171
+ )
172
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * DSH 目录选择器解析层单测。
3
+ *
4
+ * 回归点(真机踩过的):服务名是 `uiWorkspace`,不是 `workspaces` ——
5
+ * 只写 `workspaces` 时真机上永远取不到服务,于是「复用 DSH 自带选择器」根本没发生,
6
+ * 用户看到的一直是插件自己的对话框。这个文件钉住解析顺序与降级。
7
+ */
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { makeDshDirectoryPicker } from './dsh-directory-pick.ts';
11
+ import type { DirectoryPickSources } from './dsh-directory-pick.ts';
12
+
13
+ test('DSH-01 桌面全局优先:__DSH_DIRECTORY_PICKER__.pick 直接返回路径', async () => {
14
+ const calls: string[] = [];
15
+ const sources: DirectoryPickSources = {
16
+ desktop: { pick: async () => { calls.push('desktop'); return '/desktop/path' } },
17
+ getService: (name) => ({ pickDirectory: async () => { calls.push(name); return '/service/path' } }),
18
+ };
19
+ assert.equal(await makeDshDirectoryPicker(sources)(), '/desktop/path');
20
+ assert.deepEqual(calls, ['desktop'], '全局有就不该再碰服务');
21
+ });
22
+
23
+ test('DSH-02 服务名 uiWorkspace(真机口径):即使 workspaces 不存在也必须成功', async () => {
24
+ const sources: DirectoryPickSources = {
25
+ getService: (name) => (name === 'uiWorkspace' ? { pickDirectory: async () => '/ws/path' } : undefined),
26
+ };
27
+ assert.equal(await makeDshDirectoryPicker(sources)(), '/ws/path');
28
+ });
29
+
30
+ test('DSH-03 老版本回退:只有 workspaces 时仍取得到', async () => {
31
+ const sources: DirectoryPickSources = {
32
+ getService: (name) => (name === 'workspaces' ? { pickDirectory: async () => '/legacy' } : undefined),
33
+ };
34
+ assert.equal(await makeDshDirectoryPicker(sources)(), '/legacy');
35
+ });
36
+
37
+ test('DSH-04 用户取消:服务返回 null 原样传出去(不是抛错)', async () => {
38
+ const sources: DirectoryPickSources = { getService: () => ({ pickDirectory: async () => null }) };
39
+ assert.equal(await makeDshDirectoryPicker(sources)(), null);
40
+ });
41
+
42
+ test('DSH-05 没有任何入口:抛错交给上层降级,绝不冒充「用户取消」', async () => {
43
+ await assert.rejects(makeDshDirectoryPicker({})(), /DSH directory picker unavailable/);
44
+ // 服务在但没有 pickDirectory(形态不符)同样算没有。
45
+ await assert.rejects(makeDshDirectoryPicker({ getService: () => ({ other: 1 }) })(), /unavailable/);
46
+ });
47
+
48
+ test('DSH-06 惰取:每次调用重新读服务,宿主重连后能拿到新实例', async () => {
49
+ let current: unknown = { pickDirectory: async () => '/first' };
50
+ const sources: DirectoryPickSources = { getService: () => current };
51
+ const pick = makeDshDirectoryPicker(sources);
52
+ assert.equal(await pick(), '/first');
53
+ current = { pickDirectory: async () => '/second' };
54
+ assert.equal(await pick(), '/second');
55
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * DSH 自带目录选择器的解析层。
3
+ *
4
+ * 真机取证(DSH 0.2.1-rc.3,`resources/app.asar` 内):
5
+ * - 桌面壳把原生选择器挂在全局:`globalThis.__DSH_DIRECTORY_PICKER__.pick(): Promise<string | null>`,
6
+ * DSH 自己的 `@deepseek-ai/dsh-client-ui-directory-picker-native` 优先用它。
7
+ * - Web 侧走服务:`new UiWorkspaceService(...)` 里 `super(ctx, "uiWorkspace")`,
8
+ * 即服务名是 **uiWorkspace**(不是 workspaces),方法 `pickDirectory(): Promise<string | null>`。
9
+ *
10
+ * 这里只做「按优先级找一个能用的入口」,找不到就抛错交给上层降级:
11
+ * 上层顺序是 DSH 原生 → 插件自己的系统对话框 → 内置浏览器。
12
+ */
13
+
14
+ /** 桌面壳全局选择器(Electron 侧注入;web 版本没有)。 */
15
+ export interface DesktopDirectoryPicker {
16
+ pick?: () => Promise<string | null>
17
+ }
18
+
19
+ /** DSH 客户端服务里只要这个能力。 */
20
+ export interface DirectoryPickService {
21
+ pickDirectory?: () => Promise<string | null>
22
+ }
23
+
24
+ /** 解析所需的两个来源(都可缺;测试直接注入替身)。 */
25
+ export interface DirectoryPickSources {
26
+ /** `globalThis.__DSH_DIRECTORY_PICKER__` */
27
+ desktop?: DesktopDirectoryPicker | undefined
28
+ /** `ctx.get(name)`:服务名随 DSH 版本变,两个都试。 */
29
+ getService?: ((name: string) => unknown) | undefined
30
+ }
31
+
32
+ /** 服务名按 DSH 版本从新到旧:0.2.x = uiWorkspace,更早 = workspaces。 */
33
+ const SERVICE_NAMES = ['uiWorkspace', 'workspaces'] as const
34
+
35
+ function asService(value: unknown): DirectoryPickService | undefined {
36
+ if (typeof value !== 'object' || value === null) return undefined
37
+ const candidate = value as DirectoryPickService
38
+ return typeof candidate.pickDirectory === 'function' ? candidate : undefined
39
+ }
40
+
41
+ /**
42
+ * 造一个惰取 thunk:每次都重新读服务(宿主重连/服务重建后旧快照会失效)。
43
+ * 找不到任何入口就抛错 —— 调用方据此归成 `unavailable` 并降级,绝不静默当成「用户取消」。
44
+ */
45
+ export function makeDshDirectoryPicker(sources: DirectoryPickSources): () => Promise<string | null> {
46
+ return async () => {
47
+ const desktop = sources.desktop
48
+ if (desktop !== undefined && typeof desktop.pick === 'function') return desktop.pick()
49
+ for (const name of SERVICE_NAMES) {
50
+ const service = asService(sources.getService?.(name))
51
+ if (service?.pickDirectory !== undefined) return service.pickDirectory()
52
+ }
53
+ throw new Error('config-manager: DSH directory picker unavailable')
54
+ }
55
+ }
@@ -12,6 +12,7 @@ import type { ImportAnalysis, PathMapping } from '../../core/types.ts'
12
12
  import type { ConsultReport } from '../../core/migration-consult.ts'
13
13
  import type { TranslateNS } from '../client-types.ts'
14
14
  import type { UiT } from '../../ui/i18n.ts'
15
+ import type { PathBrowsePort } from './PathPickerDialog.tsx'
15
16
  import {
16
17
  Badge, Banner, Button, Card, Checkbox, Empty, SectionTitle,
17
18
  } from '../common/ui.tsx'
@@ -276,12 +277,14 @@ export function PathMappingStage(props: {
276
277
  onBack: () => void
277
278
  onNext: () => void
278
279
  t: TranslateNS<'config-manager'>
280
+ /** 「选择本机路径」弹窗的数据源(宿主只读列举端点) */
281
+ api: PathBrowsePort
279
282
  }) {
280
- const { issues, mappings, onMappingsChange, onBack, onNext, t } = props
283
+ const { issues, mappings, onMappingsChange, onBack, onNext, t, api } = props
281
284
  return (
282
285
  <div className={css.viewBody}>
283
286
  <SectionTitle title={t('import.paths.title')} info={<InfoHint text={t('import.paths.hint')} label={t('common.infoHint')} />} />
284
- <PathMappingForm issues={issues} initial={mappings} t={t} onChange={onMappingsChange} />
287
+ <PathMappingForm issues={issues} initial={mappings} t={t} onChange={onMappingsChange} api={api} />
285
288
  <div className={css.actionRow}>
286
289
  <Button variant="ghost" onClick={onBack}>{t('common.back')}</Button>
287
290
  <Button variant="primary" onClick={onNext}>{t('common.next')}</Button>
@@ -21,6 +21,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
21
21
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
22
22
  // Type-only:拉入 SlotMap / LocaleNamespaceMap 合并表(dsh-client-ui-slots)。
23
23
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
24
+ import { makeDshDirectoryPicker } from './import/dsh-directory-pick.ts'
25
+ import type { DesktopDirectoryPicker } from './import/dsh-directory-pick.ts'
24
26
  import { ConfigManagerApi } from './api.ts'
25
27
  import { ConfigManagerSection } from './ConfigManagerSection.tsx'
26
28
  import { en, zh, type ConfigManagerKey } from './locales.ts'
@@ -136,7 +138,16 @@ export function apply(ctx: ClientContext): void {
136
138
  // 模块级单例(runStore 等)用同一个活源:它们没有调用方可以传 UiT。
137
139
  setUiLanguageSource(readLanguage)
138
140
  const uiT: UiT = makeLiveUiT(readLanguage)
139
- const api = new ConfigManagerApi(uiT)
141
+ // DSH 自带的目录选择器(「选择工作区目录」那个原生窗口):**点击那一刻**才读全局/服务,
142
+ // 装载期快照会在宿主重连/服务重建后失效。解析顺序与降级见 import/dsh-directory-pick.ts
143
+ // (真机取证:服务名是 uiWorkspace,不是 workspaces;桌面壳还会挂全局 __DSH_DIRECTORY_PICKER__)。
144
+ const pickDshDirectory = makeDshDirectoryPicker({
145
+ get desktop() {
146
+ return (globalThis as { __DSH_DIRECTORY_PICKER__?: DesktopDirectoryPicker }).__DSH_DIRECTORY_PICKER__
147
+ },
148
+ getService: (name) => ctx.get(name),
149
+ })
150
+ const api = new ConfigManagerApi(uiT, pickDshDirectory)
140
151
  const syncT = ctx.locale.bind(SYNC_NS)
141
152
  const syncApi = new SyncApi(uiT)
142
153
  const marketT = ctx.locale.bind(MARKET_NS)