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
@@ -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)
@@ -289,6 +289,19 @@ export const zh = {
289
289
  'import.paths.kind.homeMismatch': '基础路径不同',
290
290
  'import.paths.unresolved': '还有 {count} 条路径未映射(留空将跳过该路径,可以继续)',
291
291
  'import.paths.empty': '无需映射路径',
292
+ // 「选择本机路径」弹窗(第③项):浏览器拿不到本机真实路径,目录列举走宿主只读端点
293
+ 'import.paths.browse': '选择本机路径',
294
+ 'import.paths.picker.title': '选择本机路径',
295
+ 'import.paths.picker.up': '上一层',
296
+ 'import.paths.picker.home': '主目录',
297
+ 'import.paths.picker.thisPc': '此电脑',
298
+ 'import.paths.picker.useCurrent': '使用当前目录',
299
+ 'import.paths.picker.empty': '这个目录里没有可进入的项',
300
+ 'import.paths.picker.truncated': '只显示前 {count} 项',
301
+ 'import.paths.picker.notFound': '这个路径不存在(已为你打开上一级)',
302
+ 'import.paths.picker.denied': '没有读取这个目录的权限',
303
+ 'import.paths.picker.notAbsolute': '请填绝对路径(如 D:\\ 或 /)',
304
+ 'import.paths.picker.failed': '读取这个目录失败',
292
305
  // —— 快照恢复 ——
293
306
  'home.schedule.title': '定时备份',
294
307
  'snapshots.selectHint': '选择快照以预览恢复计划(只读预览,不会改动任何设置):',
@@ -1160,6 +1173,18 @@ export const en: Record<keyof typeof zh, string> = {
1160
1173
  'import.paths.kind.homeMismatch': 'Different base path',
1161
1174
  'import.paths.unresolved': '{count} path(s) unmapped (blank = skip that path, you can continue)',
1162
1175
  'import.paths.empty': 'No paths to map',
1176
+ 'import.paths.browse': 'Choose a local path',
1177
+ 'import.paths.picker.title': 'Choose a local path',
1178
+ 'import.paths.picker.up': 'Up one level',
1179
+ 'import.paths.picker.home': 'Home folder',
1180
+ 'import.paths.picker.thisPc': 'This PC',
1181
+ 'import.paths.picker.useCurrent': 'Use this folder',
1182
+ 'import.paths.picker.empty': 'Nothing to open in this folder',
1183
+ 'import.paths.picker.truncated': 'Showing the first {count} entries',
1184
+ 'import.paths.picker.notFound': 'That path does not exist (opened its parent instead)',
1185
+ 'import.paths.picker.denied': 'No permission to read this folder',
1186
+ 'import.paths.picker.notAbsolute': 'Enter an absolute path (e.g. D:\\ or /)',
1187
+ 'import.paths.picker.failed': 'Could not read this folder',
1163
1188
  'home.schedule.title': 'Schedule',
1164
1189
  'snapshots.selectHint': 'Select a snapshot to preview its restore plan (read-only preview, changes nothing):',
1165
1190
  'snapshots.status': 'Status',
@@ -29,6 +29,8 @@ declare module 'lucide-react/dist/esm/icons/download.mjs' { const i: LucideIconC
29
29
  declare module 'lucide-react/dist/esm/icons/eye.mjs' { const i: LucideIconComponent; export default i }
30
30
  declare module 'lucide-react/dist/esm/icons/file.mjs' { const i: LucideIconComponent; export default i }
31
31
  declare module 'lucide-react/dist/esm/icons/folder-input.mjs' { const i: LucideIconComponent; export default i }
32
+ declare module 'lucide-react/dist/esm/icons/folder.mjs' { const i: LucideIconComponent; export default i }
33
+ declare module 'lucide-react/dist/esm/icons/folder-open.mjs' { const i: LucideIconComponent; export default i }
32
34
  declare module 'lucide-react/dist/esm/icons/hard-drive-download.mjs' { const i: LucideIconComponent; export default i }
33
35
  declare module 'lucide-react/dist/esm/icons/history.mjs' { const i: LucideIconComponent; export default i }
34
36
  declare module 'lucide-react/dist/esm/icons/info.mjs' { const i: LucideIconComponent; export default i }
@@ -74,7 +74,7 @@ import type {
74
74
  import {
75
75
  buildSelectionRequest, buildSyncChannelBody, buildSyncConfigBody, buildSyncPushBody,
76
76
  channelBackfillFromStatus, channelRemoteReady, cloudFormFromStatus, computeEncryptInvalid, encryptToggle,
77
- githubPollDecision, hasSelectedSections, includeSecretsToggle, isGithubFlowInFlight, resolveInitialChannel,
77
+ githubPollDecision, hasSelectedSections, includeSecretsToggle, isGithubFlowInFlight, pickSyncPrimary, resolveInitialChannel,
78
78
  needsSessionInventory, saveQueueOnFlush, saveQueueOnRequest, saveQueueOnSettled, toggleSectionSelection,
79
79
  validateCloudForm, DEFAULT_S3_PROVIDER,
80
80
  SYNC_CONFIG_SAVE_DEBOUNCE_MS,
@@ -1261,10 +1261,10 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1261
1261
  {ready && (
1262
1262
  <Card>
1263
1263
  {/* 动作行(方案 A 定案):用户只面对两个方向 —— 下行「一键同步」/ 上行「推送到远端」。
1264
- 合并裁决并入一键同步(有基线走三方裁决视图、无基线走老导入确认),不再单独占按钮。 */}
1264
+ 合并裁决并入一键同步(有基线走三方裁决视图、无基线走老导入确认);并排两个按钮看不出该按哪个(issue 115)⇒ 按已确证远端事实只让一个落在 primary。 */}
1265
1265
  <div className={css.actionRow}>
1266
1266
  <Button
1267
- variant="primary"
1267
+ variant={pickSyncPrimary(state.statusInfo, channel) === 'sync' ? 'primary' : 'ghost'}
1268
1268
  disabled={busy}
1269
1269
  loading={state.busy === 'sync'}
1270
1270
  onClick={() => { void runSyncSmart(channel) }}
@@ -1273,7 +1273,7 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1273
1273
  ? (syncPhase === 'preview' ? t('syncflow.checkingRemote') : t('syncflow.syncing'))
1274
1274
  : t('syncflow.button')}
1275
1275
  </Button>
1276
- <Button disabled={!computeSyncButtons(state.busy, ready, uiT).canPush || githubBusy || !hasSelectedSections(cs) || computeEncryptInvalid(cs)} onClick={() => { switchChannel(channel); void runPushPreview() }}>
1276
+ <Button variant={pickSyncPrimary(state.statusInfo, channel) === 'push' ? 'primary' : 'ghost'} disabled={!computeSyncButtons(state.busy, ready, uiT).canPush || githubBusy || !hasSelectedSections(cs) || computeEncryptInvalid(cs)} onClick={() => { switchChannel(channel); void runPushPreview() }}>
1277
1277
  {computeSyncButtons(state.busy, ready, uiT).pushLabel}
1278
1278
  </Button>
1279
1279
  {/* 测试连接(只读自检):与两个主操作同一行、排在最后;结果走 toast(不再占页面高度) */}
@@ -294,7 +294,7 @@ export function cloudIssueKey(code: string): SyncKey | null {
294
294
  /* ------------------------------------------------ G-34:gist 通道不适合大分区 */
295
295
 
296
296
  /**
297
- * G-34(docs/spec/known-gaps.md):gist 通道**刻意不做内容寻址外置** —— 一次 GET 把全部
297
+ * G-34(docs/maintenance/known-gaps.md):gist 通道**刻意不做内容寻址外置** —— 一次 GET 把全部
298
298
  * 文件内容取回、单文件内容超限会被截断、文件数还有上限,因此会话这类大分区不适合走 gist。
299
299
  *
300
300
  * 本常量 = 「gist 表单」常驻提示的字典键(组件按它取 sync 字典文案渲染 hint)。
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 原生目录选择器的调度逻辑单测(不弹窗、不 spawn 真进程:全部走注入的 spawner)。
3
+ *
4
+ * 真机那半(PowerShell 的 IFileOpenDialog 到底能不能弹出来)无法在无头测试里断言,
5
+ * 由方案的 `Probe` 自检 + 人工目测覆盖;本文件只钉住**我们自己的**判断逻辑:
6
+ * 三态区分(ok / canceled / unsupported·failed)、取消不当错误、能力缺失必须回落、
7
+ * 起始路径与标题经环境变量传递、以及路径归一化不毁掉盘符根。
8
+ */
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import {
12
+ PICK_ENV_START,
13
+ PICK_ENV_TITLE,
14
+ defaultSpawner,
15
+ pickNativeDirectory,
16
+ } from './native-path-picker.ts';
17
+ import type { NativePickExec, NativePickRun } from './native-path-picker.ts';
18
+
19
+ /** 造一个 spawner:按调用顺序吐出预设结果,并记录每次 run(便于断言 argv/env)。 */
20
+ function scripted(results: Partial<NativePickExec>[]): { spawner: (run: NativePickRun) => Promise<NativePickExec>; runs: NativePickRun[] } {
21
+ const runs: NativePickRun[] = [];
22
+ let index = 0;
23
+ return {
24
+ runs,
25
+ spawner: async (run) => {
26
+ runs.push(run);
27
+ const preset = results[index] ?? {};
28
+ index += 1;
29
+ return { code: 0, stdout: '', stderr: '', missing: false, ...preset };
30
+ },
31
+ };
32
+ }
33
+
34
+ test('win32:选中路径 → ok,且起始路径/标题走环境变量而不是命令行', async () => {
35
+ const { spawner, runs } = scripted([{ code: 0, stdout: 'D:\\Projects\\picked\\\r\n' }]);
36
+ const result = await pickNativeDirectory({ platform: 'win32', startPath: 'D:\\Projects', title: '选择本机路径', spawner });
37
+ assert.deepEqual(result, { ok: true, path: 'D:\\Projects\\picked' });
38
+ const run = runs[0];
39
+ assert.ok(run !== undefined);
40
+ assert.equal(run.file, 'powershell');
41
+ // -STA 必须在:PowerShell 7 默认 MTA,COM 模态窗口会直接失败。
42
+ assert.ok(run.args.includes('-STA'));
43
+ assert.equal(run.env[PICK_ENV_START], 'D:\\Projects');
44
+ assert.equal(run.env[PICK_ENV_TITLE], '选择本机路径');
45
+ // 用户给的字符串绝不进 argv(否则就等于拼进脚本了)。
46
+ assert.ok(!run.args.some((arg) => arg.includes('picked') || arg.includes('选择本机路径')));
47
+ });
48
+
49
+ test('win32:退出码 2(用户取消)→ canceled,不是失败', async () => {
50
+ const { spawner } = scripted([{ code: 2 }]);
51
+ assert.deepEqual(await pickNativeDirectory({ platform: 'win32', spawner }), { ok: false, reason: 'canceled' });
52
+ });
53
+
54
+ test('win32:HRESULT 错误 → failed 并带回 detail', async () => {
55
+ const { spawner } = scripted([{ code: 3, stderr: 'ERR:-2147024809\r\n' }]);
56
+ const result = await pickNativeDirectory({ platform: 'win32', spawner });
57
+ assert.deepEqual(result, { ok: false, reason: 'failed', detail: 'ERR:-2147024809' });
58
+ });
59
+
60
+ test('win32:powershell 不存在 → unsupported(客户端回落内置浏览器)', async () => {
61
+ const { spawner } = scripted([{ missing: true, code: null }]);
62
+ const result = await pickNativeDirectory({ platform: 'win32', spawner });
63
+ assert.equal(result.ok, false);
64
+ assert.equal(result.ok === false ? result.reason : '', 'unsupported');
65
+ });
66
+
67
+ test('win32:空 stdout 当作取消处理(窗口关了但没给路径)', async () => {
68
+ const { spawner } = scripted([{ code: 0, stdout: ' \r\n' }]);
69
+ assert.deepEqual(await pickNativeDirectory({ platform: 'win32', spawner }), { ok: false, reason: 'canceled' });
70
+ });
71
+
72
+ test('win32:盘符根与 POSIX 根的原样保留 / 常规尾部反斜杠剥离', async () => {
73
+ for (const [stdout, expected] of [
74
+ ['D:\\\r\n', 'D:\\'],
75
+ ['D:\\Apps\\nodejs\\\r\n', 'D:\\Apps\\nodejs'],
76
+ ['/home/me/docs/\r\n', '/home/me/docs'],
77
+ ['/\r\n', '/'],
78
+ ] as const) {
79
+ const { spawner } = scripted([{ code: 0, stdout }]);
80
+ const result = await pickNativeDirectory({ platform: 'win32', spawner });
81
+ assert.deepEqual(result, { ok: true, path: expected }, `归一化 ${JSON.stringify(stdout)} 期望 ${expected}`);
82
+ }
83
+ });
84
+
85
+ test('darwin:osascript 选中 → ok;取消(-128 / 退出码 1)→ canceled', async () => {
86
+ const ok = scripted([{ code: 0, stdout: '/Users/me/Documents\n' }]);
87
+ assert.deepEqual(await pickNativeDirectory({ platform: 'darwin', spawner: ok.spawner }), { ok: true, path: '/Users/me/Documents' });
88
+ assert.equal(ok.runs[0]?.file, 'osascript');
89
+
90
+ const canceled = scripted([{ code: 0, stderr: 'execution error: User canceled. (-128)\n' }]);
91
+ assert.deepEqual(await pickNativeDirectory({ platform: 'darwin', spawner: canceled.spawner }), { ok: false, reason: 'canceled' });
92
+ });
93
+
94
+ test('linux:zenity 缺失时自动换 kdialog;两个都没有 → unsupported', async () => {
95
+ const fallback = scripted([{ missing: true, code: null }, { code: 0, stdout: '/home/me\n' }]);
96
+ assert.deepEqual(await pickNativeDirectory({ platform: 'linux', startPath: '/home', spawner: fallback.spawner }), { ok: true, path: '/home/me' });
97
+ assert.equal(fallback.runs[0]?.file, 'zenity');
98
+ assert.equal(fallback.runs[1]?.file, 'kdialog');
99
+
100
+ const none = scripted([{ missing: true, code: null }, { missing: true, code: null }]);
101
+ const result = await pickNativeDirectory({ platform: 'linux', spawner: none.spawner });
102
+ assert.equal(result.ok === false ? result.reason : '', 'unsupported');
103
+ });
104
+
105
+ test('其他平台:直接 unsupported,且根本不 spawn', async () => {
106
+ const { spawner, runs } = scripted([]);
107
+ const result = await pickNativeDirectory({ platform: 'freebsd', spawner });
108
+ assert.equal(result.ok === false ? result.reason : '', 'unsupported');
109
+ assert.equal(runs.length, 0);
110
+ });
111
+
112
+ test('defaultSpawner:超时也保留子进程已经吐出的 stderr(排障线索不能丢)', async () => {
113
+ const script = 'process.stderr.write("boom\\n"); setTimeout(() => {}, 10000)';
114
+ const exec = await defaultSpawner({ file: process.execPath, args: ['-e', script], env: {}, timeoutMs: 400 });
115
+ assert.equal(exec.code, null);
116
+ assert.match(exec.stderr, /timed out/);
117
+ assert.match(exec.stderr, /boom/);
118
+ });