dsh-remote 0.6.8 → 0.7.0
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 +286 -37
- package/lib/index.js +51 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -358,34 +358,29 @@ window.__ModuleLoader__.load({
|
|
|
358
358
|
}
|
|
359
359
|
const last = levels[levels.length - 1]
|
|
360
360
|
const entries = last.all || []
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// (
|
|
364
|
-
return React.createElement('div', {
|
|
365
|
-
style: {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
),
|
|
385
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
|
|
386
|
-
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
|
|
387
|
-
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => acceptBrowserPick(last.path) }, '选用此路径'),
|
|
388
|
-
),
|
|
361
|
+
// Inline panel anchored inside the dialog (NOT a full-viewport overlay):
|
|
362
|
+
// a height-capped box whose directory list scrolls internally, so the
|
|
363
|
+
// dialog's own confirm row ("设为远程工作区") stays visible below it.
|
|
364
|
+
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' } },
|
|
365
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
366
|
+
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 }, '回上一级 ▴'),
|
|
367
|
+
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 }))),
|
|
368
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
|
|
369
|
+
),
|
|
370
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
371
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
372
|
+
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
373
|
+
key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
|
|
374
|
+
onClick: it.dir ? () => enterDir(it.name) : undefined,
|
|
375
|
+
style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: it.dir ? T.ok : T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
|
|
376
|
+
},
|
|
377
|
+
React.createElement('span', null, it.dir ? '📁' : '📄'),
|
|
378
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
|
|
379
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
380
|
+
),
|
|
381
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
382
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
|
|
383
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
|
|
389
384
|
),
|
|
390
385
|
)
|
|
391
386
|
}
|
|
@@ -396,7 +391,7 @@ window.__ModuleLoader__.load({
|
|
|
396
391
|
// renders identically in the narrow sidebar and in the conversation and
|
|
397
392
|
// is never squeezed into a cramped in-place column.
|
|
398
393
|
return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: () => { if (!busy) onCancel() } },
|
|
399
|
-
React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
|
|
394
|
+
React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(620px, 90vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
|
|
400
395
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
|
|
401
396
|
React.createElement('div', null, '选择工作目录'),
|
|
402
397
|
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
|
|
@@ -415,7 +410,7 @@ window.__ModuleLoader__.load({
|
|
|
415
410
|
React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
|
|
416
411
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
417
412
|
)
|
|
418
|
-
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
413
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
419
414
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
420
415
|
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
|
|
421
416
|
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
@@ -432,29 +427,266 @@ window.__ModuleLoader__.load({
|
|
|
432
427
|
),
|
|
433
428
|
),
|
|
434
429
|
// 路径输入框(带自动补全)+ 打开浏览弹层按钮
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
430
|
+
// suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
|
|
431
|
+
// 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
|
|
432
|
+
// 推到下方而不会被遮挡(issue #4「有子目录时选择框遮挡确定菜单」)。
|
|
433
|
+
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
434
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
435
|
+
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
436
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
437
|
+
),
|
|
438
|
+
// 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
|
|
439
439
|
(suggestOpen && suggest.length)
|
|
440
|
-
? React.createElement('div', { style: {
|
|
440
|
+
? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
|
|
441
441
|
suggest.map((s, i) => React.createElement('div', { key: s + i, onMouseDown: () => selectSuggestion(s), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
|
|
442
442
|
)
|
|
443
443
|
: null,
|
|
444
444
|
),
|
|
445
|
+
// 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
|
|
446
|
+
popOpen ? renderDirPopup() : null,
|
|
445
447
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
446
448
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
447
449
|
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
|
|
448
450
|
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
|
|
449
451
|
),
|
|
450
|
-
// 悬浮浏览弹层:选中的路径回填到输入框(不直接提交)
|
|
451
|
-
popOpen ? renderDirPopup() : null,
|
|
452
452
|
),
|
|
453
453
|
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
454
454
|
),
|
|
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) setLevels((prev) => (prev.length ? prev : [{ path: s.workspace, items: null }]))
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
React.useEffect(() => { refreshStatus() }, [])
|
|
523
|
+
// When the tab becomes visible, re-verify the workspace still exists
|
|
524
|
+
// (the remote connection may have been switched in settings).
|
|
525
|
+
React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
|
|
526
|
+
|
|
527
|
+
const loadDir = (p, idx) => {
|
|
528
|
+
if (loading) return
|
|
529
|
+
setLoading(true); setErr('')
|
|
530
|
+
listRemoteDir(p).then((res) => {
|
|
531
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
532
|
+
setLevels((prev) => {
|
|
533
|
+
const base = prev.length ? prev.slice() : [{ path: p, items: null }]
|
|
534
|
+
const list = items.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file' }))
|
|
535
|
+
if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
|
|
536
|
+
base[idx] = { path: p, items: list }
|
|
537
|
+
return base.slice(0, idx + 1)
|
|
538
|
+
})
|
|
539
|
+
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
540
|
+
.finally(() => setLoading(false))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Open a directory: append it as the new deepest level.
|
|
544
|
+
const enterDir = (name) => {
|
|
545
|
+
const last = levels.length ? levels[levels.length - 1] : null
|
|
546
|
+
if (!last || !last.items) return
|
|
547
|
+
const base = last.path
|
|
548
|
+
const sep = base.includes('\\') ? '\\' : '/'
|
|
549
|
+
const next = base === '/' ? '/' + name : base + sep + name
|
|
550
|
+
loadDir(next, levels.length)
|
|
551
|
+
}
|
|
552
|
+
// Jump to a breadcrumb ancestor.
|
|
553
|
+
const jumpTo = (p) => {
|
|
554
|
+
const idx = levels.findIndex((lv) => lv.path === p)
|
|
555
|
+
if (idx < 0) return
|
|
556
|
+
setLevels(levels.slice(0, idx + 1))
|
|
557
|
+
}
|
|
558
|
+
// Open a file: open the dedicated REMOTE file tab (reads the file live
|
|
559
|
+
// over /dsh-remote/read) — NOT the builtin editor, whose fs.read would
|
|
560
|
+
// hit the local filesystem and fail on a remote path.
|
|
561
|
+
const openFile = (p) => {
|
|
562
|
+
const name = String(p).split(/[\\/]/).pop() || p
|
|
563
|
+
props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
|
|
567
|
+
const curPath = levels.length ? levels[levels.length - 1].path : ''
|
|
568
|
+
const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
|
|
569
|
+
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([]) } }, '⌂')]
|
|
570
|
+
let acc = ''
|
|
571
|
+
segs.forEach((s, i) => {
|
|
572
|
+
acc = acc ? acc + '/' + s : s
|
|
573
|
+
const isLast = i === segs.length - 1
|
|
574
|
+
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))
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const row = (it, i) => React.createElement('div', {
|
|
578
|
+
key: it.name + '|' + i,
|
|
579
|
+
title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
|
|
580
|
+
onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
|
|
581
|
+
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)' },
|
|
582
|
+
}, React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'), React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name))
|
|
583
|
+
|
|
584
|
+
const rootHint = status && status.workspace
|
|
585
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
|
|
586
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
|
|
587
|
+
|
|
588
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
589
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
|
|
590
|
+
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) } }, '↻'),
|
|
591
|
+
React.createElement('div', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontFamily: 'monospace' } }, crumbs),
|
|
592
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
|
|
593
|
+
),
|
|
594
|
+
rootHint,
|
|
595
|
+
picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
|
|
596
|
+
open: true, busy: false, onPicked: (localMirror) => {
|
|
597
|
+
setPicker(false)
|
|
598
|
+
// The picker commits a mirror + workspace; refresh the tree.
|
|
599
|
+
refreshStatus()
|
|
600
|
+
fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
|
|
601
|
+
}, onCancel: () => setPicker(false),
|
|
602
|
+
})) : null,
|
|
603
|
+
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0 } },
|
|
604
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
|
|
605
|
+
: (allItems.length ? allItems.slice(0, 500).map(row)
|
|
606
|
+
: (curPath ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '(空目录)')
|
|
607
|
+
: React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区'))),
|
|
608
|
+
),
|
|
609
|
+
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, err) : null,
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Remote file tab (opened by the explorer; reads the file live) ──────
|
|
614
|
+
// A dedicated tab type instead of a file viewer: better-sidebar's viewer
|
|
615
|
+
// matching is extension/priority-based and cannot tell a REMOTE path from
|
|
616
|
+
// a LOCAL one (a catch-all viewer would hijack every local file open and
|
|
617
|
+
// then fail its local fs.read). Our own tab carries the remote path and
|
|
618
|
+
// reads it straight over /dsh-remote/read — read-only by design (the
|
|
619
|
+
// sidebar editor's save writes to the LOCAL fs, so an editable remote
|
|
620
|
+
// file would silently save into the mirror).
|
|
621
|
+
function RemoteFileTab(props) {
|
|
622
|
+
const { tab, scope } = props
|
|
623
|
+
const path = tab.path || ''
|
|
624
|
+
const [data, setData] = React.useState(null)
|
|
625
|
+
const [err, setErr] = React.useState('')
|
|
626
|
+
const [loading, setLoading] = React.useState(true)
|
|
627
|
+
|
|
628
|
+
React.useEffect(() => {
|
|
629
|
+
if (!path) { setLoading(false); return }
|
|
630
|
+
let cancelled = false
|
|
631
|
+
setLoading(true); setErr(''); setData(null)
|
|
632
|
+
readRemoteFile(path, 256 * 1024)
|
|
633
|
+
.then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
|
|
634
|
+
.catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
|
|
635
|
+
return () => { cancelled = true }
|
|
636
|
+
}, [path])
|
|
637
|
+
|
|
638
|
+
const baseName = path.split(/[\\/]/).pop() || path
|
|
639
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
640
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
|
|
641
|
+
React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
|
|
642
|
+
React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
|
|
643
|
+
React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, '只读'),
|
|
644
|
+
),
|
|
645
|
+
React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
|
|
646
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
|
|
647
|
+
: err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
|
|
648
|
+
: (data && data.binary
|
|
649
|
+
? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
|
|
650
|
+
'二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 远程查看器为只读,请用 rw_download / 本地镜像查看。')
|
|
651
|
+
: 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' } },
|
|
652
|
+
(data && data.content != null) ? data.content : '(空文件)',
|
|
653
|
+
(data && data.truncated)
|
|
654
|
+
? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
|
|
655
|
+
: null,
|
|
656
|
+
)),
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── better-sidebar registration (guarded: plugin may be absent) ────────
|
|
662
|
+
function registerSidebarIntegration(ctx) {
|
|
663
|
+
const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
|
|
664
|
+
if (!bs || typeof bs.registerTab !== 'function') return
|
|
665
|
+
const disposers = []
|
|
666
|
+
try {
|
|
667
|
+
disposers.push(bs.registerTab({
|
|
668
|
+
id: SIDEBAR_EXPLORER_ID,
|
|
669
|
+
title: () => '远程文件',
|
|
670
|
+
icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
|
|
671
|
+
order: 55,
|
|
672
|
+
single: true,
|
|
673
|
+
component: (props) => React.createElement(RemoteExplorerTab, props),
|
|
674
|
+
}))
|
|
675
|
+
disposers.push(bs.registerTab({
|
|
676
|
+
id: SIDEBAR_FILE_ID,
|
|
677
|
+
title: () => '远程文件',
|
|
678
|
+
hidden: true, // not in the + menu; opened from the explorer tree
|
|
679
|
+
dedupeKey: (tab) => tab.path,
|
|
680
|
+
component: (props) => React.createElement(RemoteFileTab, props),
|
|
681
|
+
}))
|
|
682
|
+
} catch (e) {
|
|
683
|
+
console.warn('[dsh-remote] better-sidebar integration skipped:', e)
|
|
684
|
+
disposers.forEach((d) => { try { d() } catch {} })
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
return () => disposers.forEach((d) => { try { d() } catch {} })
|
|
688
|
+
}
|
|
689
|
+
|
|
458
690
|
function apply(ctx) {
|
|
459
691
|
const slots = ctx.get('slots')
|
|
460
692
|
if (slots === undefined) return
|
|
@@ -471,6 +703,23 @@ window.__ModuleLoader__.load({
|
|
|
471
703
|
},
|
|
472
704
|
),
|
|
473
705
|
)
|
|
706
|
+
// better-sidebar integration: register the remote explorer + file tabs
|
|
707
|
+
// when dsh-better-sidebar is installed. Guarded — the plugin is optional
|
|
708
|
+
// and everything above works without it.
|
|
709
|
+
//
|
|
710
|
+
// Timing: bundle order may apply dsh-remote BEFORE dsh-better-sidebar,
|
|
711
|
+
// so `ctx.get('betterSidebar')` at apply time can be undefined. Use
|
|
712
|
+
// ctx.inject (cordis: "run once the requested services are available",
|
|
713
|
+
// re-run when a required service changes) so the integration registers
|
|
714
|
+
// the moment better-sidebar provides its service; when the plugin is
|
|
715
|
+
// never installed the fiber simply never runs — the optional integration
|
|
716
|
+
// stays inert. ctx.effect inside keeps the disposer fiber-bound.
|
|
717
|
+
ctx.inject(['betterSidebar'], (inner) => {
|
|
718
|
+
const bsDispose = registerSidebarIntegration(inner)
|
|
719
|
+
if (bsDispose) {
|
|
720
|
+
inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
|
|
721
|
+
}
|
|
722
|
+
})
|
|
474
723
|
}
|
|
475
724
|
|
|
476
725
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
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>",
|