dsh-ssh-workbench 1.7.8 → 1.7.9

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  > 产品对外名称:**SSH Workbench / 远程工作台**(v1.6.4 起统一)。包名 `dsh-ssh-workbench`、数据目录 `~/.dsh/ssh-workbench`。历史条目中的旧标识已统一为当前名称,便于检索。
6
6
 
7
+ ## [1.7.9] - 2026-11-14
8
+
9
+ ### 修复(Windows 系统「工作区文件」)
10
+
11
+ - 层级模式改为按**会话 `cwd`** 创建受限的本地文件浏览根,不再套用工作台本地面板的 `localRoot`。Windows 会话目录在用户目录之外(如 `C:\A\B\lumina`)时也可以正常浏览;越出会话目录仍会被拒绝。
12
+ - 面包屑首段显示会话完整路径,Windows 下用 `\` 分隔;路径根为完整 `C:\...` 时不再只显示最后一段。
13
+ - 本地文件路由支持 `session` 作用域;分块读写会记录并复用打开它的会话作用域。
14
+ - `withinRoot` 对 Windows 盘符/UNC 路径做大小写不敏感比较。
15
+ - 层级页工具栏增加「选择目录」按钮(调用 DSH Host 原生目录选择器),选择结果仍限制在当前会话目录内。
16
+
7
17
  ## [1.7.8] - 2026-11-14
8
18
 
9
19
  ### 变更(远程执行能力补齐)
package/README.md CHANGED
@@ -32,7 +32,7 @@ DeepSeek Harness 的**远程工作台**:SSH 终端、本地与远程文件、@
32
32
  | 执行统计 | composer 下方的 pill 只读展示本会话的远程执行次数,结构对齐 DSH Token 用量弹窗:标题「远程执行」+ 总次数、分隔线、目标名 → 次数;没有执行时不显示。执行中 pill 会实时显示当前目标(单目标为 `host165 · 执行中`,多目标为 `2 个目标 · 执行中`),1.5 秒轮询;它不切换目标、不打开工作台,也不显示成功/失败或用时 |
33
33
  | 远程工作台 | 左侧导航的独立页面:左侧终端(本地终端 / 远程 SSH 终端),右侧文件管理(本地用户主目录 / 远程 SFTP,远程默认打开登录 home),文件可**在工作台内直接预览**,拖动分隔条调整宽度;每个页签一个终端会话,仅活动页签轮询、非活动页签暂停并在切回时续读(UI 能力,不占模型工具) |
34
34
  | 侧边栏开关 | 插件管理 → 远程工作台 → 页面里的「在侧边栏显示远程工作台」可关闭左侧导航入口;关闭后 `@资源名` 远程执行、设置页与模型工具不受影响 |
35
- | 系统文件层级模式 | 插件管理 → 远程工作台 → 「系统文件浏览使用层级模式」默认关闭。开启后右侧栏「工作区文件」改为带面包屑/上级导航的层级浏览器,只浏览当前会话目录;保留 DSH 原生的刷新、搜索与「在应用中打开」(含选择其他应用);关闭恢复 DSH 原生页面 |
35
+ | 系统文件层级模式 | 插件管理 → 远程工作台 → 「系统文件浏览使用层级模式」默认关闭。开启后右侧栏「工作区文件」改为带面包屑/上级导航的层级浏览器,只浏览当前会话目录;保留 DSH 原生的刷新、搜索与「在应用中打开」(含选择其他应用),并提供「选择目录」按钮在会话目录内跳转;关闭恢复 DSH 原生页面 |
36
36
  | 危险命令黑名单 | `rm -rf /`、`mkfs`、`dd of=/dev/*`、`fdisk/parted/wipefs`、改根目录权限、fork bomb 等同步拦截;多行脚本、`&`、引号/转义、路径变体、`$()`/`eval` 都会先还原再判断;其他命令交给 DSH 审批 |
37
37
 
38
38
  **不做**:诊断包、HTTP 探测、跨主机文件分发、远程 IDE/SSHFS/透明代理(远程文件能力是显式的 `remote_*` 工具,不把本地 `read`/`write` 隐式重定向到远端)、命令分级(allow/guarded)、AI 风险解释和审计。
@@ -50,7 +50,7 @@ dsh plugin --profile desktop add dsh-ssh-workbench
50
50
  ```bash
51
51
  npm test
52
52
  npm run pack
53
- # 然后在 DSH 的插件管理里安装 dist/dsh-ssh-workbench-1.7.8.tgz
53
+ # 然后在 DSH 的插件管理里安装 dist/dsh-ssh-workbench-1.7.9.tgz
54
54
  ```
55
55
 
56
56
  发布包是**自包含**的:SSH 传输依赖(纯 JS 的 ssh2 及其依赖)已放在 `vendor/` 里随包携带,安装时不会运行 `node-gyp`、不需要 Xcode Command Line Tools,也不会额外下载依赖;包体积因此略大(约 1.1 MB),换来任何机器开箱即装。组件与许可证见 `vendor/NOTICE.md`。
@@ -101,7 +101,7 @@ npm run pack
101
101
  ## 已知限制
102
102
 
103
103
  - **stdout/stderr 顺序**:两者是独立管道,收集后分开返回(与原生 bash 的值契约一致),交错顺序会丢失;看 `make`/`pip install` 这类日志时需要留意。
104
- - **Windows 本地路径**:工作台本地文件面板已按 Windows 分隔符处理面包屑与文件名;极少数外部工具生成的混合分隔符路径仍可能显示成一段,可在路径编辑框直接输入绝对路径。
104
+ - **Windows 本地路径**:工作台本地文件面板按 Windows 分隔符处理面包屑与文件名;系统「工作区文件」层级模式以会话 `cwd` 为根,不受 `localRoot` 限制,可直接浏览 `C:\A\B\...` 这类工作区。
105
105
  - **`remote_exec` 批量规则**:一次最多 8 个 task、默认并发 4,所有结果共享 `maxBatchOutputBytes` 预算,超出会按任务截断;每个 task 都可以单独设置 `run_in_background`。
106
106
  - **后台任务**:`run_in_background` 返回的是远端 pid + 日志,不是 DSH 本地 job id;`timeoutMs` 和取消只影响启动调用,不作用于已脱离的进程。POSIX 日志默认写在 `~/.dsh/ssh-workbench/logs`(可用 `DSH_SSH_WORKBENCH_LOG_DIR` 覆盖),只有该目录不可写时才回退 `/tmp`。
107
107
  - **超时终止**:远端有 `timeout` 时,超时会连同远端进程组一起终止(支持 `-k` 时再补一次 KILL,缓冲时间可用 `timeoutKillAfterMs` 调整);远端没有 `timeout` 时只能停止等待,stderr 会提示"命令可能仍在运行"。**取消(Esc)不会立刻杀远端**:通道关闭后远端仍会计时到自己的 deadline。
@@ -1,4 +1,4 @@
1
- # SSH Workbench(dsh-ssh-workbench)架构(v1.7.8)
1
+ # SSH Workbench(dsh-ssh-workbench)架构(v1.7.9)
2
2
 
3
3
  > 产品定位与用法见 [README.md](../README.md);本文只讲实现分层。
4
4
 
@@ -10,7 +10,7 @@ npm test
10
10
  npm run pack
11
11
  ```
12
12
 
13
- 然后安装 `dist/dsh-ssh-workbench-1.7.8.tgz`。安装后重启/重载插件。
13
+ 然后安装 `dist/dsh-ssh-workbench-1.7.9.tgz`。安装后重启/重载插件。
14
14
 
15
15
  插件管理 → 远程工作台里可以打开「系统文件浏览使用层级模式」:右侧栏「工作区文件」会换成插件的层级浏览器,范围仅限当前会话目录;默认关闭。
16
16
 
package/lib/client.js CHANGED
@@ -194,6 +194,7 @@ window.__ModuleLoader__.load({
194
194
  'files.openInAppWith': '用 {app} 打开',
195
195
  'files.openInAppMore': '选择其他应用打开',
196
196
  'files.openInAppDefault': '{app}(默认)',
197
+ 'files.pickDirectory': '选择目录',
197
198
  'files.search': '搜索文件',
198
199
  'files.searchPlaceholder': '搜索文件名或路径',
199
200
  'files.searchClear': '清空搜索',
@@ -460,6 +461,7 @@ window.__ModuleLoader__.load({
460
461
  'files.openInAppWith': 'Open with {app}',
461
462
  'files.openInAppMore': 'Open with another app',
462
463
  'files.openInAppDefault': '{app} (default)',
464
+ 'files.pickDirectory': 'Choose folder',
463
465
  'files.search': 'Search files',
464
466
  'files.searchPlaceholder': 'Search by name or path',
465
467
  'files.searchClear': 'Clear search',
@@ -667,6 +669,8 @@ window.__ModuleLoader__.load({
667
669
  let sessionRemote = null
668
670
  /** Namespace service (`remote.workspaceFiles`) used by the local files search. */
