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 +10 -0
- package/README.md +3 -3
- package/docs/architecture.md +1 -1
- package/docs/quickstart.md +1 -1
- package/lib/client.js +86 -40
- package/lib/index.js +15 -0
- package/lib/routes.js +61 -16
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
|
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。
|
package/docs/architecture.md
CHANGED
package/docs/quickstart.md
CHANGED
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3542
|
+
await filesPost('/sftp/close-write', { session })
|
|
3506
3543
|
sessionOpen = false
|
|
3507
3544
|
if (target.overwrite) {
|
|
3508
|
-
try { await
|
|
3545
|
+
try { await filesPost('/sftp/remove', { ref: server, path: targetPath }) } catch { /* may already be gone */ }
|
|
3509
3546
|
}
|
|
3510
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3664
|
+
await filesPost('/sftp/close-write', { session })
|
|
3628
3665
|
sessionOpen = false
|
|
3629
|
-
try { await
|
|
3630
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3759
|
+
const openedRead = await filesPost('/sftp/open-read', { ref: server, path: entry.path })
|
|
3723
3760
|
readSession = openedRead.result.session
|
|
3724
|
-
const openedWrite = await
|
|
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
|
|
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
|
|
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
|
|
3780
|
+
await filesPost('/sftp/close-write', { session: writeSession })
|
|
3744
3781
|
writeSession = ''
|
|
3745
|
-
await
|
|
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
|
|
3751
|
-
if (readSession) { try { await
|
|
3752
|
-
try { await
|
|
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
|
|
3756
|
-
if (readSession) { try { await
|
|
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
|
|
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
|
|
3825
|
+
if (!isDir) return await filesPost('/sftp/remove', { ref: server, path })
|
|
3789
3826
|
try {
|
|
3790
|
-
return await
|
|
3827
|
+
return await filesPost('/sftp/remove', { ref: server, path, isDir: true })
|
|
3791
3828
|
} catch (cause) {
|
|
3792
|
-
const body = await
|
|
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
|
|
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
|
|
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' } },
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 =
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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