@lyhue1991/dsh-soup 0.5.3 → 0.5.5
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/explorer-data.js +731 -0
- package/lib/client/explorer-view.js +562 -0
- package/lib/client/file-address.js +56 -0
- package/lib/client/file-icons.js +211 -0
- package/lib/client/files-store.js +344 -0
- package/lib/client/files-view.js +164 -0
- package/lib/client/goal-bar.js +212 -0
- package/lib/client/html-preview.js +139 -0
- package/lib/client/i18n.js +192 -0
- package/lib/client/preview-renderers.js +802 -0
- package/lib/client/rpc.js +19 -0
- package/lib/client/speed-badge.js +159 -0
- package/lib/client/styles.js +233 -0
- package/lib/client.entry.js +3 -1
- package/lib/client.js +34836 -3402
- package/lib/client.main.js +555 -0
- package/lib/host/file-view.js +107 -0
- package/lib/host/paths.js +71 -0
- package/lib/host/resources.js +84 -0
- package/lib/host/speed.js +128 -0
- package/lib/host/system.js +51 -0
- package/lib/index.js +31 -579
- package/package.json +3 -3
- package/lib/client.bundle.js +0 -34406
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 资源管理器数据层(区域一 · 🧅 葱):树加载/自动刷新/选择/拖拽移动/
|
|
3
|
+
* 上传下载/重命名新建/展开状态记忆。状态本体存宿主 store(state),
|
|
4
|
+
* 经 getState/setState 访问器读写;files 相关动作(预览/自动保存等)由
|
|
5
|
+
* main 以包装函数延迟注入,避免与 files-store 的初始化顺序耦合。
|
|
6
|
+
*/
|
|
7
|
+
export function createExplorerData(ctx) {
|
|
8
|
+
var T = ctx.T
|
|
9
|
+
var rpc = ctx.rpc
|
|
10
|
+
var setState = ctx.setState
|
|
11
|
+
var getState = ctx.getState
|
|
12
|
+
var getActiveSessionId = ctx.getActiveSessionId
|
|
13
|
+
var setActiveSessionId = ctx.setActiveSessionId
|
|
14
|
+
var visibleRows = ctx.visibleRows
|
|
15
|
+
var pruneFilesToScope = ctx.pruneFilesToScope
|
|
16
|
+
var scheduleAutoSave = ctx.scheduleAutoSave
|
|
17
|
+
var setFiles = ctx.setFiles
|
|
18
|
+
var findFileEntry = ctx.findFileEntry
|
|
19
|
+
var openFileInTab = ctx.openFileInTab
|
|
20
|
+
var askOverwrite = ctx.askOverwrite
|
|
21
|
+
// trackSession() is called by both the hero toggle and the details panel
|
|
22
|
+
// while a blank conversation is mounted. Keep late async responses from an
|
|
23
|
+
// older caller from overwriting the current session's shared store.
|
|
24
|
+
var sessionTrackGeneration = 0
|
|
25
|
+
|
|
26
|
+
// 目录展开状态按 cwd 隔离。刷新/删除/改名/移动后先重拉根目录,
|
|
27
|
+
// 再按这些路径递归重拉仍存在的已展开目录;切换项目时才清空。
|
|
28
|
+
var expandedDirs = new Set()
|
|
29
|
+
var expandedDirsCwd = null
|
|
30
|
+
|
|
31
|
+
function forgetExpandedPaths(paths) {
|
|
32
|
+
var dropped = paths.map(function (p) { return [p, p + '/'] })
|
|
33
|
+
expandedDirs = new Set(Array.from(expandedDirs).filter(function (p) {
|
|
34
|
+
return !dropped.some(function (pair) { return p === pair[0] || p.startsWith(pair[1]) })
|
|
35
|
+
}))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rekeyExpandedPaths(from, to) {
|
|
39
|
+
var moved = Array.from(expandedDirs).filter(function (p) {
|
|
40
|
+
return p === from || p.startsWith(from + '/')
|
|
41
|
+
}).sort(function (a, b) { return a.length - b.length })
|
|
42
|
+
if (!moved.length) return
|
|
43
|
+
var next = new Set(Array.from(expandedDirs).filter(function (p) {
|
|
44
|
+
return p !== from && !p.startsWith(from + '/')
|
|
45
|
+
}))
|
|
46
|
+
moved.forEach(function (p) { next.add(to + p.slice(from.length)) })
|
|
47
|
+
expandedDirs = next
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------------
|
|
51
|
+
// ▓▓ 区域一 · 资源管理器 · 🧅 葱(共享数据层)
|
|
52
|
+
// 共享 store、/api/dsh-soup RPC、树数据加载/自动刷新、selection、拖拽/上传/下载、类型图标 —— 预览亦复用其中 store/rpc。
|
|
53
|
+
// ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
function pathJoin(dir, name) { return String(dir || '/').replace(/\/+$/, '') + '/' + name }
|
|
60
|
+
function parentOf(path) {
|
|
61
|
+
var p = String(path || '/').replace(/\/+$/, '')
|
|
62
|
+
var i = p.lastIndexOf('/')
|
|
63
|
+
return i <= 0 ? '/' : p.slice(0, i)
|
|
64
|
+
}
|
|
65
|
+
function baseName(path) {
|
|
66
|
+
var p = String(path || '').replace(/\/+$/, '')
|
|
67
|
+
var i = p.lastIndexOf('/')
|
|
68
|
+
return i < 0 ? p : p.slice(i + 1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async function loadDir(path, attempt) {
|
|
73
|
+
var res = await rpc('list', { path: path, sessionId: getActiveSessionId() })
|
|
74
|
+
// 新会话刚切换时,宿主会话注册表可能尚未纳入其 cwd——稍候重试。
|
|
75
|
+
if ((!res || !res.ok) && /超出允许范围/.test((res && res.error) || '') && (attempt || 0) < 3) {
|
|
76
|
+
await new Promise(function (r) { setTimeout(r, 600) })
|
|
77
|
+
return loadDir(path, (attempt || 0) + 1)
|
|
78
|
+
}
|
|
79
|
+
if (!res || !res.ok) {
|
|
80
|
+
setState({ error: (res && res.error) || T('explorer.readFail') })
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
return res.entries || []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function defaultCwd(sessionId, hint) {
|
|
87
|
+
// 以会话工作目录为准:优先用客户端会话快照里的 cwd(与官方细节栏同源,
|
|
88
|
+
// 始终指向"当前会话所在项目"),其次查宿主会话 header。
|
|
89
|
+
if (hint) return hint
|
|
90
|
+
if (sessionId) {
|
|
91
|
+
var res = await rpc('sessionCwd', { sessionId: sessionId })
|
|
92
|
+
if (res && res.ok && res.cwd) return res.cwd
|
|
93
|
+
}
|
|
94
|
+
// 已知会话但尚未拿到 cwd 时不要猜路径:猜测 '/' 正好会撞上桌面端
|
|
95
|
+
// 工作区围栏(Desktop 主进程通常从用户主目录启动)。等 useSessions
|
|
96
|
+
// 补上 cwd 后 effect 会重跑。
|
|
97
|
+
if (sessionId) return undefined
|
|
98
|
+
// 无当前会话(例如启动时右栏先挂载)回退到宿主 root;它一定在围栏内,
|
|
99
|
+
// 而文件系统根 '/' 不一定在。
|
|
100
|
+
var rootRes = await rpc('root')
|
|
101
|
+
if (rootRes && rootRes.ok && rootRes.root) return rootRes.root
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function trackSession(sessionId, cwdHint) {
|
|
106
|
+
var generation = ++sessionTrackGeneration
|
|
107
|
+
setActiveSessionId(sessionId || null)
|
|
108
|
+
var cwd = await defaultCwd(sessionId, cwdHint)
|
|
109
|
+
if (generation !== sessionTrackGeneration) return
|
|
110
|
+
if (!cwd) {
|
|
111
|
+
setState({ cwd: '', tree: [], selected: new Set(), renaming: null, newItem: null, menu: null, error: '', notice: '', files: [] })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
// 换项目时展开状态没有跨项目意义;回到同一项目则保留。
|
|
115
|
+
if (expandedDirsCwd !== cwd) {
|
|
116
|
+
expandedDirs.clear()
|
|
117
|
+
expandedDirsCwd = cwd
|
|
118
|
+
}
|
|
119
|
+
// 会话切换:预览列表按新 cwd 裁剪(范围外自动关闭),并清掉上一路径的旧错误横幅
|
|
120
|
+
var files = pruneFilesToScope(getState().files, cwd)
|
|
121
|
+
lastMtimes = {} // 换了目录,旧 mtime 基线全部作废(首轮 tick 重建基线)
|
|
122
|
+
setState({ cwd: cwd, selected: new Set(), renaming: null, newItem: null, menu: null, error: '', notice: '', files: files })
|
|
123
|
+
var items = await loadDir(cwd)
|
|
124
|
+
if (generation !== sessionTrackGeneration) return
|
|
125
|
+
if (items) setState({ error: '', tree: items })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function refresh() {
|
|
129
|
+
if (!getState().cwd) return
|
|
130
|
+
var items = await loadDir(getState().cwd)
|
|
131
|
+
if (!items) return
|
|
132
|
+
await restoreExpandedChildren(items)
|
|
133
|
+
setState({ error: '', tree: items })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 根目录刷新后,按记住的路径递归恢复已展开目录。 */
|
|
137
|
+
async function restoreExpandedChildren(nodes) {
|
|
138
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
139
|
+
var node = nodes[i]
|
|
140
|
+
if (node.type !== 'directory' || !expandedDirs.has(node.path)) continue
|
|
141
|
+
var items = await loadDir(node.path)
|
|
142
|
+
if (!items) {
|
|
143
|
+
expandedDirs.delete(node.path)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
node.open = true
|
|
147
|
+
node.children = items
|
|
148
|
+
items.forEach(function (child) { child.parent = node })
|
|
149
|
+
await restoreExpandedChildren(items)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ------------------------------------------------------------------
|
|
154
|
+
// 自动刷新:低频轮询宿主 mtime 探测(3s 一次),签名变了才真正重拉。
|
|
155
|
+
// watch 范围 = 当前 cwd + 已展开目录 + 打开的预览文件;面板关闭且无
|
|
156
|
+
// 预览时整个 tick 直接返回(零请求)。不做文件 watcher——fs.watch
|
|
157
|
+
// 递归语义跨平台不一致且大工作区开销高,stat 几个路径便宜得多。
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
var AUTO_REFRESH_MS = 3000
|
|
160
|
+
var AUTO_WATCH_MAX = 64
|
|
161
|
+
var lastMtimes = {} // path -> {m,s,d}|null;undefined = 尚无基线(首轮只建基线不触发)
|
|
162
|
+
var autoTicking = false
|
|
163
|
+
|
|
164
|
+
function collectWatchPaths() {
|
|
165
|
+
var paths = []
|
|
166
|
+
if (getState().cwd) paths.push(getState().cwd)
|
|
167
|
+
var walk = function (nodes) {
|
|
168
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
169
|
+
var n = nodes[i]
|
|
170
|
+
if (n.type === 'directory' && n.children != null) {
|
|
171
|
+
paths.push(n.path)
|
|
172
|
+
if (n.open && n.children.length) walk(n.children)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
walk(getState().tree)
|
|
177
|
+
for (var j = 0; j < getState().files.list.length; j++) {
|
|
178
|
+
var e = getState().files.list[j]
|
|
179
|
+
if (e.path && paths.indexOf(e.path) < 0) paths.push(e.path)
|
|
180
|
+
}
|
|
181
|
+
return paths.slice(0, AUTO_WATCH_MAX)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 预览文件在磁盘上变了:静默重读该 entry(不动激活状态、不闪 tab)。 */
|
|
185
|
+
async function silentReloadEntry(entry) {
|
|
186
|
+
var res = await rpc('read', { path: entry.path, sessionId: getActiveSessionId() })
|
|
187
|
+
if (!res || !res.ok) return
|
|
188
|
+
entry.kind = res.kind
|
|
189
|
+
entry.size = res.size || 0
|
|
190
|
+
if (res.kind === 'text') {
|
|
191
|
+
entry.truncated = !!res.truncated
|
|
192
|
+
if (!entry.dirty && !entry.editing) {
|
|
193
|
+
entry.content = res.content
|
|
194
|
+
entry.baseContent = res.content
|
|
195
|
+
entry.draft = res.content
|
|
196
|
+
entry.baseMtime = res.mtime
|
|
197
|
+
entry.baseSize = res.size
|
|
198
|
+
} else {
|
|
199
|
+
entry.conflict = true
|
|
200
|
+
entry.diskMtime = res.mtime
|
|
201
|
+
entry.diskSize = res.size
|
|
202
|
+
scheduleAutoSave(entry.path)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (res.kind === 'image') { entry.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
|
|
206
|
+
else if (res.kind === 'pdf') { entry.data = res.data }
|
|
207
|
+
setFiles({ list: getState().files.list.slice() })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 已展开目录的子项变了:重拉该目录(保持展开态)。 */
|
|
211
|
+
async function reloadDirNode(node) {
|
|
212
|
+
var items = await loadDir(node.path)
|
|
213
|
+
if (items) items.forEach(function (c) { c.parent = node })
|
|
214
|
+
node.children = items || []
|
|
215
|
+
node.open = true
|
|
216
|
+
setState({ tree: getState().tree })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 探一轮。返回 'idle'(本轮没发请求)/ true(探测成功)/ false(请求失败)。
|
|
221
|
+
* 只有真正发出去且失败的请求才累积退避;空闲不累积——面板重开时本就有
|
|
222
|
+
* 一次全量 loadDir,无需为"关着的时候宿主是否恢复"操心。
|
|
223
|
+
*/
|
|
224
|
+
async function autoRefreshTick() {
|
|
225
|
+
if (autoTicking) return 'idle'
|
|
226
|
+
if (!getState().open && getState().files.list.length === 0) return 'idle'
|
|
227
|
+
// 用户正在操作(重命名/新建/右键菜单)时跳过本轮,避免打断输入
|
|
228
|
+
if (getState().renaming || getState().newItem || getState().menu) return 'idle'
|
|
229
|
+
var paths = collectWatchPaths()
|
|
230
|
+
if (!paths.length) return 'idle'
|
|
231
|
+
autoTicking = true
|
|
232
|
+
try {
|
|
233
|
+
var res = await rpc('mtime', { paths: paths })
|
|
234
|
+
if (!res || !res.ok) return false
|
|
235
|
+
var mtimes = res.mtimes || {}
|
|
236
|
+
var dirtyDirs = []
|
|
237
|
+
var dirtyFiles = []
|
|
238
|
+
for (var i = 0; i < paths.length; i++) {
|
|
239
|
+
var p = paths[i]
|
|
240
|
+
var now = Object.prototype.hasOwnProperty.call(mtimes, p) ? mtimes[p] : null
|
|
241
|
+
var prev = Object.prototype.hasOwnProperty.call(lastMtimes, p) ? lastMtimes[p] : undefined
|
|
242
|
+
lastMtimes[p] = now
|
|
243
|
+
if (prev === undefined || prev === null || now === null) continue
|
|
244
|
+
if (now.m === prev.m && now.s === prev.s) continue
|
|
245
|
+
if (now.d) dirtyDirs.push(p)
|
|
246
|
+
else dirtyFiles.push(p)
|
|
247
|
+
}
|
|
248
|
+
for (var d = 0; d < dirtyDirs.length; d++) {
|
|
249
|
+
var dp = dirtyDirs[d]
|
|
250
|
+
if (dp === getState().cwd) { await refresh(); continue }
|
|
251
|
+
var node = findNode(getState().tree, dp)
|
|
252
|
+
if (node && node.children != null) await reloadDirNode(node)
|
|
253
|
+
}
|
|
254
|
+
for (var f = 0; f < dirtyFiles.length; f++) {
|
|
255
|
+
var entry = findFileEntry(dirtyFiles[f])
|
|
256
|
+
if (!entry || !entry.loaded) continue
|
|
257
|
+
var currentSignature = mtimes[dirtyFiles[f]]
|
|
258
|
+
if (currentSignature
|
|
259
|
+
&& currentSignature.m === entry.baseMtime
|
|
260
|
+
&& currentSignature.s === entry.baseSize) continue
|
|
261
|
+
await silentReloadEntry(entry)
|
|
262
|
+
}
|
|
263
|
+
return true
|
|
264
|
+
} finally {
|
|
265
|
+
autoTicking = false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 失败退避:宿主不可达(DSH 重启中 / 插件重载 / 断连)时别每 3s 硬打——
|
|
270
|
+
// 指数退避 3s→6s→12s→24s→48s,封顶 60s;一旦成功回到 3s。
|
|
271
|
+
// (JupyterLab FileBrowserModel 的 Poll 同思路:backoff + max 封顶。)
|
|
272
|
+
// 实现:1s 固定心跳 + 「到期才探测」,而不是变间隔定时器——宿主提供的
|
|
273
|
+
// timer 服务可 dispose,测试环境(interval 为 no-op mock)也不会挂住进程。
|
|
274
|
+
var AUTO_HEARTBEAT_MS = 1000
|
|
275
|
+
var AUTO_BACKOFF_MAX_MS = 60000
|
|
276
|
+
var autoFailCount = 0
|
|
277
|
+
var autoLastProbeAt = 0
|
|
278
|
+
|
|
279
|
+
function autoDelayMs() {
|
|
280
|
+
if (autoFailCount <= 0) return AUTO_REFRESH_MS
|
|
281
|
+
return Math.min(AUTO_REFRESH_MS * Math.pow(2, autoFailCount), AUTO_BACKOFF_MAX_MS)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function autoHeartbeat() {
|
|
285
|
+
var now = Date.now()
|
|
286
|
+
if (now - autoLastProbeAt < autoDelayMs()) return
|
|
287
|
+
autoLastProbeAt = now
|
|
288
|
+
autoRefreshTick().then(function (outcome) {
|
|
289
|
+
if (outcome === false) autoFailCount++
|
|
290
|
+
else autoFailCount = 0
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 手动刷新:重拉根目录,已展开的子目录逐个重拉、保持展开不收起。 */
|
|
295
|
+
async function refreshAll() {
|
|
296
|
+
if (!getState().cwd) return
|
|
297
|
+
await refresh()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function toggleNode(node) {
|
|
301
|
+
if (node.type !== 'directory') return
|
|
302
|
+
if (node.children == null) {
|
|
303
|
+
node.loading = true
|
|
304
|
+
setState({ tree: getState().tree })
|
|
305
|
+
var items = await loadDir(node.path)
|
|
306
|
+
node.loading = false
|
|
307
|
+
if (items) items.forEach(function (c) { c.parent = node })
|
|
308
|
+
node.children = items || []
|
|
309
|
+
node.open = !!items
|
|
310
|
+
if (items) expandedDirs.add(node.path)
|
|
311
|
+
// 拖拽可能把一个已展开目录移进此前收起的父目录。父目录首次展开时,
|
|
312
|
+
// 也要立即恢复其中被记住的子目录展开状态。
|
|
313
|
+
if (items) await restoreExpandedChildren(items)
|
|
314
|
+
setState({ tree: getState().tree })
|
|
315
|
+
} else {
|
|
316
|
+
node.open = !node.open
|
|
317
|
+
if (node.open) expandedDirs.add(node.path)
|
|
318
|
+
else expandedDirs.delete(node.path)
|
|
319
|
+
setState({ tree: getState().tree })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatSize(n) {
|
|
324
|
+
if (typeof n !== 'number' || n < 0) return ''
|
|
325
|
+
if (n < 1024) return n + ' B'
|
|
326
|
+
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
|
|
327
|
+
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
|
|
328
|
+
return (n / 1073741824).toFixed(1) + ' GB'
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
function findNode(nodes, path) {
|
|
333
|
+
if (!Array.isArray(nodes)) return null
|
|
334
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
335
|
+
var n = nodes[i]
|
|
336
|
+
if (n.path === path) return n
|
|
337
|
+
if (Array.isArray(n.children) && n.children.length) {
|
|
338
|
+
var r = findNode(n.children, path)
|
|
339
|
+
if (r) return r
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function rowIndex(path) {
|
|
346
|
+
for (var i = 0; i < visibleRows.length; i++) if (visibleRows[i] === path) return i
|
|
347
|
+
return -1
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function onRowClick(e, node) {
|
|
351
|
+
e.stopPropagation()
|
|
352
|
+
var path = node.path
|
|
353
|
+
var meta = e.metaKey || e.ctrlKey
|
|
354
|
+
var shift = e.shiftKey
|
|
355
|
+
var sel = new Set(getState().selected)
|
|
356
|
+
var lastIndex = getState().lastIndex
|
|
357
|
+
if (shift && lastIndex != null) {
|
|
358
|
+
var idx = rowIndex(path)
|
|
359
|
+
var from = Math.min(lastIndex, idx)
|
|
360
|
+
var to = Math.max(lastIndex, idx)
|
|
361
|
+
for (var i = from; i <= to; i++) { if (visibleRows[i]) sel.add(visibleRows[i]) }
|
|
362
|
+
} else if (meta) {
|
|
363
|
+
if (sel.has(path)) sel.delete(path); else sel.add(path)
|
|
364
|
+
lastIndex = rowIndex(path)
|
|
365
|
+
} else {
|
|
366
|
+
sel.clear(); sel.add(path); lastIndex = rowIndex(path)
|
|
367
|
+
}
|
|
368
|
+
setState({ selected: sel, lastIndex: lastIndex })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function onRowDoubleClick(node) {
|
|
372
|
+
if (node.type === 'directory') {
|
|
373
|
+
toggleNode(node)
|
|
374
|
+
} else {
|
|
375
|
+
openFileInTab(node.path)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function onRowContext(e, node) {
|
|
380
|
+
e.preventDefault()
|
|
381
|
+
e.stopPropagation()
|
|
382
|
+
var sel = new Set(getState().selected)
|
|
383
|
+
if (!sel.has(node.path)) { sel.clear(); sel.add(node.path); setState({ selected: sel }) }
|
|
384
|
+
setState({ menu: { x: e.clientX, y: e.clientY, node: node } })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function onBlankContext(e) {
|
|
388
|
+
e.preventDefault()
|
|
389
|
+
e.stopPropagation()
|
|
390
|
+
setState({ selected: new Set(), lastIndex: null, menu: { x: e.clientX, y: e.clientY, node: null } })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function onBlankClick(e) {
|
|
394
|
+
if (e.target && e.target.closest && e.target.closest('[data-path]')) return
|
|
395
|
+
setState({ selected: new Set(), lastIndex: null })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function onRowMore(e, node) {
|
|
399
|
+
e.preventDefault()
|
|
400
|
+
e.stopPropagation()
|
|
401
|
+
var sel = new Set(getState().selected)
|
|
402
|
+
sel.clear()
|
|
403
|
+
sel.add(node.path)
|
|
404
|
+
var rect = e.currentTarget.getBoundingClientRect()
|
|
405
|
+
setState({ selected: sel, lastIndex: null, menu: { x: rect.right - 4, y: rect.bottom + 4, node: node } })
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function openSelection() {
|
|
409
|
+
var paths = Array.from(getState().selected)
|
|
410
|
+
setState({ menu: null })
|
|
411
|
+
for (var i = 0; i < paths.length; i++) await rpc('open', { path: paths[i] })
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function trashSelection() {
|
|
415
|
+
var paths = Array.from(getState().selected)
|
|
416
|
+
setState({ menu: null })
|
|
417
|
+
var errors = []
|
|
418
|
+
var trashed = []
|
|
419
|
+
for (var i = 0; i < paths.length; i++) {
|
|
420
|
+
var res = await rpc('trash', { path: paths[i] })
|
|
421
|
+
if (res && res.ok) trashed.push(paths[i])
|
|
422
|
+
else errors.push(baseName(paths[i]) + ': ' + ((res && res.error) || T('explorer.actionFail')) + ((res && res.hint) ? '(' + res.hint + ')' : ''))
|
|
423
|
+
}
|
|
424
|
+
if (errors.length) setState({ error: errors.join(';') })
|
|
425
|
+
forgetExpandedPaths(trashed)
|
|
426
|
+
setState({ selected: new Set(), lastIndex: null })
|
|
427
|
+
await refresh()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function startRename(node) { setState({ renaming: node.path, menu: null }) }
|
|
431
|
+
|
|
432
|
+
function commitRename(path, name) {
|
|
433
|
+
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
434
|
+
setState({ renaming: null })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
var dir = parentOf(path)
|
|
438
|
+
var dest = pathJoin(dir, name)
|
|
439
|
+
var node = findNode(getState().tree, path)
|
|
440
|
+
var isDir = node && node.type === 'directory'
|
|
441
|
+
var sel = new Set(getState().selected)
|
|
442
|
+
if (sel.has(path)) { sel.delete(path); sel.add(dest) }
|
|
443
|
+
setState({ renaming: null, selected: sel })
|
|
444
|
+
;(async function () {
|
|
445
|
+
var res = await rpc('move', { from: path, to: dest })
|
|
446
|
+
if (res && res.ok) {
|
|
447
|
+
if (isDir) rekeyExpandedPaths(path, dest)
|
|
448
|
+
} else {
|
|
449
|
+
setState({ error: (res && res.error) || T('explorer.renameFail') })
|
|
450
|
+
}
|
|
451
|
+
await refresh()
|
|
452
|
+
})()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function startNew(parent, isDir) {
|
|
456
|
+
setState({ newItem: { parent: parent, isDir: isDir }, menu: null })
|
|
457
|
+
if (parent !== getState().cwd) {
|
|
458
|
+
var parentNode = findNode(getState().tree, parent)
|
|
459
|
+
if (parentNode && parentNode.children == null) toggleNode(parentNode)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function commitNew(parent, name, isDir) {
|
|
464
|
+
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
465
|
+
setState({ newItem: null })
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
setState({ newItem: null })
|
|
469
|
+
;(async function () {
|
|
470
|
+
var res = await rpc('create', { dir: parent, name: name, isDir: isDir })
|
|
471
|
+
if (!res || !res.ok) setState({ error: (res && res.error) || T('explorer.createFail') })
|
|
472
|
+
await refresh()
|
|
473
|
+
})()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 成功通知:3 秒后自动消失(仅当未被更新的消息覆盖时才清除)。 */
|
|
477
|
+
var noticeTimer = null
|
|
478
|
+
function showNotice(text) {
|
|
479
|
+
setState({ notice: text, error: '' })
|
|
480
|
+
if (noticeTimer) clearTimeout(noticeTimer)
|
|
481
|
+
noticeTimer = setTimeout(function () {
|
|
482
|
+
if (getState().notice === text) setState({ notice: '' })
|
|
483
|
+
}, 3000)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function copyPath(target) {
|
|
487
|
+
var p = target && target.path ? target.path : getState().cwd
|
|
488
|
+
setState({ menu: null })
|
|
489
|
+
try {
|
|
490
|
+
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
|
491
|
+
navigator.clipboard.writeText(p).then(function () { showNotice(T('explorer.renamed')) }).catch(function () {})
|
|
492
|
+
}
|
|
493
|
+
} catch (err) {}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function onDragStart(e, node) {
|
|
497
|
+
var paths = getState().selected.has(node.path) ? Array.from(getState().selected) : [node.path]
|
|
498
|
+
setState({ dragPaths: paths })
|
|
499
|
+
try { e.dataTransfer.setData('text/plain', paths.join('\n')) } catch (err) {}
|
|
500
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function doDrop(e, targetDir) {
|
|
504
|
+
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
505
|
+
uploadFiles(targetDir, Array.from(e.dataTransfer.files))
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
movePaths(getState().dragPaths || [], targetDir)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function onRowDrop(e, node) {
|
|
512
|
+
e.preventDefault()
|
|
513
|
+
e.stopPropagation()
|
|
514
|
+
if (node.type !== 'directory') return
|
|
515
|
+
doDrop(e, node.path)
|
|
516
|
+
setState({ dropTarget: null })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function onBodyDrop(e) {
|
|
520
|
+
e.preventDefault()
|
|
521
|
+
doDrop(e, getState().cwd)
|
|
522
|
+
setState({ dropTarget: null })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function movePaths(paths, targetDir) {
|
|
526
|
+
var errors = []
|
|
527
|
+
var moved = []
|
|
528
|
+
for (var i = 0; i < paths.length; i++) {
|
|
529
|
+
var p = paths[i]
|
|
530
|
+
if (p === '/' || !p) continue
|
|
531
|
+
var name = baseName(p)
|
|
532
|
+
var dest = pathJoin(targetDir, name)
|
|
533
|
+
if (dest === p) continue
|
|
534
|
+
if (targetDir === p || targetDir.startsWith(p + '/')) { errors.push(T('explorer.moveIntoSelf', { path: name })); continue }
|
|
535
|
+
var node = findNode(getState().tree, p)
|
|
536
|
+
var res = await rpc('move', { from: p, to: dest })
|
|
537
|
+
if (res && res.ok) {
|
|
538
|
+
moved.push({ from: p, to: dest, isDir: node && node.type === 'directory' })
|
|
539
|
+
} else {
|
|
540
|
+
errors.push(name + ': ' + ((res && res.error) || T('explorer.actionFail')))
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
setState({ dragPaths: null })
|
|
544
|
+
moved.forEach(function (item) {
|
|
545
|
+
if (item.isDir) rekeyExpandedPaths(item.from, item.to)
|
|
546
|
+
})
|
|
547
|
+
if (errors.length) setState({ error: errors.join(';') })
|
|
548
|
+
await refresh()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function fileToBase64(file) {
|
|
552
|
+
return new Promise(function (resolve, reject) {
|
|
553
|
+
var r = new FileReader()
|
|
554
|
+
r.onload = function () { var s = String(r.result || ''); var i = s.indexOf(','); resolve(i >= 0 ? s.slice(i + 1) : s) }
|
|
555
|
+
r.onerror = function () { reject(new Error(T('files.readFail'))) }
|
|
556
|
+
r.readAsDataURL(file)
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** 单块大小:超过则走分块通道(JupyterLab 同款 1MB)。 */
|
|
561
|
+
var UPLOAD_CHUNK_SIZE = 1024 * 1024
|
|
562
|
+
|
|
563
|
+
function setUploadEntry(entry) {
|
|
564
|
+
var list = (getState().uploads || []).slice()
|
|
565
|
+
var found = false
|
|
566
|
+
for (var i = 0; i < list.length; i++) {
|
|
567
|
+
if (list[i].name === entry.name) { list[i] = entry; found = true; break }
|
|
568
|
+
}
|
|
569
|
+
if (!found) list.push(entry)
|
|
570
|
+
setState({ uploads: list })
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function updateUpload(name, loaded, total, idx, totalFiles, down) {
|
|
574
|
+
setUploadEntry({ name: name, loaded: loaded, total: total, idx: idx, totalFiles: totalFiles, done: false, down: !!down })
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function finishUploads(count, okCount, failedName, down) {
|
|
578
|
+
// These translations own the `{n}` placeholder; passing no params leaves
|
|
579
|
+
// the literal token visible in the green completion notice.
|
|
580
|
+
var verb = down ? T('explorer.downloaded', { n: okCount }) : T('explorer.uploaded', { n: okCount })
|
|
581
|
+
var suffix = okCount === count ? '' : ' · ' + T('explorer.filesCountPartial', { ok: okCount, total: count })
|
|
582
|
+
setUploadEntry({
|
|
583
|
+
name: '✓ ' + verb + suffix + (failedName ? '(' + failedName + ')' : ''),
|
|
584
|
+
loaded: 0, total: 0, idx: 0, totalFiles: 0, done: true, down: !!down,
|
|
585
|
+
})
|
|
586
|
+
window.setTimeout(function () { setState({ uploads: [] }) }, 1600)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** 单文件分块上传:>1MB 切片逐块 POST,首块覆盖、后续追加。 */
|
|
590
|
+
async function uploadOneFile(dir, f, idx, totalFiles) {
|
|
591
|
+
var chunked = f.size > UPLOAD_CHUNK_SIZE
|
|
592
|
+
var chunks = Math.max(1, Math.ceil(f.size / UPLOAD_CHUNK_SIZE))
|
|
593
|
+
updateUpload(f.name, 0, f.size, idx, totalFiles)
|
|
594
|
+
for (var c = 0; c < chunks; c++) {
|
|
595
|
+
var start = c * UPLOAD_CHUNK_SIZE
|
|
596
|
+
var blob = f.slice(start, Math.min(f.size, start + UPLOAD_CHUNK_SIZE))
|
|
597
|
+
var b64 = await fileToBase64(blob)
|
|
598
|
+
var args = {
|
|
599
|
+
dir: dir, name: f.name, data: b64,
|
|
600
|
+
chunk: chunked ? c + 1 : undefined,
|
|
601
|
+
}
|
|
602
|
+
var res = await rpc('upload', args)
|
|
603
|
+
if (res && res.conflict) {
|
|
604
|
+
var overwrite = await askOverwrite(f.name)
|
|
605
|
+
if (!overwrite) throw new Error('已取消上传')
|
|
606
|
+
args.overwrite = true
|
|
607
|
+
res = await rpc('upload', args)
|
|
608
|
+
}
|
|
609
|
+
if (!res || !res.ok) throw new Error((res && res.error) || T('explorer.uploadFail'))
|
|
610
|
+
updateUpload(f.name, Math.min(f.size, start + UPLOAD_CHUNK_SIZE), f.size, idx, totalFiles)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function uploadFiles(dir, files) {
|
|
615
|
+
if (!files || !files.length) return
|
|
616
|
+
var errors = []
|
|
617
|
+
var okCount = 0
|
|
618
|
+
for (var i = 0; i < files.length; i++) {
|
|
619
|
+
var f = files[i]
|
|
620
|
+
try {
|
|
621
|
+
await uploadOneFile(dir, f, i + 1, files.length)
|
|
622
|
+
okCount++
|
|
623
|
+
} catch (err) {
|
|
624
|
+
errors.push(f.name + ': ' + String((err && err.message) || err))
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
finishUploads(files.length, okCount, errors.length ? errors[0].split(':')[0] : '')
|
|
628
|
+
if (errors.length) setState({ error: errors.join(';'), notice: '' })
|
|
629
|
+
await refresh()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** ≤100MB 走页面内 fetch + 进度行 + Blob 另存;>100MB 走浏览器原生下载。 */
|
|
633
|
+
var DOWNLOAD_PROGRESS_LIMIT = 100 * 1024 * 1024
|
|
634
|
+
|
|
635
|
+
async function downloadFile(path) {
|
|
636
|
+
setState({ menu: null })
|
|
637
|
+
var res = await rpc('download', { path: path, sessionId: getActiveSessionId() })
|
|
638
|
+
if (!res.ok && /超出允许范围/.test(res.error || '')) {
|
|
639
|
+
await new Promise(function (r) { setTimeout(r, 600) })
|
|
640
|
+
res = await rpc('download', { path: path, sessionId: getActiveSessionId() })
|
|
641
|
+
}
|
|
642
|
+
if (!res || !res.ok) {
|
|
643
|
+
setState({ error: (res && res.error) || T('explorer.downloadFail') })
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
var name = res.name || baseName(path)
|
|
647
|
+
var size = res.size || 0
|
|
648
|
+
// 大文件:浏览器原生下载(流式落盘、零内存),无进度
|
|
649
|
+
if (size > DOWNLOAD_PROGRESS_LIMIT) {
|
|
650
|
+
var a = document.createElement('a')
|
|
651
|
+
a.href = res.url
|
|
652
|
+
a.download = name
|
|
653
|
+
document.body.appendChild(a)
|
|
654
|
+
a.click()
|
|
655
|
+
a.remove()
|
|
656
|
+
showNotice(T('explorer.downloadStart', { name: name }))
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
// 中小文件:fetch 流式读字节计进度,完成后 Blob 另存(峰值≈文件体积)
|
|
660
|
+
updateUpload(name, 0, size, 0, 1, true)
|
|
661
|
+
try {
|
|
662
|
+
var r = await fetch(res.url)
|
|
663
|
+
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
664
|
+
var total = Number(r.headers.get('content-length')) || size
|
|
665
|
+
var reader = r.body.getReader()
|
|
666
|
+
var received = 0
|
|
667
|
+
var chunks = []
|
|
668
|
+
for (;;) {
|
|
669
|
+
var part = await reader.read()
|
|
670
|
+
if (part.done) break
|
|
671
|
+
chunks.push(part.value)
|
|
672
|
+
received += part.value.length
|
|
673
|
+
updateUpload(name, received, total, 0, 1, true)
|
|
674
|
+
}
|
|
675
|
+
var blobUrl = URL.createObjectURL(new Blob(chunks))
|
|
676
|
+
var da = document.createElement('a')
|
|
677
|
+
da.href = blobUrl
|
|
678
|
+
da.download = name
|
|
679
|
+
document.body.appendChild(da)
|
|
680
|
+
da.click()
|
|
681
|
+
da.remove()
|
|
682
|
+
window.setTimeout(function () { URL.revokeObjectURL(blobUrl) }, 5000)
|
|
683
|
+
finishUploads(1, 1, '', true)
|
|
684
|
+
} catch (err) {
|
|
685
|
+
setState({ error: T('explorer.downloadFailWith', { reason: String((err && err.message) || err) }), uploads: [] })
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function onUploadPicker(e) {
|
|
690
|
+
var files = Array.from((e.target && e.target.files) || [])
|
|
691
|
+
uploadFiles(getState().cwd, files)
|
|
692
|
+
e.target.value = ''
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ------------------------------------------------------------------
|
|
696
|
+
// ------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
pathJoin: pathJoin,
|
|
700
|
+
parentOf: parentOf,
|
|
701
|
+
baseName: baseName,
|
|
702
|
+
defaultCwd: defaultCwd,
|
|
703
|
+
trackSession: trackSession,
|
|
704
|
+
refresh: refresh,
|
|
705
|
+
refreshAll: refreshAll,
|
|
706
|
+
toggleNode: toggleNode,
|
|
707
|
+
formatSize: formatSize,
|
|
708
|
+
onRowClick: onRowClick,
|
|
709
|
+
onRowDoubleClick: onRowDoubleClick,
|
|
710
|
+
onRowContext: onRowContext,
|
|
711
|
+
onBlankContext: onBlankContext,
|
|
712
|
+
onBlankClick: onBlankClick,
|
|
713
|
+
onRowMore: onRowMore,
|
|
714
|
+
openSelection: openSelection,
|
|
715
|
+
trashSelection: trashSelection,
|
|
716
|
+
startRename: startRename,
|
|
717
|
+
commitRename: commitRename,
|
|
718
|
+
startNew: startNew,
|
|
719
|
+
commitNew: commitNew,
|
|
720
|
+
copyPath: copyPath,
|
|
721
|
+
onDragStart: onDragStart,
|
|
722
|
+
onRowDrop: onRowDrop,
|
|
723
|
+
onBodyDrop: onBodyDrop,
|
|
724
|
+
movePaths: movePaths,
|
|
725
|
+
downloadFile: downloadFile,
|
|
726
|
+
onUploadPicker: onUploadPicker,
|
|
727
|
+
autoHeartbeat: autoHeartbeat,
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (typeof window !== 'undefined') window.__DSH_SOUP_EXPLORER_DATA__ = { createExplorerData }
|