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 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 path field is **pre-filled with `/`** and live **autocompletes** directories; selecting a directory immediately lists its next level. A **浏览…** floating browser (shows **size + mtime**, dirs first, follows symlinks) fills the field without committing. **最近 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.
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
- - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
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) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功'); refresh() }
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
- function breadcrumb(active, cur, jumpTo) {
414
- const norm = String(cur || '').replace(/\/+$/, '')
415
- const segs = norm === '' || norm === '/' ? [] : norm.split('/')
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
- crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
418
- let acc = ''
419
- for (const s of segs) {
420
- if (!s) continue
421
- acc += '/' + s
422
- crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
423
- crumbs.push(React.createElement('span', {
424
- key: acc,
425
- style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
426
- onClick: active ? () => jumpTo(acc) : undefined,
427
- }, s))
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 (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
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
- const enterDir = (name) => {
558
- if (busy || loading) return
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
- const base = last && last.path ? last.path : ''
561
- const sep = base.includes('\\') ? '\\' : '/'
562
- const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
563
- loadLevels(machineId, next, (levels ? levels.length : 0))
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
- const isWin = t.includes('\\')
572
- const sep = isWin ? '\\' : '/'
573
- const slash = t.lastIndexOf(sep)
574
- const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
575
- const lastSeg = slash < 0 ? t : t.slice(slash + 1)
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
- : parseLs(res && res.text)
582
- const base = parent === '/' ? '/' : parent
583
- const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
584
- .slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
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
- : parseLs(res && res.text)
606
- const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
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: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
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 ? '进入 ' : '文件: ') + it.name,
744
- onClick: it.dir ? () => enterDir(it.name) : undefined,
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) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', m.id) } } }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
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) => storeLevel(dir, { error: String((e && e.message) || 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 refreshStatus = React.useCallback(() => {
950
- const applyRoot = (r) => {
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
- setData({})
953
- dataRef.current = {}
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
- storeLevel(r, { loading: true })
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 close = () => setMenu(null)
997
- document.addEventListener('mousedown', close)
998
- return () => document.removeEventListener('mousedown', close)
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
- entry.size != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(entry.size)) : null,
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
- if (!items.length) return null
1088
- return React.createElement('div', { key: dir + ':list' }, items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)))
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
- menu ? React.createElement('div', { style: { position: 'fixed', left: menu.x, top: menu.y, zIndex: 9998, background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.borderStrong, borderRadius: 8, padding: 4, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', minWidth: 170 }, onClick: (e) => e.stopPropagation() },
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
- * Accepts `exec(cmd, timeoutMs)` (legacy) or `exec(cmd, {timeoutMs, pty, env})`. */
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
- const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
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 usually means the pooled connection died
489
- // server-side (idle timeout / network reset) while keepalive
490
- // hadn't noticed. Drop it and retry ONCE on a fresh connection.
491
- if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
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 means the pooled connection is stale — drop it and
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
- throw new Error('browse failed: ' + ((err && err.message) || err))
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
- if (!ok) return { text: `not a directory (or missing) on ${p}` }
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 ${p} 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.`,
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; if omitted, lists the current remote workspace. Shows type, size, mtime.',
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
- const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
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 p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
2074
- const out = await listDirStructured(p || '/')
2075
- return sendJson(res, 200, { path: out.path, items: out.items })
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.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>",