dsh-remote 0.6.9 → 0.7.1

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/lib/client.js CHANGED
@@ -455,6 +455,280 @@ window.__ModuleLoader__.load({
455
455
  )
456
456
  }
457
457
 
458
+ // ── better-sidebar integration (optional) ────────────────────────────────
459
+ // The sidebar of dsh-better-sidebar lists LOCAL files (its fs tree reads
460
+ // the host filesystem). When dsh-remote is the active remote workspace the
461
+ // user wants to browse the REMOTE host's files live — not the local SFTP
462
+ // mirror snapshot (which rw_sync caps by depth/file-count/size).
463
+ //
464
+ // Through the public `ctx.betterSidebar` service (v0.4.0+; gracefully
465
+ // skipped when dsh-better-sidebar is not installed) we register TWO tabs:
466
+ // • `dsh-remote:explorer` — a live remote file tree (dirs expand over
467
+ // /dsh-remote/ls; every click lists the REMOTE host, not the mirror),
468
+ // • `dsh-remote:file` — a read-only remote file view (hidden from the
469
+ // + menu; opened by the explorer) that reads the file live over
470
+ // /dsh-remote/read and renders its text (or a binary notice).
471
+ //
472
+ // Both are READ-ONLY on purpose: the sidebar editor's save path writes to
473
+ // the LOCAL fs, so an editable remote file would silently save into the
474
+ // mirror. Editing stays in the rw_* tools / the mirror workflow. We do NOT
475
+ // register a file viewer (better-sidebar's matching is extension/priority
476
+ // based and cannot tell a remote path from a local one — a catch-all
477
+ // viewer would hijack every local file open).
478
+
479
+ // A stable id namespace so the persisted sidebar tabs survive reloads.
480
+ const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
481
+ const SIDEBAR_FILE_ID = 'dsh-remote:file'
482
+
483
+ /** Remote workspace state the sidebar reads (host /dsh-remote/status). */
484
+ function fetchRemoteStatus() {
485
+ return api('GET', '/dsh-remote/status').catch(() => null)
486
+ }
487
+
488
+ /** Read a remote file (live) → {binary, content?, head?, size?, truncated?}. */
489
+ function readRemoteFile(path, maxBytes) {
490
+ return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
491
+ throw new Error('远程读取失败: ' + ((e && e.message) || e))
492
+ })
493
+ }
494
+
495
+ /** List a remote dir (live) → items [{name, type}] (server-driven types). */
496
+ function listRemoteDir(path) {
497
+ return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
498
+ throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
499
+ })
500
+ }
501
+
502
+ /** Join a remote dir + entry name, honoring the dir's separator style. */
503
+ function joinRemote(base, name) {
504
+ if (!base) return name
505
+ return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
506
+ }
507
+
508
+ // ── Remote explorer tab ────────────────────────────────────────────────
509
+ function RemoteExplorerTab(props) {
510
+ const [status, setStatus] = React.useState(null)
511
+ const [levels, setLevels] = React.useState([]) // [{path, items}]
512
+ const [err, setErr] = React.useState('')
513
+ const [loading, setLoading] = React.useState(false)
514
+ const [picker, setPicker] = React.useState(false)
515
+
516
+ const refreshStatus = () => {
517
+ fetchRemoteStatus().then((s) => {
518
+ setStatus(s)
519
+ if (s && s.workspace) {
520
+ setLevels((prev) => {
521
+ // First open with a workspace: seed the root level AND load it,
522
+ // so the tree shows the remote listing immediately instead of
523
+ // an empty "(空目录)" until the user hits ↻.
524
+ if (!prev.length) {
525
+ loadDir(s.workspace, 0)
526
+ return [{ path: s.workspace, items: null }]
527
+ }
528
+ return prev
529
+ })
530
+ }
531
+ })
532
+ }
533
+ React.useEffect(() => { refreshStatus() }, [])
534
+ // When the tab becomes visible, re-verify the workspace still exists
535
+ // (the remote connection may have been switched in settings).
536
+ React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
537
+
538
+ const loadDir = (p, idx) => {
539
+ if (loading) return
540
+ setLoading(true); setErr('')
541
+ listRemoteDir(p).then((res) => {
542
+ const items = Array.isArray(res && res.items) ? res.items : []
543
+ setLevels((prev) => {
544
+ const base = prev.length ? prev.slice() : [{ path: p, items: null }]
545
+ const list = items.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file' }))
546
+ if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
547
+ base[idx] = { path: p, items: list }
548
+ return base.slice(0, idx + 1)
549
+ })
550
+ }).catch((e) => setErr(String((e && e.message) || e)))
551
+ .finally(() => setLoading(false))
552
+ }
553
+
554
+ // Open a directory: append it as the new deepest level.
555
+ const enterDir = (name) => {
556
+ const last = levels.length ? levels[levels.length - 1] : null
557
+ if (!last || !last.items) return
558
+ const base = last.path
559
+ const sep = base.includes('\\') ? '\\' : '/'
560
+ const next = base === '/' ? '/' + name : base + sep + name
561
+ loadDir(next, levels.length)
562
+ }
563
+ // Jump to a breadcrumb ancestor.
564
+ const jumpTo = (p) => {
565
+ const idx = levels.findIndex((lv) => lv.path === p)
566
+ if (idx < 0) return
567
+ setLevels(levels.slice(0, idx + 1))
568
+ }
569
+ // Open a file: open the dedicated REMOTE file tab (reads the file live
570
+ // over /dsh-remote/read) — NOT the builtin editor, whose fs.read would
571
+ // hit the local filesystem and fail on a remote path.
572
+ const openFile = (p) => {
573
+ const name = String(p).split(/[\\/]/).pop() || p
574
+ props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
575
+ }
576
+
577
+ const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
578
+ const curPath = levels.length ? levels[levels.length - 1].path : ''
579
+ const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
580
+ const crumbs = [React.createElement('span', { key: 'r', style: { cursor: 'pointer', color: '#4caf7d', fontWeight: 700 }, onClick: () => { const idx = levels.findIndex((lv) => lv.path === '/' || lv.path === '\\'); if (idx >= 0) jumpTo(levels[idx].path); else setLevels([]) } }, '⌂')]
581
+ let acc = ''
582
+ segs.forEach((s, i) => {
583
+ acc = acc ? acc + '/' + s : s
584
+ const isLast = i === segs.length - 1
585
+ crumbs.push(React.createElement('span', { key: 's' + i, style: { color: isLast ? '#e4e4e7' : '#9a9aa0', cursor: isLast ? 'default' : 'pointer', fontWeight: isLast ? 600 : 400, whiteSpace: 'nowrap' }, onClick: isLast ? undefined : () => jumpTo(acc) }, (i ? '/' : '') + s))
586
+ })
587
+
588
+ const row = (it, i) => React.createElement('div', {
589
+ key: it.name + '|' + i,
590
+ title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
591
+ onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
592
+ style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', borderRadius: 5, cursor: 'pointer', fontSize: 13, color: it.type === 'dir' ? '#4caf7d' : '#e4e4e7', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', borderBottom: '1px solid rgba(128,128,128,0.12)' },
593
+ }, React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'), React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name))
594
+
595
+ const rootHint = status && status.workspace
596
+ ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
597
+ : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
598
+
599
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
600
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
601
+ React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => { const w = status && status.workspace; if (w) { setLevels([{ path: w, items: null }]); loadDir(w, 0) } else setPicker(true) } }, '↻'),
602
+ React.createElement('div', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontFamily: 'monospace' } }, crumbs),
603
+ React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
604
+ ),
605
+ rootHint,
606
+ picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
607
+ open: true, busy: false, onPicked: (localMirror) => {
608
+ setPicker(false)
609
+ // The picker commits a mirror + workspace; refresh the tree.
610
+ refreshStatus()
611
+ fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
612
+ }, onCancel: () => setPicker(false),
613
+ })) : null,
614
+ React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0 } },
615
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
616
+ : (allItems.length ? allItems.slice(0, 500).map(row)
617
+ : (curPath ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '(空目录)')
618
+ : React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区'))),
619
+ ),
620
+ err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, err) : null,
621
+ )
622
+ }
623
+
624
+ // ── Remote file tab (opened by the explorer; reads the file live) ──────
625
+ // A dedicated tab type instead of a file viewer: better-sidebar's viewer
626
+ // matching is extension/priority-based and cannot tell a REMOTE path from
627
+ // a LOCAL one (a catch-all viewer would hijack every local file open and
628
+ // then fail its local fs.read). Our own tab carries the remote path and
629
+ // reads it straight over /dsh-remote/read — read-only by design (the
630
+ // sidebar editor's save writes to the LOCAL fs, so an editable remote
631
+ // file would silently save into the mirror).
632
+ function RemoteFileTab(props) {
633
+ const { tab, scope } = props
634
+ const path = tab.path || ''
635
+ const [data, setData] = React.useState(null)
636
+ const [err, setErr] = React.useState('')
637
+ const [loading, setLoading] = React.useState(true)
638
+
639
+ React.useEffect(() => {
640
+ if (!path) { setLoading(false); return }
641
+ let cancelled = false
642
+ setLoading(true); setErr(''); setData(null)
643
+ readRemoteFile(path, 256 * 1024)
644
+ .then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
645
+ .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
646
+ return () => { cancelled = true }
647
+ }, [path])
648
+
649
+ const baseName = path.split(/[\\/]/).pop() || path
650
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
651
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
652
+ React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
653
+ React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
654
+ React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, '只读'),
655
+ ),
656
+ React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
657
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
658
+ : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
659
+ : (data && data.binary
660
+ ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
661
+ '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 远程查看器为只读,请用 rw_download / 本地镜像查看。')
662
+ : React.createElement('div', { style: { fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
663
+ (data && data.content != null) ? data.content : '(空文件)',
664
+ (data && data.truncated)
665
+ ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
666
+ : null,
667
+ )),
668
+ ),
669
+ )
670
+ }
671
+
672
+ // ── better-sidebar registration (guarded: plugin may be absent) ────────
673
+ function registerSidebarIntegration(ctx) {
674
+ const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
675
+ if (!bs || typeof bs.registerTab !== 'function') return
676
+ const disposers = []
677
+ try {
678
+ disposers.push(bs.registerTab({
679
+ id: SIDEBAR_EXPLORER_ID,
680
+ title: () => '远程文件',
681
+ icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
682
+ order: 55,
683
+ single: true,
684
+ component: (props) => React.createElement(RemoteExplorerTab, props),
685
+ }))
686
+ disposers.push(bs.registerTab({
687
+ id: SIDEBAR_FILE_ID,
688
+ title: () => '远程文件',
689
+ hidden: true, // not in the + menu; opened from the explorer tree
690
+ dedupeKey: (tab) => tab.path,
691
+ component: (props) => React.createElement(RemoteFileTab, props),
692
+ }))
693
+ } catch (e) {
694
+ console.warn('[dsh-remote] better-sidebar integration skipped:', e)
695
+ disposers.forEach((d) => { try { d() } catch {} })
696
+ return
697
+ }
698
+ // When a remote workspace is already configured, auto-open the remote
699
+ // explorer tab so the sidebar shows REMOTE files (not the local mirror
700
+ // tree the built-in Files tab lists). openTab with a path seed expands
701
+ // the panel and lands the tab in the active pane (the right panel);
702
+ // on the single:true explorer descriptor a repeat open focuses it.
703
+ //
704
+ // Timing: at plugin apply there is usually no active session yet, so
705
+ // openTab would be a no-op (store.reduce without a session). Subscribe
706
+ // to the sidebar snapshot and open the FIRST time a session appears;
707
+ // the subscription is also the cleanup channel for the auto-open fiber.
708
+ let autoOpened = false
709
+ let disposeAuto = null
710
+ const tryAutoOpen = () => {
711
+ if (autoOpened || typeof bs.subscribeState !== 'function') return
712
+ const snap = bs.getSnapshot && bs.getSnapshot()
713
+ if (!snap || !snap.sessionId) return
714
+ fetchRemoteStatus().then((s) => {
715
+ if (!s || !s.workspace || autoOpened) return
716
+ autoOpened = true
717
+ try {
718
+ bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: s.workspace })
719
+ } catch (e2) {
720
+ console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
721
+ }
722
+ })
723
+ }
724
+ tryAutoOpen()
725
+ if (typeof bs.subscribeState === 'function') {
726
+ disposeAuto = bs.subscribeState(tryAutoOpen)
727
+ disposers.push(disposeAuto)
728
+ }
729
+ return () => disposers.forEach((d) => { try { d() } catch {} })
730
+ }
731
+
458
732
  function apply(ctx) {
459
733
  const slots = ctx.get('slots')
460
734
  if (slots === undefined) return
@@ -471,6 +745,23 @@ window.__ModuleLoader__.load({
471
745
  },
472
746
  ),
473
747
  )
