dsh-vscode-mode 0.25.0 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -16,6 +16,8 @@
16
16
  import { readBinaryPreview } from '../shared/rpc.js'
17
17
  import { baseNameOf } from '../shared/fsNames.js'
18
18
  import { rpc } from './rpc.js'
19
+ import type { SaveFallback, SaveSink } from './downloadSink.js'
20
+ import { pickSaveSink, writeFileToDir } from './downloadSink.js'
19
21
  import type { TreeMenuTarget } from './sidebar/contextMenu.js'
20
22
  import type { SidebarCtx } from './sidebar/types.js'
21
23
  import { openDownloader } from './ui/DownloadPicker.js'
@@ -41,6 +43,8 @@ export interface DownloadOpts {
41
43
  signal?: AbortSignal
42
44
  /** 每完成一个文件回调一次(done 含成功与失败)。 */
43
45
  onProgress?: (done: number, total: number, path: string) => void
46
+ /** 落盘去处;缺省按浏览器默认下载目录(`<a download>`)。 */
47
+ sink?: SaveSink
44
48
  }
45
49
 
46
50
  // --endregion
@@ -85,16 +89,20 @@ export async function fetchDownload(path: string, sessionId?: string): Promise<{
85
89
  }
86
90
 
87
91
  /**
88
- * 下载单个文件并交给浏览器落盘。
92
+ * 下载单个文件并落盘(所选目录则按相对路径写入,否则交给浏览器 `<a download>`)。
89
93
  * @author ddj 2026年11月03号
90
94
  * @param path 工作区相对路径
91
- * @param opts 会话 / 取消选项
95
+ * @param opts 会话 / 取消 / 落盘选项
92
96
  * @returns 路径与字节数
93
- * @throws 读取失败透传主机错误
97
+ * @throws 读取失败透传主机错误;目录写入失败透传底层错误
94
98
  */
95
99
  export async function downloadOne(path: string, opts: DownloadOpts = {}): Promise<DownloadedFile> {
96
100
  const file = await fetchDownload(path, opts.sessionId)
97
- downloadBytes(file.bytes, baseNameOf(path) || 'download', file.mime)
101
+ if (opts.sink?.kind === 'dir') {
102
+ await writeFileToDir(opts.sink.dir, path, file.bytes)
103
+ } else {
104
+ downloadBytes(file.bytes, baseNameOf(path) || 'download', file.mime)
105
+ }
98
106
  return { path, size: file.size }
99
107
  }
100
108
 
@@ -148,22 +156,44 @@ export async function downloadTarget(target: TreeMenuTarget, ctx?: SidebarCtx):
148
156
  }
149
157
 
150
158
  /**
151
- * 批量下载并统一反馈(开始一条、结束一条,避免逐文件刷屏)。
159
+ * 批量下载并统一反馈(先选保存目录,再逐文件落盘;开始一条、结束一条,避免逐文件刷屏)。
152
160
  * @author ddj 2026年11月03号
153
161
  * @param paths 勾选路径
154
162
  * @param ctx 面板上下文
163
+ * @sideEffects 弹出目录选择器;用户取消则整批中止
155
164
  */
156
165
  async function runBatch(paths: string[], ctx?: SidebarCtx): Promise<void> {
157
166
  if (!paths.length) return
167
+
168
+ // 选择器必须在用户手势的同步栈里触发,故放在第一个 await 之前。
169
+ const sink = await pickSaveSink()
170
+ if (sink === 'cancelled') {
171
+ ctx?.notify?.('已取消下载')
172
+ return
173
+ }
174
+ if (sink.kind === 'browser') ctx?.notify?.(fallbackText(sink.reason))
158
175
  if (paths.length > 1) ctx?.notify?.('正在下载 ' + paths.length + ' 个文件…')
176
+
159
177
  try {
160
- const out = await downloadMany(paths, { sessionId: ctx?.sessionId })
178
+ const out = await downloadMany(paths, { sessionId: ctx?.sessionId, sink })
161
179
  ctx?.notify?.(batchText(out))
162
180
  } catch (error) {
163
181
  ctx?.notify?.('下载失败:' + textOfError(error))
164
182
  }
165
183
  }
166
184
 
