@lyhue1991/dsh-soup 0.5.2 → 0.5.4

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
@@ -9,8 +9,7 @@
9
9
  *
10
10
  * 与宿主通信:走同源 HTTP 路由 `/api/dsh-soup`(POST JSON),
11
11
  * 即永久插件(profile bundle)规范桥梁,而非 dynamic 半区的 host.call。
12
- * 文件「预览」为只读展示:markdown/html/json/csv 有专属渲染器,图片预览,
13
- * 其余按纯文本;不提供编辑与保存。
12
+ * 文件「预览」按类型渲染;常见文本文件可编辑并用 mtime/size 防覆盖保存。
14
13
  */
15
14
  window.__ModuleLoader__.load({
16
15
  id: '@lyhue1991/dsh-soup',
@@ -22,6 +21,40 @@ window.__ModuleLoader__.load({
22
21
  var React = require('react')
23
22
  var create = React.createElement
24
23
 
24
+ // CodeMirror 6 follows the same single-editor model used by JupyterLab:
25
+ // text, syntax decorations, cursor, gutter and scrolling share one view.
26
+ var CMEditorView = null
27
+ var CMEditorState = null
28
+ var CMKeymap = null
29
+ var CMCommands = null
30
+ var CMLanguage = null
31
+ var CMHighlightTags = null
32
+ var CMLangs = {}
33
+ try {
34
+ CMEditorView = require('@codemirror/view')
35
+ CMEditorState = require('@codemirror/state')
36
+ CMKeymap = require('@codemirror/view')
37
+ CMCommands = require('@codemirror/commands')
38
+ CMLanguage = require('@codemirror/language')
39
+ CMHighlightTags = require('@lezer/highlight').tags
40
+ CMLangs.css = require('@codemirror/lang-css')
41
+ CMLangs.html = require('@codemirror/lang-html')
42
+ CMLangs.javascript = require('@codemirror/lang-javascript')
43
+ CMLangs.json = require('@codemirror/lang-json')
44
+ CMLangs.markdown = require('@codemirror/lang-markdown')
45
+ CMLangs.python = require('@codemirror/lang-python')
46
+ CMLangs.sql = require('@codemirror/lang-sql')
47
+ CMLangs.xml = require('@codemirror/lang-xml')
48
+ } catch (err) {
49
+ CMEditorView = null
50
+ CMEditorState = null
51
+ CMKeymap = null
52
+ CMCommands = null
53
+ CMLanguage = null
54
+ CMHighlightTags = null
55
+ CMLangs = {}
56
+ }
57
+
25
58
  // 可选依赖:DSH 自带的不可信 Markdown 渲染器(micromark 管线,与聊天
26
59
  // 消息同款;不安全协议与相对链接被禁用)与 Shiki 代码高亮块(CodeBlock,
27
60
  // 懒加载语法包 + 复制按钮)。旧版宿主缺该模块时回退纯文本预览。
@@ -128,6 +161,8 @@ window.__ModuleLoader__.load({
128
161
  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>'
129
162
  var ICON_NEW_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-2 7h-3v3h-2v-3h-3v-2h3V8h2v3h3v2z"/></svg>'
130
163
  var ICON_MORE = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.3" fill="currentColor"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><circle cx="13" cy="8" r="1.3" fill="currentColor"/></svg>'
164
+ var FILE_ICON_EDIT = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>'
165
+ var FILE_ICON_PREVIEW = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>'
131
166
  var EXPAND_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>'
132
167
  var SHRINK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>'
133
168
 
@@ -156,8 +191,8 @@ window.__ModuleLoader__.load({
156
191
  document.head.appendChild(goalTag)
157
192
  }
158
193
 
159
- // 文件标签页视图(conversation.view 'dsh-soup-files'):子 tab 条 + 预览区。
160
- // 纯预览(无编辑):markdown/html/json/csv 各有专属渲染器。
194
+ // 文件标签页视图(conversation.view 'dsh-soup-files'):子 tab 条 + 预览/编辑区。
195
+ // markdown/html/json/csv 有专属渲染器;常见文本文件支持安全编辑保存。
161
196
  var FILEVIEW_CSS =
162
197
  '.dfv-root{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-specific-sidebar-fill);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);user-select:text;-webkit-user-select:text;}' +
163
198
  '.dfv-header{position:sticky;top:0;z-index:2;flex:none;background:var(--dsw-specific-sidebar-fill);}' +
@@ -175,20 +210,40 @@ window.__ModuleLoader__.load({
175
210
  '.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;}' +
176
211
  '.dfv-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}' +
177
212
  '.dfv-btn svg{width:14px;height:14px;display:block;flex:none;}' +
213
+ '.dfv-btn:disabled{opacity:.45;cursor:default;}' +
214
+ '.dfv-btn-primary{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary);}' +
215
+ '.dfv-status{flex:none;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-state-warning-primary);}' +
216
+ '.dfv-save-error{flex:none;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-state-error-primary);}' +
217
+ '.dfv-saved{color:var(--dsw-alias-state-success-primary);}' +
218
+ '.dfv-cm-editor{--dsh-soup-code-cursor:#1f2328;--dsh-soup-code-plain:#1f2328;--dsh-soup-code-comment:#57606a;--dsh-soup-code-keyword:#cf3476;--dsh-soup-code-string:#116329;--dsh-soup-code-number:#953800;--dsh-soup-code-type:#0969da;--dsh-soup-code-variable:#1f2328;--dsh-soup-code-function:#8250df;--dsh-soup-code-property:#0550ae;--dsh-soup-code-punctuation:#57606a;--dsh-soup-code-invalid:#cf222e;--dsh-soup-json-property:#0550ae;--dsh-soup-json-string:#116329;--dsh-soup-json-number:#953800;--dsh-soup-json-literal:#8250df;--dsh-soup-json-punctuation:#57606a;flex:1;min-height:0;min-width:0;overflow:hidden;background:var(--dsw-alias-bg-layer-1);}' +
219
+ 'body[data-ds-dark-theme] .dfv-cm-editor{--dsh-soup-code-cursor:#f9fafb;--dsh-soup-code-plain:#f9fafb;--dsh-soup-code-comment:#7d8590;--dsh-soup-code-keyword:#faa2c1;--dsh-soup-code-string:#8ce99a;--dsh-soup-code-number:#ffa94d;--dsh-soup-code-type:#4dabf7;--dsh-soup-code-variable:#f9fafb;--dsh-soup-code-function:#b197fc;--dsh-soup-code-property:#82aaff;--dsh-soup-code-punctuation:#ced4da;--dsh-soup-code-invalid:#ff6b6b;--dsh-soup-json-property:#82aaff;--dsh-soup-json-string:#9ece8f;--dsh-soup-json-number:#e0a96d;--dsh-soup-json-literal:#c792ea;--dsh-soup-json-punctuation:#ced4da;}' +
220
+ '.dfv-cm-editor .cm-editor{height:100%;color:var(--dsh-soup-code-plain);background:transparent;font:12.5px/1.55 var(--ds-font-family-code);}' +
221
+ '.dfv-cm-editor .cm-scroller{overflow:auto;font:inherit;}' +
222
+ '.dfv-cm-editor .cm-content{padding:10px 14px;min-height:calc(100% - 20px);caret-color:var(--dsh-soup-code-cursor);}' +
223
+ '.dfv-cm-editor .cm-cursor{border-left-color:var(--dsh-soup-code-cursor)!important;}' +
224
+ '.dfv-cm-editor .cm-line{padding:0;}' +
225
+ '.dfv-cm-editor .cm-gutters{border-right:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-caption);font:12.5px/1.55 var(--ds-font-family-code);}' +
226
+ '.dfv-cm-editor .cm-gutterElement{box-sizing:content-box;padding:0 8px 0 12px!important;min-width:0!important;}' +
227
+ '.dfv-cm-editor .cm-activeLine,.dfv-cm-editor .cm-activeLineGutter{background:transparent;}' +
228
+ '.dfv-cm-editor .cm-selectionBackground{background:var(--dsw-alias-state-business-primary-hover);}' +
229
+ '.dfv-cm-editor .cm-focused{outline:none;}' +
230
+ '.dfv-editor{flex:1;min-height:0;width:100%;box-sizing:border-box;margin:0;padding:10px 14px;border:none;outline:none;resize:none;overflow:auto;background:transparent;color:var(--dsh-soup-code-plain);caret-color:var(--dsh-soup-code-cursor);font:12.5px/1.55 var(--ds-font-family-code);letter-spacing:normal;text-align:left;text-indent:0;text-transform:none;direction:ltr;white-space:pre;overflow-wrap:normal;word-break:normal;tab-size:2;}' +
231
+ '.dfv-editor::selection{background:var(--dsw-alias-state-business-primary-hover);}' +
232
+ '.dfv-dirty{flex:none;width:7px;height:7px;border-radius:999px;background:var(--dsw-alias-state-warning-primary);}' +
178
233
  '.dfv-error{padding:8px 14px;font-size:12px;color:var(--dsw-alias-state-error-primary);flex:none;}' +
179
234
  '.dfv-muted{padding:24px 14px;font-size:13px;color:var(--dsw-alias-label-tertiary);text-align:center;}' +
180
235
  '.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;}' +
181
236
  '.dfv-code{flex:1;min-height:0;width:100%;box-sizing:border-box;margin:0;padding:10px 14px;overflow:auto;background:transparent;color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code);font-size:12.5px;line-height:1.55;white-space:pre;tab-size:2;user-select:text;}' +
182
237
  '.dfv-code-wrap{flex:1;min-height:0;overflow:auto;padding:8px 10px;}' +
183
238
  '.dfv-code-lines{flex:1;min-height:0;display:flex;overflow:auto;}' +
184
- '.dfv-code-gutter{position:sticky;left:0;z-index:1;flex:none;padding:10px 8px 10px 12px;text-align:right;color:var(--dsw-alias-label-caption);font-family:var(--ds-font-family-code);font-size:12.5px;line-height:1.55;user-select:none;background:var(--dsw-alias-bg-layer-1);border-right:1px solid var(--dsw-alias-border-l1);}' +
239
+ '.dfv-code-gutter{position:sticky;left:0;z-index:1;flex:none;padding:10px 8px 10px 12px;box-sizing:border-box;text-align:right;color:var(--dsw-alias-label-caption);font-family:var(--ds-font-family-code);font-size:12.5px;line-height:1.55;user-select:none;background:var(--dsw-alias-bg-layer-1);border-right:1px solid var(--dsw-alias-border-l1);}' +
185
240
  '.dfv-code-main{flex:1;min-width:0;}' +
186
241
  '.dfv-code-main .dfv-code{overflow:visible;width:auto;}' +
187
242
  // CodeBlock(md-code-block)在行号布局内的适配:
188
243
  // 隐藏 banner(js 标签 + 复制按钮),统一 padding/字体与 gutter 对齐。
189
244
  '.dfv-code-main .md-code-block{margin:0;border-radius:0;background:transparent;}' +
190
245
  '.dfv-code-main .md-code-block>div:first-child{display:none!important;}' +
191
- '.dfv-code-main .md-code-block pre{padding:10px 14px!important;margin:0!important;font:12.5px/1.55 var(--ds-font-family-code)!important;background:transparent!important;border-radius:0!important;white-space:pre;word-break:normal;}' +
246
+ '.dfv-code-main .md-code-block pre{padding:10px 14px!important;margin:0!important;font:12.5px/1.55 var(--ds-font-family-code)!important;background:transparent!important;border-radius:0!important;white-space:pre!important;word-break:normal!important;}' +
192
247
  '.dfv-nb{flex:1;min-height:0;overflow:auto;background:var(--dsw-alias-bg-base);}' +
193
248
  '.dfv-nb-cell{display:flex;border-bottom:1px solid var(--dsw-alias-border-l1);}' +
194
249
  '.dfv-nb-gutter{flex:none;width:44px;padding:8px 6px;text-align:right;font-family:var(--ds-font-family-code);font-size:11px;color:var(--dsw-alias-label-caption);border-right:1px solid var(--dsw-alias-border-l1);user-select:none;background:var(--dsw-specific-sidebar-fill);}' +
@@ -230,7 +285,17 @@ window.__ModuleLoader__.load({
230
285
  // 优先用稳定选择器;旧 hash 规则保留以兼容更老的宿主版本。
231
286
  '[data-dsh-soup-preview="1"] [data-composer-seat]{display:none !important;}' +
232
287
  '[data-dsh-soup-preview="1"] .wSkVaW_composerSeat{display:none !important;}' +
233
- '[data-dsh-soup-preview="1"] .FJxK0a_root{display:none !important;}'
288
+ '[data-dsh-soup-preview="1"] .FJxK0a_root{display:none !important;}' +
289
+ // 宿主会话文本宽度调整手柄(DSH 0.1.5-rc 起带稳定属性 data-width-handle,
290
+ // left/right 各一个;旧版 hash 类名兜底)。它是给对话正文调宽用的,
291
+ // 预览页里没有对应内容,悬停边缘时不应出现。
292
+ '[data-dsh-soup-preview="1"] [data-width-handle]{display:none !important;}' +
293
+ '[data-dsh-soup-preview="1"] .wSkVaW_widthHandle{display:none !important;}' +
294
+ // Desktop 版的宽度手柄在部分版本中是会话根的相邻节点而非其后代,
295
+ // 上面的作用域选择器覆盖不到。FilesView 存活时会在 body 上设置该状态,
296
+ // 以稳定属性为主全局禁用手柄和它的 hover 指示线。
297
+ 'body.dsh-soup-preview-active [data-width-handle]{display:none !important;pointer-events:none !important;}' +
298
+ 'body.dsh-soup-preview-active .wSkVaW_widthHandle{display:none !important;pointer-events:none !important;}'
234
299
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="@lyhue1991/dsh-soup/fileview.css"]') === null) {
235
300
  var fileviewTag = document.createElement('style')
236
301
  fileviewTag.dataset.plugin = '@lyhue1991/dsh-soup'
@@ -240,6 +305,9 @@ window.__ModuleLoader__.load({
240
305
  }
241
306
 
242
307
  /** 需要的客户端服务:slots、layout、timer、sessions、locale(i18n 词典)+ remote.goals(goal 动作动词)。 */
308
+ // 注意:sidebarRight 不能进 inject——DSH Desktop (0.1.5-rc.2) 宿主里该服务
309
+ // 永远不就绪,可选注入也会让插件一直 pending("waiting for service"),
310
+ // 被 desktop 判为阻挡启动。桥接改为运行时轮询解析(见 resolveSidebarRight)。
243
311
  var inject = ['slots', 'layout', 'timer', 'sessions', 'locale', 'remote', 'remote.goals']
244
312
 
245
313
  // ------------------------------------------------------------------
@@ -310,6 +378,22 @@ window.__ModuleLoader__.load({
310
378
  'files.imageTooLarge': '图片过大({size},上限 {limit})',
311
379
  'files.pdfTooLarge': 'PDF 过大({size},上限 {limit}),请用系统打开',
312
380
  'files.truncated': '文件超过 8 MB,仅预览前 8 MB;完整内容请用系统编辑器打开。',
381
+ 'files.edit': '编辑',
382
+ 'files.editTitle': '编辑当前文本文件',
383
+ 'files.preview': '预览',
384
+ 'files.previewTitle': '返回预览模式',
385
+ 'files.save': '保存',
386
+ 'files.saveTitle': '保存修改(⌘S / Ctrl+S)',
387
+ 'files.saving': '保存中…',
388
+ 'files.saved': '已保存',
389
+ 'files.cancelEdit': '放弃修改',
390
+ 'files.unsaved': '有未保存修改',
391
+ 'files.unsavedConfirm': '当前文件有未保存修改,确认放弃?',
392
+ 'files.conflictShort': '文件在外部被修改',
393
+ 'files.conflict': '其他程序修改了磁盘文件;当前草稿仍保留在编辑器中,下次自动保存会覆盖磁盘版本。',
394
+ 'files.overwriteConfirm': '文件已被其他程序修改。要用当前编辑内容覆盖磁盘上的最新版本吗?',
395
+ 'files.notEditable': '当前文件类型不支持编辑',
396
+ 'files.truncatedEdit': '文件超过 8 MB,已截断,不能编辑保存。',
313
397
  'files.jsonFail': 'JSON 解析失败,按纯文本显示',
314
398
  'files.nbFail': 'ipynb 解析失败,按纯文本显示',
315
399
  'files.nbEmpty': '(空笔记本)',
@@ -381,6 +465,22 @@ window.__ModuleLoader__.load({
381
465
  'files.imageTooLarge': 'Image too large ({size}, limit {limit})',
382
466
  'files.pdfTooLarge': 'PDF too large ({size}, limit {limit}) — open with the system viewer',
383
467
  'files.truncated': 'File exceeds 8 MB — showing the first 8 MB; open in a system editor for the full content.',
468
+ 'files.edit': 'Edit',
469
+ 'files.editTitle': 'Edit this text file',
470
+ 'files.preview': 'Preview',
471
+ 'files.previewTitle': 'Back to preview mode',
472
+ 'files.save': 'Save',
473
+ 'files.saveTitle': 'Save changes (⌘S / Ctrl+S)',
474
+ 'files.saving': 'Saving…',
475
+ 'files.saved': 'Saved',
476
+ 'files.cancelEdit': 'Discard changes',
477
+ 'files.unsaved': 'Unsaved changes',
478
+ 'files.unsavedConfirm': 'This file has unsaved changes. Discard them?',
479
+ 'files.conflictShort': 'Changed outside the editor',
480
+ 'files.conflict': 'Another program changed the file on disk. Your draft is preserved here, and the next auto-save will overwrite the disk version.',
481
+ 'files.overwriteConfirm': 'The file was changed externally. Overwrite the latest disk version with your current editor content?',
482
+ 'files.notEditable': 'Editing is not supported for this file type',
483
+ 'files.truncatedEdit': 'The file exceeds 8 MB and was truncated, so it cannot be safely edited.',
384
484
  'files.jsonFail': 'JSON parse failed — shown as plain text',
385
485
  'files.nbFail': 'ipynb parse failed — shown as plain text',
386
486
  'files.nbEmpty': '(empty notebook)',
@@ -417,6 +517,11 @@ window.__ModuleLoader__.load({
417
517
  // while a blank conversation is mounted. Keep late async responses from an
418
518
  // older caller from overwriting the current session's shared store.
419
519
  var sessionTrackGeneration = 0
520
+ var autoSaveTimers = new Map()
521
+ var autoSaveSavedTimers = new Map()
522
+ var manualSavePaths = new Set()
523
+ var AUTO_SAVE_DELAY_MS = 5000
524
+ var SAVED_INDICATOR_MS = 1500
420
525
 
421
526
  // 目录展开状态按 cwd 隔离。刷新/删除/改名/移动后先重拉根目录,
422
527
  // 再按这些路径递归重拉仍存在的已展开目录;切换项目时才清空。
@@ -620,7 +725,21 @@ window.__ModuleLoader__.load({
620
725
  if (!res || !res.ok) return
621
726
  entry.kind = res.kind
622
727
  entry.size = res.size || 0
623
- if (res.kind === 'text') { entry.content = res.content; entry.truncated = !!res.truncated }
728
+ if (res.kind === 'text') {
729
+ entry.truncated = !!res.truncated
730
+ if (!entry.dirty && !entry.editing) {
731
+ entry.content = res.content
732
+ entry.baseContent = res.content
733
+ entry.draft = res.content
734
+ entry.baseMtime = res.mtime
735
+ entry.baseSize = res.size
736
+ } else {
737
+ entry.conflict = true
738
+ entry.diskMtime = res.mtime
739
+ entry.diskSize = res.size
740
+ scheduleAutoSave(entry.path)
741
+ }
742
+ }
624
743
  else if (res.kind === 'image') { entry.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
625
744
  else if (res.kind === 'pdf') { entry.data = res.data }
626
745
  setFiles({ list: state.files.list.slice() })
@@ -672,7 +791,12 @@ window.__ModuleLoader__.load({
672
791
  }
673
792
  for (var f = 0; f < dirtyFiles.length; f++) {
674
793
  var entry = findFileEntry(dirtyFiles[f])
675
- if (entry && entry.loaded) await silentReloadEntry(entry)
794
+ if (!entry || !entry.loaded) continue
795
+ var currentSignature = mtimes[dirtyFiles[f]]
796
+ if (currentSignature
797
+ && currentSignature.m === entry.baseMtime
798
+ && currentSignature.s === entry.baseSize) continue
799
+ await silentReloadEntry(entry)
676
800
  }
677
801
  return true
678
802
  } finally {
@@ -1844,6 +1968,9 @@ window.__ModuleLoader__.load({
1844
1968
  loading: true, loaded: false, error: '',
1845
1969
  kind: null, content: '', dataUrl: '', data: '',
1846
1970
  truncated: false, size: 0,
1971
+ baseContent: '', baseMtime: undefined, baseSize: undefined,
1972
+ draft: '', dirty: false, editing: false, saving: false, justSaved: false,
1973
+ saveError: '', conflict: false,
1847
1974
  }
1848
1975
  // FIFO:最多同时 5 个预览,新开的挤掉最早打开的
1849
1976
  var list = state.files.list.slice()
@@ -1874,13 +2001,96 @@ window.__ModuleLoader__.load({
1874
2001
  cur.loaded = true
1875
2002
  cur.kind = res.kind
1876
2003
  cur.size = res.size || 0
1877
- if (res.kind === 'text') { cur.content = res.content; cur.truncated = !!res.truncated }
2004
+ if (res.kind === 'text') {
2005
+ cur.truncated = !!res.truncated
2006
+ if (!cur.dirty && !cur.editing) {
2007
+ cur.content = res.content
2008
+ cur.baseContent = res.content
2009
+ cur.draft = res.content
2010
+ cur.baseMtime = res.mtime
2011
+ cur.baseSize = res.size
2012
+ } else {
2013
+ cur.conflict = true
2014
+ cur.diskMtime = res.mtime
2015
+ cur.diskSize = res.size
2016
+ scheduleAutoSave(path)
2017
+ }
2018
+ }
1878
2019
  else if (res.kind === 'image') { cur.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
1879
2020
  else if (res.kind === 'pdf') { cur.data = res.data }
1880
2021
  }
1881
2022
  setFiles({ list: state.files.list.slice(), active: path })
1882
2023
  }
1883
2024
 
2025
+ // ------------------------------------------------------------------
2026
+ // 官方 file 地址桥接:dsh-soup 遮蔽 rightbar 槽位后,官方 SidebarRight
2027
+ // 面板组件永不挂载,会话里的文件产物卡片 / 内联文件链接调用
2028
+ // ctx.sidebarRight.openResource('dsh-resource://file/…') 会抛
2029
+ // "sidebarRight: no session surface is mounted"。这里把 file 地址解析
2030
+ // 回路径,转投 dsh-soup 自己的预览 tab(openFileInTab)。
2031
+ // ------------------------------------------------------------------
2032
+ var FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
2033
+
2034
+ function decodeSegmentSafe(segment) {
2035
+ try { return decodeURIComponent(segment) } catch (e) { return null }
2036
+ }
2037
+
2038
+ /** 解析官方 file 地址;非 file 地址或编码非法返回 null(与 workspace-path 同规则)。 */
2039
+ function parseFileAddress(address) {
2040
+ if (typeof address !== 'string' || address.indexOf(FILE_ADDRESS_PREFIX) !== 0) return null
2041
+ var end = address.search(/[?#]/)
2042
+ var body = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end)
2043
+ var parts = body.split('/')
2044
+ if (parts[0] === 'session') {
2045
+ var sessionId = decodeSegmentSafe(parts[1] || '')
2046
+ if (!sessionId) return null
2047
+ var segments = []
2048
+ for (var i = 2; i < parts.length; i++) {
2049
+ var seg = decodeSegmentSafe(parts[i])
2050
+ if (seg === null) return null
2051
+ segments.push(seg)
2052
+ }
2053
+ if (!segments.length) return null
2054
+ return { scope: 'session', sessionId: sessionId, path: segments.join('/') }
2055
+ }
2056
+ if (parts[0] === 'absolute') {
2057
+ var absolute = []
2058
+ for (var j = 1; j < parts.length; j++) {
2059
+ var abs = decodeSegmentSafe(parts[j])
2060
+ if (abs === null) return null
2061
+ absolute.push(abs)
2062
+ }
2063
+ if (!absolute.length) return null
2064
+ return { scope: 'absolute', path: '/' + absolute.join('/') }
2065
+ }
2066
+ return null
2067
+ }
2068
+
2069
+ /** 同步判断:该 file 地址是否应由 dsh-soup 接住(当前会话或绝对路径)。 */
2070
+ function fileAddressMatches(address, sessionIdHint) {
2071
+ var parsed = parseFileAddress(address)
2072
+ if (!parsed) return false
2073
+ if (parsed.scope === 'session' && (sessionIdHint || parsed.sessionId) !== activeSessionId) return false
2074
+ return true
2075
+ }
2076
+
2077
+ /** file 地址 → dsh-soup 预览。session 作用域的路径相对会话 cwd,
2078
+ * 必须先解析成绝对路径再读——宿主 read 对相对路径按服务进程 cwd
2079
+ * (桌面端为 launch-root)解析,直接透传会 ENOENT。 */
2080
+ async function openFileAddressInSoup(address, sessionIdHint) {
2081
+ var parsed = parseFileAddress(address)
2082
+ if (!parsed) return false
2083
+ var sid = sessionIdHint || parsed.sessionId
2084
+ if (parsed.scope === 'session' && sid !== activeSessionId) return false
2085
+ var target = parsed.path
2086
+ if (parsed.scope === 'session' && target.charAt(0) !== '/') {
2087
+ var cwd = state.cwd || await defaultCwd(parsed.sessionId)
2088
+ if (cwd) target = String(cwd).replace(/\/+$/, '') + '/' + target
2089
+ }
2090
+ openFileInTab(target)
2091
+ return true
2092
+ }
2093
+
1884
2094
  async function reloadFile(path) {
1885
2095
  var entry = findFileEntry(path)
1886
2096
  if (!entry) return
@@ -1889,11 +2099,145 @@ window.__ModuleLoader__.load({
1889
2099
  await openFileInTab(path)
1890
2100
  }
1891
2101
 
2102
+ function isEditableTextFile(path) {
2103
+ var kind = previewKind(path)
2104
+ var ext = extOfName(path)
2105
+ return kind === 'markdown' || kind === 'html' || kind === 'json'
2106
+ || Object.prototype.hasOwnProperty.call(CODE_LANG_BY_EXT, ext)
2107
+ }
2108
+
2109
+ function startEditing(path) {
2110
+ var entry = findFileEntry(path)
2111
+ if (!entry || !entry.loaded || entry.kind !== 'text') return
2112
+ if (!isEditableTextFile(path)) {
2113
+ entry.saveError = T('files.notEditable')
2114
+ setFiles({ list: state.files.list.slice() })
2115
+ return
2116
+ }
2117
+ if (entry.truncated) {
2118
+ entry.saveError = T('files.truncatedEdit')
2119
+ setFiles({ list: state.files.list.slice() })
2120
+ return
2121
+ }
2122
+ if (!entry.editing) {
2123
+ entry.draft = entry.dirty ? entry.draft : entry.content
2124
+ entry.editing = true
2125
+ }
2126
+ entry.saveError = ''
2127
+ setFiles({ list: state.files.list.slice() })
2128
+ }
2129
+
2130
+ function scheduleAutoSave(path) {
2131
+ clearTimeout(autoSaveTimers.get(path))
2132
+ autoSaveTimers.set(path, setTimeout(function () {
2133
+ autoSaveTimers.delete(path)
2134
+ saveActiveFile(path)
2135
+ }, AUTO_SAVE_DELAY_MS))
2136
+ }
2137
+
2138
+ function showSavedIndicator(path) {
2139
+ clearTimeout(autoSaveSavedTimers.get(path))
2140
+ autoSaveSavedTimers.set(path, setTimeout(function () {
2141
+ autoSaveSavedTimers.delete(path)
2142
+ var entry = findFileEntry(path)
2143
+ if (!entry || entry.saving || entry.dirty) return
2144
+ entry.justSaved = false
2145
+ setFiles({ list: state.files.list.slice() })
2146
+ }, SAVED_INDICATOR_MS))
2147
+ }
2148
+
2149
+ function manuallySaveFile(path) {
2150
+ manualSavePaths.add(path)
2151
+ saveActiveFile(path)
2152
+ }
2153
+
2154
+ function stopEditing(path) {
2155
+ var entry = findFileEntry(path)
2156
+ if (!entry || !entry.editing) return
2157
+ clearTimeout(autoSaveTimers.get(path))
2158
+ autoSaveTimers.delete(path)
2159
+ clearTimeout(autoSaveSavedTimers.get(path))
2160
+ autoSaveSavedTimers.delete(path)
2161
+ if (entry.dirty) {
2162
+ if (entry.saving) entry.saveOnExit = true
2163
+ else saveActiveFile(path)
2164
+ }
2165
+ entry.editing = false
2166
+ setFiles({ list: state.files.list.slice() })
2167
+ }
2168
+
2169
+ function updateDraft(path, value) {
2170
+ var entry = findFileEntry(path)
2171
+ if (!entry || !entry.editing) return
2172
+ entry.draft = value
2173
+ entry.dirty = value !== entry.baseContent
2174
+ setFiles({ list: state.files.list.slice() })
2175
+ scheduleAutoSave(path)
2176
+ }
2177
+
2178
+ async function saveActiveFile(path) {
2179
+ var entry = findFileEntry(path)
2180
+ if (!entry || (!entry.editing && !entry.saveOnExit) || entry.saving || entry.truncated) return
2181
+ if (!entry.dirty) return
2182
+ entry.saving = true
2183
+ entry.saveError = ''
2184
+ setFiles({ list: state.files.list.slice() })
2185
+ if (entry.conflict) {
2186
+ entry.baseMtime = entry.diskMtime
2187
+ entry.baseSize = entry.diskSize
2188
+ entry.conflict = false
2189
+ }
2190
+ var savedDraft = entry.draft
2191
+ var wasManualSave = manualSavePaths.has(path)
2192
+ manualSavePaths.delete(path)
2193
+ var res = await rpc('write', {
2194
+ path: path,
2195
+ content: entry.draft,
2196
+ expectedMtime: entry.baseMtime,
2197
+ expectedSize: entry.baseSize,
2198
+ sessionId: activeSessionId,
2199
+ })
2200
+ var cur = findFileEntry(path)
2201
+ if (!cur) return
2202
+ cur.saving = false
2203
+ if (!res || !res.ok) {
2204
+ cur.saveError = (res && res.error) || T('files.saveTitle')
2205
+ if (res && res.conflict) {
2206
+ cur.conflict = true
2207
+ cur.diskMtime = res.mtime
2208
+ cur.diskSize = res.size
2209
+ }
2210
+ if (cur.editing || cur.saveOnExit) scheduleAutoSave(path)
2211
+ setFiles({ list: state.files.list.slice() })
2212
+ return
2213
+ }
2214
+ cur.content = savedDraft
2215
+ cur.baseContent = savedDraft
2216
+ cur.dirty = cur.draft !== savedDraft
2217
+ if (!cur.dirty) cur.saveOnExit = false
2218
+ else if (cur.editing || cur.saveOnExit) scheduleAutoSave(path)
2219
+ cur.conflict = false
2220
+ cur.saveError = ''
2221
+ cur.justSaved = wasManualSave
2222
+ cur.size = res.size
2223
+ cur.baseMtime = res.mtime
2224
+ cur.baseSize = res.size
2225
+ setFiles({ list: state.files.list.slice() })
2226
+ refresh()
2227
+ if (wasManualSave) showSavedIndicator(path)
2228
+ }
2229
+
1892
2230
  function closeFileTab(path) {
1893
2231
  var list = state.files.list
1894
2232
  var idx = -1
1895
2233
  for (var i = 0; i < list.length; i++) if (list[i].path === path) { idx = i; break }
1896
2234
  if (idx < 0) return
2235
+ clearTimeout(autoSaveTimers.get(path))
2236
+ autoSaveTimers.delete(path)
2237
+ clearTimeout(autoSaveSavedTimers.get(path))
2238
+ autoSaveSavedTimers.delete(path)
2239
+ var closing = list[idx]
2240
+ if (closing.dirty && typeof window !== 'undefined' && !window.confirm(T('files.unsavedConfirm'))) return
1897
2241
  var next = list.slice(0, idx).concat(list.slice(idx + 1))
1898
2242
  var active = state.files.active
1899
2243
  if (active === path) {
@@ -1936,6 +2280,126 @@ window.__ModuleLoader__.load({
1936
2280
  return 'text'
1937
2281
  }
1938
2282
 
2283
+ function codeMirrorLanguage(path) {
2284
+ var ext = extOfName(path)
2285
+ var factory = ext === 'py' ? CMLangs.python && CMLangs.python.python
2286
+ : ext === 'js' || ext === 'mjs' || ext === 'cjs' ? CMLangs.javascript && CMLangs.javascript.javascript
2287
+ : ext === 'jsx' ? CMLangs.javascript && CMLangs.javascript.javascript
2288
+ : ext === 'ts' || ext === 'mts' || ext === 'cts' ? CMLangs.javascript && CMLangs.javascript.javascript
2289
+ : ext === 'tsx' ? CMLangs.javascript && CMLangs.javascript.javascript
2290
+ : ext === 'json' || ext === 'jsonc' ? CMLangs.json && CMLangs.json.json
2291
+ : ext === 'md' || ext === 'markdown' || ext === 'mdx' ? CMLangs.markdown && CMLangs.markdown.markdown
2292
+ : ext === 'html' || ext === 'htm' ? CMLangs.html && CMLangs.html.html
2293
+ : ext === 'css' || ext === 'scss' || ext === 'less' ? CMLangs.css && CMLangs.css.css
2294
+ : ext === 'sql' ? CMLangs.sql && CMLangs.sql.sql
2295
+ : ext === 'xml' ? CMLangs.xml && CMLangs.xml.xml
2296
+ : null
2297
+ if (!factory) return null
2298
+ try {
2299
+ if (ext === 'js' || ext === 'mjs' || ext === 'cjs' || ext === 'jsx') return factory({ jsx: true })
2300
+ if (ext === 'ts' || ext === 'mts' || ext === 'cts' || ext === 'tsx') return factory({ typescript: true, jsx: ext === 'tsx' })
2301
+ return factory()
2302
+ } catch (err) { return null }
2303
+ }
2304
+
2305
+ var PREVIEW_HIGHLIGHT_STYLE = (CMLanguage && CMLanguage.HighlightStyle && CMHighlightTags)
2306
+ ? CMLanguage.HighlightStyle.define([
2307
+ { tag: CMHighlightTags.comment, color: 'var(--dsh-soup-code-comment)' },
2308
+ { tag: CMHighlightTags.keyword, color: 'var(--dsh-soup-code-keyword)' },
2309
+ { tag: CMHighlightTags.controlKeyword, color: 'var(--dsh-soup-code-keyword)' },
2310
+ { tag: CMHighlightTags.definitionKeyword, color: 'var(--dsh-soup-code-keyword)' },
2311
+ { tag: CMHighlightTags.moduleKeyword, color: 'var(--dsh-soup-code-keyword)' },
2312
+ { tag: CMHighlightTags.operator, color: 'var(--dsh-soup-code-keyword)' },
2313
+ { tag: CMHighlightTags.operatorKeyword, color: 'var(--dsh-soup-code-keyword)' },
2314
+ { tag: CMHighlightTags.string, color: 'var(--dsh-soup-code-string)' },
2315
+ { tag: CMHighlightTags.escape, color: 'var(--dsh-soup-code-string)' },
2316
+ { tag: CMHighlightTags.regexp, color: 'var(--dsh-soup-code-string)' },
2317
+ { tag: CMHighlightTags.number, color: 'var(--dsh-soup-code-number)' },
2318
+ { tag: CMHighlightTags.bool, color: 'var(--dsh-soup-code-keyword)' },
2319
+ { tag: CMHighlightTags.null, color: 'var(--dsh-soup-code-keyword)' },
2320
+ { tag: CMHighlightTags.typeName, color: 'var(--dsh-soup-code-type)' },
2321
+ { tag: CMHighlightTags.namespace, color: 'var(--dsh-soup-code-type)' },
2322
+ { tag: CMHighlightTags.variableName, color: 'var(--dsh-soup-code-variable)' },
2323
+ { tag: CMHighlightTags.function(CMHighlightTags.variableName), color: 'var(--dsh-soup-code-function)' },
2324
+ { tag: CMHighlightTags.className, color: 'var(--dsh-soup-code-function)' },
2325
+ { tag: CMHighlightTags.macroName, color: 'var(--dsh-soup-code-function)' },
2326
+ { tag: CMHighlightTags.propertyName, color: 'var(--dsh-soup-code-property)' },
2327
+ { tag: CMHighlightTags.punctuation, color: 'var(--dsh-soup-code-punctuation)' },
2328
+ { tag: CMHighlightTags.bracket, color: 'var(--dsh-soup-code-punctuation)' },
2329
+ { tag: CMHighlightTags.invalid, color: 'var(--dsh-soup-code-invalid)' },
2330
+ ])
2331
+ : null
2332
+
2333
+ var PREVIEW_JSON_HIGHLIGHT_STYLE = (CMLanguage && CMLanguage.HighlightStyle && CMHighlightTags)
2334
+ ? CMLanguage.HighlightStyle.define([
2335
+ { tag: CMHighlightTags.propertyName, color: 'var(--dsh-soup-json-property)' },
2336
+ { tag: CMHighlightTags.string, color: 'var(--dsh-soup-json-string)' },
2337
+ { tag: CMHighlightTags.number, color: 'var(--dsh-soup-json-number)' },
2338
+ { tag: CMHighlightTags.bool, color: 'var(--dsh-soup-json-literal)' },
2339
+ { tag: CMHighlightTags.null, color: 'var(--dsh-soup-json-literal)' },
2340
+ { tag: CMHighlightTags.punctuation, color: 'var(--dsh-soup-json-punctuation)' },
2341
+ { tag: CMHighlightTags.bracket, color: 'var(--dsh-soup-json-punctuation)' },
2342
+ { tag: CMHighlightTags.invalid, color: 'var(--dsh-soup-code-invalid)' },
2343
+ ])
2344
+ : null
2345
+
2346
+ /** CodeMirror 6 编辑器:文本、行号、语法、高亮、光标和滚动由同一 EditorView 管理。 */
2347
+ function TextEditor(props) {
2348
+ var entry = props.entry
2349
+ var hostRef = React.useRef(null)
2350
+ var viewRef = React.useRef(null)
2351
+ React.useEffect(function () {
2352
+ if (!hostRef.current || !CMEditorView || !CMEditorState) return
2353
+ var EditorView = CMEditorView.EditorView
2354
+ var EditorState = CMEditorState.EditorState
2355
+ var extensions = [
2356
+ EditorView.updateListener.of(function (update) {
2357
+ if (update.docChanged) updateDraft(entry.path, update.state.doc.toString())
2358
+ }),
2359
+ ]
2360
+ if (CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of([
2361
+ { key: 'Mod-s', run: function () { manuallySaveFile(entry.path); return true } },
2362
+ ]))
2363
+ if (CMCommands) {
2364
+ if (CMCommands.history) extensions.push(CMCommands.history())
2365
+ if (CMCommands.historyKeymap && CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of(CMCommands.historyKeymap))
2366
+ if (CMCommands.defaultKeymap && CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of(CMCommands.defaultKeymap))
2367
+ if (CMCommands.indentWithTab && CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of([CMCommands.indentWithTab]))
2368
+ }
2369
+ if (CMLanguage) {
2370
+ if (CMLanguage.bracketMatching) extensions.push(CMLanguage.bracketMatching())
2371
+ if (CMLanguage.indentOnInput) extensions.push(CMLanguage.indentOnInput())
2372
+ }
2373
+ if (CMEditorView.lineNumbers) extensions.push(CMEditorView.lineNumbers())
2374
+ if (CMEditorView.drawSelection) extensions.push(CMEditorView.drawSelection())
2375
+ if (CMEditorView.highlightActiveLine) extensions.push(CMEditorView.highlightActiveLine())
2376
+ if (CMEditorView.highlightSpecialChars) extensions.push(CMEditorView.highlightSpecialChars())
2377
+ var language = codeMirrorLanguage(entry.path)
2378
+ if (language) extensions.push(language)
2379
+ var ext = extOfName(entry.path)
2380
+ var highlightStyle = (ext === 'json' || ext === 'jsonc')
2381
+ ? PREVIEW_JSON_HIGHLIGHT_STYLE
2382
+ : PREVIEW_HIGHLIGHT_STYLE
2383
+ if (highlightStyle) extensions.push(CMLanguage.syntaxHighlighting(highlightStyle))
2384
+ viewRef.current = new EditorView({
2385
+ state: EditorState.create({ doc: entry.draft, extensions: extensions }),
2386
+ parent: hostRef.current,
2387
+ })
2388
+ viewRef.current.focus()
2389
+ return function () {
2390
+ if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null }
2391
+ }
2392
+ }, [entry.path])
2393
+ if (!CMEditorView || !CMEditorState) {
2394
+ return create('textarea', {
2395
+ className: 'dfv-editor', value: entry.draft, spellCheck: false,
2396
+ 'aria-label': entry.name,
2397
+ onChange: function (e) { updateDraft(entry.path, e.target.value) },
2398
+ })
2399
+ }
2400
+ return create('div', { className: 'dfv-cm-editor', ref: hostRef, 'aria-label': entry.name })
2401
+ }
2402
+
1939
2403
  // 扩展名 → Shiki 语言提示。对齐 DSH 读卡片的 LANG_BY_EXTENSION
1940
2404
  // (packages/fs/tool-fs read-render.ts),json/md/markdown/html/htm 除外——
1941
2405
  // 它们走上方专属渲染器。
@@ -2490,24 +2954,26 @@ window.__ModuleLoader__.load({
2490
2954
  if (entry.kind === 'pdf') {
2491
2955
  return create(PdfPreview, { data: entry.data })
2492
2956
  }
2493
- // 文本类:按扩展名选预览渲染器(纯只读,无编辑)
2957
+ if (entry.editing) return create(TextEditor, { entry: entry })
2958
+ // 文本类:按扩展名选预览渲染器;编辑态返回预览时展示当前草稿
2959
+ var text = entry.dirty ? entry.draft : entry.content
2494
2960
  var pk = previewKind(entry.path)
2495
2961
  var view = null
2496
- if (pk === 'markdown') view = create(MarkdownPreview, { text: entry.content, baseDir: parentOf(entry.path) })
2497
- else if (pk === 'html') view = create(HtmlPreview, { text: entry.content, title: entry.name })
2498
- else if (pk === 'json') view = create(JsonPreview, { text: entry.content })
2499
- else if (pk === 'notebook') view = create(NotebookPreview, { text: entry.content })
2500
- else if (pk === 'csv' || pk === 'tsv') view = create(DelimitedPreview, { text: entry.content, delim: pk === 'tsv' ? '\t' : ',' })
2962
+ if (pk === 'markdown') view = create(MarkdownPreview, { text: text, baseDir: parentOf(entry.path) })
2963
+ else if (pk === 'html') view = create(HtmlPreview, { text: text, title: entry.name })
2964
+ else if (pk === 'json') view = create(CodeWithLines, { text: text }, create(JsonPreview, { text: text }))
2965
+ else if (pk === 'notebook') view = create(NotebookPreview, { text: text })
2966
+ else if (pk === 'csv' || pk === 'tsv') view = create(DelimitedPreview, { text: text, delim: pk === 'tsv' ? '\t' : ',' })
2501
2967
  else {
2502
2968
  // 代码文件:DSH CodeBlock(Shiki 高亮,语法包懒加载);无高亮条件回退纯文本
2503
2969
  // 都包在 CodeWithLines 里加左侧行号
2504
2970
  var lang = Object.prototype.hasOwnProperty.call(CODE_LANG_BY_EXT, extOfName(entry.path))
2505
2971
  ? CODE_LANG_BY_EXT[extOfName(entry.path)]
2506
2972
  : undefined
2507
- var codeEl = (CodeBlock && lang && entry.content.length <= CODE_HIGHLIGHT_MAX_CHARS)
2508
- ? create(CodeBlock, { code: entry.content, lang: lang })
2509
- : create('pre', { className: 'dfv-code' }, entry.content)
2510
- view = create(CodeWithLines, { text: entry.content }, codeEl)
2973
+ var codeEl = (CodeBlock && lang && text.length <= CODE_HIGHLIGHT_MAX_CHARS)
2974
+ ? create(CodeBlock, { code: text, lang: lang })
2975
+ : create('pre', { className: 'dfv-code' }, text)
2976
+ view = create(CodeWithLines, { text: text }, codeEl)
2511
2977
  }
2512
2978
  return create(React.Fragment, null,
2513
2979
  entry.truncated ? create('div', { className: 'dfv-cap' },
@@ -2516,6 +2982,53 @@ window.__ModuleLoader__.load({
2516
2982
  )
2517
2983
  }
2518
2984
 
2985
+ function FileToolbar(props) {
2986
+ var active = props.entry
2987
+ var buttons = []
2988
+ if (active.loaded && active.kind === 'text' && isEditableTextFile(active.path)) {
2989
+ if (!active.editing) {
2990
+ buttons.push(create('button', {
2991
+ type: 'button',
2992
+ className: 'dfv-btn',
2993
+ title: T('files.editTitle'),
2994
+ disabled: active.truncated,
2995
+ onClick: function () { startEditing(active.path) },
2996
+ },
2997
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: FILE_ICON_EDIT } }),
2998
+ T('files.edit'),
2999
+ ))
3000
+ } else {
3001
+ buttons.push(create('button', {
3002
+ type: 'button',
3003
+ className: 'dfv-btn',
3004
+ title: T('files.previewTitle'),
3005
+ onClick: function () { stopEditing(active.path) },
3006
+ },
3007
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: FILE_ICON_PREVIEW } }),
3008
+ T('files.preview'),
3009
+ ))
3010
+ }
3011
+ }
3012
+ buttons.push(active.loaded ? create('button', {
3013
+ type: 'button',
3014
+ className: 'dfv-btn',
3015
+ title: T('files.reloadTitle'),
3016
+ onClick: function () { reloadFile(active.path) },
3017
+ },
3018
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }),
3019
+ T('files.reload'),
3020
+ ) : null)
3021
+ return create(React.Fragment, null,
3022
+ create('span', { className: 'dfv-toolbar-path', title: active.path }, active.path),
3023
+ typeof active.size === 'number' && active.size > 0 ? create('span', null, formatBytes(active.size)) : null,
3024
+ active.saving ? create('span', { className: 'dfv-status' }, T('files.saving')) : null,
3025
+ active.justSaved && !active.saving && !active.dirty ? create('span', { className: 'dfv-status dfv-saved' }, T('files.saved')) : null,
3026
+ active.conflict ? create('span', { className: 'dfv-status', title: T('files.conflict') }, T('files.conflictShort')) : null,
3027
+ active.saveError ? create('span', { className: 'dfv-save-error', title: active.saveError }, active.saveError) : null,
3028
+ buttons,
3029
+ )
3030
+ }
3031
+
2519
3032
  /**
2520
3033
  * 纯阅读模式标记:在会话根(含 composerSeat 的最近祖先,即 DSH
2521
3034
  * ConversationRoot 的 root 节点)上打 data-dsh-soup-preview,由上面
@@ -2526,6 +3039,7 @@ window.__ModuleLoader__.load({
2526
3039
  React.useEffect(function () {
2527
3040
  var observer = null
2528
3041
  var marked = null
3042
+ document.body.classList.add('dsh-soup-preview-active')
2529
3043
  function apply() {
2530
3044
  var el = rootRef.current
2531
3045
  if (!el) return
@@ -2545,6 +3059,7 @@ window.__ModuleLoader__.load({
2545
3059
  return function () {
2546
3060
  if (observer) observer.disconnect()
2547
3061
  if (marked) marked.removeAttribute('data-dsh-soup-preview')
3062
+ document.body.classList.remove('dsh-soup-preview-active')
2548
3063
  marked = null
2549
3064
  }
2550
3065
  }, [])
@@ -2565,6 +3080,7 @@ window.__ModuleLoader__.load({
2565
3080
  title: entry.path,
2566
3081
  onClick: function () { setActiveFile(entry.path) },
2567
3082
  },
3083
+ entry.dirty ? create('span', { className: 'dfv-dirty', title: T('files.unsaved') }) : null,
2568
3084
  create('span', { className: 'dfv-tab-name' }, entry.name),
2569
3085
  create('span', {
2570
3086
  className: 'dfv-close',
@@ -2584,17 +3100,7 @@ window.__ModuleLoader__.load({
2584
3100
  )
2585
3101
  } else {
2586
3102
  toolbar = create('div', { className: 'dfv-toolbar' },
2587
- create('span', { className: 'dfv-toolbar-path', title: active.path }, active.path),
2588
- typeof active.size === 'number' && active.size > 0 ? create('span', null, formatBytes(active.size)) : null,
2589
- active.loaded ? create('button', {
2590
- type: 'button',
2591
- className: 'dfv-btn',
2592
- title: T('files.reloadTitle'),
2593
- onClick: function () { reloadFile(active.path) },
2594
- },
2595
- create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }),
2596
- T('files.reload'),
2597
- ) : null,
3103
+ create(FileToolbar, { entry: active }),
2598
3104
  create('button', {
2599
3105
  type: 'button',
2600
3106
  className: 'dfv-btn',
@@ -2626,6 +3132,7 @@ window.__ModuleLoader__.load({
2626
3132
  if (!active) return null
2627
3133
  var close = function () { setFiles({ overlay: false, overlayMax: false, overlayReturn: null }) }
2628
3134
  var closeAndCloseTab = function () {
3135
+ if (active.dirty && typeof window !== 'undefined' && !window.confirm(T('files.unsavedConfirm'))) return
2629
3136
  close()
2630
3137
  closeFileTab(active.path)
2631
3138
  }
@@ -2643,17 +3150,7 @@ window.__ModuleLoader__.load({
2643
3150
  },
2644
3151
  create('div', { className: 'dfv-overlay-panel' + (files.overlayMax ? ' dfv-overlay-max' : '') },
2645
3152
  create('div', { className: 'dfv-toolbar' },
2646
- create('span', { className: 'dfv-toolbar-path', title: active.path }, active.path),
2647
- typeof active.size === 'number' && active.size > 0 ? create('span', null, formatBytes(active.size)) : null,
2648
- active.loaded ? create('button', {
2649
- type: 'button',
2650
- className: 'dfv-btn',
2651
- title: T('files.reloadTitle'),
2652
- onClick: function () { reloadFile(active.path) },
2653
- },
2654
- create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }),
2655
- T('files.reload'),
2656
- ) : null,
3153
+ create(FileToolbar, { entry: active }),
2657
3154
  create('button', {
2658
3155
  type: 'button', className: 'dfv-overlay-close',
2659
3156
  title: files.overlayMax ? '还原' : '最大化', 'aria-label': files.overlayMax ? '还原' : '最大化',
@@ -3092,6 +3589,57 @@ window.__ModuleLoader__.load({
3092
3589
  document.addEventListener('click', onOfficialExpand, true)
3093
3590
  disposers.push(function () { document.removeEventListener('click', onOfficialExpand, true) })
3094
3591
  }
3592
+ // 官方产物链接桥接:包装 sidebarRight 服务(见 parseFileAddress 处注释)。
3593
+ // 优先走可选注入的 ctx.sidebarRight(框架保证就绪),并保留
3594
+ // ctx.get / ctx.reflect.get 多条解析路径与短轮询兜底。
3595
+ var bridgePatched = false
3596
+ function resolveSidebarRight() {
3597
+ try { if (ctx.sidebarRight) return ctx.sidebarRight } catch (e) {}
3598
+ try {
3599
+ if (typeof ctx.get === 'function') {
3600
+ var viaGet = ctx.get('sidebarRight', false)
3601
+ if (viaGet) return viaGet
3602
+ }
3603
+ } catch (e) {}
3604
+ try {
3605
+ if (ctx.reflect && typeof ctx.reflect.get === 'function') {
3606
+ var viaReflect = ctx.reflect.get('sidebarRight', false)
3607
+ if (viaReflect) return viaReflect
3608
+ }
3609
+ } catch (e) {}
3610
+ return null
3611
+ }
3612
+ function patchSidebarRightBridge() {
3613
+ if (bridgePatched) return true
3614
+ var service = resolveSidebarRight()
3615
+ if (!service || typeof service.openResource !== 'function') return false
3616
+ if (!service.__dshSoupFileBridge) {
3617
+ var origResource = service.openResource
3618
+ var origResourceIn = service.openResourceIn
3619
+ service.openResource = function (address, options) {
3620
+ if (fileAddressMatches(address, null)) { openFileAddressInSoup(address, null); return }
3621
+ return origResource.call(service, address, options)
3622
+ }
3623
+ if (typeof origResourceIn === 'function') {
3624
+ service.openResourceIn = function (sessionId, address, options) {
3625
+ if (fileAddressMatches(address, sessionId)) { openFileAddressInSoup(address, sessionId); return }
3626
+ return origResourceIn.call(service, sessionId, address, options)
3627
+ }
3628
+ }
3629
+ try { Object.defineProperty(service, '__dshSoupFileBridge', { value: true }) }
3630
+ catch (e) { service.__dshSoupFileBridge = true }
3631
+ }
3632
+ bridgePatched = true
3633
+ try { if (typeof window !== 'undefined') window.__DSH_SOUP_FILE_BRIDGE = true } catch (e) {}
3634
+ return true
3635
+ }
3636
+ patchSidebarRightBridge()
3637
+ var bridgeTries = 0
3638
+ var bridgeTimer = setInterval(function () {
3639
+ bridgeTries += 1
3640
+ if (patchSidebarRightBridge() || bridgeTries >= 10) clearInterval(bridgeTimer)
3641
+ }, 1000)
3642
+ disposers.push(function () { clearInterval(bridgeTimer) })
3095
3643
  // i18n:注册双语词典;模块级 T 供组件外回调(菜单/错误横幅)取当前语言文案。
3096
3644
  if (ctx.locale && typeof ctx.locale.register === 'function') {
3097
3645
  ctx.effect(function () { return ctx.locale.register(NS, DICT) }, 'dsh-soup: dictionaries')