669
671
  let workspaceFilesRemote = null
672
+ /** Workspace navigation service, for the Host-native directory picker. */
673
+ let uiWorkspaceService = null
670
674
 
671
675
 
672
676
  /** The open conversation's session id, exposed on the chat region for a11y/debugging. */
@@ -2511,6 +2515,7 @@ window.__ModuleLoader__.load({
2511
2515
  const iconFileGlyph = () => icon(14, [h('path', { d: 'M6.2 3.5h7.1l4.5 4.5v12.5H6.2z' }), h('path', { d: 'M13.3 3.5V8h4.5' })])
2512
2516
  const iconUpload = () => icon(14, [h('path', { d: 'M12 16V4' }), h('polyline', { points: '6 10 12 4 18 10' }), h('line', { x1: 4, y1: 20, x2: 20, y2: 20 })])
2513
2517
  const iconFolderPlus = () => icon(14, [h('path', { d: 'M3 7.4A1.7 1.7 0 0 1 4.7 5.7h3.9l1.8 2.1h7.9A1.7 1.7 0 0 1 20 9.5v7.8a1.7 1.7 0 0 1-1.7 1.7H4.7A1.7 1.7 0 0 1 3 17.3V7.4z' }), h('line', { x1: 12, y1: 12, x2: 12, y2: 17 }), h('line', { x1: 9.5, y1: 14.5, x2: 14.5, y2: 14.5 })])
2518
+ const iconFolderOpen = () => icon(14, [h('path', { d: 'M3 7.4A1.7 1.7 0 0 1 4.7 5.7h3.9l1.8 2.1h6.4A1.7 1.7 0 0 1 18.5 9.5v1' }), h('path', { d: 'M3.4 10.2h17.2l-1.8 7.4a1.5 1.5 0 0 1-1.5 1.2H5.2a1.5 1.5 0 0 1-1.5-1.2L2 11.6a1.2 1.2 0 0 1 1.4-1.4z' })])
2514
2519
 
2515
2520
  function nativeFileIcon(name) {
2516
2521
  const FileTypeIcon = primitives && primitives.FileTypeIcon
@@ -2985,6 +2990,18 @@ window.__ModuleLoader__.load({
2985
2990
  }
2986
2991
  }
2987
2992
 
2993
+ /** Ask the Host for a directory. The hierarchy page still clamps it to the session root. */
2994
+ async function pickHostDirectory() {
2995
+ const workspace = uiWorkspaceService
2996
+ if (!workspace || typeof workspace.pickDirectory !== 'function') return null
2997
+ try {
2998
+ const picked = await workspace.pickDirectory()
2999
+ return typeof picked === 'string' && picked ? picked : null
3000
+ } catch {
3001
+ return null
3002
+ }
3003
+ }
3004
+
2988
3005
  /**
2989
3006
  * List the apps the Host would use for one path: the same call the official
2990
3007
  * "open in app" split button makes, so the menu matches DSH exactly.
@@ -3075,12 +3092,23 @@ window.__ModuleLoader__.load({
3075
3092
  * gains "open in app" instead.
3076
3093
  */
3077
3094
  const localChrome = localMode && props.openNative === true
3095
+ /**
3096
+ * The hierarchy page asks the Host for the session workspace root. Local
3097
+ * file routes use this id to scope the browser to that workspace instead
3098
+ * of the workbench's localRoot (Windows workspaces may live elsewhere).
3099
+ */
3100
+ const localSession = localMode && typeof props.sessionId === 'string' && props.sessionId ? props.sessionId : undefined
3101
+ const filesPost = (path, payload) => post(path, localSession ? { ...payload, session: localSession } : payload)
3078
3102
  const withinRoot = (value) => {
3079
3103
  const normalize = (input) => String(input || '/').replace(/[\\/]+/g, '/').replace(/\/+$/, '') || '/'
3080
3104
  const root = normalize(navRoot)
3081
3105
  const next = normalize(value)
3082
3106
  if (root === '/') return next.startsWith('/')
3083
- return next === root || next.startsWith(`${root}/`)
3107
+ // Windows paths and UNC shares are case-insensitive.
3108
+ const fold = (text) => (/^[A-Za-z]:/.test(text) || text.startsWith('//') ? text.toLowerCase() : text)
3109
+ const foldedRoot = fold(root)
3110
+ const foldedNext = fold(next)
3111
+ return foldedNext === foldedRoot || foldedNext.startsWith(`${foldedRoot}/`)
3084
3112
  }
3085
3113
  const [servers, setServers] = useState([])
3086
3114
  const [server, setServer] = useState(localMode ? (controlledServer ?? '__local__') : (controlledServer ?? ''))
@@ -3178,7 +3206,7 @@ window.__ModuleLoader__.load({
3178
3206
  setLoading(true)
3179
3207
  setError('')
3180
3208
  try {
3181
- const body = await post('/sftp/list', { ref: name, path, ...(force ? { refresh: true } : {}) })
3209
+ const body = await filesPost('/sftp/list', { ref: name, path, ...(force ? { refresh: true } : {}) })
3182
3210
  if (seq !== loadSeqRef.current) return
3183
3211
  setEntries(sortEntries(body.result || []))
3184
3212
  } catch (cause) {
@@ -3259,6 +3287,15 @@ window.__ModuleLoader__.load({
3259
3287
  setDir(target)
3260
3288
  setNotice('')
3261
3289
  }
3290
+ const chooseDirectory = async () => {
3291
+ const picked = await pickHostDirectory()
3292
+ if (!picked) return
3293
+ if (lockRoot && !withinRoot(picked)) {
3294
+ setError(t('files.outsideRoot'))
3295
+ return
3296
+ }
3297
+ navigate(picked)
3298
+ }
3262
3299
  const goBack = () => {
3263
3300
  if (backStack.length === 0) return
3264
3301
  setDir(backStack[backStack.length - 1])
@@ -3413,7 +3450,7 @@ window.__ModuleLoader__.load({
3413
3450
  })
3414
3451
  let listing = entries
3415
3452
  try {
3416
- const body = await post('/sftp/list', { ref: server, path: dir })
3453
+ const body = await filesPost('/sftp/list', { ref: server, path: dir })
3417
3454
  listing = body.result || []
3418
3455
  } catch { /* keep the cached listing */ }
3419
3456
  const reserved = new Set(listing.map((entry) => entry.name))
@@ -3470,7 +3507,7 @@ window.__ModuleLoader__.load({
3470
3507
  let sessionError = null
3471
3508
  let currentBytes = 0
3472
3509
  try {
3473
- const opened = await post('/sftp/open-write', { ref: server, path: partPath, truncate: true })
3510
+ const opened = await filesPost('/sftp/open-write', { ref: server, path: partPath, truncate: true })
3474
3511
  session = opened.result.session
3475
3512
  sessionOpen = true
3476
3513
  partCreated = true
@@ -3486,7 +3523,7 @@ window.__ModuleLoader__.load({
3486
3523
  signal: controller.signal,
3487
3524
  loadChunk: async (offset, length) => new Uint8Array(await file.slice(offset, offset + length).arrayBuffer()),
3488
3525
  saveChunk: async (offset, chunk) => {
3489
- await post('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3526
+ await filesPost('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3490
3527
  },
3491
3528
  onChunkDone: (bytes) => { fileDone += bytes; currentBytes = fileDone; report() },
3492
3529
  onContiguousDone: (offset) => { contiguous = offset },
@@ -3495,19 +3532,19 @@ window.__ModuleLoader__.load({
3495
3532
  cancelled = true
3496
3533
  sessionError = new Error('cancelled')
3497
3534
  if (sessionOpen) {
3498
- try { await post('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3535
+ try { await filesPost('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3499
3536
  sessionOpen = false
3500
3537
  }
3501
3538
  if (single && partCreated) {
3502
3539
  singlePart = { partPath, targetPath, contiguousBytes: contiguous, sourceMtimeMs: file.lastModified, transferredBytes: currentBytes }
3503
3540
  }
3504
3541
  } else {
3505
- await post('/sftp/close-write', { session })
3542
+ await filesPost('/sftp/close-write', { session })
3506
3543
  sessionOpen = false
3507
3544
  if (target.overwrite) {
3508
- try { await post('/sftp/remove', { ref: server, path: targetPath }) } catch { /* may already be gone */ }
3545
+ try { await filesPost('/sftp/remove', { ref: server, path: targetPath }) } catch { /* may already be gone */ }
3509
3546
  }
3510
- await post('/sftp/rename', { ref: server, path: partPath, targetPath })
3547
+ await filesPost('/sftp/rename', { ref: server, path: partPath, targetPath })
3511
3548
  reserved.add(target.name)
3512
3549
  doneBytes += file.size
3513
3550
  uploaded += 1
@@ -3516,14 +3553,14 @@ window.__ModuleLoader__.load({
3516
3553
  } catch (cause) {
3517
3554
  lastError = cause
3518
3555
  if (sessionOpen) {
3519
- try { await post('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3556
+ try { await filesPost('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3520
3557
  }
3521
3558
  if (controller.signal.aborted) cancelled = true
3522
3559
  else failed += 1
3523
3560
  if (single && partCreated) {
3524
3561
  singlePart = { partPath, targetPath, contiguousBytes: contiguous, sourceMtimeMs: file.lastModified, transferredBytes: currentBytes }
3525
3562
  } else {
3526
- try { await post('/sftp/remove', { ref: server, path: partPath }) } catch { /* best effort */ }
3563
+ try { await filesPost('/sftp/remove', { ref: server, path: partPath }) } catch { /* best effort */ }
3527
3564
  }
3528
3565
  }
3529
3566
  if (cancelled) break
@@ -3600,7 +3637,7 @@ window.__ModuleLoader__.load({
3600
3637
  let session = ''
3601
3638
  let sessionOpen = false
3602
3639
  try {
3603
- const opened = await post('/sftp/open-write', { ref: server, path: record.partPath, truncate: false })
3640
+ const opened = await filesPost('/sftp/open-write', { ref: server, path: record.partPath, truncate: false })
3604
3641
  session = opened.result.session
3605
3642
  sessionOpen = true
3606
3643
  let fileDone = startOffset
@@ -3613,27 +3650,27 @@ window.__ModuleLoader__.load({
3613
3650
  signal: controller.signal,
3614
3651
  loadChunk: async (offset, length) => new Uint8Array(await file.slice(offset, offset + length).arrayBuffer()),
3615
3652
  saveChunk: async (offset, chunk) => {
3616
- await post('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3653
+ await filesPost('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3617
3654
  },
3618
3655
  onChunkDone: (bytes) => { fileDone += bytes; doneBytes = fileDone; report() },
3619
3656
  })
3620
3657
  if (outcome.outcome === 'cancelled') {
3621
3658
  patchRecord(record.id, { status: 'cancelled', bytes: doneBytes, contiguousBytes: outcome.contiguous, finishedAt: Date.now() })
3622
- await post('/sftp/close-write', { session, abort: true }).catch(() => undefined)
3659
+ await filesPost('/sftp/close-write', { session, abort: true }).catch(() => undefined)
3623
3660
  sessionOpen = false
3624
3661
  setNotice(t('files.transferStatusCancelled'))
3625
3662
  return
3626
3663
  }
3627
- await post('/sftp/close-write', { session })
3664
+ await filesPost('/sftp/close-write', { session })
3628
3665
  sessionOpen = false
3629
- try { await post('/sftp/remove', { ref: server, path: record.targetPath }) } catch { /* target may not exist */ }
3630
- await post('/sftp/rename', { ref: server, path: record.partPath, targetPath: record.targetPath })
3666
+ try { await filesPost('/sftp/remove', { ref: server, path: record.targetPath }) } catch { /* target may not exist */ }
3667
+ await filesPost('/sftp/rename', { ref: server, path: record.partPath, targetPath: record.targetPath })
3631
3668
  patchRecord(record.id, { status: 'completed', bytes: file.size, contiguousBytes: file.size, finishedAt: Date.now() })
3632
3669
  setNotice(t('files.transferStatusCompleted'))
3633
3670
  await loadDir(server, dir)
3634
3671
  } catch (cause) {
3635
3672
  if (sessionOpen) {
3636
- try { await post('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3673
+ try { await filesPost('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3637
3674
  }
3638
3675
  patchRecord(record.id, { status: controller.signal.aborted ? 'cancelled' : 'partial', bytes: doneBytes, contiguousBytes: startOffset, finishedAt: Date.now(), error: messageOf(cause) })
3639
3676
  setError(t('files.uploadFailed', { msg: messageOf(cause) }))
@@ -3670,7 +3707,7 @@ window.__ModuleLoader__.load({
3670
3707
  let session = ''
3671
3708
  let received = 0
3672
3709
  try {
3673
- const opened = await post('/sftp/open-read', { ref: server, path: entry.path })
3710
+ const opened = await filesPost('/sftp/open-read', { ref: server, path: entry.path })
3674
3711
  session = opened.result.session
3675
3712
  const size = opened.result.size ?? entry.size
3676
3713
  const data = new Uint8Array(size)
@@ -3679,7 +3716,7 @@ window.__ModuleLoader__.load({
3679
3716
  totalBytes: size,
3680
3717
  signal: controller.signal,
3681
3718
  loadChunk: async (offset, length) => {
3682
- const body = await post('/sftp/read-chunk', { session, offset, length })
3719
+ const body = await filesPost('/sftp/read-chunk', { session, offset, length })
3683
3720
  return filesBase64ToBytes(body.result.base64)
3684
3721
  },
3685
3722
  saveChunk: async (offset, chunk) => { data.set(chunk, offset) },
@@ -3697,7 +3734,7 @@ window.__ModuleLoader__.load({
3697
3734
  setError(t('files.downloadFailed', { msg: messageOf(cause) }))
3698
3735
  pushRecord({ direction: 'download', name: entry.name, kind: 'file', source: entry.path, destination: entry.name, status: controller.signal.aborted ? 'cancelled' : 'failed', totalBytes: entry.size, bytes: received, fileCount: 1, failedCount: 1, averageSpeed: 0, startedAt, finishedAt: Date.now(), targetServer: server, error: messageOf(cause) })
3699
3736
  } finally {
3700
- if (session) { try { await post('/sftp/close-read', { session }) } catch { /* closed */ } }
3737
+ if (session) { try { await filesPost('/sftp/close-read', { session }) } catch { /* closed */ } }
3701
3738
  setBusy('')
3702
3739
  endTransfer()
3703
3740
  }
@@ -3719,20 +3756,20 @@ window.__ModuleLoader__.load({
3719
3756
  let writeSession = ''
3720
3757
  let copied = 0
3721
3758
  try {
3722
- const openedRead = await post('/sftp/open-read', { ref: server, path: entry.path })
3759
+ const openedRead = await filesPost('/sftp/open-read', { ref: server, path: entry.path })
3723
3760
  readSession = openedRead.result.session
3724
- const openedWrite = await post('/sftp/open-write', { ref: server, path: targetPath, truncate: true })
3761
+ const openedWrite = await filesPost('/sftp/open-write', { ref: server, path: targetPath, truncate: true })
3725
3762
  writeSession = openedWrite.result.session
3726
3763
  const report = filesThrottle(() => patchTransfer({ label: t('files.copying', { name: entry.name }), ...transferProgress(copied, entry.size, startedAt) }))
3727
3764
  const outcome = await filesRunChunkWindow({
3728
3765
  totalBytes: entry.size,
3729
3766
  signal: controller.signal,
3730
3767
  loadChunk: async (offset, length) => {
3731
- const body = await post('/sftp/read-chunk', { session: readSession, offset, length }, { signal: controller.signal })
3768
+ const body = await filesPost('/sftp/read-chunk', { session: readSession, offset, length }, { signal: controller.signal })
3732
3769
  return filesBase64ToBytes(body.result.base64)
3733
3770
  },
3734
3771
  saveChunk: async (offset, chunk) => {
3735
- await post('/sftp/write-chunk', { session: writeSession, offset, contentBase64: filesBytesToBase64(chunk) }, { signal: controller.signal })
3772
+ await filesPost('/sftp/write-chunk', { session: writeSession, offset, contentBase64: filesBytesToBase64(chunk) }, { signal: controller.signal })
3736
3773
  },
3737
3774
  onChunkDone: (bytes) => { copied += bytes; report() },
3738
3775
  })
@@ -3740,20 +3777,20 @@ window.__ModuleLoader__.load({
3740
3777
  setNotice(t('files.transferStatusCancelled'))
3741
3778
  return
3742
3779
  }
3743
- await post('/sftp/close-write', { session: writeSession })
3780
+ await filesPost('/sftp/close-write', { session: writeSession })
3744
3781
  writeSession = ''
3745
- await post('/sftp/close-read', { session: readSession })
3782
+ await filesPost('/sftp/close-read', { session: readSession })
3746
3783
  readSession = ''
3747
3784
  setNotice(t('files.transferStatusCompleted'))
3748
3785
  await loadDir(server, dir)
3749
3786
  } catch (cause) {
3750
- if (writeSession) { try { await post('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3751
- if (readSession) { try { await post('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3752
- try { await post('/sftp/remove', { ref: server, path: targetPath }) } catch { /* best effort */ }
3787
+ if (writeSession) { try { await filesPost('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3788
+ if (readSession) { try { await filesPost('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3789
+ try { await filesPost('/sftp/remove', { ref: server, path: targetPath }) } catch { /* best effort */ }
3753
3790
  setError(t('files.copyFailed', { msg: messageOf(cause) }))
3754
3791
  } finally {
3755
- if (writeSession) { try { await post('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3756
- if (readSession) { try { await post('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3792
+ if (writeSession) { try { await filesPost('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3793
+ if (readSession) { try { await filesPost('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3757
3794
  setBusy('')
3758
3795
  endTransfer()
3759
3796
  }
@@ -3773,7 +3810,7 @@ window.__ModuleLoader__.load({
3773
3810
  setBusy('rename')
3774
3811
  setError('')
3775
3812
  try {
3776
- await post('/sftp/rename', { ref: server, path: entry.path, targetPath: joinRemotePath(parentRemotePath(entry.path), value) })
3813
+ await filesPost('/sftp/rename', { ref: server, path: entry.path, targetPath: joinRemotePath(parentRemotePath(entry.path), value) })
3777
3814
  setRenameTarget(null)
3778
3815
  setNotice(t('files.transferStatusCompleted'))
3779
3816
  await loadDir(server, dir)
@@ -3785,15 +3822,15 @@ window.__ModuleLoader__.load({
3785
3822
  }
3786
3823
 
3787
3824
  const removeRecursive = async (path, isDir) => {
3788
- if (!isDir) return await post('/sftp/remove', { ref: server, path })
3825
+ if (!isDir) return await filesPost('/sftp/remove', { ref: server, path })
3789
3826
  try {
3790
- return await post('/sftp/remove', { ref: server, path, isDir: true })
3827
+ return await filesPost('/sftp/remove', { ref: server, path, isDir: true })
3791
3828
  } catch (cause) {
3792
- const body = await post('/sftp/list', { ref: server, path })
3829
+ const body = await filesPost('/sftp/list', { ref: server, path })
3793
3830
  const children = body.result || []
3794
3831
  if (children.length === 0) throw cause
3795
3832
  for (const child of children) await removeRecursive(child.path, child.type === 'dir')
3796
- return await post('/sftp/remove', { ref: server, path, isDir: true })
3833
+ return await filesPost('/sftp/remove', { ref: server, path, isDir: true })
3797
3834
  }
3798
3835
  }
3799
3836
 
@@ -3823,7 +3860,7 @@ window.__ModuleLoader__.load({
3823
3860
  setBusy('mkdir')
3824
3861
  setError('')
3825
3862
  try {
3826
- await post('/sftp/mkdir', { ref: server, path: targetPath })
3863
+ await filesPost('/sftp/mkdir', { ref: server, path: targetPath })
3827
3864
  setDialog(null)
3828
3865
  setNotice(t('files.created', { name: value }))
3829
3866
  navigate(targetPath)
@@ -4245,6 +4282,12 @@ window.__ModuleLoader__.load({
4245
4282
  : appsMenu
4246
4283
  ? appsMenu
4247
4284
  : tool(t('files.openInApp'), iconRightUp(), () => { void openHostPath(dir) }, !server)
4285
+ // The hierarchy page is rooted at the session workspace. This picker lets
4286
+ // the user jump within that workspace without typing a path; selections
4287
+ // outside it are rejected by chooseDirectory().
4288
+ const pickControl = !localChrome
4289
+ ? null
4290
+ : tool(t('files.pickDirectory'), iconFolderOpen(), () => { void chooseDirectory() }, !server)
4248
4291
 
4249
4292
  const transferBar = transfer
4250
4293
  ? h('div', {
@@ -4495,7 +4538,7 @@ window.__ModuleLoader__.load({
4495
4538
  ? crumbs.map((crumb, index) => {
4496
4539
  const isLast = index === crumbs.length - 1
4497
4540
  return h('span', { key: crumb.path, style: { display: 'inline-flex', alignItems: 'center', flexShrink: 0 } },
4498
- index > 1 ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary, inherit)', padding: '0 1px' } }, '/') : null,
4541
+ index > 1 ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary, inherit)', padding: '0 1px' } }, pathSeparatorFor(navRoot)) : null,
4499
4542
  h('button', {
4500
4543
  type: 'button',
4501
4544
  className: 'dsh-ssh-workbench-files-crumb',
@@ -4531,6 +4574,7 @@ window.__ModuleLoader__.load({
4531
4574
  ? null
4532
4575
  : tool(t('files.newFolder'), iconFolderPlus(), () => setDialog({ value: '' }), !server || transfer != null),
4533
4576
  tool(showHidden ? t('files.hideHidden') : t('files.showHidden'), iconEye(showHidden), toggleHidden, !server, { 'aria-pressed': showHidden }),
4577
+ pickControl,
4534
4578
  tool(t('files.refresh'), RefreshIcon ? h(RefreshIcon, { size: 15 }) : refreshingIcon(), () => loadDir(server, dir, true), !server),
4535
4579
  searchToggle,
4536
4580
  localChrome
@@ -5595,6 +5639,8 @@ ${d.join(`
5595
5639
  // Workspace search for the local hierarchy page comes from the same
5596
5640
  // gateway namespace the native files page uses (packaged ripgrep).
5597
5641
  ctx.inject(['remote.workspaceFiles'], (inner) => { workspaceFilesRemote = inner.get('remote.workspaceFiles') ?? null })
5642
+ // Host-native directory picker used by the hierarchy page path control.
5643
+ ctx.inject(['uiWorkspace'], (inner) => { uiWorkspaceService = inner.get('uiWorkspace') ?? null })
5598
5644
  // Native file rows reuse the official context menu: right-click and
5599
5645
  // Shift+F10 come from its trigger bindings, and the actions below are
5600
5646
  // resolved when the menu opens.
@@ -5794,7 +5840,7 @@ ${d.join(`
5794
5840
  controlledServer: '__local__',
5795
5841
  rootPath: cwd,
5796
5842
  initialPath: cwd,
5797
- rootLabel: baseNameOf(cwd) || cwd,
5843
+ rootLabel: cwd,
5798
5844
  lockRoot: true,
5799
5845
  openNative: true,
5800
5846
  hideServerPicker: true,
package/lib/index.js CHANGED
@@ -368,10 +368,18 @@ export function apply(ctx, config, deps = {}) {
368
368
  // the OS user home (what users expect from the local pane of an SSH client);
369
369
  // `localRoot` can pin another directory. All access stays confined by localfs.
370
370
  let subprocessService = null
371
+ let sessionsService = null
371
372
  if (typeof ctx.inject === 'function') {
372
373
  ctx.inject(['subprocess'], (inner) => {
373
374
  subprocessService = inner.get('subprocess') ?? null
374
375
  })
376
+ // The hierarchy files page browses the session workspace, which may live
377
+ // outside the local-pane root (`C:\A\B\lumina` on Windows). Resolving the
378
+ // root from the live session keeps that page usable without widening the
379
+ // workbench's localRoot boundary for every other local operation.
380
+ ctx.inject(['sessions'], (inner) => {
381
+ sessionsService = inner.get('sessions') ?? null
382
+ })
375
383
  }
376
384
  const expandedLocalRoot = expandLocalRoot(config.localRoot)
377
385
  if (expandedLocalRoot && !path.isAbsolute(expandedLocalRoot)) {
@@ -405,6 +413,13 @@ export function apply(ctx, config, deps = {}) {
405
413
  prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
406
414
  terminals,
407
415
  localfs,
416
+ getSessionCwd: (sessionId) => {
417
+ try {
418
+ return sessionsService?.get?.(sessionId)?.header?.cwd
419
+ } catch {
420
+ return undefined
421
+ }
422
+ },
408
423
  publicConfig: () => publicConfig(config, loadPrefs()),
409
424
  }),
410
425
  )
package/lib/routes.js CHANGED
@@ -10,9 +10,11 @@
10
10
  * Every browser-facing request is fenced by `ctx.connection.requestRejection`
11
11
  * when the Connection service exists.
12
12
  */
13
+ import path from 'node:path'
13
14
  import { Readable } from 'node:stream'
14
15
 
15
16
  import { createLocalFileApi, LOCAL_FILE_REF } from './core/local-file-api.js'
17
+ import { createLocalFs } from './core/localfs.js'
16
18
  import { effectiveDialect } from './core/dialect.js'
17
19
  import { SshError } from './core/errors.js'
18
20
  import { summarizeExecution } from './core/execution-stats.js'
@@ -165,7 +167,7 @@ function lastExecutedServer(entries, session) {
165
167
  return entry?.server ?? null
166
168
  }
167
169
 
168
- export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, liveExecutions, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
170
+ export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, liveExecutions, refreshFacts, prefs, terminals, localfs, getSessionCwd, publicConfig, activity }) {
169
171
  const prefsApi = prefs ?? {
170
172
  load: () => ({ sidebar: true }),
171
173
  save: () => ({ sidebar: true }),
@@ -173,6 +175,36 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
173
175
  // Reuse the files panel for the local workspace by giving it the same
174
176
  // method surface as the SSH runtime under the synthetic `__local__` ref.
175
177
  const localFiles = localfs ? createLocalFileApi(localfs) : null
178
+ /**
179
+ * Session-scoped local file APIs. The hierarchy page browses a session's
180
+ * cwd, which may sit outside the local-pane root (e.g. `C:\A\B\lumina` on
181
+ * Windows). The scoped API keeps the same boundary checks, just rooted at the
182
+ * live session workspace instead of `localRoot`.
183
+ */
184
+ const scopedLocalFiles = new Map()
185
+ /** Local transfer session id → the API instance that opened it. */
186
+ const localTransferOwners = new Map()
187
+
188
+ function localApiForSession(sessionId) {
189
+ if (!localfs || !getSessionCwd || typeof sessionId !== 'string' || !sessionId) return localFiles
190
+ let cwd
191
+ try {
192
+ cwd = getSessionCwd(sessionId)
193
+ } catch {
194
+ return localFiles
195
+ }
196
+ if (typeof cwd !== 'string' || !cwd || !path.isAbsolute(cwd)) return localFiles
197
+ let api = scopedLocalFiles.get(cwd)
198
+ if (!api) {
199
+ api = createLocalFileApi(createLocalFs({ getRoot: () => cwd }))
200
+ scopedLocalFiles.set(cwd, api)
201
+ }
202
+ return api
203
+ }
204
+
205
+ function localTransferApi(sessionId) {
206
+ return localTransferOwners.get(sessionId) ?? (localFiles && sessionId.startsWith('loc') ? localFiles : null)
207
+ }
176
208
  // Small UI-facing caches: a repeated directory view or connection test should
177
209
  // not pay another round trip. Write operations invalidate the affected server.
178
210
  const TEST_TTL_MS = 30000
@@ -262,9 +294,10 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
262
294
  * workstream); fall back to `localfs.rootReal()` and finally the lexical
263
295
  * `localfs.root()` so this route keeps working while the two land.
264
296
  */
265
- async function localHomeDirectory() {
297
+ async function localHomeDirectory(sessionId) {
266
298
  const sources = []
267
- if (typeof localFiles?.sftpHome === 'function') sources.push(() => localFiles.sftpHome())
299
+ const api = localApiForSession(sessionId)
300
+ if (typeof api?.sftpHome === 'function') sources.push(() => api.sftpHome())
268
301
  if (typeof localfs?.rootReal === 'function') sources.push(() => localfs.rootReal())
269
302
  if (typeof localfs?.root === 'function') sources.push(() => localfs.root())
270
303
  if (sources.length === 0) throw new Error('local files are unavailable on this host')
@@ -291,11 +324,11 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
291
324
  }
292
325
 
293
326
  /** GET/POST /sftp/home → `{ ok:true, result:{ home } }` (U5). */
294
- async function serveSftpHome(res, ref) {
327
+ async function serveSftpHome(res, ref, sessionId) {
295
328
  if (!ref) return send(res, 400, { ok: false, error: 'ref is required' })
296
329
  try {
297
330
  if (ref === LOCAL_FILE_REF) {
298
- const home = await localHomeDirectory()
331
+ const home = await localHomeDirectory(sessionId)
299
332
  return send(res, 200, { ok: true, result: { home } })
300
333
  }
301
334
  const server = findTarget(loadServers(), ref)
@@ -366,15 +399,19 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
366
399
  if (req.method === 'GET' && rel === '/localfs/root') {
367
400
  if (!localfs) return send(res, 503, { ok: false, error: 'local files are unavailable on this host' })
368
401
  // S3: list()/entry paths are canonicalized through realpath, so the
369
- // navigation root handed to the client must use the same form.
370
- const root = typeof localfs.rootReal === 'function'
371
- ? await localfs.rootReal()
372
- : await Promise.resolve(localfs.root())
402
+ // navigation root handed to the client must use the same form. A
403
+ // `session` query scopes this to the session workspace (hierarchy page).
404
+ const api = localApiForSession(searchParams.get('session'))
405
+ const root = typeof api?.sftpHome === 'function'
406
+ ? await api.sftpHome()
407
+ : typeof localfs.rootReal === 'function'
408
+ ? await localfs.rootReal()
409
+ : await Promise.resolve(localfs.root())
373
410
  return send(res, 200, { ok: true, result: { root } })
374
411
  }
375
412
  if (req.method === 'GET' && rel === '/sftp/home') {
376
413
  const ref = resolveRef(searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'))
377
- return await serveSftpHome(res, ref)
414
+ return await serveSftpHome(res, ref, searchParams.get('session'))
378
415
  }
379
416
  if (req.method !== 'POST') return send(res, 404, { ok: false, error: 'not found' })
380
417
  const body = await readBody(req)
@@ -383,7 +420,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
383
420
  const ref = resolveRef(
384
421
  body.ref ?? body.server ?? body.name ?? searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'),
385
422
  )
386
- return await serveSftpHome(res, ref)
423
+ return await serveSftpHome(res, ref, body.session)
387
424
  }
388
425
 
389
426
  if (rel === '/prefs') {
@@ -451,7 +488,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
451
488
  // Chunked transfer sessions: the client holds a session id, so these
452
489
  // routes do not take a server ref. They must run before ref resolution.
453
490
  const sessionId = typeof body.session === 'string' ? body.session : ''
454
- const localTransfer = localFiles && sessionId.startsWith('loc') ? localFiles : null
491
+ const localTransfer = localTransferApi(sessionId)
455
492
  if (rel === '/sftp/write-chunk') {
456
493
  if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
457
494
  // B10: offset used to be silently coerced to 0 by the runtime, which
@@ -477,6 +514,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
477
514
  return send(res, 200, { ok: true, result })
478
515
  }
479
516
  const result = await fileOp('finish upload', undefined, () => api.sftpCloseTransfer(sessionId, { verify: true }))
517
+ localTransferOwners.delete(sessionId)
480
518
  // F1/B5/B8: the local adapter used to return no `server`, so the dir
481
519
  // cache was cleared for `undefined` and the activity log said
482
520
  // "unknown". Prefer the reported id, fall back to the synthetic local
@@ -508,6 +546,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
508
546
  if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
509
547
  const api = localTransfer ?? runtime
510
548
  const result = await fileOp('close download', undefined, () => api.sftpCloseTransfer(sessionId))
549
+ localTransferOwners.delete(sessionId)
511
550
  return send(res, 200, { ok: true, result })
512
551
  }
513
552
 
@@ -515,9 +554,11 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
515
554
  const servers = loadServers()
516
555
  let target
517
556
  let fileApi = runtime
557
+ let localApi = null
518
558
  if (ref === LOCAL_FILE_REF && localFiles) {
519
- target = localFiles.server
520
- fileApi = localFiles
559
+ localApi = localApiForSession(body.session)
560
+ target = localApi.server
561
+ fileApi = localApi
521
562
  } else {
522
563
  target = ref ? findTarget(servers, ref) : undefined
523
564
  }
@@ -619,11 +660,15 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
619
660
 
620
661
  if (rel === '/sftp/open-write') {
621
662
  if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
622
- return send(res, 200, { ok: true, result: await fileOp('open for writing', body.path, () => fileApi.sftpOpenWrite(target, body.path, body.truncate !== false)) })
663
+ const result = await fileOp('open for writing', body.path, () => fileApi.sftpOpenWrite(target, body.path, body.truncate !== false))
664
+ if (localApi && typeof result?.session === 'string') localTransferOwners.set(result.session, localApi)
665
+ return send(res, 200, { ok: true, result })
623
666
  }
624
667
  if (rel === '/sftp/open-read') {
625
668
  if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
626
- return send(res, 200, { ok: true, result: await fileOp('open for reading', body.path, () => fileApi.sftpOpenRead(target, body.path)) })
669
+ const result = await fileOp('open for reading', body.path, () => fileApi.sftpOpenRead(target, body.path))
670
+ if (localApi && typeof result?.session === 'string') localTransferOwners.set(result.session, localApi)
671
+ return send(res, 200, { ok: true, result })
627
672
  }
628
673
 
629
674
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-workbench",
3
- "version": "1.7.8",
3
+ "version": "1.7.9",
4
4
  "description": "远程工作台:SSH 终端、本地与远程文件、@服务器 对话执行;显式目标的远程 shell 与 read/glob/grep,Windows 宿主和目标都能用。DeepSeek Harness SSH workbench plugin.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",