185
+ /**
186
+ * 目录选择不可用时的提示文案(说明文件去了浏览器默认下载目录)。
187
+ * @author ddj 2026年11月03号
188
+ * @param reason 回落原因
189
+ * @returns 单行提示文案
190
+ */
191
+ function fallbackText(reason: SaveFallback): string {
192
+ if (reason === 'unsupported') return '浏览器不支持选择保存目录,已保存到默认下载目录'
193
+ if (reason === 'insecure') return '当前页面不是安全上下文(需 https 或 localhost),已保存到默认下载目录'
194
+ return '未获得目录写入权限,已保存到默认下载目录'
195
+ }
196
+
167
197
  /**
168
198
  * 批量结果口播文案。
169
199
  * @author ddj 2026年11月03号
@@ -0,0 +1,162 @@
1
+ /**
2
+ * dsh-vscode-mode client — 下载落盘位置(目录选择器)。
3
+ *
4
+ * 浏览器原生 `<a download>` 由浏览器决定保存位置,页面拿不到路径;本模块用
5
+ * File System Access API 的 `showDirectoryPicker` 让用户在点「下载」时选一次目标文件夹,
6
+ * 之后把文件按工作区相对路径逐个写进去(文件夹下载自动建子目录,仍不打包)。
7
+ *
8
+ * 约束:
9
+ * - 选择器必须在用户手势的同步栈里调用,故入口函数都把 `pickSaveSink()` 放在第一个 await;
10
+ * - 需要安全上下文(https / localhost)与 Chromium 系浏览器,Firefox / Safari / 局域网 http
11
+ * 一律回落到浏览器默认下载目录,并在界面上说明原因,不静默改语义;
12
+ * - 用户取消选择(AbortError)与「不可用」区分开:前者终止整批,后者继续下载。
13
+ * 作者 ddj 2026年11月03号
14
+ */
15
+
16
+ // --region 类型
17
+
18
+ /** 落盘去处:写入用户所选目录,或回落到浏览器默认下载目录。 */
19
+ export type SaveSink =
20
+ | { kind: 'dir'; dir: SinkDir }
21
+ | { kind: 'browser'; reason: SaveFallback }
22
+
23
+ /** 回落到浏览器下载的原因(调用方据此给出提示文案)。 */
24
+ export type SaveFallback = 'unsupported' | 'insecure' | 'denied'
25
+
26
+ /** 可写文件的句柄(File System Access API 的子集)。 */
27
+ export interface SinkWritable {
28
+ write(data: Uint8Array): Promise<void>
29
+ close(): Promise<void>
30
+ }
31
+
32
+ /** 文件句柄(仅用到创建可写流)。 */
33
+ export interface SinkFileHandle {
34
+ createWritable(): Promise<SinkWritable>
35
+ }
36
+
37
+ /** 目录句柄(仅用到列目录与建子目录/文件)。 */
38
+ export interface SinkDir {
39
+ name: string
40
+ getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<SinkDir>
41
+ getFileHandle(name: string, opts?: { create?: boolean }): Promise<SinkFileHandle>
42
+ queryPermission?(opts: { mode: 'readwrite' }): Promise<string>
43
+ requestPermission?(opts: { mode: 'readwrite' }): Promise<string>
44
+ }
45
+
46
+ /** `window.showDirectoryPicker` 的最小签名。 */
47
+ type DirPicker = (opts?: { id?: string; mode?: string; startIn?: string }) => Promise<SinkDir>
48
+
49
+ // --endregion
50
+
51
+ // --region 选择器
52
+
53
+ /**
54
+ * 当前浏览器/上下文是否可能弹出目录选择器(安全上下文 + API 存在)。
55
+ * @author ddj 2026年11月03号
56
+ * @returns 可用为 true;不安全上下文或缺 API 为 false
57
+ */
58
+ export function canPickDirectory(): boolean {
59
+ return pickerOf() !== undefined
60
+ }
61
+
62
+ /**
63
+ * 让用户选择保存目录。
64
+ * @author ddj 2026年11月03号
65
+ * @returns 所选目录 / 回落原因 / 'cancelled'(用户取消,整批终止)
66
+ * @sideEffects 弹出系统目录选择器,成功后可能触发一次写权限请求
67
+ */
68
+ export async function pickSaveSink(): Promise<SaveSink | 'cancelled'> {
69
+ const win = globalThis as { isSecureContext?: boolean; showDirectoryPicker?: DirPicker }
70
+ if (win.isSecureContext === false) return { kind: 'browser', reason: 'insecure' }
71
+
72
+ const picker = pickerOf()
73
+ if (!picker) return { kind: 'browser', reason: 'unsupported' }
74
+
75
+ let dir: SinkDir
76
+ try {
77
+ dir = await picker({ id: 'vsm-download', mode: 'readwrite', startIn: 'downloads' })
78
+ } catch (error) {
79
+ return isAbort(error) ? 'cancelled' : { kind: 'browser', reason: 'denied' }
80
+ }
81
+
82
+ return (await ensureWritable(dir)) ? { kind: 'dir', dir } : { kind: 'browser', reason: 'denied' }
83
+ }
84
+
85
+ /**
86
+ * 取回 `showDirectoryPicker`(不安全上下文视为没有)。
87
+ * @author ddj 2026年11月03号
88
+ * @returns 绑定到 globalThis 的选目录函数;不可用为 undefined
89
+ */
90
+ function pickerOf(): DirPicker | undefined {
91
+ const win = globalThis as { isSecureContext?: boolean; showDirectoryPicker?: DirPicker }
92
+ if (win.isSecureContext === false) return undefined
93
+ return typeof win.showDirectoryPicker === 'function' ? win.showDirectoryPicker.bind(win) : undefined
94
+ }
95
+
96
+ /**
97
+ * 确认目录可写(部分浏览器选择后仍需显式请求一次 readwrite 权限)。
98
+ * @author ddj 2026年11月03号
99
+ * @param dir 目录句柄
100
+ * @returns 已获得写权限为 true
101
+ * @throws 权限查询本身抛错时向上透传(由调用方按回落处理)
102
+ */
103
+ async function ensureWritable(dir: SinkDir): Promise<boolean> {
104
+ if (!dir.queryPermission) return true
105
+ if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') return true
106
+ if (!dir.requestPermission) return false
107
+ return (await dir.requestPermission({ mode: 'readwrite' })) === 'granted'
108
+ }
109
+
110
+ // --endregion
111
+
112
+ // --region 写入
113
+
114
+ /**
115
+ * 把字节写到所选目录下的相对路径处(自动逐级建子目录)。
116
+ * @author ddj 2026年11月03号
117
+ * @param dir 目标目录句柄
118
+ * @param relPath 工作区相对路径(分隔符 `/`;越界段 `..` 会被丢弃)
119
+ * @param bytes 原始字节
120
+ * @sideEffects 在用户所选目录内创建目录与文件并覆盖同名文件
121
+ * @throws 磁盘写入失败或文件名非法时透传底层错误
122
+ */
123
+ export async function writeFileToDir(dir: SinkDir, relPath: string, bytes: Uint8Array): Promise<void> {
124
+ const parts = pathSegments(relPath)
125
+ const fileName = parts.pop() ?? 'download'
126
+
127
+ let parent = dir
128
+ for (const part of parts) parent = await parent.getDirectoryHandle(part, { create: true })
129
+
130
+ const handle = await parent.getFileHandle(fileName, { create: true })
131
+ const writable = await handle.createWritable()
132
+ try {
133
+ await writable.write(bytes)
134
+ } finally {
135
+ await writable.close()
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 相对路径切段(过滤空段、`.` 与越界 `..`,兼容反斜杠)。
141
+ * @author ddj 2026年11月03号
142
+ * @param relPath 工作区相对路径
143
+ * @returns 安全的路径段(末段为文件名)
144
+ */
145
+ function pathSegments(relPath: string): string[] {
146
+ return relPath
147
+ .split(/[\\/]+/)
148
+ .map((part) => part.trim())
149
+ .filter((part) => part !== '' && part !== '.' && part !== '..')
150
+ }
151
+
152
+ /**
153
+ * 判断是否用户取消(AbortError),不依赖 DOMException 构造函数的存在。
154
+ * @author ddj 2026年11月03号
155
+ * @param error 捕获值
156
+ * @returns 取消为 true
157
+ */
158
+ function isAbort(error: unknown): boolean {
159
+ return typeof error === 'object' && error !== null && (error as { name?: string }).name === 'AbortError'
160
+ }
161
+
162
+ // --endregion
@@ -126,6 +126,9 @@ export const SIDECAR_KEEP_TITLE = '标记这些历史改动为已看过:只改
126
126
  /** 非官方 Undo 的作用域说明:走原有回滚校验写回磁盘,但不影响官方记录。 */
127
127
  export const SIDECAR_UNDO_TITLE = '撤销这些历史改动(写回受回滚校验保护):只影响这份历史记录,不影响官方审查。'
128
128
 
129
+ /** 非官方 Keep 之后的完成态:该通道 Keep 不写盘,不能沿用「已采纳」这种会让人以为改过文件的措辞。 */
130
+ export const SIDECAR_KEEP_DONE = '已标记为看过(未改动文件)'
131
+
129
132
  /** 当前端没有该会话时,差异栏里给中性说明而不是红色报错。 */
130
133
  export const SESSION_DOWN_NOTICE = '这个会话没在当前端激活,官方审查数据暂时读不到;下面的历史改动仍可查看、Keep、Undo。'
131
134
 
@@ -30,7 +30,15 @@ export async function loadPending(port: Pick<BatchPort, 'snapshot'>): Promise<Re
30
30
  return page.items
31
31
  }
32
32
 
33
- /** @public @author ddj 2026年10月27号 @param port Guarded existing per-file decision adapter. @param reject Undo instead of Keep. @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback. */
33
+ /**
34
+ * @public
35
+ * @author ddj 2026年10月27号
36
+ * @description: 逐条决策引擎,现仅供 Undo All 使用(Keep 已改走 `reviewKeepAll.ts` 的宿主批量通道);
37
+ * 保留 reject 形态以便对照既有契约与测试。
38
+ * @param port Guarded existing per-file decision adapter.
39
+ * @param reject Undo instead of Keep.
40
+ * @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback.
41
+ */
34
42
  export async function runReviewBatch(port: BatchPort, reject: boolean): Promise<BatchResult> {
35
43
  const result: BatchResult = { completed: 0, total: 0, paths: [], error: '' }
36
44
  try {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 批量 Keep:一次完整快照 + 一次批量 RPC,单条基线问题只跳过自己,不再整批中止。
3
+ * Undo 仍走 reviewBatch.ts 的逐条通道(写盘要逐项校验并在冲突处停止)。
4
+ * @author ddj 2026年11月03号
5
+ */
6
+ import type { KeepAllItem, KeepAllResult, ReviewItem, ReviewPage, ReviewResult } from '../shared/review.js'
7
+ import { loadPending } from './reviewBatch.js'
8
+
9
+ /** 批量 Keep 的 RPC 适配器;guard 在取快照前后各校验一次会话寿命。 */
10
+ export interface KeepAllPort {
11
+ snapshot: () => Promise<ReviewPage>
12
+ keepAll: (items: KeepAllItem[], batchId: string) => Promise<ReviewResult<{ result: KeepAllResult }>>
13
+ guard: () => void
14
+ }
15
+
16
+ /**
17
+ * @private
18
+ * @author ddj 2026年11月03号
19
+ * @description: 取一次完整待审快照;完整性校验失败时重取一次,避免瞬时漂移被当成不可恢复错误。
20
+ * @param port 已鉴权的 RPC 适配器
21
+ * @returns 冻结的批量目标
22
+ * @throws 两次都取不到一致快照时抛出原始原因
23
+ */
24
+ async function snapshotOnce(port: KeepAllPort): Promise<KeepAllItem[]> {
25
+ try {
26
+ return toItems(await loadPending(port))
27
+ } catch {
28
+ // 分页期间数量漂移多为瞬时:guard 仍有效就重新取一次,仍不一致才放弃
29
+ port.guard()
30
+ return toItems(await loadPending(port))
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @private
36
+ * @author ddj 2026年11月03号
37
+ * @description: 把快照条目裁成批量目标,只带 host 需要的身份与版本线索。
38
+ * @param items 完整待审集
39
+ * @returns 批量目标
40
+ */
41
+ function toItems(items: ReviewItem[]): KeepAllItem[] {
42
+ return items.map((item) => ({ id: item.id, display: item.display, seq: item.seq, revision: item.revision }))
43
+ }
44
+
45
+ /**
46
+ * @public
47
+ * @author ddj 2026年11月03号
48
+ * @description: 批量保留全部待审记录(Keep 不改写磁盘)。一次往返完成,跳过项只进 skipped,
49
+ * 段级异常才写 error;guard 失效时保留已完成计数交给调用方呈现。
50
+ * @param port 已鉴权的 RPC 适配器
51
+ * @returns 与 BatchResult 同构的批量结果(含跳过明细)
52
+ */
53
+ export async function runKeepAll(port: KeepAllPort): Promise<KeepAllResult> {
54
+ const result: KeepAllResult = { completed: 0, total: 0, paths: [], skipped: [], error: '' }
55
+ try {
56
+ port.guard()
57
+ const items = await snapshotOnce(port)
58
+ result.total = items.length
59
+ if (!items.length) return result
60
+
61
+ port.guard()
62
+ const reply = await port.keepAll(items, crypto.randomUUID())
63
+ if (!reply.ok) {
64
+ result.error = reply.error
65
+ return result
66
+ }
67
+ return reply.result
68
+ } catch (cause) {
69
+ result.error = cause instanceof Error ? cause.message : String(cause)
70
+ return result
71
+ }
72
+ }
@@ -32,6 +32,39 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
32
32
  return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key)
33
33
  }
34
34
 
35
+ /**
36
+ * @author ddj 2026年11月3号
37
+ * @description: 本页待审条目占用的路径键:路径本身与其身份各一份。
38
+ * @param page 审查页
39
+ * @returns 待审覆盖的路径键集合
40
+ */
41
+ function pendingCoverKeys(page: ReviewPage | null): Set<string> {
42
+ const keys = new Set<string>()
43
+ for (const item of page?.items ?? []) {
44
+ if (!item.id.startsWith(PENDING_PREFIX)) continue
45
+ // 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
46
+ // 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
47
+ // 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
48
+ keys.add(diffPathKey(item.currentPath || item.path))
49
+ keys.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
50
+ }
51
+ return keys
52
+ }
53
+
54
+ /**
55
+ * @public
56
+ * @author ddj 2026年11月3号
57
+ * @description: 取本页"已有权威通道在管"的路径键:官方汇总覆盖集 + 待审条目。
58
+ * 编辑器与差异汇总必须同口径,否则同一改动在列表里被压掉、在编辑器里却仍然画得出来并可以点。
59
+ * @param page 审查页
60
+ * @returns 路径比较键集合
61
+ */
62
+ export function coveredPathKeys(page: ReviewPage | null): Set<string> {
63
+ const keys = pendingCoverKeys(page)
64
+ for (const path of page?.overview?.coveredPaths ?? []) keys.add(diffPathKey(path))
65
+ return keys
66
+ }
67
+
35
68
  /**
36
69
  * @author ddj 2026年10月30号
37
70
  * @description: 取出本页待审条目(id 带待审前缀)并折算成文件行。
@@ -40,15 +73,9 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
40
73
  */
41
74
  function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Set<string> } {
42
75
  const rows: DiffFile[] = []
43
- const covered = new Set<string>()
44
76
  for (const item of page?.items ?? []) {
45
77
  if (!item.id.startsWith(PENDING_PREFIX)) continue
46
78
  const path = item.currentPath || item.path
47
- // 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
48
- // 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
49
- // 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
50
- covered.add(diffPathKey(path))
51
- covered.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
52
79
  rows.push({
53
80
  path,
54
81
  label: item.display || path,
@@ -59,7 +86,7 @@ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Se
59
86
  sourceKind: 'pending',
60
87
  })
61
88
  }
62
- return { rows, covered }
89
+ return { rows, covered: pendingCoverKeys(page) }
63
90
  }
64
91
 
65
92
  /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Pending first, then official; legacy paths remain explicitly labelled. */
@@ -114,6 +114,7 @@ export function DownloadPicker(props: DownloadPickerProps): React.ReactElement {
114
114
  },
115
115
  React.createElement('h3', null, '下载到本机'),
116
116
  React.createElement('div', { className: 'edrv-dl-sub' }, root ? '/' + root : '工作区根目录'),
117
+ React.createElement('div', { className: 'edrv-dl-hint' }, '点下载后会先选择保存文件夹,文件按相对路径写入'),
117
118
  renderTree({ state, root, rows, empty, loadDir, renderRow, onToggleDir, onToggleFile }),
118
119
  renderFoot({
119
120
  picked,
@@ -182,7 +183,7 @@ function renderFoot(opts: FootOpts): React.ReactElement {
182
183
  className: 'edrv-dl-go',
183
184
  disabled: !picked.length,
184
185
  onClick: () => onConfirm(picked),
185
- }, '下载 ' + picked.length + ' 个文件'),
186
+ }, '选择位置并下载 ' + picked.length + ' 个文件'),
186
187
  )
187
188
  }
188
189