@lyhue1991/dsh-soup 0.2.0 → 0.4.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 CHANGED
@@ -53,8 +53,9 @@ window.__ModuleLoader__.load({
53
53
  '.expl-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
54
54
  '.expl-title{font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;font-size:14px;}' +
55
55
  '.expl-head-btns{display:flex;gap:2px;flex-wrap:nowrap;}' +
56
- '.expl-btn{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-size:15px;cursor:pointer;padding:3px 7px;border-radius:8px;}' +
56
+ '.expl-btn{width:26px;height:26px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-size:15px;cursor:pointer;padding:0;border-radius:8px;flex:none;}' +
57
57
  '.expl-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}' +
58
+ '.expl-btn svg{width:16px;height:16px;display:block;}' +
58
59
  '.expl-path{font-size:11px;color:var(--dsw-alias-label-secondary);padding:7px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left;flex:none;cursor:default;}' +
59
60
  '.expl-error{color:var(--dsw-alias-state-error-primary);padding:6px 14px;font-size:12px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
60
61
  '.expl-notice{color:var(--dsw-alias-state-success-primary);padding:6px 14px;font-size:12px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
@@ -78,7 +79,8 @@ window.__ModuleLoader__.load({
78
79
  '.expl-muted{color:var(--dsw-alias-label-tertiary);padding:4px 14px;font-size:12px;}' +
79
80
  '.expl-menu-mask{position:fixed;inset:0;z-index:1980;}' +
80
81
  '.expl-menu{position:fixed;z-index:1990;min-width:172px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;padding:4px;box-shadow:var(--dsw-shadow-lv3);pointer-events:auto;}' +
81
- '.expl-menu-item{display:block;width:100%;text-align:left;background:transparent;border:none;color:var(--dsw-alias-label-primary);font-size:13px;padding:6px 10px;border-radius:8px;cursor:pointer;line-height:1.4;}' +
82
+ '.expl-menu-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:transparent;border:none;color:var(--dsw-alias-label-primary);font-size:13px;padding:6px 10px;border-radius:8px;cursor:pointer;line-height:1.4;}' +
83
+ '.expl-menu-ico{flex:none;display:inline-flex;align-items:center;}.expl-menu-ico svg{width:14px;height:14px;display:block;}' +
82
84
  '.expl-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover);}' +
83
85
  '.expl-danger{color:var(--dsw-alias-state-error-primary);} .expl-menu-item.expl-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);}' +
84
86
  '.expl-menu-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1);}' +
@@ -97,6 +99,14 @@ window.__ModuleLoader__.load({
97
99
  document.head.appendChild(tag)
98
100
  }
99
101
 
102
+ // ------------------------------------------------------------------
103
+ // 工具栏图标:取自 JupyterLab ui-components(BSD-3-Clause,与文件类型
104
+ // 图标同源的引用方式),fill 改为 currentColor 以跟随 DSH 主题 token。
105
+ // 注意:只引入刷新图标——ICON_CLOSE / ICON_UPLOAD 与 GoalBar 段的
106
+ // 同名 React 元素常量冲突(var 提升覆盖),字符样式反而更稳。
107
+ // ------------------------------------------------------------------
108
+ var ICON_REFRESH = '<svg xmlns="http://www.w3.org/2000/svg" width="16" viewBox="0 0 18 18"><path fill="currentColor" d="M9 13.5c-2.49 0-4.5-2.01-4.5-4.5S6.51 4.5 9 4.5c1.24 0 2.36.52 3.17 1.33L10 8h5V3l-1.76 1.76A6 6 0 0 0 9 3C5.69 3 3.01 5.69 3.01 9S5.69 15 9 15a5.98 5.98 0 0 0 5.9-5h-1.52c-.46 2-2.24 3.5-4.38 3.5"/></svg>'
109
+
100
110
  // GoalBar 多行版:沿用 DSH 原生 GoalBar 的结构和主题,仅放开单行截断。
101
111
  // 布局分两行:head 行(图标 + 相位标签 + 操作按钮),body 行(objective 多行)。
102
112
  var GOAL_CSS =
@@ -137,8 +147,9 @@ window.__ModuleLoader__.load({
137
147
  '.dfv-body{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);}' +
138
148
  '.dfv-toolbar{display:flex;align-items:center;gap:8px;flex:none;padding:6px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary);}' +
139
149
  '.dfv-toolbar-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;font-family:var(--ds-font-family-code);}' +
140
- '.dfv-btn{flex:none;background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font-size:11px;line-height:1;padding:4px 9px;border-radius:7px;cursor:pointer;}' +
150
+ '.dfv-btn{flex:none;display:inline-flex;align-items:center;gap:5px;height:26px;box-sizing:border-box;background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1;padding:0 10px;border-radius:7px;cursor:pointer;}' +
141
151
  '.dfv-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}' +
152
+ '.dfv-btn svg{width:14px;height:14px;display:block;flex:none;}' +
142
153
  '.dfv-error{padding:8px 14px;font-size:12px;color:var(--dsw-alias-state-error-primary);flex:none;}' +
143
154
  '.dfv-muted{padding:24px 14px;font-size:13px;color:var(--dsw-alias-label-tertiary);text-align:center;}' +
144
155
  '.dfv-cap{padding:5px 14px;font-size:11px;color:var(--dsw-alias-label-caption);border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
@@ -169,7 +180,13 @@ window.__ModuleLoader__.load({
169
180
  '.dfv-table-wrap{flex:1;min-height:0;overflow:auto;}' +
170
181
  '.dfv-table{border-collapse:collapse;font-size:12px;font-family:var(--ds-font-family-code);}' +
171
182
  '.dfv-th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:600;text-align:left;padding:5px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);border-right:1px solid var(--dsw-alias-border-l1);white-space:nowrap;}' +
172
- '.dfv-td{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);border-right:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere;max-width:360px;}'
183
+ '.dfv-td{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);border-right:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere;max-width:360px;}' +
184
+ // 纯阅读模式:预览 tab 激活时隐藏整个输入区(composerSeat:输入框 +
185
+ // GoalBar + 速度徽标所在的 input.dock)与轮次统计行(StatsLine)。
186
+ // class 来自 DSH 客户端编译产物(CSS module hash),升级后若失效仅
187
+ // 表现为「预览页仍显示输入区」,不影响其它功能——属可接受的降级。
188
+ '[data-dsh-soup-preview="1"] .wSkVaW_composerSeat{display:none !important;}' +
189
+ '[data-dsh-soup-preview="1"] .FJxK0a_root{display:none !important;}'
173
190
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="@lyhue1991/dsh-soup/fileview.css"]') === null) {
174
191
  var fileviewTag = document.createElement('style')
175
192
  fileviewTag.dataset.plugin = '@lyhue1991/dsh-soup'
@@ -178,8 +195,146 @@ window.__ModuleLoader__.load({
178
195
  document.head.appendChild(fileviewTag)
179
196
  }
180
197
 
181
- /** 需要的客户端服务:slots、layout、timer、sessions + remote.goals(goal 动作动词)。 */
182
- var inject = ['slots', 'layout', 'timer', 'sessions', 'remote', 'remote.goals']
198
+ /** 需要的客户端服务:slots、layout、timer、sessions、locale(i18n 词典)+ remote.goals(goal 动作动词)。 */
199
+ var inject = ['slots', 'layout', 'timer', 'sessions', 'locale', 'remote', 'remote.goals']
200
+
201
+ // ------------------------------------------------------------------
202
+ // i18n:走 DSH 官方插件词典机制(ui-cordis / ui-conversation 同款)——
203
+ // ctx.locale.register(NS, DICT) 注册;槽位配置加 locale: NS 后组件 props
204
+ // 获得 t(语言切换铸造新 t 引用,靠浅比较自动重渲染整棵子树);组件外
205
+ // 回调(菜单 label、错误横幅)用模块级 T(bind 的 t 每次调用解析当前语言)。
206
+ // ------------------------------------------------------------------
207
+ var NS = 'dsh-soup'
208
+ var DICT = {
209
+ zh: {
210
+ 'explorer.title': '📁 资源管理器',
211
+ 'explorer.label': '资源管理器',
212
+ 'explorer.refresh': '刷新',
213
+ 'explorer.refreshTitle': '刷新(保持已展开目录)',
214
+ 'explorer.upload': '上传文件',
215
+ 'explorer.close': '关闭',
216
+ 'explorer.closePanel': '关闭资源管理器',
217
+ 'explorer.emptyDir': '(空目录)',
218
+ 'explorer.bulkSelected': '已选 {n} 项 · 拖到文件夹可移动',
219
+ 'explorer.readFail': '读取失败',
220
+ 'explorer.actionFail': '失败',
221
+ 'explorer.uploading': '上传中 ',
222
+ 'explorer.downloading': '下载中 ',
223
+ 'explorer.uploaded': '已上传 {n} 个文件',
224
+ 'explorer.filesCount': '{n} 个文件',
225
+ 'explorer.filesCountPartial': '{ok}/{total} 个文件',
226
+ 'explorer.downloaded': '已下载 {n} 个文件',
227
+ 'explorer.uploadFail': '上传失败',
228
+ 'explorer.downloadFail': '下载失败',
229
+ 'explorer.downloadFailWith': '下载失败: {reason}',
230
+ 'explorer.downloadStart': '已开始下载 {name}(大文件,由浏览器接管)',
231
+ 'explorer.renamed': '已复制路径',
232
+ 'explorer.renameFail': '重命名失败',
233
+ 'explorer.createFail': '创建失败',
234
+ 'explorer.moveIntoSelf': '不能把文件夹移入自身: {path}',
235
+ 'explorer.hostUnreachable': '宿主不可达: {reason}',
236
+ 'explorer.hostBadResponse': '宿主响应解析失败',
237
+ 'menu.preview': '👁 预览',
238
+ 'menu.open': '🖥 用系统打开',
239
+ 'menu.rename': '✎ 重命名',
240
+ 'menu.trash': '🗑 移到废纸篓',
241
+ 'menu.trashMulti': '🗑 移到废纸篓 ({n} 项)',
242
+ 'menu.newFile': '📄 新建文件',
243
+ 'menu.newFolder': '📁 新建文件夹',
244
+ 'menu.copyPath': '⧉ 复制路径',
245
+ 'menu.download': '⬇ 下载',
246
+ 'menu.deselect': '取消选择',
247
+ 'files.tab': '预览',
248
+ 'files.emptyHint': '在右侧资源管理器中双击或右键「预览」文件',
249
+ 'files.loading': '加载中…',
250
+ 'files.readFail': '读取文件失败',
251
+ 'files.reload': '重载',
252
+ 'files.reloadTitle': '从磁盘重新读取(刷新预览)',
253
+ 'files.binary': '二进制文件({size}),请在资源管理器中用系统打开',
254
+ 'files.imageTooLarge': '图片过大({size},上限 {limit})',
255
+ 'files.pdfTooLarge': 'PDF 过大({size},上限 {limit}),请用系统打开',
256
+ 'files.truncated': '文件超过 2 MB,仅预览前 2 MB;完整内容请用系统编辑器打开。',
257
+ 'files.jsonFail': 'JSON 解析失败,按纯文本显示',
258
+ 'files.nbFail': 'ipynb 解析失败,按纯文本显示',
259
+ 'files.nbEmpty': '(空笔记本)',
260
+ 'files.nbCells': '共 {n} 个单元格',
261
+ 'files.csvTruncated': '共 {total} 行,仅预览前 {n} 行',
262
+ 'files.htmlFrame': 'HTML 预览',
263
+ 'files.pdfFrame': 'PDF 预览',
264
+ 'files.pdfLoading': 'PDF 加载中…',
265
+ 'files.pdfFail': 'PDF 预览失败: {reason}',
266
+ 'files.pdfUnsupported': '当前浏览器无法内嵌预览 PDF,请右键用系统打开。',
267
+ 'files.nbHtmlOut': 'HTML 输出',
268
+ 'speed.label': '速度徽标',
269
+ 'speed.waiting': '正在等待模型',
270
+ 'speed.operateFail': '操作失败',
271
+ },
272
+ en: {
273
+ 'explorer.title': '📁 Explorer',
274
+ 'explorer.label': 'Explorer',
275
+ 'explorer.refresh': 'Refresh',
276
+ 'explorer.refreshTitle': 'Refresh (keeps expanded folders)',
277
+ 'explorer.upload': 'Upload files',
278
+ 'explorer.close': 'Close',
279
+ 'explorer.closePanel': 'Close explorer',
280
+ 'explorer.emptyDir': '(empty)',
281
+ 'explorer.bulkSelected': '{n} selected · drag onto a folder to move',
282
+ 'explorer.readFail': 'Failed to read',
283
+ 'explorer.actionFail': 'Failed',
284
+ 'explorer.uploading': 'Uploading ',
285
+ 'explorer.downloading': 'Downloading ',
286
+ 'explorer.uploaded': 'Uploaded {n} file(s)',
287
+ 'explorer.filesCount': '{n} file(s)',
288
+ 'explorer.filesCountPartial': '{ok}/{total} file(s)',
289
+ 'explorer.downloaded': 'Downloaded {n} file(s)',
290
+ 'explorer.uploadFail': 'Upload failed',
291
+ 'explorer.downloadFail': 'Download failed',
292
+ 'explorer.downloadFailWith': 'Download failed: {reason}',
293
+ 'explorer.downloadStart': 'Downloading {name} (large file, handled by the browser)',
294
+ 'explorer.renamed': 'Path copied',
295
+ 'explorer.renameFail': 'Rename failed',
296
+ 'explorer.createFail': 'Create failed',
297
+ 'explorer.moveIntoSelf': 'Cannot move a folder into itself: {path}',
298
+ 'explorer.hostUnreachable': 'Host unreachable: {reason}',
299
+ 'explorer.hostBadResponse': 'Failed to parse host response',
300
+ 'menu.preview': '👁 Preview',
301
+ 'menu.open': '🖥 Open with system',
302
+ 'menu.rename': '✎ Rename',
303
+ 'menu.trash': '🗑 Move to trash',
304
+ 'menu.trashMulti': '🗑 Move to trash ({n})',
305
+ 'menu.newFile': '📄 New file',
306
+ 'menu.newFolder': '📁 New folder',
307
+ 'menu.copyPath': '⧉ Copy path',
308
+ 'menu.download': '⬇ Download',
309
+ 'menu.deselect': 'Clear selection',
310
+ 'files.tab': 'Preview',
311
+ 'files.emptyHint': 'Double-click a file (or right-click → Preview) in the explorer',
312
+ 'files.loading': 'Loading…',
313
+ 'files.readFail': 'Failed to read file',
314
+ 'files.reload': 'Reload',
315
+ 'files.reloadTitle': 'Re-read from disk (refresh preview)',
316
+ 'files.binary': 'Binary file ({size}) — open with the system explorer',
317
+ 'files.imageTooLarge': 'Image too large ({size}, limit {limit})',
318
+ 'files.pdfTooLarge': 'PDF too large ({size}, limit {limit}) — open with the system viewer',
319
+ 'files.truncated': 'File exceeds 2 MB — showing the first 2 MB; open in a system editor for the full content.',
320
+ 'files.jsonFail': 'JSON parse failed — shown as plain text',
321
+ 'files.nbFail': 'ipynb parse failed — shown as plain text',
322
+ 'files.nbEmpty': '(empty notebook)',
323
+ 'files.nbCells': '{n} cells',
324
+ 'files.csvTruncated': '{total} rows — showing the first {n}',
325
+ 'files.htmlFrame': 'HTML preview',
326
+ 'files.pdfFrame': 'PDF preview',
327
+ 'files.pdfLoading': 'Loading PDF…',
328
+ 'files.pdfFail': 'PDF preview failed: {reason}',
329
+ 'files.pdfUnsupported': 'This browser cannot embed PDF previews — right-click and open with the system viewer.',
330
+ 'files.nbHtmlOut': 'HTML output',
331
+ 'speed.label': 'Speed badge',
332
+ 'speed.waiting': 'Waiting for the model',
333
+ 'speed.operateFail': 'Action failed',
334
+ },
335
+ }
336
+ /** 模块级翻译函数(apply 里由 ctx.locale.bind 填充)。 */
337
+ var T = function (key, params) { return key }
183
338
 
184
339
  // ------------------------------------------------------------------
185
340
  // 状态存储(模块级,供 Panel 与 HeaderAction 共享)
@@ -243,9 +398,9 @@ window.__ModuleLoader__.load({
243
398
  headers: { 'content-type': 'application/json', 'x-dsh-soup': '1' },
244
399
  body: JSON.stringify({ action: action, args: args || {} }),
245
400
  }).then(function (res) {
246
- return res.json().catch(function () { return { ok: false, error: '宿主响应解析失败' } })
401
+ return res.json().catch(function () { return { ok: false, error: T('explorer.hostBadResponse') } })
247
402
  }).catch(function (err) {
248
- return { ok: false, error: '宿主不可达: ' + String((err && err.message) || err) }
403
+ return { ok: false, error: T('explorer.hostUnreachable', { reason: String((err && err.message) || err) }) }
249
404
  })
250
405
  }
251
406
 
@@ -257,7 +412,7 @@ window.__ModuleLoader__.load({
257
412
  return loadDir(path, (attempt || 0) + 1)
258
413
  }
259
414
  if (!res || !res.ok) {
260
- setState({ error: (res && res.error) || '读取失败' })
415
+ setState({ error: (res && res.error) || T('explorer.readFail') })
261
416
  return null
262
417
  }
263
418
  return res.entries || []
@@ -281,6 +436,7 @@ window.__ModuleLoader__.load({
281
436
  var cwd = await defaultCwd(sessionId, cwdHint)
282
437
  // 会话切换:预览列表按新 cwd 裁剪(范围外自动关闭),并清掉上一路径的旧错误横幅
283
438
  var files = pruneFilesToScope(state.files, cwd)
439
+ lastMtimes = {} // 换了目录,旧 mtime 基线全部作废(首轮 tick 重建基线)
284
440
  setState({ cwd: cwd, selected: new Set(), renaming: null, newItem: null, menu: null, error: '', notice: '', files: files })
285
441
  var items = await loadDir(cwd)
286
442
  if (items) setState({ error: '', tree: items })
@@ -292,6 +448,150 @@ window.__ModuleLoader__.load({
292
448
  if (items) setState({ error: '', tree: items })
293
449
  }
294
450
 
451
+ // ------------------------------------------------------------------
452
+ // 自动刷新:低频轮询宿主 mtime 探测(3s 一次),签名变了才真正重拉。
453
+ // watch 范围 = 当前 cwd + 已展开目录 + 打开的预览文件;面板关闭且无
454
+ // 预览时整个 tick 直接返回(零请求)。不做文件 watcher——fs.watch
455
+ // 递归语义跨平台不一致且大工作区开销高,stat 几个路径便宜得多。
456
+ // ------------------------------------------------------------------
457
+ var AUTO_REFRESH_MS = 3000
458
+ var AUTO_WATCH_MAX = 64
459
+ var lastMtimes = {} // path -> {m,s,d}|null;undefined = 尚无基线(首轮只建基线不触发)
460
+ var autoTicking = false
461
+
462
+ function collectWatchPaths() {
463
+ var paths = []
464
+ if (state.cwd) paths.push(state.cwd)
465
+ var walk = function (nodes) {
466
+ for (var i = 0; i < nodes.length; i++) {
467
+ var n = nodes[i]
468
+ if (n.type === 'directory' && n.children != null) {
469
+ paths.push(n.path)
470
+ if (n.open && n.children.length) walk(n.children)
471
+ }
472
+ }
473
+ }
474
+ walk(state.tree)
475
+ for (var j = 0; j < state.files.list.length; j++) {
476
+ var e = state.files.list[j]
477
+ if (e.path && paths.indexOf(e.path) < 0) paths.push(e.path)
478
+ }
479
+ return paths.slice(0, AUTO_WATCH_MAX)
480
+ }
481
+
482
+ /** 预览文件在磁盘上变了:静默重读该 entry(不动激活状态、不闪 tab)。 */
483
+ async function silentReloadEntry(entry) {
484
+ var res = await rpc('read', { path: entry.path, sessionId: activeSessionId })
485
+ if (!res || !res.ok) return
486
+ entry.kind = res.kind
487
+ entry.size = res.size || 0
488
+ if (res.kind === 'text') { entry.content = res.content; entry.truncated = !!res.truncated }
489
+ else if (res.kind === 'image') { entry.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
490
+ else if (res.kind === 'pdf') { entry.data = res.data }
491
+ setFiles({ list: state.files.list.slice() })
492
+ }
493
+
494
+ /** 已展开目录的子项变了:重拉该目录(保持展开态)。 */
495
+ async function reloadDirNode(node) {
496
+ var items = await loadDir(node.path)
497
+ if (items) items.forEach(function (c) { c.parent = node })
498
+ node.children = items || []
499
+ node.open = true
500
+ setState({ tree: state.tree })
501
+ }
502
+
503
+ /**
504
+ * 探一轮。返回 'idle'(本轮没发请求)/ true(探测成功)/ false(请求失败)。
505
+ * 只有真正发出去且失败的请求才累积退避;空闲不累积——面板重开时本就有
506
+ * 一次全量 loadDir,无需为"关着的时候宿主是否恢复"操心。
507
+ */
508
+ async function autoRefreshTick() {
509
+ if (autoTicking) return 'idle'
510
+ if (!state.open && state.files.list.length === 0) return 'idle'
511
+ // 用户正在操作(重命名/新建/右键菜单)时跳过本轮,避免打断输入
512
+ if (state.renaming || state.newItem || state.menu) return 'idle'
513
+ var paths = collectWatchPaths()
514
+ if (!paths.length) return 'idle'
515
+ autoTicking = true
516
+ try {
517
+ var res = await rpc('mtime', { paths: paths })
518
+ if (!res || !res.ok) return false
519
+ var mtimes = res.mtimes || {}
520
+ var dirtyDirs = []
521
+ var dirtyFiles = []
522
+ for (var i = 0; i < paths.length; i++) {
523
+ var p = paths[i]
524
+ var now = Object.prototype.hasOwnProperty.call(mtimes, p) ? mtimes[p] : null
525
+ var prev = Object.prototype.hasOwnProperty.call(lastMtimes, p) ? lastMtimes[p] : undefined
526
+ lastMtimes[p] = now
527
+ if (prev === undefined || prev === null || now === null) continue
528
+ if (now.m === prev.m && now.s === prev.s) continue
529
+ if (now.d) dirtyDirs.push(p)
530
+ else dirtyFiles.push(p)
531
+ }
532
+ for (var d = 0; d < dirtyDirs.length; d++) {
533
+ var dp = dirtyDirs[d]
534
+ if (dp === state.cwd) { await refresh(); continue }
535
+ var node = findNode(state.tree, dp)
536
+ if (node && node.children != null) await reloadDirNode(node)
537
+ }
538
+ for (var f = 0; f < dirtyFiles.length; f++) {
539
+ var entry = findFileEntry(dirtyFiles[f])
540
+ if (entry && entry.loaded) await silentReloadEntry(entry)
541
+ }
542
+ return true
543
+ } finally {
544
+ autoTicking = false
545
+ }
546
+ }
547
+
548
+ // 失败退避:宿主不可达(DSH 重启中 / 插件重载 / 断连)时别每 3s 硬打——
549
+ // 指数退避 3s→6s→12s→24s→48s,封顶 60s;一旦成功回到 3s。
550
+ // (JupyterLab FileBrowserModel 的 Poll 同思路:backoff + max 封顶。)
551
+ // 实现:1s 固定心跳 + 「到期才探测」,而不是变间隔定时器——宿主提供的
552
+ // timer 服务可 dispose,测试环境(interval 为 no-op mock)也不会挂住进程。
553
+ var AUTO_HEARTBEAT_MS = 1000
554
+ var AUTO_BACKOFF_MAX_MS = 60000
555
+ var autoFailCount = 0
556
+ var autoLastProbeAt = 0
557
+
558
+ function autoDelayMs() {
559
+ if (autoFailCount <= 0) return AUTO_REFRESH_MS
560
+ return Math.min(AUTO_REFRESH_MS * Math.pow(2, autoFailCount), AUTO_BACKOFF_MAX_MS)
561
+ }
562
+
563
+ function autoHeartbeat() {
564
+ var now = Date.now()
565
+ if (now - autoLastProbeAt < autoDelayMs()) return
566
+ autoLastProbeAt = now
567
+ autoRefreshTick().then(function (outcome) {
568
+ if (outcome === false) autoFailCount++
569
+ else autoFailCount = 0
570
+ })
571
+ }
572
+
573
+ /** 手动刷新:重拉根目录,已展开的子目录逐个重拉、保持展开不收起。 */
574
+ async function refreshAll() {
575
+ if (!state.cwd) return
576
+ var expanded = []
577
+ var walk = function (nodes) {
578
+ for (var i = 0; i < nodes.length; i++) {
579
+ var n = nodes[i]
580
+ if (n.type === 'directory' && n.children != null) {
581
+ if (n.path !== state.cwd) expanded.push({ path: n.path, open: !!n.open })
582
+ if (n.open && n.children.length) walk(n.children)
583
+ }
584
+ }
585
+ }
586
+ walk(state.tree)
587
+ await refresh()
588
+ for (var j = 0; j < expanded.length; j++) {
589
+ if (!expanded[j].open) continue
590
+ var node = findNode(state.tree, expanded[j].path)
591
+ if (node) await reloadDirNode(node)
592
+ }
593
+ }
594
+
295
595
  async function toggleNode(node) {
296
596
  if (node.type !== 'directory') return
297
597
  if (node.children == null) {
@@ -443,7 +743,7 @@ window.__ModuleLoader__.load({
443
743
  var errors = []
444
744
  for (var i = 0; i < paths.length; i++) {
445
745
  var res = await rpc('trash', { path: paths[i] })
446
- if (!res || !res.ok) errors.push(baseName(paths[i]) + ': ' + ((res && res.error) || '失败') + ((res && res.hint) ? '(' + res.hint + ')' : ''))
746
+ if (!res || !res.ok) errors.push(baseName(paths[i]) + ': ' + ((res && res.error) || T('explorer.actionFail')) + ((res && res.hint) ? '(' + res.hint + ')' : ''))
447
747
  }
448
748
  if (errors.length) setState({ error: errors.join(';') })
449
749
  setState({ selected: new Set(), lastIndex: null })
@@ -464,7 +764,7 @@ window.__ModuleLoader__.load({
464
764
  setState({ renaming: null, selected: sel })
465
765
  ;(async function () {
466
766
  var res = await rpc('move', { from: path, to: dest })
467
- if (!res || !res.ok) setState({ error: (res && res.error) || '重命名失败' })
767
+ if (!res || !res.ok) setState({ error: (res && res.error) || T('explorer.renameFail') })
468
768
  await refresh()
469
769
  })()
470
770
  }
@@ -485,7 +785,7 @@ window.__ModuleLoader__.load({
485
785
  setState({ newItem: null })
486
786
  ;(async function () {
487
787
  var res = await rpc('create', { dir: parent, name: name, isDir: isDir })
488
- if (!res || !res.ok) setState({ error: (res && res.error) || '创建失败' })
788
+ if (!res || !res.ok) setState({ error: (res && res.error) || T('explorer.createFail') })
489
789
  await refresh()
490
790
  })()
491
791
  }
@@ -505,7 +805,7 @@ window.__ModuleLoader__.load({
505
805
  setState({ menu: null })
506
806
  try {
507
807
  if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
508
- navigator.clipboard.writeText(p).then(function () { showNotice('已复制路径') }).catch(function () {})
808
+ navigator.clipboard.writeText(p).then(function () { showNotice(T('explorer.renamed')) }).catch(function () {})
509
809
  }
510
810
  } catch (err) {}
511
811
  }
@@ -547,9 +847,9 @@ window.__ModuleLoader__.load({
547
847
  var name = baseName(p)
548
848
  var dest = pathJoin(targetDir, name)
549
849
  if (dest === p) continue
550
- if (targetDir === p || targetDir.startsWith(p + '/')) { errors.push('不能把文件夹移入自身: ' + name); continue }
850
+ if (targetDir === p || targetDir.startsWith(p + '/')) { errors.push(T('explorer.moveIntoSelf', { path: name })); continue }
551
851
  var res = await rpc('move', { from: p, to: dest })
552
- if (!res || !res.ok) errors.push(name + ': ' + ((res && res.error) || '失败'))
852
+ if (!res || !res.ok) errors.push(name + ': ' + ((res && res.error) || T('explorer.actionFail')))
553
853
  }
554
854
  setState({ dragPaths: null })
555
855
  if (errors.length) setState({ error: errors.join(';') })
@@ -560,7 +860,7 @@ window.__ModuleLoader__.load({
560
860
  return new Promise(function (resolve, reject) {
561
861
  var r = new FileReader()
562
862
  r.onload = function () { var s = String(r.result || ''); var i = s.indexOf(','); resolve(i >= 0 ? s.slice(i + 1) : s) }
563
- r.onerror = function () { reject(new Error('读取文件失败')) }
863
+ r.onerror = function () { reject(new Error(T('files.readFail'))) }
564
864
  r.readAsDataURL(file)
565
865
  })
566
866
  }
@@ -583,9 +883,9 @@ window.__ModuleLoader__.load({
583
883
  }
584
884
 
585
885
  function finishUploads(count, okCount, failedName, down) {
586
- var verb = down ? '已下载' : '已上传'
886
+ var verb = down ? T('explorer.downloaded') : T('explorer.uploaded')
587
887
  setUploadEntry({
588
- name: '✓ ' + verb + ' ' + (okCount === count ? count + ' 个文件' : okCount + '/' + count + ' 个文件' + (failedName ? '(' + failedName + ' 失败)' : '')),
888
+ name: '✓ ' + verb + ' ' + (okCount === count ? T('explorer.filesCount', { n: count }) : T('explorer.filesCountPartial', { ok: okCount, total: count }) + (failedName ? '(' + failedName + ')' : '')),
589
889
  loaded: 0, total: 0, idx: 0, totalFiles: 0, done: true, down: !!down,
590
890
  })
591
891
  window.setTimeout(function () { setState({ uploads: [] }) }, 1600)
@@ -604,7 +904,7 @@ window.__ModuleLoader__.load({
604
904
  dir: dir, name: f.name, data: b64,
605
905
  chunk: chunked ? c + 1 : undefined,
606
906
  })
607
- if (!res || !res.ok) throw new Error((res && res.error) || '上传失败')
907
+ if (!res || !res.ok) throw new Error((res && res.error) || T('explorer.uploadFail'))
608
908
  updateUpload(f.name, Math.min(f.size, start + UPLOAD_CHUNK_SIZE), f.size, idx, totalFiles)
609
909
  }
610
910
  }
@@ -638,7 +938,7 @@ window.__ModuleLoader__.load({
638
938
  res = await rpc('download', { path: path, sessionId: activeSessionId })
639
939
  }
640
940
  if (!res || !res.ok) {
641
- setState({ error: (res && res.error) || '下载失败' })
941
+ setState({ error: (res && res.error) || T('explorer.downloadFail') })
642
942
  return
643
943
  }
644
944
  var name = res.name || baseName(path)
@@ -651,7 +951,7 @@ window.__ModuleLoader__.load({
651
951
  document.body.appendChild(a)
652
952
  a.click()
653
953
  a.remove()
654
- showNotice('已开始下载 ' + name + '(大文件,由浏览器接管)')
954
+ showNotice(T('explorer.downloadStart', { name: name }))
655
955
  return
656
956
  }
657
957
  // 中小文件:fetch 流式读字节计进度,完成后 Blob 另存(峰值≈文件体积)
@@ -680,7 +980,7 @@ window.__ModuleLoader__.load({
680
980
  window.setTimeout(function () { URL.revokeObjectURL(blobUrl) }, 5000)
681
981
  finishUploads(1, 1, '', true)
682
982
  } catch (err) {
683
- setState({ error: '下载失败: ' + String((err && err.message) || err), uploads: [] })
983
+ setState({ error: T('explorer.downloadFailWith', { reason: String((err && err.message) || err) }), uploads: [] })
684
984
  }
685
985
  }
686
986
 
@@ -696,7 +996,7 @@ window.__ModuleLoader__.load({
696
996
  // markdown/html/json/csv 有专属渲染器;图片预览;其余按纯文本。
697
997
  // 状态存模块级 store,切走 tab 再切回来内容不丢。
698
998
  // ------------------------------------------------------------------
699
- var FILES_TAB_LABEL = '预览'
999
+ var FILES_TAB_LABEL = function () { return T('files.tab') }
700
1000
 
701
1001
  /** 当前活跃会话:list/read/download 携带给宿主,供围栏直查该会话 cwd。 */
702
1002
  var activeSessionId = null
@@ -745,7 +1045,7 @@ window.__ModuleLoader__.load({
745
1045
  try {
746
1046
  var tabs = document.querySelectorAll('[role="tab"]')
747
1047
  for (var i = 0; i < tabs.length; i++) {
748
- if (tabs[i].textContent === FILES_TAB_LABEL) { tabs[i].click(); return true }
1048
+ if (tabs[i].textContent === FILES_TAB_LABEL()) { tabs[i].click(); return true }
749
1049
  }
750
1050
  } catch (err) {}
751
1051
  return false
@@ -783,7 +1083,7 @@ window.__ModuleLoader__.load({
783
1083
  if (!cur) return
784
1084
  cur.loading = false
785
1085
  if (!res || !res.ok) {
786
- cur.error = (res && res.error) || '读取失败'
1086
+ cur.error = (res && res.error) || T('explorer.readFail')
787
1087
  } else {
788
1088
  cur.error = ''
789
1089
  cur.loaded = true
@@ -915,7 +1215,7 @@ window.__ModuleLoader__.load({
915
1215
  }
916
1216
  if (isDir && node.open) {
917
1217
  if (node.loading) {
918
- children.push(create('div', { key: '__loading__', className: 'expl-muted', style: { paddingLeft: 8 + (depth + 1) * 16 } }, '加载中…'))
1218
+ children.push(create('div', { key: '__loading__', className: 'expl-muted', style: { paddingLeft: 8 + (depth + 1) * 16 } }, T('files.loading')))
919
1219
  } else {
920
1220
  ;(node.children || []).forEach(function (c) { children.push(create(Row, { key: c.path, node: c, depth: depth + 1 })) })
921
1221
  }
@@ -930,29 +1230,29 @@ window.__ModuleLoader__.load({
930
1230
  if (node) {
931
1231
  if (!multi) {
932
1232
  if (node.type !== 'directory') {
933
- items.push({ key: 'open-tab', label: '👁 预览', onClick: function () { openFileInTab(node.path) } })
934
- items.push({ key: 'open', label: '🖥 用系统打开', onClick: openSelection, separatorAfter: true })
1233
+ items.push({ key: 'open-tab', label: T('menu.preview'), onClick: function () { openFileInTab(node.path) } })
1234
+ items.push({ key: 'open', label: T('menu.open'), onClick: openSelection, separatorAfter: true })
935
1235
  } else {
936
- items.push({ key: 'open', label: '🖥 用系统打开', onClick: openSelection, separatorAfter: true })
1236
+ items.push({ key: 'open', label: T('menu.open'), onClick: openSelection, separatorAfter: true })
937
1237
  }
938
- items.push({ key: 'rename', label: '✎ 重命名', onClick: function () { startRename(node) } })
939
- items.push({ key: 'trash', label: '🗑 移到废纸篓', danger: true, onClick: trashSelection, separatorAfter: true })
1238
+ items.push({ key: 'rename', label: T('menu.rename'), onClick: function () { startRename(node) } })
1239
+ items.push({ key: 'trash', label: T('menu.trash'), danger: true, onClick: trashSelection, separatorAfter: true })
940
1240
  } else {
941
- items.push({ key: 'trash', label: '🗑 移到废纸篓 (' + state.selected.size + ' 项)', danger: true, onClick: trashSelection, separatorAfter: true })
1241
+ items.push({ key: 'trash', label: T('menu.trashMulti', { n: state.selected.size }), danger: true, onClick: trashSelection, separatorAfter: true })
942
1242
  }
943
1243
  if (node.type === 'directory') {
944
- items.push({ key: 'newfile', label: '📄 新建文件', onClick: function () { startNew(node.path, false) } })
945
- items.push({ key: 'newfolder', label: '📁 新建文件夹', onClick: function () { startNew(node.path, true) }, separatorAfter: true })
1244
+ items.push({ key: 'newfile', label: T('menu.newFile'), onClick: function () { startNew(node.path, false) } })
1245
+ items.push({ key: 'newfolder', label: T('menu.newFolder'), onClick: function () { startNew(node.path, true) }, separatorAfter: true })
946
1246
  }
947
- items.push({ key: 'copy', label: '⧉ 复制路径', onClick: function () { copyPath(node) } })
1247
+ items.push({ key: 'copy', label: T('menu.copyPath'), onClick: function () { copyPath(node) } })
948
1248
  if (node.type !== 'directory') {
949
- items.push({ key: 'download', label: '⬇ 下载', onClick: function () { downloadFile(node.path) } })
1249
+ items.push({ key: 'download', label: T('menu.download'), onClick: function () { downloadFile(node.path) } })
950
1250
  }
951
1251
  } else {
952
- items.push({ key: 'newfile', label: '📄 新建文件', onClick: function () { startNew(state.cwd, false) } })
953
- items.push({ key: 'newfolder', label: '📁 新建文件夹', onClick: function () { startNew(state.cwd, true) } })
954
- items.push({ key: 'refresh', label: '⟳ 刷新', onClick: refresh, separatorAfter: true })
955
- if (state.selected.size > 0) items.push({ key: 'none', label: '取消选择', onClick: function () { setState({ selected: new Set(), lastIndex: null }) } })
1252
+ items.push({ key: 'newfile', label: T('menu.newFile'), onClick: function () { startNew(state.cwd, false) } })
1253
+ items.push({ key: 'newfolder', label: T('menu.newFolder'), onClick: function () { startNew(state.cwd, true) } })
1254
+ items.push({ key: 'refresh', label: [create('span', { className: 'expl-menu-ico', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }), T('explorer.refresh')], onClick: refresh, separatorAfter: true })
1255
+ if (state.selected.size > 0) items.push({ key: 'none', label: T('menu.deselect'), onClick: function () { setState({ selected: new Set(), lastIndex: null }) } })
956
1256
  }
957
1257
  return items
958
1258
  }
@@ -1100,13 +1400,46 @@ window.__ModuleLoader__.load({
1100
1400
  var CSV_MAX_ROWS = 500
1101
1401
  var CSV_MAX_COLS = 64
1102
1402
 
1403
+ // ------------------------------------------------------------------
1404
+ // Markdown 相对图片 → dsh-soup 同源 img 端点绝对 URL。
1405
+ // 预览 markdown 时,DSH 的 MarkdownText 渲染器「禁相对链接/相对资源」,
1406
+ // `![x](view.jpg)` 这类相对路径图片会被直接丢弃。因此把 src 改写成
1407
+ // 本插件同名端点(GET /api/dsh-soup/img)的同源绝对地址,交给 <img>
1408
+ // 从磁盘内联加载。绝对(http/https/data 等)与根相对路径放行不处理。
1409
+ // ------------------------------------------------------------------
1410
+ var MD_IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
1411
+ function posixResolve(dir, rel) {
1412
+ var combined = (String(dir || '/').replace(/\/+$/, '') || '/') + '/' + String(rel)
1413
+ var out = []
1414
+ combined.split('/').forEach(function (seg) {
1415
+ if (seg === '' || seg === '.') return
1416
+ if (seg === '..') { if (out.length) out.pop(); return }
1417
+ out.push(seg)
1418
+ })
1419
+ return '/' + out.join('/')
1420
+ }
1421
+ function imgUrl(absPath) {
1422
+ return hostBase() + '/api/dsh-soup/img?p=' + encodeURIComponent(absPath)
1423
+ }
1424
+ function absolutizeMarkdownImages(text, baseDir) {
1425
+ if (!text) return text
1426
+ var dir = baseDir || '/'
1427
+ return text.replace(MD_IMG_RE, function (m0, alt, src) {
1428
+ var s = String(src || '').trim()
1429
+ if (/^(?:[a-z][a-z0-9+.-]*:|#|\/)/i.test(s)) return m0
1430
+ return '![' + alt + '](' + imgUrl(posixResolve(dir, s)) + ')'
1431
+ })
1432
+ }
1433
+
1103
1434
  function MarkdownPreview(props) {
1435
+ var text = props.text
1104
1436
  if (MarkdownText) {
1437
+ var md = props.baseDir ? absolutizeMarkdownImages(text, props.baseDir) : text
1105
1438
  return create('div', { className: 'dfv-md-wrap' },
1106
- create('div', { className: 'dfv-md' }, create(MarkdownText, { text: props.text })))
1439
+ create('div', { className: 'dfv-md' }, create(MarkdownText, { text: md })))
1107
1440
  }
1108
1441
  // 回退:宿主缺 ui-primitives 时按纯文本展示
1109
- return create('pre', { className: 'dfv-code' }, props.text)
1442
+ return create('pre', { className: 'dfv-code' }, text)
1110
1443
  }
1111
1444
 
1112
1445
  function HtmlPreview(props) {
@@ -1117,7 +1450,7 @@ window.__ModuleLoader__.load({
1117
1450
  className: 'dfv-frame',
1118
1451
  sandbox: 'allow-scripts allow-popups allow-forms allow-modals',
1119
1452
  referrerPolicy: 'no-referrer',
1120
- title: props.title || 'HTML 预览',
1453
+ title: props.title || T('files.htmlFrame'),
1121
1454
  srcDoc: props.text,
1122
1455
  })
1123
1456
  }
@@ -1146,14 +1479,14 @@ window.__ModuleLoader__.load({
1146
1479
  if (made) URL.revokeObjectURL(made)
1147
1480
  }
1148
1481
  }, [props.data])
1149
- if (state.error) return create('div', { className: 'dfv-error' }, 'PDF 预览失败: ' + state.error)
1150
- if (!state.url) return create('div', { className: 'dfv-empty' }, 'PDF 加载中…')
1482
+ if (state.error) return create('div', { className: 'dfv-error' }, T('files.pdfFail', { reason: state.error }))
1483
+ if (!state.url) return create('div', { className: 'dfv-empty' }, T('files.pdfLoading'))
1151
1484
  return create('object', {
1152
1485
  className: 'dfv-frame dfv-pdf',
1153
1486
  type: 'application/pdf',
1154
1487
  data: state.url,
1155
- 'aria-label': 'PDF 预览',
1156
- }, '当前浏览器无法内嵌预览 PDF,请右键用系统打开。')
1488
+ 'aria-label': T('files.pdfFrame'),
1489
+ }, T('files.pdfUnsupported'))
1157
1490
  }
1158
1491
 
1159
1492
  // ------------------------------------------------------------------
@@ -1206,7 +1539,7 @@ window.__ModuleLoader__.load({
1206
1539
  return create('img', { className: 'dfv-nb-img', src: 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(nbSource(out.data[mime])))), alt: 'notebook output svg' })
1207
1540
  }
1208
1541
  if (mime === 'text/html') {
1209
- return create('iframe', { className: 'dfv-nb-html', sandbox: '', title: 'HTML 输出', srcDoc: nbSource(out.data[mime]) })
1542
+ return create('iframe', { className: 'dfv-nb-html', sandbox: '', title: T('files.nbHtmlOut'), srcDoc: nbSource(out.data[mime]) })
1210
1543
  }
1211
1544
  if (mime === 'text/markdown' && MarkdownText) {
1212
1545
  return create('div', { className: 'dfv-md-wrap dfv-nb-md' }, create(MarkdownText, { text: nbSource(out.data[mime]) }))
@@ -1222,7 +1555,7 @@ window.__ModuleLoader__.load({
1222
1555
  try { nb = JSON.parse(props.text) } catch (err) { nb = undefined }
1223
1556
  if (nb === undefined || typeof nb !== 'object' || nb === null) {
1224
1557
  return create(React.Fragment, null,
1225
- create('div', { className: 'dfv-cap' }, 'ipynb 解析失败,按纯文本显示'),
1558
+ create('div', { className: 'dfv-cap' }, T('files.nbFail')),
1226
1559
  create('pre', { className: 'dfv-code' }, props.text))
1227
1560
  }
1228
1561
  var cells = Array.isArray(nb.cells) ? nb.cells : []
@@ -1269,8 +1602,8 @@ window.__ModuleLoader__.load({
1269
1602
 
1270
1603
  return create('div', { className: 'dfv-nb' },
1271
1604
  create('div', { className: 'dfv-cap' },
1272
- 'Jupyter Notebook' + (kernelName ? ' · ' + kernelName : '') + ' · ' + cells.length + ' 个单元格'),
1273
- cellEls.length ? cellEls : create('div', { className: 'dfv-muted' }, '(空笔记本)'))
1605
+ 'Jupyter Notebook' + (kernelName ? ' · ' + kernelName : '') + ' · ' + T('files.nbCells', { n: cells.length })),
1606
+ cellEls.length ? cellEls : create('div', { className: 'dfv-muted' }, T('files.nbEmpty')))
1274
1607
  }
1275
1608
 
1276
1609
  function JsonPreview(props) {
@@ -1278,7 +1611,7 @@ window.__ModuleLoader__.load({
1278
1611
  try { JSON.parse(props.text) } catch (err) { failed = true }
1279
1612
  if (failed) {
1280
1613
  return create(React.Fragment, null,
1281
- create('div', { className: 'dfv-cap' }, 'JSON 解析失败,按纯文本显示'),
1614
+ create('div', { className: 'dfv-cap' }, T('files.jsonFail')),
1282
1615
  create('pre', { className: 'dfv-code' }, props.text))
1283
1616
  }
1284
1617
  return create('pre', { className: 'dfv-json dfv-code' }, jsonColorNodes(JSON.stringify(JSON.parse(props.text), null, 2)))
@@ -1295,7 +1628,7 @@ window.__ModuleLoader__.load({
1295
1628
  cols = Math.max(cols, Math.min(rows[r].length, CSV_MAX_COLS))
1296
1629
  }
1297
1630
  var caps = []
1298
- if (totalRows > CSV_MAX_ROWS + 1) caps.push('共 ' + totalRows + ' 行,仅预览前 ' + CSV_MAX_ROWS + ' 行')
1631
+ if (totalRows > CSV_MAX_ROWS + 1) caps.push(T('files.csvTruncated', { total: totalRows, n: CSV_MAX_ROWS }))
1299
1632
  var trs = []
1300
1633
  for (var ri = 1; ri <= Math.min(totalRows - 1, CSV_MAX_ROWS); ri++) {
1301
1634
  var cells = rows[ri]
@@ -1331,18 +1664,18 @@ window.__ModuleLoader__.load({
1331
1664
  function FileContent(props) {
1332
1665
  var entry = props.entry
1333
1666
  if (entry.loading) {
1334
- return create('div', { className: 'dfv-empty' }, '加载中…')
1667
+ return create('div', { className: 'dfv-empty' }, T('files.loading'))
1335
1668
  }
1336
1669
  if (entry.error) {
1337
1670
  return create('div', { className: 'dfv-error' }, entry.error)
1338
1671
  }
1339
1672
  if (entry.kind === 'binary') {
1340
1673
  return create('div', { className: 'dfv-muted' },
1341
- '二进制文件(' + formatBytes(entry.size) + '),请在资源管理器中用系统打开')
1674
+ T('files.binary', { size: formatBytes(entry.size) }))
1342
1675
  }
1343
1676
  if (entry.kind === 'image-too-large') {
1344
1677
  return create('div', { className: 'dfv-muted' },
1345
- '图片过大(' + formatBytes(entry.size) + ',上限 ' + formatBytes(entry.limit) + ')')
1678
+ T('files.imageTooLarge', { size: formatBytes(entry.size), limit: formatBytes(entry.limit) }))
1346
1679
  }
1347
1680
  if (entry.kind === 'image') {
1348
1681
  return create('div', { className: 'dfv-image-wrap' },
@@ -1350,7 +1683,7 @@ window.__ModuleLoader__.load({
1350
1683
  }
1351
1684
  if (entry.kind === 'pdf-too-large') {
1352
1685
  return create('div', { className: 'dfv-muted' },
1353
- 'PDF 过大(' + formatBytes(entry.size) + ',上限 ' + formatBytes(entry.limit) + '),请用系统打开')
1686
+ T('files.pdfTooLarge', { size: formatBytes(entry.size), limit: formatBytes(entry.limit) }))
1354
1687
  }
1355
1688
  if (entry.kind === 'pdf') {
1356
1689
  return create(PdfPreview, { data: entry.data })
@@ -1358,7 +1691,7 @@ window.__ModuleLoader__.load({
1358
1691
  // 文本类:按扩展名选预览渲染器(纯只读,无编辑)
1359
1692
  var pk = previewKind(entry.path)
1360
1693
  var view = null
1361
- if (pk === 'markdown') view = create(MarkdownPreview, { text: entry.content })
1694
+ if (pk === 'markdown') view = create(MarkdownPreview, { text: entry.content, baseDir: parentOf(entry.path) })
1362
1695
  else if (pk === 'html') view = create(HtmlPreview, { text: entry.content, title: entry.name })
1363
1696
  else if (pk === 'json') view = create(JsonPreview, { text: entry.content })
1364
1697
  else if (pk === 'notebook') view = create(NotebookPreview, { text: entry.content })
@@ -1376,15 +1709,51 @@ window.__ModuleLoader__.load({
1376
1709
  }
1377
1710
  return create(React.Fragment, null,
1378
1711
  entry.truncated ? create('div', { className: 'dfv-cap' },
1379
- '文件超过 2 MB,仅预览前 2 MB;完整内容请用系统编辑器打开。') : null,
1712
+ T('files.truncated')) : null,
1380
1713
  view,
1381
1714
  )
1382
1715
  }
1383
1716
 
1717
+ /**
1718
+ * 纯阅读模式标记:在会话根(含 composerSeat 的最近祖先,即 DSH
1719
+ * ConversationRoot 的 root 节点)上打 data-dsh-soup-preview,由上面
1720
+ * 注入的 CSS 隐藏输入区与轮次统计行。DSH 重渲染可能重建该节点——
1721
+ * MutationObserver 在 FilesView 存活期间持续确保标记存在。
1722
+ */
1723
+ function usePreviewReadingMode(rootRef) {
1724
+ React.useEffect(function () {
1725
+ var observer = null
1726
+ var marked = null
1727
+ function apply() {
1728
+ var el = rootRef.current
1729
+ if (!el) return
1730
+ if (marked && marked.contains(el) && marked.getAttribute('data-dsh-soup-preview') === '1') return
1731
+ var host = el.parentElement
1732
+ while (host) {
1733
+ if (host.querySelector && host.querySelector('.wSkVaW_composerSeat')) break
1734
+ host = host.parentElement
1735
+ }
1736
+ if (!host) return
1737
+ host.setAttribute('data-dsh-soup-preview', '1')
1738
+ marked = host
1739
+ }
1740
+ apply()
1741
+ observer = new MutationObserver(function () { apply() })
1742
+ observer.observe(document.body, { childList: true, subtree: true })
1743
+ return function () {
1744
+ if (observer) observer.disconnect()
1745
+ if (marked) marked.removeAttribute('data-dsh-soup-preview')
1746
+ marked = null
1747
+ }
1748
+ }, [])
1749
+ }
1750
+
1384
1751
  function FilesView(props) {
1385
1752
  var s = useStore()
1386
1753
  var files = s.files
1387
1754
  var active = files.active ? findFileEntry(files.active) : null
1755
+ var rootRef = React.useRef(null)
1756
+ usePreviewReadingMode(rootRef)
1388
1757
 
1389
1758
  var tabs = files.list.map(function (entry) {
1390
1759
  return create('button', {
@@ -1398,7 +1767,7 @@ window.__ModuleLoader__.load({
1398
1767
  create('span', {
1399
1768
  className: 'dfv-close',
1400
1769
  role: 'button',
1401
- title: '关闭',
1770
+ title: T('explorer.close'),
1402
1771
  onClick: function (e) { e.stopPropagation(); closeFileTab(entry.path) },
1403
1772
  }, '✕'),
1404
1773
  )
@@ -1408,7 +1777,7 @@ window.__ModuleLoader__.load({
1408
1777
  if (!active) {
1409
1778
  body = create('div', { className: 'dfv-empty' },
1410
1779
  create('span', { style: { fontSize: 22 } }, '📄'),
1411
- '在右侧资源管理器中双击或右键「预览」文件',
1780
+ T('files.emptyHint'),
1412
1781
  )
1413
1782
  } else {
1414
1783
  body = create('div', { className: 'dfv-body' },
@@ -1418,15 +1787,18 @@ window.__ModuleLoader__.load({
1418
1787
  active.loaded ? create('button', {
1419
1788
  type: 'button',
1420
1789
  className: 'dfv-btn',
1421
- title: '从磁盘重新读取(刷新预览)',
1790
+ title: T('files.reloadTitle'),
1422
1791
  onClick: function () { reloadFile(active.path) },
1423
- }, '⟳ 重载') : null,
1792
+ },
1793
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }),
1794
+ T('files.reload'),
1795
+ ) : null,
1424
1796
  ),
1425
1797
  create(FileContent, { key: active.path, entry: active }),
1426
1798
  )
1427
1799
  }
1428
1800
 
1429
- return create('div', { className: 'dfv-root' },
1801
+ return create('div', { className: 'dfv-root', ref: rootRef },
1430
1802
  files.list.length > 0 ? create('div', { className: 'dfv-tabbar' }, tabs) : null,
1431
1803
  body,
1432
1804
  )
@@ -1566,7 +1938,7 @@ window.__ModuleLoader__.load({
1566
1938
  ))
1567
1939
  }
1568
1940
  if (s.tree.length === 0 && !(state.newItem && state.newItem.parent === state.cwd)) {
1569
- bodyChildren.push(create('div', { key: '__empty__', className: 'expl-muted' }, '(空目录)'))
1941
+ bodyChildren.push(create('div', { key: '__empty__', className: 'expl-muted' }, T('explorer.emptyDir')))
1570
1942
  } else {
1571
1943
  s.tree.forEach(function (node) { bodyChildren.push(create(Row, { key: node.path, node: node, depth: 0 })) })
1572
1944
  }
@@ -1583,17 +1955,29 @@ window.__ModuleLoader__.load({
1583
1955
  return create('div', { ref: panelRef, className: 'expl-panel' },
1584
1956
  create('div', { className: 'expl-resize', onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp }),
1585
1957
  create('div', { className: 'expl-head' },
1586
- create('span', { className: 'expl-title' }, '📁 资源管理器'),
1958
+ create('span', { className: 'expl-title' }, T('explorer.title')),
1587
1959
  create('div', { className: 'expl-head-btns' },
1588
- create('button', { className: 'expl-btn', onClick: function () { if (uploadInputEl) uploadInputEl.click() }, title: '上传文件' }, '⬆'),
1589
1960
  create('button', {
1590
1961
  className: 'expl-btn',
1962
+ onClick: function () { refreshAll() },
1963
+ title: T('explorer.refreshTitle'),
1964
+ 'aria-label': T('explorer.refresh'),
1965
+ }, create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } })),
1966
+ create('button', {
1967
+ className: 'expl-btn',
1968
+ onClick: function () { if (uploadInputEl) uploadInputEl.click() },
1969
+ title: T('explorer.upload'),
1970
+ 'aria-label': T('explorer.upload'),
1971
+ }, '⬆'),
1972
+ create('button', {
1973
+ className: 'expl-btn',
1974
+ style: { fontSize: '18px' },
1591
1975
  onClick: function (event) {
1592
1976
  try { layout.closeDetails() } catch (e) {}
1593
1977
  setHeroDetailsWidth(event.currentTarget, 0)
1594
1978
  },
1595
- title: '关闭',
1596
- 'aria-label': '关闭资源管理器',
1979
+ title: T('explorer.close'),
1980
+ 'aria-label': T('explorer.closePanel'),
1597
1981
  }, '✕'),
1598
1982
  ),
1599
1983
  ),
@@ -1605,7 +1989,7 @@ window.__ModuleLoader__.load({
1605
1989
  create('span', { className: 'expl-upload-glyph' }, u.done ? '✓' : (u.down ? '⬇' : '⬆')),
1606
1990
  create('span', { className: 'expl-upload-text' },
1607
1991
  u.done ? u.name
1608
- : (u.down ? '下载中 ' : '上传中 ') + (u.totalFiles > 1 ? '(' + u.idx + '/' + u.totalFiles + ') ' : '') + u.name,
1992
+ : (u.down ? T('explorer.downloading') : T('explorer.uploading')) + (u.totalFiles > 1 ? '(' + u.idx + '/' + u.totalFiles + ') ' : '') + u.name,
1609
1993
  ),
1610
1994
  !u.done && u.total ? create('span', { className: 'expl-upload-pct' }, pct + '%') : null,
1611
1995
  )
@@ -1615,7 +1999,7 @@ window.__ModuleLoader__.load({
1615
1999
  s.error ? create('div', { className: 'expl-error' }, s.error) : null,
1616
2000
  s.notice ? create('div', { className: 'expl-notice' }, s.notice) : null,
1617
2001
  s.selected.size > 1
1618
- ? create('div', { className: 'expl-bulk' }, '已选 ' + s.selected.size + ' 项 · 拖到文件夹可移动')
2002
+ ? create('div', { className: 'expl-bulk' }, T('explorer.bulkSelected', { n: s.selected.size }))
1619
2003
  : null,
1620
2004
  container,
1621
2005
  s.menu ? create('div', { className: 'expl-menu-mask', onMouseDown: function () { setState({ menu: null }) }, onContextMenu: function (e) { e.preventDefault(); setState({ menu: null }) } }) : null,
@@ -1637,8 +2021,8 @@ window.__ModuleLoader__.load({
1637
2021
  type: 'button',
1638
2022
  className: 'expl-toggle expl-tool' + (s.open ? ' expl-active' : ''),
1639
2023
  onClick: function () { try { if (s.open) layout.closeDetails(); else layout.openDetails() } catch (e) {} },
1640
- title: '项目资源管理器',
1641
- 'aria-label': '项目资源管理器',
2024
+ title: T('explorer.label'),
2025
+ 'aria-label': T('explorer.label'),
1642
2026
  }, '📁')
1643
2027
  }
1644
2028
 
@@ -1666,8 +2050,8 @@ window.__ModuleLoader__.load({
1666
2050
  try { layout.openDetails() } catch (e) {}
1667
2051
  }
1668
2052
  },
1669
- title: '项目资源管理器',
1670
- 'aria-label': '项目资源管理器',
2053
+ title: T('explorer.label'),
2054
+ 'aria-label': T('explorer.label'),
1671
2055
  }, '📁'))
1672
2056
  }
1673
2057
 
@@ -1734,7 +2118,7 @@ window.__ModuleLoader__.load({
1734
2118
  el.style.cssText = 'display:inline-flex;align-items:center;margin-left:10px;font-weight:400;font-size:13px;color:var(--dsw-alias-label-caption);'
1735
2119
  el.style.setProperty('color', 'var(--dsw-alias-label-caption)', 'important')
1736
2120
  el.style.setProperty('-webkit-text-fill-color', 'var(--dsw-alias-label-caption)', 'important')
1737
- el.textContent = '正在等待模型' + new Array(dotsRef.current + 1).join('.')
2121
+ el.textContent = T('speed.waiting') + new Array(dotsRef.current + 1).join('.')
1738
2122
  return
1739
2123
  }
1740
2124
  // 流式阶段:token 计数立即显示;瞬时速率窗口未满(tps=0)时先不显示徽标,
@@ -1908,7 +2292,7 @@ window.__ModuleLoader__.load({
1908
2292
  pendingRef.current = false
1909
2293
  pending[1](false)
1910
2294
  if (!result || !result.ok) {
1911
- var err = result && result.error ? (result.error.message + ' (' + result.error.code + ')') : '操作失败'
2295
+ var err = result && result.error ? (result.error.message + ' (' + result.error.code + ')') : T('speed.operateFail')
1912
2296
  actionError[1](err)
1913
2297
  }
1914
2298
  return result
@@ -2026,9 +2410,20 @@ window.__ModuleLoader__.load({
2026
2410
  var sessions = ctx.sessions
2027
2411
  var remoteGoals = ctx.remote.goals
2028
2412
  var disposers = []
2413
+ // i18n:注册双语词典;模块级 T 供组件外回调(菜单/错误横幅)取当前语言文案。
2414
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
2415
+ ctx.effect(function () { return ctx.locale.register(NS, DICT) }, 'dsh-soup: dictionaries')
2416
+ if (typeof ctx.locale.bind === 'function') T = ctx.locale.bind(NS)
2417
+ }
2418
+ // 自动刷新心跳:1s 固定心跳 + 到期才探测(基础 3s;宿主不可达时指数退避,封顶 60s)。
2419
+ // 面板关且无预览时 tick 内直接返回(零请求);首个 tick 只建基线。
2420
+ if (timerRef && typeof timerRef.interval === 'function') {
2421
+ var stopAuto = timerRef.interval(function () { autoHeartbeat() }, AUTO_HEARTBEAT_MS)
2422
+ disposers.push(function () { try { stopAuto() } catch (e) {} })
2423
+ }
2029
2424
  slots.inject('conversation.session.header.utilities', function () {
2030
2425
  disposers.push(slots.register(
2031
- { name: 'conversation.session.header.utilities', id: 'dsh-soup-toggle', order: 10, label: '资源管理器' },
2426
+ { name: 'conversation.session.header.utilities', id: 'dsh-soup-toggle', order: 10, locale: NS, label: function () { return T('explorer.label') } },
2032
2427
  function (props) { return create(HeaderAction, props) },
2033
2428
  ))
2034
2429
  })
@@ -2036,7 +2431,7 @@ window.__ModuleLoader__.load({
2036
2431
  disposers.push(slots.register(
2037
2432
  // `details` is a single slot occupied by the shell at priority 0.
2038
2433
  // Lower priorities render first, so this intentionally shadows it.
2039
- { name: 'details', priority: -1 },
2434
+ { name: 'details', priority: -1, locale: NS },
2040
2435
  function (props) { return create(Panel, props) },
2041
2436
  ))
2042
2437
  })
@@ -2044,7 +2439,7 @@ window.__ModuleLoader__.load({
2044
2439
  // 槽位 entry 是静态的,多文件由 FilesView 内部子 tab 条管理。
2045
2440
  slots.inject('conversation.view', function () {
2046
2441
  disposers.push(slots.register(
2047
- { name: 'conversation.view', id: 'dsh-soup-files', order: 20, label: FILES_TAB_LABEL },
2442
+ { name: 'conversation.view', id: 'dsh-soup-files', order: 20, locale: NS, label: function () { return T('files.tab') } },
2048
2443
  function (props) { return create(FilesView, props) },
2049
2444
  ))
2050
2445
  })
@@ -2064,7 +2459,7 @@ window.__ModuleLoader__.load({
2064
2459
  }
2065
2460
  slots.inject('conversation.input.dock', function () {
2066
2461
  disposers.push(slots.register(
2067
- { name: 'conversation.input.dock', id: 'dsh-soup-hero-toggle', order: -10 },
2462
+ { name: 'conversation.input.dock', id: 'dsh-soup-hero-toggle', order: -10, locale: NS },
2068
2463
  function (props) { return create(HeroAction, props) },
2069
2464
  ))
2070
2465
  // GoalBar 多行版:同 id 'goal'、更低 priority (-1) -> 遮蔽默认单行实现。
@@ -2100,7 +2495,7 @@ window.__ModuleLoader__.load({
2100
2495
  function (props) { return create(GoalDock, props) },
2101
2496
  ))
2102
2497
  disposers.push(slots.register(
2103
- { name: 'conversation.input.dock', id: 'dsh-speed-badge', order: 30, label: '速度徽标' },
2498
+ { name: 'conversation.input.dock', id: 'dsh-speed-badge', order: 30, locale: NS, label: function () { return T('speed.label') } },
2104
2499
  function (props) { return create(SpeedBadge, props) },
2105
2500
  ))
2106
2501
  })