dsh-remote 0.8.8 → 0.8.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/README.md +4 -1
- package/README.zh.md +4 -1
- package/lib/client.js +271 -85
- package/lib/index.js +239 -24
- package/lib/paths.js +48 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,9 @@ Real capture (host scrubbed to a placeholder):
|
|
|
36
36
|
- **`~/.ssh/config` import** — the Settings page lists your `Host` aliases; one click fills the form (path reference only, the plugin never reads key material).
|
|
37
37
|
- **Two-tab workspace picker** (fills the native "Add workspace" flow):
|
|
38
38
|
- **本机 / Local** — opens the **native OS folder chooser** over the host (macOS `osascript` / Linux `zenity`→`kdialog` / **Windows `FolderBrowserDialog`**), or lets you type a local path → adopted directly as a normal DSH local workspace.
|
|
39
|
-
- **远程 / Remote** — the picker is a **centered modal**. Pick a **machine** → the
|
|
39
|
+
- **远程 / Remote** — the picker is a **centered modal**. Pick a **machine** → on Windows hosts the root shows a **"This PC" drive view** (`C:\`, `D:\`, `E:\`… instead of the Git Bash MSYS root) and the path field live **autocompletes** directories (accepts `C:\Users\…` or `/c/Users/…` — Windows paths are rewritten to the Git Bash form underneath); selecting a directory immediately lists its next level. A **浏览…** floating browser (Windows-aware breadcrumb `此电脑 / C:\ / Users / dev`, drive rows, size + mtime, dirs first, follows symlinks) fills the field without committing; the **回上一级** button works at any depth (even when the browser was opened at the path bar's value). **最近 workspaces** quick-pick, **`~` 主目录** shortcut and **新建目录** are one click away. On confirm it creates a **real local mirror** under `$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<base>` that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP.
|
|
40
|
+
- **Git Bash default terminal (Windows remotes)** — the remote platform is auto-detected (`cmd /c ver`, plus an `uname -s` MINGW/MSYS probe as fallback); on Windows the plugin locates Git Bash (`config.shell` can pin a path or `native` disables wrapping) and pipes every command to `bash -s` over the exec channel, so quoting/backslash escaping is never an issue regardless of the SSH default shell. `rw_exec` runs with a Git Bash cwd (`/c/Users/…` form). `/dsh-remote/status`, `rw_info` and the 测试连接 button report the detected platform + shell.
|
|
41
|
+
- **Windows path auto-conversion** — typing `C:\Users\dev\project` (or `C:/…`, `/c/…`, `/C:/…`) is normalized underneath to the Git Bash form `/c/Users/dev/project` for shell commands, while workspaces are stored and shown Windows-style (`C:\Users\dev\project`). All model tools accept and report both forms; SFTP access uses the Win32-OpenSSH `/D:/…` form (see `toSftpPath`).
|
|
40
42
|
- **Bidirectional SFTP sync, conflict-aware** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) are **three-way** (remote vs local vs last-synced snapshot): files changed on both sides are **reported as conflicts and never silently overwritten** (`force=true` overrides). Both support **dry-run**, **background tasks**, and honor **gitignore-style ignore rules** (`.dsh-remote-ignore` under `remote-workspaces`, defaults cover `.git/node_modules/target/dist/build/…`).
|
|
41
43
|
- **Model tools** — 20 tools, all Windows/POSIX portable via SFTP: `rw_info`, `rw_connect` (with `save`), `rw_pick_workspace`, `rw_list_dir` (size+mtime), `rw_stat`, `rw_read_file` (encoding-aware: utf-8/gbk), `rw_write_file`, **`rw_edit`** (literal replace + mtime optimistic lock), `rw_append`, `rw_mkdir`, `rw_remove` (recursive, bounded), `rw_move`, `rw_exec` (pty/env), **`rw_search`** (SFTP tree walk — works on Windows too, honors ignore rules, context lines), `rw_download`/`rw_upload` (streaming fastGet/fastPut + size caps), **`rw_forward`** (SSH tunnels), `rw_sync`, `rw_push`, `rw_disconnect`.
|
|
42
44
|
- **Port forwarding panel** — create/start/stop/remove **local** (`127.0.0.1:port → remote`) and **reverse** (`remote → local`) tunnels in the Settings page or via `rw_forward`; definitions persist, auto-restart on reconnect when enabled, all tunnels stop on disconnect.
|
|
@@ -190,6 +192,7 @@ and should be done only when you intend to release.
|
|
|
190
192
|
| `privateKeyPath` | string | `''` | private key path (used only when explicitly provided) |
|
|
191
193
|
| `passphrase` | string | `''` | passphrase for an encrypted private key |
|
|
192
194
|
| `workspace` | string | `''` | default remote workspace path |
|
|
195
|
+
| `shell` | string | `''` | remote command terminal strategy: `''`=auto-detect (Git Bash on Windows remotes), `'git-bash'`=prefer Git Bash, `'native'`=never wrap, anything else=explicit bash.exe path (e.g. `C:\Program Files\Git\bin\bash.exe`) |
|
|
193
196
|
| `commandTimeoutMs` | int | 20000 | per remote command timeout |
|
|
194
197
|
| `connectTimeoutMs` | int | 15000 | SSH connect timeout |
|
|
195
198
|
| `maxFileBytes` | int | 52428800 | skip mirroring/reading files larger than this (0 = no cap) |
|
package/README.zh.md
CHANGED
|
@@ -35,7 +35,9 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
35
35
|
- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
|
|
36
36
|
- **双 tab 工作区选择器**(填充原生「Add workspace」流程):
|
|
37
37
|
- **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
|
|
38
|
-
- **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** →
|
|
38
|
+
- **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → Windows 主机根级显示 **「此电脑」多盘视图**(`C:\`、`D:\`、`E:\`…,而不是 Git Bash 的 MSYS 根),路径框**实时补全**目录(支持 `C:\Users\…` 或 `/c/Users/…` 任意写法,Windows 路径在底层自动改写为 Git Bash 形式);**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」文件选择式浮层**(Windows 面包屑 `此电脑 / C:\ / Users / dev` 可点击跳级、驱动器行、大小/时间、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定;「回上一级」任意深度可用(包括浮层直接打开在路径栏当前路径时)。**最近工作区**快捷入口、**`~` 主目录**、**新建目录**一键可达。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
|
|
39
|
+
- **Git Bash 默认终端(Windows 主机)** —— 自动探测远程平台(`cmd /c ver`,附 `uname -s` 的 MINGW/MSYS 探测兜底);Windows 机器自动定位 Git Bash(`config.shell` 可显式指定或 `native` 关闭),所有命令经 `bash -s` 从 SSH 通道 stdin 管道执行,不依赖 cmd/PowerShell,也不受引号/反斜杠转义困扰;`rw_exec` 默认在 Git Bash 形式的 cwd(`/c/Users/…`)下执行。`/dsh-remote/status`、`rw_info`、设置页「测试连接」都会报告检测到的平台与 shell。
|
|
40
|
+
- **Windows 路径自动改写** —— 用户输入 `C:\Users\dev\project`(或 `C:/…`、`/c/…`、`/C:/…`)时底层自动规范为 Git Bash 形式 `/c/Users/dev/project` 执行;工作区存储与展示为 Windows 形式 `C:\Users\dev\project`。模型工具全部接受并展示两种写法;SFTP 访问使用 Win32-OpenSSH 的 `/D:/…` 形式(见 `toSftpPath`)。
|
|
39
41
|
- **双向 SFTP 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。
|
|
40
42
|
- **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`(默认在工作区目录执行,可传 `cwd=`)、`rw_search`(可移植递归 grep)、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_disconnect`。
|
|
41
43
|
- **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。
|
|
@@ -170,6 +172,7 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
170
172
|
| `password` | string | `''` | 默认 SSH 密码(非空覆盖 key) |
|
|
171
173
|
| `privateKeyPath` | string | `''` | 私钥路径(仅在显式提供时使用) |
|
|
172
174
|
| `workspace` | string | `''` | 默认远程工作区路径 |
|
|
175
|
+
| `shell` | string | `''` | 远程命令终端策略:`''`=自动检测(Windows 找 Git Bash)、`'git-bash'`=优先 Git Bash、`'native'`=不包装、其他=显式 bash.exe 路径(如 `C:\Program Files\Git\bin\bash.exe`) |
|
|
173
176
|
| `commandTimeoutMs` | int | 20000 | 单条远程命令超时 |
|
|
174
177
|
| `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
|
|
175
178
|
| `maxFileBytes` | int | 52428800 | 镜像同步时跳过超过该大小的文件(0=不设上限) |
|
package/lib/client.js
CHANGED
|
@@ -86,6 +86,29 @@ window.__ModuleLoader__.load({
|
|
|
86
86
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Write text to the clipboard. Prefers the async Clipboard API (works on
|
|
90
|
+
// http://127.0.0.1 secure contexts) and falls back to the legacy
|
|
91
|
+
// execCommand('copy') path, mirroring primitives' writeClipboard semantics.
|
|
92
|
+
function copyToClipboard(text) {
|
|
93
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
94
|
+
return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const ta = document.createElement('textarea')
|
|
98
|
+
ta.value = String(text)
|
|
99
|
+
ta.style.position = 'fixed'
|
|
100
|
+
ta.style.opacity = '0'
|
|
101
|
+
document.body.appendChild(ta)
|
|
102
|
+
ta.focus()
|
|
103
|
+
ta.select()
|
|
104
|
+
const ok = document.execCommand('copy')
|
|
105
|
+
ta.remove()
|
|
106
|
+
return Promise.resolve(ok)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return Promise.resolve(false)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
let WORKSPACES = null // ctx.get('workspaces'), set in apply()
|
|
90
113
|
let SESSIONS = null // ctx.get('sessions'), set in apply()
|
|
91
114
|
|
|
@@ -118,7 +141,10 @@ window.__ModuleLoader__.load({
|
|
|
118
141
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
119
142
|
})
|
|
120
143
|
.then((r) => {
|
|
121
|
-
if (r && r.ok) {
|
|
144
|
+
if (r && r.ok) {
|
|
145
|
+
const shellTag = r.platform === 'windows' ? (r.gitBash ? '(Windows · Git Bash)' : '(Windows · 未找到 Git Bash)') : (r.platform === 'posix' ? '(POSIX)' : '')
|
|
146
|
+
setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)${shellTag}` : '连接成功'); refresh()
|
|
147
|
+
}
|
|
122
148
|
else { setTst('failing'); setErr((r && r.error) || '连接失败') }
|
|
123
149
|
})
|
|
124
150
|
.catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
|
|
@@ -409,25 +435,40 @@ window.__ModuleLoader__.load({
|
|
|
409
435
|
return out
|
|
410
436
|
}
|
|
411
437
|
|
|
412
|
-
// Clickable breadcrumb of the current remote path.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
438
|
+
// Clickable breadcrumb of the current remote path. Windows remotes show a
|
|
439
|
+
// "This PC" root and `\` separators (此电脑 / C:\ / Users / dev); POSIX `/`.
|
|
440
|
+
function breadcrumb(active, cur, win, jumpTo) {
|
|
441
|
+
const raw = String(cur || '')
|
|
442
|
+
const isWinPath = win && (raw === '' || /^[a-zA-Z]:/.test(raw))
|
|
443
|
+
const rootLabel = isWinPath ? '此电脑' : '/'
|
|
416
444
|
const crumbs = []
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
crumbs.push(React.createElement('span', { key:
|
|
423
|
-
crumbs.push(React.createElement('span', {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
445
|
+
const rootStyle = { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted, whiteSpace: 'nowrap' }
|
|
446
|
+
crumbs.push(React.createElement('span', { key: 'root', style: rootStyle, onClick: active ? () => jumpTo('') : undefined }, rootLabel))
|
|
447
|
+
if (isWinPath && raw !== '') {
|
|
448
|
+
const driveM = raw.match(/^([a-zA-Z]):(.*)$/)
|
|
449
|
+
const drive = driveM[1].toUpperCase() + ':\\'
|
|
450
|
+
crumbs.push(React.createElement('span', { key: 'sep0', style: { color: T.muted } }, '\\'))
|
|
451
|
+
crumbs.push(React.createElement('span', { key: 'drive', style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: raw === drive ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(drive) : undefined }, drive))
|
|
452
|
+
let acc = drive
|
|
453
|
+
for (const s of (driveM[2] || '').split('\\')) {
|
|
454
|
+
if (!s) continue
|
|
455
|
+
acc += s + '\\'
|
|
456
|
+
const target = acc.slice(0, -1)
|
|
457
|
+
crumbs.push(React.createElement('span', { key: 's|' + target, style: { color: T.muted } }, '\\'))
|
|
458
|
+
crumbs.push(React.createElement('span', { key: target, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: target === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(target) : undefined }, s))
|
|
459
|
+
}
|
|
460
|
+
} else if (!isWinPath && raw !== '' && raw !== '/') {
|
|
461
|
+
const segs = raw.replace(/\/+$/, '').split('/')
|
|
462
|
+
let acc = ''
|
|
463
|
+
for (const s of segs) {
|
|
464
|
+
if (!s) continue
|
|
465
|
+
acc += '/' + s
|
|
466
|
+
crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
|
|
467
|
+
crumbs.push(React.createElement('span', { key: acc, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(acc) : undefined }, s))
|
|
468
|
+
}
|
|
428
469
|
}
|
|
429
|
-
if (
|
|
430
|
-
return React.createElement('span', { key: 'crumb' }, crumbs)
|
|
470
|
+
if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? ' 选择驱动器' : ''))
|
|
471
|
+
return React.createElement('span', { key: 'crumb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', maxWidth: '100%' } }, crumbs)
|
|
431
472
|
}
|
|
432
473
|
|
|
433
474
|
function DirPicker(props) {
|
|
@@ -444,6 +485,7 @@ window.__ModuleLoader__.load({
|
|
|
444
485
|
const [suggest, setSuggest] = React.useState([])
|
|
445
486
|
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
446
487
|
const [recent, setRecent] = React.useState([])
|
|
488
|
+
const [win, setWin] = React.useState(false) // remote platform: windows = Git-Bash host
|
|
447
489
|
// 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
|
|
448
490
|
const [localPop, setLocalPop] = React.useState(null)
|
|
449
491
|
const [newDirName, setNewDirName] = React.useState('')
|
|
@@ -472,15 +514,29 @@ window.__ModuleLoader__.load({
|
|
|
472
514
|
return dirs.concat(files)
|
|
473
515
|
}
|
|
474
516
|
|
|
517
|
+
// Client-side full-path fallback: when the server did not attach a `path`
|
|
518
|
+
// (older host half), join base + name with the base's own separator.
|
|
519
|
+
const joinDisplay = (base, name) => {
|
|
520
|
+
const b = String(base || '')
|
|
521
|
+
const n = String(name || '')
|
|
522
|
+
if (!b || b === '/') return '/' + n
|
|
523
|
+
const sep = /^[a-zA-Z]:/.test(b) || b.includes('\\') ? '\\' : '/'
|
|
524
|
+
return b.replace(/[\\/]+$/, '') + sep + n
|
|
525
|
+
}
|
|
526
|
+
|
|
475
527
|
const loadLevels = (id, p, toIndex) => {
|
|
476
528
|
if (!id) return
|
|
477
529
|
setLoading(true); setErr('')
|
|
478
530
|
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
479
531
|
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
|
|
480
532
|
.then((res) => {
|
|
533
|
+
setWin(res && res.platform === 'windows')
|
|
481
534
|
const real = res && res.path ? res.path : (p || '')
|
|
535
|
+
// Server returns [{ type, name, size, mtime, path }] where `path` is
|
|
536
|
+
// the FULL display-form path (C:\Users\dev on Windows) — the client
|
|
537
|
+
// never joins paths itself, so drives and Windows paths just work.
|
|
482
538
|
const list = Array.isArray(res && res.items)
|
|
483
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime }))
|
|
539
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
|
|
484
540
|
: parseLs(res && res.text)
|
|
485
541
|
const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
|
|
486
542
|
setLevels((prev) => {
|
|
@@ -496,6 +552,9 @@ window.__ModuleLoader__.load({
|
|
|
496
552
|
}
|
|
497
553
|
|
|
498
554
|
React.useEffect(() => { if (open) {
|
|
555
|
+
// Bug fix: the picker must NOT reopen with the previous selection's
|
|
556
|
+
// path — reset the path bar (and the browse state) on every open.
|
|
557
|
+
setPath(''); setLevels(null); setWin(false)
|
|
499
558
|
setLocalPop(null); setNewDirName('')
|
|
500
559
|
api('GET', '/dsh-remote/machines').then((r) => {
|
|
501
560
|
setMachines(r.machines || [])
|
|
@@ -548,19 +607,47 @@ window.__ModuleLoader__.load({
|
|
|
548
607
|
|
|
549
608
|
const switchTab = (t) => {
|
|
550
609
|
setTab(t); setErr('')
|
|
610
|
+
// each tab keeps its own path — never leak the other tab's selection
|
|
611
|
+
setPath('')
|
|
551
612
|
if (t === 'remote') {
|
|
613
|
+
// Windows remotes show a "This PC" drive view at the root; POSIX shows
|
|
614
|
+
// the filesystem root. Either way loadLevels('') lists that root.
|
|
552
615
|
if (machineId) loadLevels(machineId, '', 0)
|
|
553
|
-
if (!path.trim()) { setPath('/'); loadSuggestions('/') }
|
|
554
616
|
}
|
|
555
617
|
}
|
|
556
618
|
|
|
557
|
-
|
|
558
|
-
|
|
619
|
+
// Parent path of a display-form remote path (client-side, both separators).
|
|
620
|
+
const parentOf = (p) => {
|
|
621
|
+
const t = String(p || '')
|
|
622
|
+
if (!t || t === '/') return null
|
|
623
|
+
const win = /^[a-zA-Z]:/.test(t)
|
|
624
|
+
if (win && /^[a-zA-Z]:\\?$/.test(t)) return null // drive root: no parent
|
|
625
|
+
const sep = win ? '\\' : '/'
|
|
626
|
+
const idx = t.lastIndexOf(sep)
|
|
627
|
+
if (idx <= 0) return null
|
|
628
|
+
const par = t.slice(0, idx)
|
|
629
|
+
if (win && /^[a-zA-Z]:$/.test(par)) return par + '\\'
|
|
630
|
+
return par || '/'
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// enterDir(it): drive into the given entry — `it.path` is the FULL
|
|
634
|
+
// display-form path from the server (drives C:\, Windows and POSIX alike).
|
|
635
|
+
const enterDir = (it) => {
|
|
636
|
+
if (busy || loading || !it || !it.dir) return
|
|
637
|
+
loadLevels(machineId, it.path, (levels ? levels.length : 0))
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Navigate one level up in the browse popup. When the current level was
|
|
641
|
+
// opened directly at a deep path (levels.length === 1 — e.g. the popup
|
|
642
|
+
// was opened at the path bar's value) the parent is loaded as the new top
|
|
643
|
+
// level instead of being disabled.
|
|
644
|
+
const goUp = () => {
|
|
645
|
+
if (loading) return
|
|
559
646
|
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
loadLevels(machineId,
|
|
647
|
+
if (!last) return
|
|
648
|
+
if (levels.length > 1) { setLevels((p) => p.slice(0, p.length - 1)); return }
|
|
649
|
+
const par = parentOf(last.path)
|
|
650
|
+
if (par) loadLevels(machineId, par, 1)
|
|
564
651
|
}
|
|
565
652
|
|
|
566
653
|
const loadSuggestions = (raw, mid) => {
|
|
@@ -568,20 +655,23 @@ window.__ModuleLoader__.load({
|
|
|
568
655
|
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
569
656
|
const t = String(raw || '').trim()
|
|
570
657
|
if (!t) { setSuggest([]); setSuggestOpen(false); return }
|
|
571
|
-
|
|
572
|
-
const
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
|
|
658
|
+
// Separator-aware split: `\` when the input looks like a Windows path.
|
|
659
|
+
const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
|
|
660
|
+
const sep = backslash ? '\\' : '/'
|
|
661
|
+
const idx = t.lastIndexOf(sep)
|
|
662
|
+
let parent, lastSeg
|
|
663
|
+
if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
|
|
664
|
+
else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
|
|
665
|
+
else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
|
|
576
666
|
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
577
667
|
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
|
|
578
668
|
.then((res) => {
|
|
579
669
|
const list = Array.isArray(res && res.items)
|
|
580
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
581
|
-
:
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
.slice(0, 40).map((it) =>
|
|
670
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
|
|
671
|
+
: []
|
|
672
|
+
const matches = list
|
|
673
|
+
.filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
|
|
674
|
+
.slice(0, 40).map((it) => it.path)
|
|
585
675
|
setSuggest(matches)
|
|
586
676
|
setSuggestOpen(!!matches.length)
|
|
587
677
|
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
@@ -596,23 +686,20 @@ window.__ModuleLoader__.load({
|
|
|
596
686
|
const continueSuggest = (dir) => {
|
|
597
687
|
if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
|
|
598
688
|
setSuggestOpen(false)
|
|
599
|
-
const sep = String(dir).includes('\\') ? '\\' : '/'
|
|
600
689
|
api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
|
|
601
690
|
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
|
|
602
691
|
.then((res) => {
|
|
603
692
|
const list = Array.isArray(res && res.items)
|
|
604
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
605
|
-
:
|
|
606
|
-
const
|
|
607
|
-
// Show every entry (dirs + files), matching loadSuggestions — the
|
|
608
|
-
// previous dir-only filter made the next-level list look truncated.
|
|
609
|
-
const kids = list.slice(0, 40).map((it) => (base === '/' ? sep + it.name : base + sep + it.name))
|
|
693
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
|
|
694
|
+
: []
|
|
695
|
+
const kids = list.slice(0, 40).map((it) => it.path)
|
|
610
696
|
setSuggest(kids)
|
|
611
697
|
setSuggestOpen(!!kids.length)
|
|
612
698
|
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
613
699
|
}
|
|
614
700
|
|
|
615
701
|
const selectSuggestion = (s) => {
|
|
702
|
+
if (!s) { setSuggestOpen(false); return }
|
|
616
703
|
// Auto-append the trailing separator so the path reads as a directory
|
|
617
704
|
// and the user can keep typing the next segment right away.
|
|
618
705
|
const sep = String(s).includes('\\') ? '\\' : '/'
|
|
@@ -732,27 +819,27 @@ window.__ModuleLoader__.load({
|
|
|
732
819
|
const entries = last.all || []
|
|
733
820
|
return React.createElement('div', { style: { border: '1px solid ' + T.borderStrong, borderRadius: 10, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', maxHeight: 'min(300px, 46vh)', overflow: 'hidden' } },
|
|
734
821
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
735
|
-
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick:
|
|
736
|
-
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(machineId, last.path, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
|
|
822
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, '回上一级 ▴'),
|
|
823
|
+
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(!!machineId, last.path, win, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
|
|
737
824
|
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
|
|
738
825
|
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
|
|
739
826
|
),
|
|
740
827
|
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
741
828
|
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
742
829
|
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
743
|
-
key: i, title: (it.dir ? '进入 ' : '文件: '
|
|
744
|
-
onClick: it.dir ? () => enterDir(it
|
|
830
|
+
key: i, title: it.drive ? '进入驱动器 ' + it.name : (it.dir ? '进入 ' + it.name : '文件: ' + it.name),
|
|
831
|
+
onClick: it.dir ? () => enterDir(it) : undefined,
|
|
745
832
|
style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
|
|
746
833
|
},
|
|
747
|
-
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label } }, it.dir
|
|
834
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label, fontSize: 13 } }, it.drive ? '💽' : (it.dir
|
|
748
835
|
? React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor' }, React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }))
|
|
749
|
-
: React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }))),
|
|
836
|
+
: React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' })))),
|
|
750
837
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
|
|
751
838
|
React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
|
|
752
839
|
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
753
840
|
),
|
|
754
841
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
755
|
-
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
|
|
842
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + (last.path || (win ? '(驱动器根)' : '/'))),
|
|
756
843
|
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
|
|
757
844
|
),
|
|
758
845
|
)
|
|
@@ -791,7 +878,7 @@ window.__ModuleLoader__.load({
|
|
|
791
878
|
mOpen
|
|
792
879
|
? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
|
|
793
880
|
machines.length
|
|
794
|
-
? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setMOpen(false); if (m.id)
|
|
881
|
+
? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setPath(''); setMOpen(false); if (m.id) loadLevels(m.id, '', 0) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
|
|
795
882
|
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
|
|
796
883
|
)
|
|
797
884
|
: null,
|
|
@@ -803,8 +890,8 @@ window.__ModuleLoader__.load({
|
|
|
803
890
|
recent.map((w, i) => React.createElement('button', { key: w + i, onClick: () => { setPath(w); setErr(''); continueSuggest(w) }, style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace' } }, w))) : null,
|
|
804
891
|
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
805
892
|
React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
806
|
-
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
807
|
-
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
893
|
+
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? (win ? '输入远程路径,如 C:\\Users\\…(自动补全)' : '输入远程路径(自动补全)') : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
894
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (!machineId) return; const p = path.trim(); loadLevels(machineId, p || '', 0); setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
808
895
|
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
|
|
809
896
|
),
|
|
810
897
|
(suggestOpen && suggest.length)
|
|
@@ -916,6 +1003,8 @@ window.__ModuleLoader__.load({
|
|
|
916
1003
|
const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
|
|
917
1004
|
const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
|
|
918
1005
|
const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
|
|
1006
|
+
/** Row whose path was just copied — shows a brief「已复制」label. */
|
|
1007
|
+
const [copiedPath, setCopiedPath] = React.useState('')
|
|
919
1008
|
const expanded = Array.isArray(props.expanded) ? props.expanded : []
|
|
920
1009
|
|
|
921
1010
|
const storeLevel = (path, level) => {
|
|
@@ -923,8 +1012,13 @@ window.__ModuleLoader__.load({
|
|
|
923
1012
|
setData(dataRef.current)
|
|
924
1013
|
}
|
|
925
1014
|
|
|
926
|
-
const loadDir = React.useCallback((dir) => {
|
|
1015
|
+
const loadDir = React.useCallback((dir, opts) => {
|
|
927
1016
|
const cur = dataRef.current
|
|
1017
|
+
const existing = cur[dir]
|
|
1018
|
+
// If we already have data (entries loaded) and this is a background
|
|
1019
|
+
// refresh, keep what we have — don't flash a loading state over a
|
|
1020
|
+
// populated tree.
|
|
1021
|
+
if (existing && !existing.loading && opts && opts.keep) return
|
|
928
1022
|
if (cur[dir] !== undefined && !cur[dir].loading) return
|
|
929
1023
|
storeLevel(dir, { loading: true })
|
|
930
1024
|
listRemoteDir(dir)
|
|
@@ -937,7 +1031,17 @@ window.__ModuleLoader__.load({
|
|
|
937
1031
|
})
|
|
938
1032
|
storeLevel(dir, { entries: sorted })
|
|
939
1033
|
})
|
|
940
|
-
.catch((e) =>
|
|
1034
|
+
.catch((e) => {
|
|
1035
|
+
// Keep any previously-loaded entries under this dir (so a transient
|
|
1036
|
+
// SFTP failure doesn't collapse the whole subtree); surface a
|
|
1037
|
+
// message only when there was nothing to show before.
|
|
1038
|
+
const prev = dataRef.current[dir]
|
|
1039
|
+
if (prev && prev.entries) {
|
|
1040
|
+
storeLevel(dir, { ...prev, softError: String((e && e.message) || e) })
|
|
1041
|
+
} else {
|
|
1042
|
+
storeLevel(dir, { error: String((e && e.message) || e) })
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
941
1045
|
}, [])
|
|
942
1046
|
|
|
943
1047
|
// Resolve this session's remote root, then load it and every expanded dir.
|
|
@@ -946,13 +1050,32 @@ window.__ModuleLoader__.load({
|
|
|
946
1050
|
// own workspace dir.
|
|
947
1051
|
const scopeCwd = props.scope && props.scope.cwd
|
|
948
1052
|
const sessionId = props.scope && props.scope.sessionId
|
|
949
|
-
const
|
|
950
|
-
|
|
1053
|
+
const rootRef = React.useRef('')
|
|
1054
|
+
const refreshStatus = React.useCallback((opts) => {
|
|
1055
|
+
const applyRoot = (r, connected) => {
|
|
1056
|
+
const prevRoot = rootRef.current
|
|
1057
|
+
rootRef.current = r
|
|
951
1058
|
setRoot(r)
|
|
952
|
-
|
|
953
|
-
|
|
1059
|
+
// Full reset ONLY when the session's remote root actually changed
|
|
1060
|
+
// (different workspace / different conversation). When we just come
|
|
1061
|
+
// back to this tab with the same workspace, keep the loaded tree so
|
|
1062
|
+
// expanded dirs and their contents stay put.
|
|
1063
|
+
const sameRoot = prevRoot && r && prevRoot === r
|
|
1064
|
+
if (!sameRoot) {
|
|
1065
|
+
setData({})
|
|
1066
|
+
dataRef.current = {}
|
|
1067
|
+
}
|
|
954
1068
|
if (r) {
|
|
955
|
-
|
|
1069
|
+
// Not connected (e.g. freshly restarted, machine no longer active):
|
|
1070
|
+
// show a clear disconnected state instead of firing ls requests
|
|
1071
|
+
// that all fail with AggregateError / 500 in a loop.
|
|
1072
|
+
if (!connected) {
|
|
1073
|
+
storeLevel(r, { error: '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)' })
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
if (!sameRoot) {
|
|
1077
|
+
storeLevel(r, { loading: true })
|
|
1078
|
+
}
|
|
956
1079
|
listRemoteDir(r)
|
|
957
1080
|
.then((res) => {
|
|
958
1081
|
const items = Array.isArray(res && res.items) ? res.items : []
|
|
@@ -961,10 +1084,19 @@ window.__ModuleLoader__.load({
|
|
|
961
1084
|
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
962
1085
|
return String(a.name).localeCompare(String(b.name))
|
|
963
1086
|
})
|
|
964
|
-
storeLevel(r, { entries: sorted })
|
|
965
|
-
for (const dir of expanded) loadDir(dir)
|
|
1087
|
+
storeLevel(r, { entries: sorted, missing: !!(res && res.missing) })
|
|
1088
|
+
for (const dir of expanded) loadDir(dir, { keep: true })
|
|
1089
|
+
})
|
|
1090
|
+
.catch((e) => {
|
|
1091
|
+
// Transient connection failure: keep the previously visible
|
|
1092
|
+
// root level so the tree doesn't collapse to an error.
|
|
1093
|
+
const prev = dataRef.current[r]
|
|
1094
|
+
if (prev && prev.entries) {
|
|
1095
|
+
storeLevel(r, { ...prev, softError: String((e && e.message) || e) })
|
|
1096
|
+
} else {
|
|
1097
|
+
storeLevel(r, { error: String((e && e.message) || e) })
|
|
1098
|
+
}
|
|
966
1099
|
})
|
|
967
|
-
.catch((e) => storeLevel(r, { error: String((e && e.message) || e) }))
|
|
968
1100
|
}
|
|
969
1101
|
}
|
|
970
1102
|
const resolveRoot = () => {
|
|
@@ -982,20 +1114,44 @@ window.__ModuleLoader__.load({
|
|
|
982
1114
|
// Issue #13: NEVER fall back to the machine-level workspace here —
|
|
983
1115
|
// a session whose cwd is not a mirror is a LOCAL session and shows
|
|
984
1116
|
// the "no remote workspace" empty state instead.
|
|
985
|
-
applyRoot(r || '')
|
|
1117
|
+
applyRoot(r || '', !!(s && s.connected))
|
|
986
1118
|
})
|
|
987
1119
|
})
|
|
988
1120
|
}, [scopeCwd, sessionId])
|
|
989
1121
|
|
|
990
1122
|
React.useEffect(() => { refreshStatus() }, [refreshStatus])
|
|
991
|
-
React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
|
|
992
|
-
// Load dirs as they get expanded.
|
|
993
|
-
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir) }, [root, expanded, loadDir])
|
|
1123
|
+
React.useEffect(() => { if (props.visible) refreshStatus({ visible: true }) }, [props.visible])
|
|
1124
|
+
// Load dirs as they get expanded (keep already-loaded content).
|
|
1125
|
+
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir, { keep: true }) }, [root, expanded, loadDir])
|
|
1126
|
+
// ── Context-menu portal: append to document.body so the sidebar's
|
|
1127
|
+
// overflow/transform cannot clip it. Pure DOM, no React portal needed.
|
|
994
1128
|
React.useEffect(() => {
|
|
995
1129
|
if (!menu) return
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
1130
|
+
const host = document.createElement('div')
|
|
1131
|
+
host.style.cssText = 'position:fixed;z-index:999999;pointer-events:auto'
|
|
1132
|
+
const box = document.createElement('div')
|
|
1133
|
+
box.style.cssText = 'position:fixed;left:' + menu.x + 'px;top:' + menu.y + 'px;z-index:999999;background:#1f1f23;border:1px solid rgba(128,128,128,0.5);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.4);min-width:170;font-family:system-ui,sans-serif'
|
|
1134
|
+
const mkItem = (label, danger, fn) => {
|
|
1135
|
+
const d = document.createElement('div')
|
|
1136
|
+
d.textContent = label
|
|
1137
|
+
d.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;color:' + (danger ? '#e06c75' : '#e4e4e7') + ';border-radius:4px;white-space:nowrap;transition:background 0.1s'
|
|
1138
|
+
d.onmouseenter = () => { d.style.background = 'rgba(128,128,128,0.14)' }
|
|
1139
|
+
d.onmouseleave = () => { d.style.background = 'transparent' }
|
|
1140
|
+
d.addEventListener('click', (e) => { e.stopPropagation(); setMenu(null); fn() })
|
|
1141
|
+
return d
|
|
1142
|
+
}
|
|
1143
|
+
box.appendChild(mkItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }))
|
|
1144
|
+
box.appendChild(mkItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })))
|
|
1145
|
+
box.appendChild(mkItem('📋 复制相对地址', false, () => copyPath(relPath(menu.path), menu.path)))
|
|
1146
|
+
box.appendChild(mkItem('📋 复制绝对地址', false, () => copyPath(menu.path, menu.path)))
|
|
1147
|
+
box.appendChild(mkItem('✏ 重命名', false, () => { const nm = window.prompt('重命名为(新名字)', menu.name); if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true) }))
|
|
1148
|
+
box.appendChild(mkItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => { if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true) }))
|
|
1149
|
+
host.appendChild(box)
|
|
1150
|
+
document.body.appendChild(host)
|
|
1151
|
+
const close = (e) => { if (!box.contains(e.target)) { setMenu(null) } }
|
|
1152
|
+
// Delay adding listener so the same mousedown that opened the menu doesn't close it
|
|
1153
|
+
const tid = setTimeout(() => document.addEventListener('mousedown', close), 0)
|
|
1154
|
+
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close); host.remove() }
|
|
999
1155
|
}, [menu])
|
|
1000
1156
|
|
|
1001
1157
|
const openFile = (p) => {
|
|
@@ -1028,6 +1184,32 @@ window.__ModuleLoader__.load({
|
|
|
1028
1184
|
style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
|
|
1029
1185
|
}, label)
|
|
1030
1186
|
|
|
1187
|
+
// The path shown by「复制相对地址」: relative to this session's remote
|
|
1188
|
+
// workspace root (mirror of the built-in local tree's cwd-relative copy).
|
|
1189
|
+
const relPath = (p) => {
|
|
1190
|
+
if (!root) return p
|
|
1191
|
+
const r = String(root).replace(/[\\/]+$/, '')
|
|
1192
|
+
const s = String(p)
|
|
1193
|
+
if (s === r) return '.'
|
|
1194
|
+
if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
1195
|
+
if (s.startsWith(r + '\\')) return s.slice(r.length + 1)
|
|
1196
|
+
return s
|
|
1197
|
+
}
|
|
1198
|
+
// Copy `text`, then briefly flag the row whose path was copied.
|
|
1199
|
+
const copyPath = (text, path) => {
|
|
1200
|
+
copyToClipboard(text).then((ok) => {
|
|
1201
|
+
if (!ok) return
|
|
1202
|
+
setCopiedPath(path)
|
|
1203
|
+
window.setTimeout(() => {
|
|
1204
|
+
setCopiedPath((cur) => (cur === path ? '' : cur))
|
|
1205
|
+
}, 1200)
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
/** Trailing「已复制」feedback for a row (after a successful copy). */
|
|
1209
|
+
const copiedMark = (path) => (copiedPath === path
|
|
1210
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, '已复制')
|
|
1211
|
+
: null)
|
|
1212
|
+
|
|
1031
1213
|
// One tree row (dir toggles via the framework's onToggleDir; file opens).
|
|
1032
1214
|
// Text uses the normal foreground (theme-following) — no accent green.
|
|
1033
1215
|
const row = (entry, depth) => {
|
|
@@ -1055,6 +1237,7 @@ window.__ModuleLoader__.load({
|
|
|
1055
1237
|
},
|
|
1056
1238
|
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
|
|
1057
1239
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
|
|
1240
|
+
copiedMark(entry.path),
|
|
1058
1241
|
),
|
|
1059
1242
|
isOpen ? renderLevel(entry.path, depth + 1) : null,
|
|
1060
1243
|
)
|
|
@@ -1071,7 +1254,11 @@ window.__ModuleLoader__.load({
|
|
|
1071
1254
|
},
|
|
1072
1255
|
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
|
|
1073
1256
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
|
|
1074
|
-
|
|
1257
|
+
copiedMark(entry.path),
|
|
1258
|
+
(entry.size != null || entry.mtime)
|
|
1259
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } },
|
|
1260
|
+
humanSize(entry.size) + (entry.mtime ? ' ' + fmtTime(entry.mtime) : ''))
|
|
1261
|
+
: null,
|
|
1075
1262
|
)
|
|
1076
1263
|
}
|
|
1077
1264
|
|
|
@@ -1080,12 +1267,21 @@ window.__ModuleLoader__.load({
|
|
|
1080
1267
|
if (level === undefined || (level.loading && !level.entries)) {
|
|
1081
1268
|
return React.createElement('div', { key: dir + ':loading', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '加载中…')
|
|
1082
1269
|
}
|
|
1270
|
+
if (level.missing) {
|
|
1271
|
+
return React.createElement('div', { key: dir + ':missing', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '目录不存在或已被删除')
|
|
1272
|
+
}
|
|
1083
1273
|
if (level.error !== undefined) {
|
|
1084
1274
|
return React.createElement('div', { key: dir + ':err', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, color: '#e06c75' } }, level.error)
|
|
1085
1275
|
}
|
|
1086
1276
|
const items = level.entries || []
|
|
1087
|
-
|
|
1088
|
-
|
|
1277
|
+
const soft = level.softError
|
|
1278
|
+
? React.createElement('div', { key: dir + ':softerr', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 11, opacity: 0.6, color: '#e6c07b' } }, '⚠ ' + level.softError + '(显示缓存,可点 ↻ 刷新)')
|
|
1279
|
+
: null
|
|
1280
|
+
if (!items.length) return soft || null
|
|
1281
|
+
return React.createElement('div', { key: dir + ':list' },
|
|
1282
|
+
items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)),
|
|
1283
|
+
soft,
|
|
1284
|
+
)
|
|
1089
1285
|
}
|
|
1090
1286
|
|
|
1091
1287
|
const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
|
|
@@ -1124,18 +1320,8 @@ window.__ModuleLoader__.load({
|
|
|
1124
1320
|
rootLevel && rootOpen ? renderLevel(root, 1) : null,
|
|
1125
1321
|
),
|
|
1126
1322
|
),
|
|
1127
|
-
// context menu
|
|
1128
|
-
|
|
1129
|
-
ctxItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }),
|
|
1130
|
-
ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })),
|
|
1131
|
-
ctxItem('✏ 重命名', false, () => {
|
|
1132
|
-
const nm = window.prompt('重命名为(新名字)', menu.name)
|
|
1133
|
-
if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true)
|
|
1134
|
-
}),
|
|
1135
|
-
ctxItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => {
|
|
1136
|
-
if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true)
|
|
1137
|
-
}),
|
|
1138
|
-
) : null,
|
|
1323
|
+
// context menu — now rendered via DOM portal in useEffect above
|
|
1324
|
+
null,
|
|
1139
1325
|
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
|
|
1140
1326
|
)
|
|
1141
1327
|
}
|
package/lib/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import iconv from 'iconv-lite'
|
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
32
|
shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs,
|
|
33
|
-
toSftpPath, remotePathBase, truncate, shortHash, relPathUnder,
|
|
33
|
+
toSftpPath, toShellPath, toDisplayPath, remotePathBase, truncate, shortHash, relPathUnder,
|
|
34
34
|
} from './paths.js'
|
|
35
35
|
import { blobAlgorithm, keyFingerprint } from './hostkey.js'
|
|
36
36
|
import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
|
|
@@ -68,6 +68,12 @@ export const Config = z.object({
|
|
|
68
68
|
passphrase: z.string().default(''),
|
|
69
69
|
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
70
70
|
workspace: z.string().default(''),
|
|
71
|
+
/** Remote command terminal strategy. '' = auto-detect (Windows remotes look
|
|
72
|
+
* for Git Bash and pipe every command through `bash -s`); 'git-bash' = prefer
|
|
73
|
+
* Git Bash on Windows; 'native' = never wrap; any other value = explicit
|
|
74
|
+
* bash.exe path (e.g. 'C:\\Program Files\\Git\\bin\\bash.exe'). Git Bash makes
|
|
75
|
+
* a Windows remote behave like a POSIX host (/c/Users/... paths). */
|
|
76
|
+
shell: z.string().default(''),
|
|
71
77
|
/** Per-command timeout. */
|
|
72
78
|
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
73
79
|
/** SSH connection establishment timeout. */
|
|
@@ -306,6 +312,14 @@ class SshPool {
|
|
|
306
312
|
// Generational token: bumped on every target change / close so a stale
|
|
307
313
|
// in-flight connect can never hand this pool a connection to an old host.
|
|
308
314
|
this.epoch = 0
|
|
315
|
+
/** Auto-detected remote platform: unknown | windows | posix (per target). */
|
|
316
|
+
this.platform = 'unknown'
|
|
317
|
+
/** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
|
|
318
|
+
this.gitBashPath = ''
|
|
319
|
+
/** Resolved terminal strategy: native | git-bash. */
|
|
320
|
+
this.shellMode = 'native'
|
|
321
|
+
/** In-flight platform detection promise (cached). */
|
|
322
|
+
this._detecting = null
|
|
309
323
|
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
310
324
|
this.passwordResolver = null
|
|
311
325
|
/** Optional hook called with the live client after a successful connect. */
|
|
@@ -333,6 +347,11 @@ class SshPool {
|
|
|
333
347
|
if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
|
|
334
348
|
if (proxy !== undefined) this.config.proxy = proxy
|
|
335
349
|
if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
|
|
350
|
+
// the new target may be a different OS — re-detect on the next command
|
|
351
|
+
this.platform = 'unknown'
|
|
352
|
+
this.gitBashPath = ''
|
|
353
|
+
this.shellMode = 'native'
|
|
354
|
+
this._detecting = null
|
|
336
355
|
this.close()
|
|
337
356
|
return this
|
|
338
357
|
}
|
|
@@ -470,25 +489,133 @@ class SshPool {
|
|
|
470
489
|
})
|
|
471
490
|
}
|
|
472
491
|
|
|
492
|
+
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
493
|
+
detect() {
|
|
494
|
+
if (this.platform !== 'unknown') return Promise.resolve()
|
|
495
|
+
if (this._detecting) return this._detecting
|
|
496
|
+
this._detecting = this._detect().finally(() => {
|
|
497
|
+
this._detecting = null
|
|
498
|
+
})
|
|
499
|
+
return this._detecting
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async _detect() {
|
|
503
|
+
let res
|
|
504
|
+
try {
|
|
505
|
+
// `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
|
|
506
|
+
// "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
|
|
507
|
+
res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.platform = 'unknown'
|
|
510
|
+
this.shellMode = 'native'
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
514
|
+
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
515
|
+
this.platform = 'windows'
|
|
516
|
+
await this._resolveGitBash()
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
// inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
|
|
520
|
+
try {
|
|
521
|
+
const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
522
|
+
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
523
|
+
this.platform = 'windows'
|
|
524
|
+
await this._resolveGitBash()
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
} catch {}
|
|
528
|
+
this.platform = 'posix'
|
|
529
|
+
this.shellMode = 'native'
|
|
530
|
+
this.gitBashPath = ''
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
|
|
534
|
+
async _resolveGitBash() {
|
|
535
|
+
const cfg = String(this.config.shell || '').trim()
|
|
536
|
+
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
537
|
+
if (await this._cmdExists(cfg)) {
|
|
538
|
+
this.gitBashPath = cfg
|
|
539
|
+
this.shellMode = 'git-bash'
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (cfg === 'native') {
|
|
544
|
+
this.shellMode = 'native'
|
|
545
|
+
this.gitBashPath = ''
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
|
|
550
|
+
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
551
|
+
if (first) {
|
|
552
|
+
this.gitBashPath = first
|
|
553
|
+
this.shellMode = 'git-bash'
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
} catch {}
|
|
557
|
+
const candidates = [
|
|
558
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
559
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
560
|
+
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
561
|
+
'%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
|
|
562
|
+
]
|
|
563
|
+
for (const c of candidates) {
|
|
564
|
+
if (await this._cmdExists(c)) {
|
|
565
|
+
this.gitBashPath = c
|
|
566
|
+
this.shellMode = 'git-bash'
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
|
|
571
|
+
this.gitBashPath = ''
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async _cmdExists(p) {
|
|
575
|
+
try {
|
|
576
|
+
const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
|
|
577
|
+
return /Y/.test(String(r.stdout || ''))
|
|
578
|
+
} catch {
|
|
579
|
+
return false
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
473
583
|
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
474
|
-
*
|
|
584
|
+
* On Windows remotes with Git Bash the script is piped to `bash -s` over the
|
|
585
|
+
* exec-channel stdin — no shell quoting round-trip, so any content (quotes,
|
|
586
|
+
* backslashes, newlines) survives verbatim. */
|
|
475
587
|
exec(command, timeoutMsOrOpts) {
|
|
476
588
|
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
477
|
-
|
|
589
|
+
return this.detect().then(() => {
|
|
590
|
+
if (this.platform === 'windows' && this.gitBashPath) {
|
|
591
|
+
const script = String(command)
|
|
592
|
+
return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
|
|
593
|
+
try { stream.end(script) } catch {}
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
return this._execRaw(command, opts)
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
|
|
601
|
+
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
602
|
+
_execRaw(command, opts, stdinWriter) {
|
|
603
|
+
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
478
604
|
return this.connect().then(
|
|
479
605
|
(client) =>
|
|
480
606
|
new Promise((resolve, reject) => {
|
|
481
607
|
let retried = false
|
|
482
608
|
const runOn = (c) => {
|
|
483
609
|
const execOpts = {}
|
|
484
|
-
if (opts.pty) execOpts.pty = true
|
|
485
|
-
if (opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
610
|
+
if (opts && opts.pty) execOpts.pty = true
|
|
611
|
+
if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
486
612
|
c.exec(command, execOpts, (err, stream) => {
|
|
487
613
|
if (err) {
|
|
488
|
-
// Channel-open failure
|
|
489
|
-
// server-side (idle timeout /
|
|
490
|
-
// hadn't noticed. Drop it and
|
|
491
|
-
|
|
614
|
+
// Channel-open failure — or a session termination — usually
|
|
615
|
+
// means the pooled connection died server-side (idle timeout /
|
|
616
|
+
// network reset) while keepalive hadn't noticed. Drop it and
|
|
617
|
+
// retry ONCE on a fresh connection.
|
|
618
|
+
if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
492
619
|
retried = true
|
|
493
620
|
this.invalidate()
|
|
494
621
|
return this.connect().then(
|
|
@@ -549,6 +676,9 @@ class SshPool {
|
|
|
549
676
|
clearTimeout(timer)
|
|
550
677
|
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
551
678
|
})
|
|
679
|
+
if (stdinWriter) {
|
|
680
|
+
try { stdinWriter(stream) } catch {}
|
|
681
|
+
}
|
|
552
682
|
})
|
|
553
683
|
}
|
|
554
684
|
runOn(client)
|
|
@@ -566,9 +696,11 @@ class SshPool {
|
|
|
566
696
|
c.sftp((err, sftp) => {
|
|
567
697
|
if (err) {
|
|
568
698
|
// Same dead-connection recovery as exec(): a channel open
|
|
569
|
-
// failure
|
|
699
|
+
// failure — or a session termination (remote closed the
|
|
700
|
+
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
701
|
+
// drop) — means the pooled connection is stale: drop it and
|
|
570
702
|
// retry ONCE on a fresh connection.
|
|
571
|
-
if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
|
|
703
|
+
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
572
704
|
retried = true
|
|
573
705
|
this.invalidate()
|
|
574
706
|
return this.connect().then(
|
|
@@ -783,6 +915,27 @@ export async function apply(ctx, config) {
|
|
|
783
915
|
if (cur && cur.host && !store.currentId && !registryExplicitNone) applyMachine(config, cur)
|
|
784
916
|
}
|
|
785
917
|
|
|
918
|
+
// ── auto-restore the last active machine (best-effort) ───────────────────
|
|
919
|
+
// When the harness (re)starts, bring back the machine that was "current"
|
|
920
|
+
// last time: apply its config to the pool and probe a real SSH connection so
|
|
921
|
+
// the sidebar shows connected instead of a disconnected 500-spamming state.
|
|
922
|
+
// Failures are swallowed — an unreachable host must never break plugin boot
|
|
923
|
+
// (the sidebar will show「未连接」and the user can reconnect or set current).
|
|
924
|
+
if (store.currentId) {
|
|
925
|
+
const i = machineIndex(store.currentId)
|
|
926
|
+
if (i >= 0 && machines[i] && machines[i].host) {
|
|
927
|
+
setImmediate(async () => {
|
|
928
|
+
try {
|
|
929
|
+
await applyActiveMachine()
|
|
930
|
+
await pool.exec('echo dsh-remote-restore', { timeoutMs: Math.min(config.commandTimeoutMs || 20000, 15000) })
|
|
931
|
+
} catch {
|
|
932
|
+
// Offline / bad credentials: leave pool unconnected; UI falls back
|
|
933
|
+
// to the "未连接" state and the user can reconnect manually.
|
|
934
|
+
}
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
786
939
|
/** Persist the active workspace on the machine the pool is actually bound to
|
|
787
940
|
* (fixes the old bug where a tool-connected machine saved its workspace onto
|
|
788
941
|
* the registry's current machine instead). */
|
|
@@ -920,7 +1073,14 @@ export async function apply(ctx, config) {
|
|
|
920
1073
|
try {
|
|
921
1074
|
list = await sftp.readdir(target)
|
|
922
1075
|
} catch (err) {
|
|
923
|
-
|
|
1076
|
+
// Missing directory / deleted folder: not an internal error. Report it
|
|
1077
|
+
// distinctly so the UI can show「目录不存在」without treating it as a
|
|
1078
|
+
// hard failure (500) that collapses the whole tree.
|
|
1079
|
+
const msg = String((err && err.message) || err)
|
|
1080
|
+
if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
|
|
1081
|
+
return { path: target, items: [], missing: true }
|
|
1082
|
+
}
|
|
1083
|
+
throw new Error('browse failed: ' + msg)
|
|
924
1084
|
}
|
|
925
1085
|
const items = []
|
|
926
1086
|
const symIdx = []
|
|
@@ -958,6 +1118,29 @@ export async function apply(ctx, config) {
|
|
|
958
1118
|
return { path: target, items }
|
|
959
1119
|
}
|
|
960
1120
|
|
|
1121
|
+
/** Windows drive letters as display-form dir entries (the "This PC" root view).
|
|
1122
|
+
* `cmd /c fsutil fsinfo drives` works under cmd/PowerShell/Git Bash and prints
|
|
1123
|
+
* e.g. "Drives: C:\\ D:\\ E:\\"; falls back to listing Git-Bash mount points
|
|
1124
|
+
* (/c /d …). Returns [] on POSIX hosts or when enumeration fails. */
|
|
1125
|
+
const listRemoteDrives = async () => {
|
|
1126
|
+
let letters = []
|
|
1127
|
+
try {
|
|
1128
|
+
const res = await pool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1129
|
+
letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
|
|
1130
|
+
} catch {}
|
|
1131
|
+
if (!letters.length) {
|
|
1132
|
+
try {
|
|
1133
|
+
const res = await pool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1134
|
+
letters = String(res.stdout || '')
|
|
1135
|
+
.split(/\s+/)
|
|
1136
|
+
.map((s) => s.replace(/^\/+|\/+$/g, ''))
|
|
1137
|
+
.filter((s) => /^[a-zA-Z]$/.test(s))
|
|
1138
|
+
.map((s) => s.toUpperCase())
|
|
1139
|
+
} catch {}
|
|
1140
|
+
}
|
|
1141
|
+
return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
|
|
1142
|
+
}
|
|
1143
|
+
|
|
961
1144
|
const isRemoteDir = async (p) => {
|
|
962
1145
|
const target = normalizeRemotePath(p)
|
|
963
1146
|
try {
|
|
@@ -1055,7 +1238,7 @@ export async function apply(ctx, config) {
|
|
|
1055
1238
|
port: config.port,
|
|
1056
1239
|
username: config.username,
|
|
1057
1240
|
connected: !!pool.client,
|
|
1058
|
-
workspace: wsPath(),
|
|
1241
|
+
workspace: toDisplayPath(wsPath(), pool.platform),
|
|
1059
1242
|
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
1060
1243
|
currentId: store.currentId || null,
|
|
1061
1244
|
activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
|
|
@@ -1070,6 +1253,9 @@ export async function apply(ctx, config) {
|
|
|
1070
1253
|
forwards: forwards.list(),
|
|
1071
1254
|
auditEnabled: !!config.auditLog,
|
|
1072
1255
|
backend: platformBackend(),
|
|
1256
|
+
platform: pool.platform,
|
|
1257
|
+
shell: pool.shellMode,
|
|
1258
|
+
gitBash: pool.gitBashPath || '',
|
|
1073
1259
|
})
|
|
1074
1260
|
|
|
1075
1261
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
@@ -1098,6 +1284,7 @@ export async function apply(ctx, config) {
|
|
|
1098
1284
|
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
1099
1285
|
`Session remote context: ${s.sessionRemotePath ? s.sessionRemotePath : '(this session is LOCAL — no remote workspace bound)'}`,
|
|
1100
1286
|
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
1287
|
+
`Remote shell: ${s.platform === 'windows' ? (s.gitBash ? 'Git Bash (' + s.gitBash + ')' : 'Windows (Git Bash not found — set config.shell to a bash.exe path)') : (s.platform === 'posix' ? 'native POSIX' : 'detecting…')}`,
|
|
1101
1288
|
`Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
|
|
1102
1289
|
`Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
|
|
1103
1290
|
'',
|
|
@@ -1184,21 +1371,22 @@ export async function apply(ctx, config) {
|
|
|
1184
1371
|
defineTool({
|
|
1185
1372
|
name: 'rw_pick_workspace',
|
|
1186
1373
|
description:
|
|
1187
|
-
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
|
|
1374
|
+
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure. Accepts POSIX (/home/dev/project) or Windows (C:\\Users\\dev\\project) paths.',
|
|
1188
1375
|
parameters: {
|
|
1189
|
-
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
1376
|
+
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project or C:\\Users\\dev\\project' },
|
|
1190
1377
|
},
|
|
1191
1378
|
output: textOut,
|
|
1192
1379
|
async execute(args) {
|
|
1193
1380
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1194
1381
|
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
1195
1382
|
const ok = await isRemoteDir(p)
|
|
1196
|
-
|
|
1383
|
+
const shown = toDisplayPath(p, pool.platform)
|
|
1384
|
+
if (!ok) return { text: `not a directory (or missing) on ${shown}` }
|
|
1197
1385
|
persistWorkspace(p)
|
|
1198
1386
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1199
1387
|
startAutoPush(local)
|
|
1200
1388
|
return {
|
|
1201
|
-
text: `Remote workspace set to ${
|
|
1389
|
+
text: `Remote workspace set to ${shown} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
1202
1390
|
}
|
|
1203
1391
|
},
|
|
1204
1392
|
}),
|
|
@@ -1306,7 +1494,7 @@ export async function apply(ctx, config) {
|
|
|
1306
1494
|
defineTool({
|
|
1307
1495
|
name: 'rw_list_dir',
|
|
1308
1496
|
description:
|
|
1309
|
-
'List a remote directory (or a single file) via SSH. Path is absolute;
|
|
1497
|
+
'List a remote directory (or a single file) via SSH. Path is absolute; accepts Windows paths like C:\\Users\\dev\\project on Git Bash remotes. If omitted, lists the current remote workspace. Shows type, size, mtime.',
|
|
1310
1498
|
parameters: {
|
|
1311
1499
|
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
1312
1500
|
},
|
|
@@ -1588,7 +1776,15 @@ export async function apply(ctx, config) {
|
|
|
1588
1776
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
1589
1777
|
const ws = wsPath()
|
|
1590
1778
|
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1591
|
-
|
|
1779
|
+
let full = cmd
|
|
1780
|
+
if (cwd) {
|
|
1781
|
+
if (pool.platform === 'windows' && pool.gitBashPath) {
|
|
1782
|
+
// Git Bash terminal: cd in the /c/Users/… mount form
|
|
1783
|
+
full = `cd ${shq(toShellPath(cwd))} && ${cmd}`
|
|
1784
|
+
} else if (!cwd.includes('\\')) {
|
|
1785
|
+
full = `cd ${shq(cwd)} && ${cmd}`
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1592
1788
|
try {
|
|
1593
1789
|
const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
|
|
1594
1790
|
audit('exec', cmd, res.code)
|
|
@@ -1785,7 +1981,7 @@ export async function apply(ctx, config) {
|
|
|
1785
1981
|
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
1786
1982
|
return (
|
|
1787
1983
|
'## Remote workspace\n' +
|
|
1788
|
-
`Current remote workspace: ${config.username || 'user'}@${config.host}:${remotePath}\n` +
|
|
1984
|
+
`Current remote workspace: ${config.username || 'user'}@${config.host}:${toDisplayPath(remotePath, pool.platform)}\n` +
|
|
1789
1985
|
'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
|
|
1790
1986
|
extra
|
|
1791
1987
|
)
|
|
@@ -2069,10 +2265,29 @@ export async function apply(ctx, config) {
|
|
|
2069
2265
|
path: '/dsh-remote/ls',
|
|
2070
2266
|
handler: async (req, res) => {
|
|
2071
2267
|
try {
|
|
2268
|
+
await pool.detect()
|
|
2072
2269
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
2073
|
-
const
|
|
2074
|
-
const
|
|
2075
|
-
|
|
2270
|
+
const raw = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
2271
|
+
const canon = normalizeRemotePath(raw)
|
|
2272
|
+
const win = pool.platform === 'windows'
|
|
2273
|
+
// Windows root → "This PC" drive view (C:\ D:\ E:\), not the Git-Bash
|
|
2274
|
+
// MSYS root. When the platform probe was inconclusive we still try — a
|
|
2275
|
+
// real Windows box answers fsutil, a POSIX box yields no letters and
|
|
2276
|
+
// falls through to the normal root listing.
|
|
2277
|
+
if (canon === '/' || raw === '' || raw === '/') {
|
|
2278
|
+
if (win || pool.platform !== 'posix') {
|
|
2279
|
+
const drives = await listRemoteDrives()
|
|
2280
|
+
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives })
|
|
2281
|
+
}
|
|
2282
|
+
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [] })
|
|
2283
|
+
}
|
|
2284
|
+
const out = await listDirStructured(canon)
|
|
2285
|
+
const items = out.items.map((it) => ({
|
|
2286
|
+
...it,
|
|
2287
|
+
// full display-form path so the client never joins paths itself
|
|
2288
|
+
path: toDisplayPath(joinRemotePath(canon, it.name), pool.platform),
|
|
2289
|
+
}))
|
|
2290
|
+
return sendJson(res, 200, { path: toDisplayPath(out.path, pool.platform), platform: win ? 'windows' : 'posix', items })
|
|
2076
2291
|
} catch (err) {
|
|
2077
2292
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
2078
2293
|
}
|
|
@@ -2520,7 +2735,7 @@ export async function apply(ctx, config) {
|
|
|
2520
2735
|
machines[mi].latencyMs = latencyMs
|
|
2521
2736
|
saveMachines(machines, store.currentId)
|
|
2522
2737
|
}
|
|
2523
|
-
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString() })
|
|
2738
|
+
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString(), platform: probe.platform, shell: probe.shellMode, gitBash: probe.gitBashPath || '' })
|
|
2524
2739
|
} catch (err) {
|
|
2525
2740
|
return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body && body.host, port: body && body.port }) })
|
|
2526
2741
|
}
|
package/lib/paths.js
CHANGED
|
@@ -127,6 +127,54 @@ export function toSftpPath(p) {
|
|
|
127
127
|
return norm
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Convert a remote path to the form used in SHELL commands on the remote.
|
|
132
|
+
*
|
|
133
|
+
* On Windows remotes running Git Bash, native `D:\Code` (or `D:/Code`,
|
|
134
|
+
* `/D:/Code`, `/d/Code`) becomes the Git-Bash mount form `/d/Code`; a path
|
|
135
|
+
* already in Git-Bash form passes through unchanged. UNC (`\\server\share`)
|
|
136
|
+
* maps to `//server/share`. POSIX paths are normalized as-is. The conversion
|
|
137
|
+
* is syntactic, so it is safe on POSIX remotes too (only unambiguous Windows
|
|
138
|
+
* forms are rewritten).
|
|
139
|
+
*/
|
|
140
|
+
export function toShellPath(p) {
|
|
141
|
+
let t = String(p || '').replace(/\\/g, '/')
|
|
142
|
+
const unc = t.match(/^\/\/([^/]+)(?:\/(.*))?$/)
|
|
143
|
+
if (unc) return '//' + unc[1] + (unc[2] ? '/' + unc[2] : '')
|
|
144
|
+
const drive = t.match(/^([a-zA-Z]):(\/|$)/) || t.match(/^\/([a-zA-Z]):(\/|$)/)
|
|
145
|
+
if (drive) {
|
|
146
|
+
t = '/' + drive[1].toLowerCase() + (drive[2] ? '/' : '') + t.slice(drive[0].length)
|
|
147
|
+
}
|
|
148
|
+
const parts = []
|
|
149
|
+
for (const seg of t.split('/')) {
|
|
150
|
+
if (seg === '' || seg === '.') continue
|
|
151
|
+
if (seg === '..') { parts.pop(); continue }
|
|
152
|
+
parts.push(seg)
|
|
153
|
+
}
|
|
154
|
+
return '/' + parts.join('/')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Display form of a path for a Windows remote: a Git-Bash mount `/d/Code` (or
|
|
159
|
+
* `/D:/Code`, `D:/Code`) renders as `D:\Code`; anything else (POSIX paths,
|
|
160
|
+
* native Windows paths) is returned normalized. POSIX remotes always pass
|
|
161
|
+
* through unchanged.
|
|
162
|
+
*/
|
|
163
|
+
export function toDisplayPath(p, platform) {
|
|
164
|
+
if (platform !== 'windows') return normalizeRemotePath(p)
|
|
165
|
+
const t = String(p || '').replace(/\\/g, '/')
|
|
166
|
+
// /X:/rest (Win32-OpenSSH SFTP form)
|
|
167
|
+
const gs = t.match(/^\/([a-zA-Z]):\/(.*)$/)
|
|
168
|
+
if (gs) return gs[1].toUpperCase() + ':\\' + gs[2].replace(/\//g, '\\')
|
|
169
|
+
// /x/rest (Git-Bash mount form) and bare /x
|
|
170
|
+
const git = t.match(/^\/([a-zA-Z])(?:\/(.*))?$/)
|
|
171
|
+
if (git) return git[1].toUpperCase() + ':\\' + (git[2] || '').replace(/\//g, '\\')
|
|
172
|
+
// native X:/rest
|
|
173
|
+
const drive = t.match(/^([a-zA-Z]):(?:\/(.*))?$/)
|
|
174
|
+
if (drive) return drive[1].toUpperCase() + ':\\' + (drive[2] || '').replace(/\//g, '\\')
|
|
175
|
+
return t
|
|
176
|
+
}
|
|
177
|
+
|
|
130
178
|
/** Basename of a remote absolute path (POSIX or Windows). */
|
|
131
179
|
export function remotePathBase(p) {
|
|
132
180
|
const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 21 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|