dsh-remote 0.8.4 → 0.8.6
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/LICENSE +21 -21
- package/lib/client.js +245 -129
- package/lib/index.js +2546 -2431
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dsh-remote contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-remote contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/client.js
CHANGED
|
@@ -87,6 +87,7 @@ window.__ModuleLoader__.load({
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
let WORKSPACES = null // ctx.get('workspaces'), set in apply()
|
|
90
|
+
let SESSIONS = null // ctx.get('sessions'), set in apply()
|
|
90
91
|
|
|
91
92
|
// ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
|
|
92
93
|
function RemoteWorkspacePage() {
|
|
@@ -378,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
378
379
|
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
|
|
379
380
|
React.createElement('span', {}, '·'),
|
|
380
381
|
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
|
|
381
|
-
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote
|
|
382
|
+
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
|
|
382
383
|
),
|
|
383
384
|
)
|
|
384
385
|
}
|
|
@@ -562,15 +563,21 @@ window.__ModuleLoader__.load({
|
|
|
562
563
|
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
563
564
|
: parseLs(res && res.text)
|
|
564
565
|
const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
|
|
565
|
-
|
|
566
|
+
// Show every entry (dirs + files), matching loadSuggestions — the
|
|
567
|
+
// previous dir-only filter made the next-level list look truncated.
|
|
568
|
+
const kids = list.slice(0, 40).map((it) => (base === '/' ? sep + it.name : base + sep + it.name))
|
|
566
569
|
setSuggest(kids)
|
|
567
570
|
setSuggestOpen(!!kids.length)
|
|
568
571
|
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
569
572
|
}
|
|
570
573
|
|
|
571
574
|
const selectSuggestion = (s) => {
|
|
572
|
-
|
|
573
|
-
|
|
575
|
+
// Auto-append the trailing separator so the path reads as a directory
|
|
576
|
+
// and the user can keep typing the next segment right away.
|
|
577
|
+
const sep = String(s).includes('\\') ? '\\' : '/'
|
|
578
|
+
const withSep = String(s).replace(/[\\/]+$/, '') + sep
|
|
579
|
+
setPath(withSep); setErr(''); setSuggestOpen(false)
|
|
580
|
+
continueSuggest(withSep)
|
|
574
581
|
}
|
|
575
582
|
|
|
576
583
|
const commitPath = (p) => {
|
|
@@ -623,9 +630,11 @@ window.__ModuleLoader__.load({
|
|
|
623
630
|
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
624
631
|
key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
|
|
625
632
|
onClick: it.dir ? () => enterDir(it.name) : undefined,
|
|
626
|
-
style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color:
|
|
633
|
+
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 },
|
|
627
634
|
},
|
|
628
|
-
React.createElement('span',
|
|
635
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label } }, it.dir
|
|
636
|
+
? 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' }))
|
|
637
|
+
: 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' }))),
|
|
629
638
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
|
|
630
639
|
React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
|
|
631
640
|
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
@@ -716,6 +725,33 @@ window.__ModuleLoader__.load({
|
|
|
716
725
|
return api('GET', '/dsh-remote/status').catch(() => null)
|
|
717
726
|
}
|
|
718
727
|
|
|
728
|
+
// Resolve the remote path this session should show: prefer the session's
|
|
729
|
+
// own cwd (a remote mirror dir) mapped through /dsh-remote/resolve-mirror;
|
|
730
|
+
// fall back to the machine workspace when the session has no cwd or the
|
|
731
|
+
// host side has no mirror match.
|
|
732
|
+
function resolveSessionRemote(sessionId) {
|
|
733
|
+
if (!sessionId) return Promise.resolve('')
|
|
734
|
+
// 1) Prefer the client-side session list cwd (mirror dir under
|
|
735
|
+
// $DSH_HOME/remote-workspaces/...) passed as ?local=.
|
|
736
|
+
let local = ''
|
|
737
|
+
try {
|
|
738
|
+
const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
|
|
739
|
+
? SESSIONS.list.getSnapshot()
|
|
740
|
+
: null
|
|
741
|
+
const entry = list && list.byId && list.byId[sessionId]
|
|
742
|
+
if (entry && entry.cwd) local = String(entry.cwd)
|
|
743
|
+
} catch { /* sessions store shape differs */ }
|
|
744
|
+
if (local) {
|
|
745
|
+
return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
|
|
746
|
+
.then((r) => (r && r.remotePath) || '')
|
|
747
|
+
.catch(() => '')
|
|
748
|
+
}
|
|
749
|
+
// 2) Fall back to host-side session header cwd.
|
|
750
|
+
return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
|
|
751
|
+
.then((r) => (r && r.remotePath) || '')
|
|
752
|
+
.catch(() => '')
|
|
753
|
+
}
|
|
754
|
+
|
|
719
755
|
function readRemoteFile(path, maxBytes) {
|
|
720
756
|
return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
|
|
721
757
|
throw new Error('远程读取失败: ' + ((e && e.message) || e))
|
|
@@ -734,31 +770,109 @@ window.__ModuleLoader__.load({
|
|
|
734
770
|
}
|
|
735
771
|
|
|
736
772
|
// ── Remote explorer tab ────────────────────────────────────────────────
|
|
773
|
+
// Inline monochrome icons (fill: currentColor → follows the row text color,
|
|
774
|
+
// i.e. black on the sidebar). Folder open/closed + file.
|
|
775
|
+
const IconFolder = (open) => React.createElement('svg', {
|
|
776
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
|
|
777
|
+
},
|
|
778
|
+
open
|
|
779
|
+
? 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' })
|
|
780
|
+
: 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' }),
|
|
781
|
+
)
|
|
782
|
+
const IconFile = () => React.createElement('svg', {
|
|
783
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
|
|
784
|
+
},
|
|
785
|
+
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' }),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
// Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
|
|
789
|
+
// contract as the built-in local file tree (better-sidebar framework
|
|
790
|
+
// manages + persists the expanded set), so interaction and look match the
|
|
791
|
+
// local files tab. Data is listed live from the remote host over
|
|
792
|
+
// /dsh-remote/ls, rooted at this session's remote workspace dir.
|
|
737
793
|
function RemoteExplorerTab(props) {
|
|
738
794
|
const [status, setStatus] = React.useState(null)
|
|
739
|
-
const [
|
|
795
|
+
const [root, setRoot] = React.useState('') // remote root path for this session
|
|
796
|
+
const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
|
|
797
|
+
const dataRef = React.useRef(data)
|
|
740
798
|
const [err, setErr] = React.useState('')
|
|
741
|
-
const [loading, setLoading] = React.useState(false)
|
|
742
|
-
const [picker, setPicker] = React.useState(false)
|
|
743
|
-
const [menu, setMenu] = React.useState(null) // { x, y, item }
|
|
744
799
|
const [busy, setBusy] = React.useState('')
|
|
800
|
+
const [picker, setPicker] = React.useState(false)
|
|
801
|
+
const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
|
|
802
|
+
const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
|
|
803
|
+
const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
|
|
804
|
+
const expanded = Array.isArray(props.expanded) ? props.expanded : []
|
|
805
|
+
|
|
806
|
+
const storeLevel = (path, level) => {
|
|
807
|
+
dataRef.current = { ...dataRef.current, [path]: level }
|
|
808
|
+
setData(dataRef.current)
|
|
809
|
+
}
|
|
745
810
|
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
return
|
|
811
|
+
const loadDir = React.useCallback((dir) => {
|
|
812
|
+
const cur = dataRef.current
|
|
813
|
+
if (cur[dir] !== undefined && !cur[dir].loading) return
|
|
814
|
+
storeLevel(dir, { loading: true })
|
|
815
|
+
listRemoteDir(dir)
|
|
816
|
+
.then((res) => {
|
|
817
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
818
|
+
const sorted = items.slice().sort((a, b) => {
|
|
819
|
+
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
820
|
+
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
821
|
+
return String(a.name).localeCompare(String(b.name))
|
|
756
822
|
})
|
|
823
|
+
storeLevel(dir, { entries: sorted })
|
|
824
|
+
})
|
|
825
|
+
.catch((e) => storeLevel(dir, { error: String((e && e.message) || e) }))
|
|
826
|
+
}, [])
|
|
827
|
+
|
|
828
|
+
// Resolve this session's remote root, then load it and every expanded dir.
|
|
829
|
+
// Re-runs whenever the session scope changes (switching conversations, or
|
|
830
|
+
// the async sessionCwd fetch landing), so the sidebar follows the session's
|
|
831
|
+
// own workspace dir.
|
|
832
|
+
const scopeCwd = props.scope && props.scope.cwd
|
|
833
|
+
const sessionId = props.scope && props.scope.sessionId
|
|
834
|
+
const refreshStatus = React.useCallback(() => {
|
|
835
|
+
const applyRoot = (r) => {
|
|
836
|
+
setRoot(r)
|
|
837
|
+
setData({})
|
|
838
|
+
dataRef.current = {}
|
|
839
|
+
if (r) {
|
|
840
|
+
storeLevel(r, { loading: true })
|
|
841
|
+
listRemoteDir(r)
|
|
842
|
+
.then((res) => {
|
|
843
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
844
|
+
const sorted = items.slice().sort((a, b) => {
|
|
845
|
+
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
846
|
+
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
847
|
+
return String(a.name).localeCompare(String(b.name))
|
|
848
|
+
})
|
|
849
|
+
storeLevel(r, { entries: sorted })
|
|
850
|
+
for (const dir of expanded) loadDir(dir)
|
|
851
|
+
})
|
|
852
|
+
.catch((e) => storeLevel(r, { error: String((e && e.message) || e) }))
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const resolveRoot = () => {
|
|
856
|
+
if (scopeCwd) {
|
|
857
|
+
return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
|
|
858
|
+
.then((r) => (r && r.remotePath) || '')
|
|
859
|
+
.catch(() => '')
|
|
757
860
|
}
|
|
861
|
+
if (sessionId) return resolveSessionRemote(sessionId)
|
|
862
|
+
return Promise.resolve('')
|
|
863
|
+
}
|
|
864
|
+
resolveRoot().then((r) => {
|
|
865
|
+
fetchRemoteStatus().then((s) => {
|
|
866
|
+
setStatus(s)
|
|
867
|
+
applyRoot(r || (s && s.workspace) || '')
|
|
868
|
+
})
|
|
758
869
|
})
|
|
759
|
-
}
|
|
760
|
-
|
|
870
|
+
}, [scopeCwd, sessionId])
|
|
871
|
+
|
|
872
|
+
React.useEffect(() => { refreshStatus() }, [refreshStatus])
|
|
761
873
|
React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
|
|
874
|
+
// Load dirs as they get expanded.
|
|
875
|
+
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir) }, [root, expanded, loadDir])
|
|
762
876
|
React.useEffect(() => {
|
|
763
877
|
if (!menu) return
|
|
764
878
|
const close = () => setMenu(null)
|
|
@@ -766,143 +880,142 @@ window.__ModuleLoader__.load({
|
|
|
766
880
|
return () => document.removeEventListener('mousedown', close)
|
|
767
881
|
}, [menu])
|
|
768
882
|
|
|
769
|
-
const loadDir = (p, idx) => {
|
|
770
|
-
if (loading) return
|
|
771
|
-
setLoading(true); setErr('')
|
|
772
|
-
listRemoteDir(p).then((res) => {
|
|
773
|
-
const items = Array.isArray(res && res.items) ? res.items : []
|
|
774
|
-
const sorted = items.slice().sort((a, b) => {
|
|
775
|
-
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
776
|
-
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
777
|
-
return String(a.name).localeCompare(String(b.name))
|
|
778
|
-
})
|
|
779
|
-
setLevels((prev) => {
|
|
780
|
-
const base = prev.length ? prev.slice() : [{ path: p, items: null }]
|
|
781
|
-
const list = sorted.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file', size: it.size, mtime: it.mtime }))
|
|
782
|
-
if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
|
|
783
|
-
base[idx] = { path: p, items: list }
|
|
784
|
-
return base.slice(0, idx + 1)
|
|
785
|
-
})
|
|
786
|
-
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
787
|
-
.finally(() => setLoading(false))
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const enterDir = (name) => {
|
|
791
|
-
const last = levels.length ? levels[levels.length - 1] : null
|
|
792
|
-
if (!last || !last.items) return
|
|
793
|
-
const base = last.path
|
|
794
|
-
const sep = base.includes('\\') ? '\\' : '/'
|
|
795
|
-
const next = base === '/' ? '/' + name : base + sep + name
|
|
796
|
-
loadDir(next, levels.length)
|
|
797
|
-
}
|
|
798
|
-
const jumpTo = (p) => {
|
|
799
|
-
// Breadcrumb segments may lack the leading separator (built by
|
|
800
|
-
// splitting the absolute path), so normalize to the absolute form
|
|
801
|
-
// before matching the level stack.
|
|
802
|
-
const target = p.startsWith('/') || p.startsWith('\\') || /^[A-Za-z]:/.test(p) ? p : '/' + p
|
|
803
|
-
const idx = levels.findIndex((lv) => lv.path === target)
|
|
804
|
-
if (idx < 0) return
|
|
805
|
-
setLevels(levels.slice(0, idx + 1))
|
|
806
|
-
}
|
|
807
883
|
const openFile = (p) => {
|
|
808
884
|
const name = String(p).split(/[\\/]/).pop() || p
|
|
809
885
|
props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
|
|
810
886
|
}
|
|
811
887
|
|
|
812
|
-
const doFs = (op, payload) => {
|
|
888
|
+
const doFs = (op, payload, refreshParent) => {
|
|
813
889
|
setBusy(op)
|
|
814
890
|
return api('POST', '/dsh-remote/fs', { op, ...payload })
|
|
815
891
|
.then(() => {
|
|
816
|
-
const last = levels.length ? levels[levels.length - 1] : null
|
|
817
|
-
if (last) loadDir(last.path, levels.length - 1)
|
|
818
892
|
setErr('')
|
|
893
|
+
if (refreshParent) {
|
|
894
|
+
dataRef.current = {}
|
|
895
|
+
setData({})
|
|
896
|
+
if (root) { storeLevel(root, { loading: true }); loadDir(root) }
|
|
897
|
+
for (const dir of expanded) loadDir(dir)
|
|
898
|
+
}
|
|
819
899
|
})
|
|
820
900
|
.catch((e) => setErr(String((e && e.message) || e)))
|
|
821
901
|
.finally(() => setBusy(''))
|
|
822
902
|
}
|
|
823
903
|
|
|
824
|
-
const onCtx = (e,
|
|
904
|
+
const onCtx = (e, path, isDir, name) => {
|
|
825
905
|
e.preventDefault(); e.stopPropagation()
|
|
826
|
-
setMenu({ x: e.clientX, y: e.clientY,
|
|
906
|
+
setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
|
|
827
907
|
}
|
|
828
|
-
const curPath = levels.length ? levels[levels.length - 1].path : ''
|
|
829
|
-
const menuFull = menu ? joinRemote(curPath, menu.item.name) : ''
|
|
830
|
-
|
|
831
|
-
const row = (it, i) => React.createElement('div', {
|
|
832
|
-
key: it.name + '|' + i,
|
|
833
|
-
title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
|
|
834
|
-
onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
|
|
835
|
-
onContextMenu: (e) => onCtx(e, it),
|
|
836
|
-
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)' },
|
|
837
|
-
},
|
|
838
|
-
React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'),
|
|
839
|
-
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
|
|
840
|
-
it.type !== 'dir' ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(it.size)) : null)
|
|
841
|
-
|
|
842
|
-
const rootHint = status && status.workspace
|
|
843
|
-
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
|
|
844
|
-
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
|
|
845
|
-
|
|
846
|
-
const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
|
|
847
|
-
const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
|
|
848
|
-
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([]) } }, '⌂')]
|
|
849
|
-
let acc = ''
|
|
850
|
-
segs.forEach((s, i) => {
|
|
851
|
-
acc = acc ? acc + '/' + s : s
|
|
852
|
-
const isLast = i === segs.length - 1
|
|
853
|
-
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))
|
|
854
|
-
})
|
|
855
|
-
|
|
856
908
|
const ctxItem = (label, danger, fn) => React.createElement('div', {
|
|
857
909
|
onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
|
|
858
910
|
style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
|
|
859
911
|
}, label)
|
|
860
912
|
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
913
|
+
// One tree row (dir toggles via the framework's onToggleDir; file opens).
|
|
914
|
+
// Text uses the normal foreground (theme-following) — no accent green.
|
|
915
|
+
const row = (entry, depth) => {
|
|
916
|
+
const pad = depth * 22 + 6
|
|
917
|
+
const hovered = hoverPath === entry.path
|
|
918
|
+
const base = {
|
|
919
|
+
display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
|
|
920
|
+
borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
|
|
921
|
+
overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
|
|
922
|
+
background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
|
|
923
|
+
color: T.label,
|
|
924
|
+
}
|
|
925
|
+
if (entry.type === 'dir') {
|
|
926
|
+
const isOpen = expanded.includes(entry.path)
|
|
927
|
+
return React.createElement('div', { key: entry.path },
|
|
928
|
+
React.createElement('div', {
|
|
929
|
+
role: 'button', tabIndex: 0,
|
|
930
|
+
onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
|
|
931
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
|
|
932
|
+
onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
|
|
933
|
+
onMouseEnter: () => setHoverPath(entry.path),
|
|
934
|
+
onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
|
|
935
|
+
style: base,
|
|
936
|
+
title: entry.path,
|
|
937
|
+
},
|
|
938
|
+
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
|
|
939
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
|
|
940
|
+
),
|
|
941
|
+
isOpen ? renderLevel(entry.path, depth + 1) : null,
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
return React.createElement('div', {
|
|
945
|
+
role: 'button', tabIndex: 0, key: entry.path,
|
|
946
|
+
onClick: () => openFile(entry.path),
|
|
947
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
|
|
948
|
+
onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
|
|
949
|
+
onMouseEnter: () => setHoverPath(entry.path),
|
|
950
|
+
onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
|
|
951
|
+
style: base,
|
|
952
|
+
title: entry.path,
|
|
953
|
+
},
|
|
954
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
|
|
955
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
|
|
956
|
+
entry.size != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(entry.size)) : null,
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const renderLevel = (dir, depth) => {
|
|
961
|
+
const level = data[dir]
|
|
962
|
+
if (level === undefined || (level.loading && !level.entries)) {
|
|
963
|
+
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 } }, '加载中…')
|
|
964
|
+
}
|
|
965
|
+
if (level.error !== undefined) {
|
|
966
|
+
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)
|
|
967
|
+
}
|
|
968
|
+
const items = level.entries || []
|
|
969
|
+
if (!items.length) return null
|
|
970
|
+
return React.createElement('div', { key: dir + ':list' }, items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)))
|
|
867
971
|
}
|
|
868
972
|
|
|
973
|
+
const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
|
|
974
|
+
const rootLevel = data[root]
|
|
975
|
+
|
|
869
976
|
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
870
977
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
|
|
871
|
-
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick:
|
|
978
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
|
|
872
979
|
React.createElement('button', {
|
|
873
980
|
style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
|
|
874
|
-
onClick:
|
|
875
|
-
|
|
876
|
-
title: '返回上一级目录',
|
|
877
|
-
}, '↑ 上一级'),
|
|
878
|
-
React.createElement('div', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontFamily: 'monospace' } }, crumbs),
|
|
879
|
-
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => { const last = levels.length ? levels[levels.length - 1] : null; const base = (last && last.path) || '/'; const nm = window.prompt('新建目录名(在 ' + base + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(base, nm.trim()) }) } }, '新建目录'),
|
|
981
|
+
onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
|
|
982
|
+
}, '新建目录'),
|
|
880
983
|
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
|
|
881
984
|
),
|
|
882
|
-
|
|
985
|
+
status && status.workspace
|
|
986
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
|
|
987
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区'),
|
|
883
988
|
picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
|
|
884
|
-
open: true, busy: false, onPicked: (
|
|
885
|
-
setPicker(false)
|
|
886
|
-
refreshStatus()
|
|
887
|
-
fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
|
|
888
|
-
}, onCancel: () => setPicker(false),
|
|
989
|
+
open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
|
|
889
990
|
})) : null,
|
|
890
|
-
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0 } },
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
991
|
+
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
|
|
992
|
+
!root
|
|
993
|
+
? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区')
|
|
994
|
+
: React.createElement('div', { key: root },
|
|
995
|
+
React.createElement('div', {
|
|
996
|
+
role: 'button', tabIndex: 0,
|
|
997
|
+
onClick: () => setRootOpen((v) => !v),
|
|
998
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
|
|
999
|
+
onContextMenu: (e) => onCtx(e, root, true, rootName),
|
|
1000
|
+
style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: 6, borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: T.label, fontWeight: 600 },
|
|
1001
|
+
title: root,
|
|
1002
|
+
},
|
|
1003
|
+
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
|
|
1004
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
|
|
1005
|
+
),
|
|
1006
|
+
rootLevel && rootOpen ? renderLevel(root, 1) : null,
|
|
1007
|
+
),
|
|
895
1008
|
),
|
|
896
1009
|
// context menu
|
|
897
1010
|
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() },
|
|
898
|
-
ctxItem(menu.
|
|
899
|
-
ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path:
|
|
1011
|
+
ctxItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }),
|
|
1012
|
+
ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })),
|
|
900
1013
|
ctxItem('✏ 重命名', false, () => {
|
|
901
|
-
const nm = window.prompt('重命名为(新名字)', menu.
|
|
902
|
-
if (nm && nm.trim() && nm.trim() !== menu.
|
|
1014
|
+
const nm = window.prompt('重命名为(新名字)', menu.name)
|
|
1015
|
+
if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true)
|
|
903
1016
|
}),
|
|
904
|
-
ctxItem('🗑 删除' + (menu.
|
|
905
|
-
if (window.confirm('确认删除 ' +
|
|
1017
|
+
ctxItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => {
|
|
1018
|
+
if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true)
|
|
906
1019
|
}),
|
|
907
1020
|
) : null,
|
|
908
1021
|
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
|
|
@@ -1017,17 +1130,19 @@ window.__ModuleLoader__.load({
|
|
|
1017
1130
|
disposers.forEach((d) => { try { d() } catch {} })
|
|
1018
1131
|
return
|
|
1019
1132
|
}
|
|
1020
|
-
let
|
|
1133
|
+
let autoOpenedFor = new Set()
|
|
1021
1134
|
let disposeAuto = null
|
|
1022
1135
|
const tryAutoOpen = () => {
|
|
1023
|
-
if (
|
|
1136
|
+
if (typeof bs.subscribeState !== 'function') return
|
|
1024
1137
|
const snap = bs.getSnapshot && bs.getSnapshot()
|
|
1025
1138
|
if (!snap || !snap.sessionId) return
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1139
|
+
if (autoOpenedFor.has(snap.sessionId)) return
|
|
1140
|
+
const sessionId = snap.sessionId
|
|
1141
|
+
resolveSessionRemote(sessionId).then((remotePath) => {
|
|
1142
|
+
if (!remotePath || autoOpenedFor.has(sessionId)) return
|
|
1143
|
+
autoOpenedFor.add(sessionId)
|
|
1029
1144
|
try {
|
|
1030
|
-
bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path:
|
|
1145
|
+
bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
|
|
1031
1146
|
} catch (e2) {
|
|
1032
1147
|
console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
|
|
1033
1148
|
}
|
|
@@ -1045,6 +1160,7 @@ window.__ModuleLoader__.load({
|
|
|
1045
1160
|
const slots = ctx.get('slots')
|
|
1046
1161
|
if (slots === undefined) return
|
|
1047
1162
|
WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
|
|
1163
|
+
SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
|
|
1048
1164
|
slots.inject('settings.section', () =>
|
|
1049
1165
|
slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
|
|
1050
1166
|
)
|