748
+ // better-sidebar integration: register the remote explorer + file tabs
749
+ // when dsh-better-sidebar is installed. Guarded — the plugin is optional
750
+ // and everything above works without it.
751
+ //
752
+ // Timing: bundle order may apply dsh-remote BEFORE dsh-better-sidebar,
753
+ // so `ctx.get('betterSidebar')` at apply time can be undefined. Use
754
+ // ctx.inject (cordis: "run once the requested services are available",
755
+ // re-run when a required service changes) so the integration registers
756
+ // the moment better-sidebar provides its service; when the plugin is
757
+ // never installed the fiber simply never runs — the optional integration
758
+ // stays inert. ctx.effect inside keeps the disposer fiber-bound.
759
+ ctx.inject(['betterSidebar'], (inner) => {
760
+ const bsDispose = registerSidebarIntegration(inner)
761
+ if (bsDispose) {
762
+ inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
763
+ }
764
+ })
474
765
  }
475
766
 
476
767
  exports.name = name
package/lib/index.js CHANGED
@@ -1492,6 +1492,57 @@ export async function apply(ctx, config) {
1492
1492
  }
1493
1493
  },
1494
1494
  },
1495
+ {
1496
+ // Read a remote file over SFTP (live, not the local mirror) for the
1497
+ // sidebar's remote file viewer. Text files come back as UTF-8 content
1498
+ // (CRLF→LF), binary files are reported as binary with size+head bytes
1499
+ // (base64) so the client can sniff the format without loading the
1500
+ // whole file. `maxBytes` caps how much text is returned.
1501
+ kind: 'exact',
1502
+ path: '/dsh-remote/read',
1503
+ handler: async (req, res) => {
1504
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1505
+ try {
1506
+ const q = new URL(req.url, 'http://localhost').searchParams
1507
+ let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
1508
+ if (req.method === 'POST') {
1509
+ const body = JSON.parse((await readBody(req)) || '{}')
1510
+ if (!p) p = String(body.path || '')
1511
+ }
1512
+ if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
1513
+ const target = normalizeRemotePath(p)
1514
+ const maxBytes = Math.min(Math.max(Number(q.get('maxBytes')) || 256 * 1024, 1024), 2 * 1024 * 1024)
1515
+ let sftp
1516
+ try {
1517
+ sftp = await pool.sftp()
1518
+ } catch (err) {
1519
+ return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
1520
+ }
1521
+ let buf
1522
+ try {
1523
+ buf = await sftp.readFile(target)
1524
+ } catch (err) {
1525
+ return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1526
+ }
1527
+ // Binary sniff: NUL byte in the first 8KB (or the whole file when
1528
+ // smaller) marks the file binary — matches the sidebar's convention.
1529
+ const headN = Math.min(buf.length, 8192)
1530
+ let binary = false
1531
+ for (let i = 0; i < headN; i++) {
1532
+ if (buf[i] === 0) { binary = true; break }
1533
+ }
1534
+ if (binary) {
1535
+ return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
1536
+ }
1537
+ let content = buf.toString('utf8').replace(/\r\n/g, '\n')
1538
+ const truncated = content.length > maxBytes
1539
+ if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
1540
+ return sendJson(res, 200, { ok: true, binary: false, content, truncated })
1541
+ } catch (err) {
1542
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1543
+ }
1544
+ },
1545
+ },
1495
1546
  {
1496
1547
  kind: 'exact',
1497
1548
  path: '/dsh-remote/workspace',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",