@lyhue1991/dsh-soup 0.5.2 → 0.5.3

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);}' +
@@ -310,6 +365,22 @@ window.__ModuleLoader__.load({
310
365
  'files.imageTooLarge': '图片过大({size},上限 {limit})',
311
366
  'files.pdfTooLarge': 'PDF 过大({size},上限 {limit}),请用系统打开',
312
367
  'files.truncated': '文件超过 8 MB,仅预览前 8 MB;完整内容请用系统编辑器打开。',
368
+ 'files.edit': '编辑',
369
+ 'files.editTitle': '编辑当前文本文件',
370
+ 'files.preview': '预览',
371
+ 'files.previewTitle': '返回预览模式',
372
+ 'files.save': '保存',
373
+ 'files.saveTitle': '保存修改(⌘S / Ctrl+S)',
374
+ 'files.saving': '保存中…',
375
+ 'files.saved': '已保存',
376
+ 'files.cancelEdit': '放弃修改',
377
+ 'files.unsaved': '有未保存修改',
378
+ 'files.unsavedConfirm': '当前文件有未保存修改,确认放弃?',
379
+ 'files.conflictShort': '文件在外部被修改',
380
+ 'files.conflict': '其他程序修改了磁盘文件;当前草稿仍保留在编辑器中,下次自动保存会覆盖磁盘版本。',
381
+ 'files.overwriteConfirm': '文件已被其他程序修改。要用当前编辑内容覆盖磁盘上的最新版本吗?',
382
+ 'files.notEditable': '当前文件类型不支持编辑',
383
+ 'files.truncatedEdit': '文件超过 8 MB,已截断,不能编辑保存。',
313
384
  'files.jsonFail': 'JSON 解析失败,按纯文本显示',
314
385
  'files.nbFail': 'ipynb 解析失败,按纯文本显示',
315
386
  'files.nbEmpty': '(空笔记本)',
@@ -381,6 +452,22 @@ window.__ModuleLoader__.load({
381
452
  'files.imageTooLarge': 'Image too large ({size}, limit {limit})',
382
453
  'files.pdfTooLarge': 'PDF too large ({size}, limit {limit}) — open with the system viewer',
383
454
  'files.truncated': 'File exceeds 8 MB — showing the first 8 MB; open in a system editor for the full content.',
455
+ 'files.edit': 'Edit',
456
+ 'files.editTitle': 'Edit this text file',
457
+ 'files.preview': 'Preview',
458
+ 'files.previewTitle': 'Back to preview mode',
459
+ 'files.save': 'Save',
460
+ 'files.saveTitle': 'Save changes (⌘S / Ctrl+S)',
461
+ 'files.saving': 'Saving…',
462
+ 'files.saved': 'Saved',
463
+ 'files.cancelEdit': 'Discard changes',
464
+ 'files.unsaved': 'Unsaved changes',
465
+ 'files.unsavedConfirm': 'This file has unsaved changes. Discard them?',
466
+ 'files.conflictShort': 'Changed outside the editor',
467
+ 'files.conflict': 'Another program changed the file on disk. Your draft is preserved here, and the next auto-save will overwrite the disk version.',
468
+ 'files.overwriteConfirm': 'The file was changed externally. Overwrite the latest disk version with your current editor content?',
469
+ 'files.notEditable': 'Editing is not supported for this file type',
470
+ 'files.truncatedEdit': 'The file exceeds 8 MB and was truncated, so it cannot be safely edited.',
384
471
  'files.jsonFail': 'JSON parse failed — shown as plain text',
385
472
  'files.nbFail': 'ipynb parse failed — shown as plain text',
386
473
  'files.nbEmpty': '(empty notebook)',
@@ -417,6 +504,11 @@ window.__ModuleLoader__.load({
417
504
  // while a blank conversation is mounted. Keep late async responses from an
418
505
  // older caller from overwriting the current session's shared store.
419
506
  var sessionTrackGeneration = 0
507
+ var autoSaveTimers = new Map()
508
+ var autoSaveSavedTimers = new Map()
509
+ var manualSavePaths = new Set()
510
+ var AUTO_SAVE_DELAY_MS = 5000
511
+ var SAVED_INDICATOR_MS = 1500
420
512
 
421
513
  // 目录展开状态按 cwd 隔离。刷新/删除/改名/移动后先重拉根目录,
422
514
  // 再按这些路径递归重拉仍存在的已展开目录;切换项目时才清空。
@@ -620,7 +712,21 @@ window.__ModuleLoader__.load({
620
712
  if (!res || !res.ok) return
621
713
  entry.kind = res.kind
622
714
  entry.size = res.size || 0
623
- if (res.kind === 'text') { entry.content = res.content; entry.truncated = !!res.truncated }
715
+ if (res.kind === 'text') {
716
+ entry.truncated = !!res.truncated
717
+ if (!entry.dirty && !entry.editing) {
718
+ entry.content = res.content
719
+ entry.baseContent = res.content
720
+ entry.draft = res.content
721
+ entry.baseMtime = res.mtime
722
+ entry.baseSize = res.size
723
+ } else {
724
+ entry.conflict = true
725
+ entry.diskMtime = res.mtime
726
+ entry.diskSize = res.size
727
+ scheduleAutoSave(entry.path)
728
+ }
729
+ }
624
730
  else if (res.kind === 'image') { entry.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
625
731
  else if (res.kind === 'pdf') { entry.data = res.data }
626
732
  setFiles({ list: state.files.list.slice() })
@@ -672,7 +778,12 @@ window.__ModuleLoader__.load({
672
778
  }
673
779
  for (var f = 0; f < dirtyFiles.length; f++) {
674
780
  var entry = findFileEntry(dirtyFiles[f])
675
- if (entry && entry.loaded) await silentReloadEntry(entry)
781
+ if (!entry || !entry.loaded) continue
782
+ var currentSignature = mtimes[dirtyFiles[f]]
783
+ if (currentSignature
784
+ && currentSignature.m === entry.baseMtime
785
+ && currentSignature.s === entry.baseSize) continue
786
+ await silentReloadEntry(entry)
676
787
  }
677
788
  return true
678
789
  } finally {
@@ -1844,6 +1955,9 @@ window.__ModuleLoader__.load({
1844
1955
  loading: true, loaded: false, error: '',
1845
1956
  kind: null, content: '', dataUrl: '', data: '',
1846
1957
  truncated: false, size: 0,
1958
+ baseContent: '', baseMtime: undefined, baseSize: undefined,
1959
+ draft: '', dirty: false, editing: false, saving: false, justSaved: false,
1960
+ saveError: '', conflict: false,
1847
1961
  }
1848
1962
  // FIFO:最多同时 5 个预览,新开的挤掉最早打开的
1849
1963
  var list = state.files.list.slice()
@@ -1874,7 +1988,21 @@ window.__ModuleLoader__.load({
1874
1988
  cur.loaded = true
1875
1989
  cur.kind = res.kind
1876
1990
  cur.size = res.size || 0
1877
- if (res.kind === 'text') { cur.content = res.content; cur.truncated = !!res.truncated }
1991
+ if (res.kind === 'text') {
1992
+ cur.truncated = !!res.truncated
1993
+ if (!cur.dirty && !cur.editing) {
1994
+ cur.content = res.content
1995
+ cur.baseContent = res.content
1996
+ cur.draft = res.content
1997
+ cur.baseMtime = res.mtime
1998
+ cur.baseSize = res.size
1999
+ } else {
2000
+ cur.conflict = true
2001
+ cur.diskMtime = res.mtime
2002
+ cur.diskSize = res.size
2003
+ scheduleAutoSave(path)
2004
+ }
2005
+ }
1878
2006
  else if (res.kind === 'image') { cur.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
1879
2007
  else if (res.kind === 'pdf') { cur.data = res.data }
1880
2008
  }
@@ -1889,11 +2017,145 @@ window.__ModuleLoader__.load({
1889
2017
  await openFileInTab(path)
1890
2018
  }
1891
2019
 
2020
+ function isEditableTextFile(path) {
2021
+ var kind = previewKind(path)
2022
+ var ext = extOfName(path)
2023
+ return kind === 'markdown' || kind === 'html' || kind === 'json'
2024
+ || Object.prototype.hasOwnProperty.call(CODE_LANG_BY_EXT, ext)
2025
+ }
2026
+
2027
+ function startEditing(path) {
2028
+ var entry = findFileEntry(path)
2029
+ if (!entry || !entry.loaded || entry.kind !== 'text') return
2030
+ if (!isEditableTextFile(path)) {
2031
+ entry.saveError = T('files.notEditable')
2032
+ setFiles({ list: state.files.list.slice() })
2033
+ return
2034
+ }
2035
+ if (entry.truncated) {
2036
+ entry.saveError = T('files.truncatedEdit')
2037
+ setFiles({ list: state.files.list.slice() })
2038
+ return
2039
+ }
2040
+ if (!entry.editing) {
2041
+ entry.draft = entry.dirty ? entry.draft : entry.content
2042
+ entry.editing = true
2043
+ }
2044
+ entry.saveError = ''
2045
+ setFiles({ list: state.files.list.slice() })
2046
+ }
2047
+
2048
+ function scheduleAutoSave(path) {
2049
+ clearTimeout(autoSaveTimers.get(path))
2050
+ autoSaveTimers.set(path, setTimeout(function () {
2051
+ autoSaveTimers.delete(path)
2052
+ saveActiveFile(path)
2053
+ }, AUTO_SAVE_DELAY_MS))
2054
+ }
2055
+
2056
+ function showSavedIndicator(path) {
2057
+ clearTimeout(autoSaveSavedTimers.get(path))
2058
+ autoSaveSavedTimers.set(path, setTimeout(function () {
2059
+ autoSaveSavedTimers.delete(path)
2060
+ var entry = findFileEntry(path)
2061
+ if (!entry || entry.saving || entry.dirty) return
2062
+ entry.justSaved = false
2063
+ setFiles({ list: state.files.list.slice() })
2064
+ }, SAVED_INDICATOR_MS))
2065
+ }
2066
+
2067
+ function manuallySaveFile(path) {
2068
+ manualSavePaths.add(path)
2069
+ saveActiveFile(path)
2070
+ }
2071
+
2072
+ function stopEditing(path) {
2073
+ var entry = findFileEntry(path)
2074
+ if (!entry || !entry.editing) return
2075
+ clearTimeout(autoSaveTimers.get(path))
2076
+ autoSaveTimers.delete(path)
2077
+ clearTimeout(autoSaveSavedTimers.get(path))
2078
+ autoSaveSavedTimers.delete(path)
2079
+ if (entry.dirty) {
2080
+ if (entry.saving) entry.saveOnExit = true
2081
+ else saveActiveFile(path)
2082
+ }
2083
+ entry.editing = false
2084
+ setFiles({ list: state.files.list.slice() })
2085
+ }
2086
+
2087
+ function updateDraft(path, value) {
2088
+ var entry = findFileEntry(path)
2089
+ if (!entry || !entry.editing) return
2090
+ entry.draft = value
2091
+ entry.dirty = value !== entry.baseContent
2092
+ setFiles({ list: state.files.list.slice() })
2093
+ scheduleAutoSave(path)
2094
+ }
2095
+
2096
+ async function saveActiveFile(path) {
2097
+ var entry = findFileEntry(path)
2098
+ if (!entry || (!entry.editing && !entry.saveOnExit) || entry.saving || entry.truncated) return
2099
+ if (!entry.dirty) return
2100
+ entry.saving = true
2101
+ entry.saveError = ''
2102
+ setFiles({ list: state.files.list.slice() })
2103
+ if (entry.conflict) {
2104
+ entry.baseMtime = entry.diskMtime
2105
+ entry.baseSize = entry.diskSize
2106
+ entry.conflict = false
2107
+ }
2108
+ var savedDraft = entry.draft
2109
+ var wasManualSave = manualSavePaths.has(path)
2110
+ manualSavePaths.delete(path)
2111
+ var res = await rpc('write', {
2112
+ path: path,
2113
+ content: entry.draft,
2114
+ expectedMtime: entry.baseMtime,
2115
+ expectedSize: entry.baseSize,
2116
+ sessionId: activeSessionId,
2117
+ })
2118
+ var cur = findFileEntry(path)
2119
+ if (!cur) return
2120
+ cur.saving = false
2121
+ if (!res || !res.ok) {
2122
+ cur.saveError = (res && res.error) || T('files.saveTitle')
2123
+ if (res && res.conflict) {
2124
+ cur.conflict = true
2125
+ cur.diskMtime = res.mtime
2126
+ cur.diskSize = res.size
2127
+ }
2128
+ if (cur.editing || cur.saveOnExit) scheduleAutoSave(path)
2129
+ setFiles({ list: state.files.list.slice() })
2130
+ return
2131
+ }
2132
+ cur.content = savedDraft
2133
+ cur.baseContent = savedDraft
2134
+ cur.dirty = cur.draft !== savedDraft
2135
+ if (!cur.dirty) cur.saveOnExit = false
2136
+ else if (cur.editing || cur.saveOnExit) scheduleAutoSave(path)
2137
+ cur.conflict = false
2138
+ cur.saveError = ''
2139
+ cur.justSaved = wasManualSave
2140
+ cur.size = res.size
2141
+ cur.baseMtime = res.mtime
2142
+ cur.baseSize = res.size
2143
+ setFiles({ list: state.files.list.slice() })
2144
+ refresh()
2145
+ if (wasManualSave) showSavedIndicator(path)
2146
+ }
2147
+
1892
2148
  function closeFileTab(path) {
1893
2149
  var list = state.files.list
1894
2150
  var idx = -1
1895
2151
  for (var i = 0; i < list.length; i++) if (list[i].path === path) { idx = i; break }
1896
2152
  if (idx < 0) return
2153
+ clearTimeout(autoSaveTimers.get(path))
2154
+ autoSaveTimers.delete(path)
2155
+ clearTimeout(autoSaveSavedTimers.get(path))
2156
+ autoSaveSavedTimers.delete(path)
2157
+ var closing = list[idx]
2158
+ if (closing.dirty && typeof window !== 'undefined' && !window.confirm(T('files.unsavedConfirm'))) return
1897
2159
  var next = list.slice(0, idx).concat(list.slice(idx + 1))
1898
2160
  var active = state.files.active
1899
2161
  if (active === path) {
@@ -1936,6 +2198,126 @@ window.__ModuleLoader__.load({
1936
2198
  return 'text'
1937
2199
  }
1938
2200
 
2201
+ function codeMirrorLanguage(path) {
2202
+ var ext = extOfName(path)
2203
+ var factory = ext === 'py' ? CMLangs.python && CMLangs.python.python
2204
+ : ext === 'js' || ext === 'mjs' || ext === 'cjs' ? CMLangs.javascript && CMLangs.javascript.javascript
2205
+ : ext === 'jsx' ? CMLangs.javascript && CMLangs.javascript.javascript
2206
+ : ext === 'ts' || ext === 'mts' || ext === 'cts' ? CMLangs.javascript && CMLangs.javascript.javascript
2207
+ : ext === 'tsx' ? CMLangs.javascript && CMLangs.javascript.javascript
2208
+ : ext === 'json' || ext === 'jsonc' ? CMLangs.json && CMLangs.json.json
2209
+ : ext === 'md' || ext === 'markdown' || ext === 'mdx' ? CMLangs.markdown && CMLangs.markdown.markdown
2210
+ : ext === 'html' || ext === 'htm' ? CMLangs.html && CMLangs.html.html
2211
+ : ext === 'css' || ext === 'scss' || ext === 'less' ? CMLangs.css && CMLangs.css.css
2212
+ : ext === 'sql' ? CMLangs.sql && CMLangs.sql.sql
2213
+ : ext === 'xml' ? CMLangs.xml && CMLangs.xml.xml
2214
+ : null
2215
+ if (!factory) return null
2216
+ try {
2217
+ if (ext === 'js' || ext === 'mjs' || ext === 'cjs' || ext === 'jsx') return factory({ jsx: true })
2218
+ if (ext === 'ts' || ext === 'mts' || ext === 'cts' || ext === 'tsx') return factory({ typescript: true, jsx: ext === 'tsx' })
2219
+ return factory()
2220
+ } catch (err) { return null }
2221
+ }
2222
+
2223
+ var PREVIEW_HIGHLIGHT_STYLE = (CMLanguage && CMLanguage.HighlightStyle && CMHighlightTags)
2224
+ ? CMLanguage.HighlightStyle.define([
2225
+ { tag: CMHighlightTags.comment, color: 'var(--dsh-soup-code-comment)' },
2226
+ { tag: CMHighlightTags.keyword, color: 'var(--dsh-soup-code-keyword)' },
2227
+ { tag: CMHighlightTags.controlKeyword, color: 'var(--dsh-soup-code-keyword)' },
2228
+ { tag: CMHighlightTags.definitionKeyword, color: 'var(--dsh-soup-code-keyword)' },
2229
+ { tag: CMHighlightTags.moduleKeyword, color: 'var(--dsh-soup-code-keyword)' },
2230
+ { tag: CMHighlightTags.operator, color: 'var(--dsh-soup-code-keyword)' },
2231
+ { tag: CMHighlightTags.operatorKeyword, color: 'var(--dsh-soup-code-keyword)' },
2232
+ { tag: CMHighlightTags.string, color: 'var(--dsh-soup-code-string)' },
2233
+ { tag: CMHighlightTags.escape, color: 'var(--dsh-soup-code-string)' },
2234
+ { tag: CMHighlightTags.regexp, color: 'var(--dsh-soup-code-string)' },
2235
+ { tag: CMHighlightTags.number, color: 'var(--dsh-soup-code-number)' },
2236
+ { tag: CMHighlightTags.bool, color: 'var(--dsh-soup-code-keyword)' },
2237
+ { tag: CMHighlightTags.null, color: 'var(--dsh-soup-code-keyword)' },
2238
+ { tag: CMHighlightTags.typeName, color: 'var(--dsh-soup-code-type)' },
2239
+ { tag: CMHighlightTags.namespace, color: 'var(--dsh-soup-code-type)' },
2240
+ { tag: CMHighlightTags.variableName, color: 'var(--dsh-soup-code-variable)' },
2241
+ { tag: CMHighlightTags.function(CMHighlightTags.variableName), color: 'var(--dsh-soup-code-function)' },
2242
+ { tag: CMHighlightTags.className, color: 'var(--dsh-soup-code-function)' },
2243
+ { tag: CMHighlightTags.macroName, color: 'var(--dsh-soup-code-function)' },
2244
+ { tag: CMHighlightTags.propertyName, color: 'var(--dsh-soup-code-property)' },
2245
+ { tag: CMHighlightTags.punctuation, color: 'var(--dsh-soup-code-punctuation)' },
2246
+ { tag: CMHighlightTags.bracket, color: 'var(--dsh-soup-code-punctuation)' },
2247
+ { tag: CMHighlightTags.invalid, color: 'var(--dsh-soup-code-invalid)' },
2248
+ ])
2249
+ : null
2250
+
2251
+ var PREVIEW_JSON_HIGHLIGHT_STYLE = (CMLanguage && CMLanguage.HighlightStyle && CMHighlightTags)
2252
+ ? CMLanguage.HighlightStyle.define([
2253
+ { tag: CMHighlightTags.propertyName, color: 'var(--dsh-soup-json-property)' },
2254
+ { tag: CMHighlightTags.string, color: 'var(--dsh-soup-json-string)' },
2255
+ { tag: CMHighlightTags.number, color: 'var(--dsh-soup-json-number)' },
2256
+ { tag: CMHighlightTags.bool, color: 'var(--dsh-soup-json-literal)' },
2257
+ { tag: CMHighlightTags.null, color: 'var(--dsh-soup-json-literal)' },
2258
+ { tag: CMHighlightTags.punctuation, color: 'var(--dsh-soup-json-punctuation)' },
2259
+ { tag: CMHighlightTags.bracket, color: 'var(--dsh-soup-json-punctuation)' },
2260
+ { tag: CMHighlightTags.invalid, color: 'var(--dsh-soup-code-invalid)' },
2261
+ ])
2262
+ : null
2263
+
2264
+ /** CodeMirror 6 编辑器:文本、行号、语法、高亮、光标和滚动由同一 EditorView 管理。 */
2265
+ function TextEditor(props) {
2266
+ var entry = props.entry
2267
+ var hostRef = React.useRef(null)
2268
+ var viewRef = React.useRef(null)
2269
+ React.useEffect(function () {
2270
+ if (!hostRef.current || !CMEditorView || !CMEditorState) return
2271
+ var EditorView = CMEditorView.EditorView
2272
+ var EditorState = CMEditorState.EditorState
2273
+ var extensions = [
2274
+ EditorView.updateListener.of(function (update) {
2275
+ if (update.docChanged) updateDraft(entry.path, update.state.doc.toString())
2276
+ }),
2277
+ ]
2278
+ if (CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of([
2279
+ { key: 'Mod-s', run: function () { manuallySaveFile(entry.path); return true } },
2280
+ ]))
2281
+ if (CMCommands) {
2282
+ if (CMCommands.history) extensions.push(CMCommands.history())
2283
+ if (CMCommands.historyKeymap && CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of(CMCommands.historyKeymap))
2284
+ if (CMCommands.defaultKeymap && CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of(CMCommands.defaultKeymap))
2285
+ if (CMCommands.indentWithTab && CMKeymap && CMKeymap.keymap) extensions.push(CMKeymap.keymap.of([CMCommands.indentWithTab]))
2286
+ }
2287
+ if (CMLanguage) {
2288
+ if (CMLanguage.bracketMatching) extensions.push(CMLanguage.bracketMatching())
2289
+ if (CMLanguage.indentOnInput) extensions.push(CMLanguage.indentOnInput())
2290
+ }
2291
+ if (CMEditorView.lineNumbers) extensions.push(CMEditorView.lineNumbers())
2292
+ if (CMEditorView.drawSelection) extensions.push(CMEditorView.drawSelection())
2293
+ if (CMEditorView.highlightActiveLine) extensions.push(CMEditorView.highlightActiveLine())
2294
+ if (CMEditorView.highlightSpecialChars) extensions.push(CMEditorView.highlightSpecialChars())
2295
+ var language = codeMirrorLanguage(entry.path)
2296
+ if (language) extensions.push(language)
2297
+ var ext = extOfName(entry.path)
2298
+ var highlightStyle = (ext === 'json' || ext === 'jsonc')
2299
+ ? PREVIEW_JSON_HIGHLIGHT_STYLE
2300
+ : PREVIEW_HIGHLIGHT_STYLE
2301
+ if (highlightStyle) extensions.push(CMLanguage.syntaxHighlighting(highlightStyle))
2302
+ viewRef.current = new EditorView({
2303
+ state: EditorState.create({ doc: entry.draft, extensions: extensions }),
2304
+ parent: hostRef.current,
2305
+ })
2306
+ viewRef.current.focus()
2307
+ return function () {
2308
+ if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null }
2309
+ }
2310
+ }, [entry.path])
2311
+ if (!CMEditorView || !CMEditorState) {
2312
+ return create('textarea', {
2313
+ className: 'dfv-editor', value: entry.draft, spellCheck: false,
2314
+ 'aria-label': entry.name,
2315
+ onChange: function (e) { updateDraft(entry.path, e.target.value) },
2316
+ })
2317
+ }
2318
+ return create('div', { className: 'dfv-cm-editor', ref: hostRef, 'aria-label': entry.name })
2319
+ }
2320
+
1939
2321
  // 扩展名 → Shiki 语言提示。对齐 DSH 读卡片的 LANG_BY_EXTENSION
1940
2322
  // (packages/fs/tool-fs read-render.ts),json/md/markdown/html/htm 除外——
1941
2323
  // 它们走上方专属渲染器。
@@ -2490,24 +2872,26 @@ window.__ModuleLoader__.load({
2490
2872
  if (entry.kind === 'pdf') {
2491
2873
  return create(PdfPreview, { data: entry.data })
2492
2874
  }
2493
- // 文本类:按扩展名选预览渲染器(纯只读,无编辑)
2875
+ if (entry.editing) return create(TextEditor, { entry: entry })
2876
+ // 文本类:按扩展名选预览渲染器;编辑态返回预览时展示当前草稿
2877
+ var text = entry.dirty ? entry.draft : entry.content
2494
2878
  var pk = previewKind(entry.path)
2495
2879
  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' : ',' })
2880
+ if (pk === 'markdown') view = create(MarkdownPreview, { text: text, baseDir: parentOf(entry.path) })
2881
+ else if (pk === 'html') view = create(HtmlPreview, { text: text, title: entry.name })
2882
+ else if (pk === 'json') view = create(CodeWithLines, { text: text }, create(JsonPreview, { text: text }))
2883
+ else if (pk === 'notebook') view = create(NotebookPreview, { text: text })
2884
+ else if (pk === 'csv' || pk === 'tsv') view = create(DelimitedPreview, { text: text, delim: pk === 'tsv' ? '\t' : ',' })
2501
2885
  else {
2502
2886
  // 代码文件:DSH CodeBlock(Shiki 高亮,语法包懒加载);无高亮条件回退纯文本
2503
2887
  // 都包在 CodeWithLines 里加左侧行号
2504
2888
  var lang = Object.prototype.hasOwnProperty.call(CODE_LANG_BY_EXT, extOfName(entry.path))
2505
2889
  ? CODE_LANG_BY_EXT[extOfName(entry.path)]
2506
2890
  : 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)
2891
+ var codeEl = (CodeBlock && lang && text.length <= CODE_HIGHLIGHT_MAX_CHARS)
2892
+ ? create(CodeBlock, { code: text, lang: lang })
2893
+ : create('pre', { className: 'dfv-code' }, text)
2894
+ view = create(CodeWithLines, { text: text }, codeEl)
2511
2895
  }
2512
2896
  return create(React.Fragment, null,
2513
2897
  entry.truncated ? create('div', { className: 'dfv-cap' },
@@ -2516,6 +2900,53 @@ window.__ModuleLoader__.load({
2516
2900
  )
2517
2901
  }
2518
2902
 
2903
+ function FileToolbar(props) {
2904
+ var active = props.entry
2905
+ var buttons = []
2906
+ if (active.loaded && active.kind === 'text' && isEditableTextFile(active.path)) {
2907
+ if (!active.editing) {
2908
+ buttons.push(create('button', {
2909
+ type: 'button',
2910
+ className: 'dfv-btn',
2911
+ title: T('files.editTitle'),
2912
+ disabled: active.truncated,
2913
+ onClick: function () { startEditing(active.path) },
2914
+ },
2915
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: FILE_ICON_EDIT } }),
2916
+ T('files.edit'),
2917
+ ))
2918
+ } else {
2919
+ buttons.push(create('button', {
2920
+ type: 'button',
2921
+ className: 'dfv-btn',
2922
+ title: T('files.previewTitle'),
2923
+ onClick: function () { stopEditing(active.path) },
2924
+ },
2925
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: FILE_ICON_PREVIEW } }),
2926
+ T('files.preview'),
2927
+ ))
2928
+ }
2929
+ }
2930
+ buttons.push(active.loaded ? create('button', {
2931
+ type: 'button',
2932
+ className: 'dfv-btn',
2933
+ title: T('files.reloadTitle'),
2934
+ onClick: function () { reloadFile(active.path) },
2935
+ },
2936
+ create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }),
2937
+ T('files.reload'),
2938
+ ) : null)
2939
+ return create(React.Fragment, null,
2940
+ create('span', { className: 'dfv-toolbar-path', title: active.path }, active.path),
2941
+ typeof active.size === 'number' && active.size > 0 ? create('span', null, formatBytes(active.size)) : null,
2942
+ active.saving ? create('span', { className: 'dfv-status' }, T('files.saving')) : null,
2943
+ active.justSaved && !active.saving && !active.dirty ? create('span', { className: 'dfv-status dfv-saved' }, T('files.saved')) : null,
2944
+ active.conflict ? create('span', { className: 'dfv-status', title: T('files.conflict') }, T('files.conflictShort')) : null,
2945
+ active.saveError ? create('span', { className: 'dfv-save-error', title: active.saveError }, active.saveError) : null,
2946
+ buttons,
2947
+ )
2948
+ }
2949
+
2519
2950
  /**
2520
2951
  * 纯阅读模式标记:在会话根(含 composerSeat 的最近祖先,即 DSH
2521
2952
  * ConversationRoot 的 root 节点)上打 data-dsh-soup-preview,由上面
@@ -2565,6 +2996,7 @@ window.__ModuleLoader__.load({
2565
2996
  title: entry.path,
2566
2997
  onClick: function () { setActiveFile(entry.path) },
2567
2998
  },
2999
+ entry.dirty ? create('span', { className: 'dfv-dirty', title: T('files.unsaved') }) : null,
2568
3000
  create('span', { className: 'dfv-tab-name' }, entry.name),
2569
3001
  create('span', {
2570
3002
  className: 'dfv-close',
@@ -2584,17 +3016,7 @@ window.__ModuleLoader__.load({
2584
3016
  )
2585
3017
  } else {
2586
3018
  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,
3019
+ create(FileToolbar, { entry: active }),
2598
3020
  create('button', {
2599
3021
  type: 'button',
2600
3022
  className: 'dfv-btn',
@@ -2626,6 +3048,7 @@ window.__ModuleLoader__.load({
2626
3048
  if (!active) return null
2627
3049
  var close = function () { setFiles({ overlay: false, overlayMax: false, overlayReturn: null }) }
2628
3050
  var closeAndCloseTab = function () {
3051
+ if (active.dirty && typeof window !== 'undefined' && !window.confirm(T('files.unsavedConfirm'))) return
2629
3052
  close()
2630
3053
  closeFileTab(active.path)
2631
3054
  }
@@ -2643,17 +3066,7 @@ window.__ModuleLoader__.load({
2643
3066
  },
2644
3067
  create('div', { className: 'dfv-overlay-panel' + (files.overlayMax ? ' dfv-overlay-max' : '') },
2645
3068
  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,
3069
+ create(FileToolbar, { entry: active }),
2657
3070
  create('button', {
2658
3071
  type: 'button', className: 'dfv-overlay-close',
2659
3072
  title: files.overlayMax ? '还原' : '最大化', 'aria-label': files.overlayMax ? '还原' : '最大化',