dsh-vscode-mode 0.6.0 → 0.8.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.
Files changed (57) hide show
  1. package/README.md +40 -6
  2. package/assets/icon.svg +7 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +9317 -7494
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1295 -173
  7. package/lib/index.js.map +1 -1
  8. package/locale/en.json +4 -0
  9. package/locale/zh.json +4 -0
  10. package/package.json +3 -1
  11. package/src/client/compat.ts +134 -4
  12. package/src/client/copyText.ts +28 -0
  13. package/src/client/fileClipboard.ts +46 -0
  14. package/src/client/imagePreview.ts +76 -2
  15. package/src/client/index.ts +68 -39
  16. package/src/client/monaco/lsp/lspClient.ts +46 -0
  17. package/src/client/monaco/lsp/providers.ts +245 -1
  18. package/src/client/nativeOpenStore.ts +91 -0
  19. package/src/client/officialSidebar.ts +11 -45
  20. package/src/client/openWithDialog.ts +66 -0
  21. package/src/client/outline/index.ts +1 -1
  22. package/src/client/pdf/pdfPanel.ts +18 -0
  23. package/src/client/searchSeed.ts +36 -0
  24. package/src/client/settingsContext.ts +6 -4
  25. package/src/client/sidebar/contextMenu.ts +51 -10
  26. package/src/client/sidebar/menuItems.ts +473 -50
  27. package/src/client/sidebar/panels/FileExplorer.ts +1 -1
  28. package/src/client/sidebar/panels/SearchPanel.ts +24 -3
  29. package/src/client/sidebar/panels/SvnPanel.ts +1 -1
  30. package/src/client/sidebar/panels/index.ts +1 -1
  31. package/src/client/sidebar/types.ts +6 -0
  32. package/src/client/styles/editor.css +5 -3
  33. package/src/client/svnStore.ts +6 -1
  34. package/src/client/tabActions.ts +22 -10
  35. package/src/client/ui/EditorView.ts +198 -25
  36. package/src/client/ui/McpSettings.ts +30 -0
  37. package/src/client/ui/OfficialSideTab.ts +1 -0
  38. package/src/client/ui/PerfSettings.ts +4 -1
  39. package/src/client/ui/PromptDialog.ts +63 -0
  40. package/src/client/ui/SideEditorTab.ts +1 -0
  41. package/src/client/ui/commandCatalog.ts +14 -0
  42. package/src/client/ui/icons.ts +72 -0
  43. package/src/compat.ts +2 -2
  44. package/src/dshVersion.ts +2 -1
  45. package/src/fileOpenSettings.ts +163 -40
  46. package/src/index.ts +15 -1
  47. package/src/lsp/client.ts +24 -0
  48. package/src/lsp/rpc.ts +57 -0
  49. package/src/lsp/server.ts +272 -2
  50. package/src/perf.ts +90 -4
  51. package/src/routes.ts +13 -1
  52. package/src/rpc.ts +294 -36
  53. package/src/shared/fsNames.ts +115 -0
  54. package/src/shared/keybindings.ts +2 -0
  55. package/src/shared/lsp.ts +79 -0
  56. package/src/shared/nativeOpen.ts +91 -0
  57. package/src/shared/rpc.ts +158 -1
@@ -12,7 +12,7 @@ import { rpc } from '../../rpc.js'
12
12
  import type { SidebarCtx } from '../types.js'
13
13
  import { CACHE_KEY } from '../../paths.js'
14
14
  import { workspaceScopeOf } from '../../state/scopeStore.js'
15
- import { takeSearchSeed } from '../../searchSeed.js'
15
+ import { takeSearchSeed, takeSearchScope } from '../../searchSeed.js'
16
16
 
17
17
  const DEBOUNCE_MS = 250
18
18
  const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
@@ -237,15 +237,36 @@ export function SearchPanel(props) {
237
237
  return true
238
238
  }
239
239
 
240
+ /**
241
+ * 取用一次性目录过滤种子(资源管理器右键「在文件夹中查找…」)。
242
+ * 有种子即覆盖「包含」过滤为该目录、关闭「仅当前文件」并展开过滤区(让用户看到限定范围);
243
+ * 已有可用搜索词时按新范围立即重搜。无种子为 no-op。
244
+ * @author ddj 2026年09月22号
245
+ * @returns 是否消费到种子
246
+ */
247
+ const applyScopeSeed = () => {
248
+ const dir = takeSearchScope()
249
+ if (!dir) return false
250
+ const glob = dir + '/**'
251
+ if (timerRef.current) clearTimeout(timerRef.current)
252
+ setIncludeText(glob)
253
+ setOnlyActive(false)
254
+ setSectionOpen(true)
255
+ requestRef.current = Object.assign({}, requestRef.current, { include: splitGlobs(glob) })
256
+ if (String(queryRef.current).trim().length >= 2) runSearch(queryRef.current)
257
+ return true
258
+ }
259
+
240
260
  // 挂载时消费种子:侧栏原本收起 → 派发 edrv:search-focus 时本面板尚未挂载,
241
261
  // 事件无人接收,故必须由挂载路径兜底。
242
262
  // ⚠️ 声明在「按作用域恢复」effect 之后:React 按声明序执行 effect,恢复值先落地,
243
263
  // 种子再覆盖,避免被记忆的旧查询词盖掉用户刚选中的内容。
244
- React.useEffect(() => { applySeed() }, [])
264
+ React.useEffect(() => { applyScopeSeed(); applySeed() }, [])
245
265
 
246
- // Ctrl+Shift+F 重复触发:消费种子(有则填入)并聚焦输入框(EditorView 派发 edrv:search-focus)
266
+ // Ctrl+Shift+F / 「在文件夹中查找…」重复触发:消费种子(有则填入)并聚焦输入框(EditorView 派发 edrv:search-focus)
247
267
  React.useEffect(() => {
248
268
  const onFocus = () => {
269
+ applyScopeSeed()
249
270
  applySeed()
250
271
  inputRef.current?.focus?.()
251
272
  }
@@ -13,7 +13,7 @@
13
13
  * 作者 ddj 2026年09月16号
14
14
  */
15
15
  import React from 'react'
16
- import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
16
+ import { IconRefreshOutline16 } from '../../ui/icons.js'
17
17
  import {
18
18
  SVN_ADD_LABEL,
19
19
  SVN_DIFF_BASE_LABEL,
@@ -6,7 +6,7 @@
6
6
  * 作者 ddj 2026-08-26 / 2026-09-03 / 2026-09-10 / 2026-09-16
7
7
  */
8
8
  import React from 'react'
9
- import { IconBranchOutline16, IconFolderOpenOutline16, IconListPenOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
9
+ import { IconBranchOutline16, IconFolderOpenOutline16, IconListPenOutline16, IconSearchOutline16 } from '../../ui/icons.js'
10
10
  import { dapStore } from '../../dap/store.js'
11
11
  import { FileExplorer } from './FileExplorer.js'
12
12
  import { SearchPanel } from './SearchPanel.js'
@@ -51,6 +51,12 @@ export interface SidebarCtx {
51
51
  confirm?: (message: string) => boolean
52
52
  /** 面板动作反馈(如右键菜单操作结果 → 编辑区路径栏状态)。 */
53
53
  notify?: (message: string) => void
54
+ /** 名称输入弹窗(资源管理器右键的新建/重命名;取消/空输入返回 null,缺省时相关项降级提示)。 */
55
+ prompt?: (title: string, initial: string) => Promise<string | null>
56
+ /** 「打开方式…」:在已注册打开器间选择并打开(缺省时该项隐藏)。 */
57
+ openWith?: (path: string) => void
58
+ /** 「在文件夹中查找…」:限定目录并跳到搜索面板(缺省时该项隐藏)。 */
59
+ searchInFolder?: (dir: string) => void
54
60
  }
55
61
 
56
62
  /** 单个侧边栏面板定义。 */
@@ -71,9 +71,11 @@
71
71
  [data-edrv-view] .edrv-imgview { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
72
72
  [data-edrv-view] .edrv-imgview-bar { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; flex-shrink: 0; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); background: var(--dsw-alias-bg-layer-1, transparent); }
73
73
  [data-edrv-view] .edrv-imgview-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); white-space: nowrap; }
74
- [data-edrv-view] .edrv-imgview-stage { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 14px; background-color: var(--dsw-alias-bg-layer-1, #f0f4f4); background-image: conic-gradient(rgba(128,128,128,.14) 25%, transparent 0 50%, rgba(128,128,128,.14) 0 75%, transparent 0); background-size: 16px 16px; }
75
- [data-edrv-view] .edrv-imgview-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 2px 12px rgba(31,41,51,.18); }
76
- [data-edrv-view] .edrv-imgview-broken { display: flex; flex-direction: column; align-items: center; gap: 10px; font-size: 13px; color: var(--dsw-alias-label-tertiary, #777); }
74
+ /* 图片预览:居中交给子项 margin:auto(auto 外边距优先吃正向空闲空间;溢出时空闲为负 → 外边距归 0、
75
+ flex-start 起点对齐 → 放大后四边均可滚动到达,规避 justify-content:center 双侧溢出不可达问题) */
76
+ [data-edrv-view] .edrv-imgview-stage { flex: 1 1 auto; min-height: 0; display: flex; overflow: auto; padding: 14px; background-color: var(--dsw-alias-bg-layer-1, #f0f4f4); background-image: conic-gradient(rgba(128,128,128,.14) 25%, transparent 0 50%, rgba(128,128,128,.14) 0 75%, transparent 0); background-size: 16px 16px; }
77
+ [data-edrv-view] .edrv-imgview-img { max-width: 100%; max-height: 100%; flex-shrink: 0; margin: auto; align-self: flex-start; object-fit: contain; border-radius: 4px; box-shadow: 0 2px 12px rgba(31,41,51,.18); }
78
+ [data-edrv-view] .edrv-imgview-broken { display: flex; flex-direction: column; align-items: center; gap: 10px; font-size: 13px; color: var(--dsw-alias-label-tertiary, #777); margin: auto; }
77
79
  [data-edrv-view] .edrv-pdf-host { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
78
80
  [data-edrv-view] .edrv-pdf-host > .edrv-pdf { position: absolute; inset: 0; }
79
81
  [data-edrv-view] .edrv-pdf { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
@@ -90,7 +90,12 @@ export function createSvnStore<M extends RpcMethod, T>(config: SvnStoreConfig<M,
90
90
 
91
91
  const ensure = (sessionId: string | undefined, key: string | null | undefined, force = false, extra?: Record<string, unknown>): void => {
92
92
  if (!key || inFlight.has(key)) return
93
- if (!force && cache.has(key)) return
93
+ if (!force && cache.has(key)) {
94
+ // 缓存命中也要把跨 scope 镜像切回该 key:否则从非 SVN 工作区切回后,
95
+ // latest()(命令栏 svnCurrentStatus 等只读判定的数据源)仍残留上一个工作区的载荷
96
+ latestValue = cache.get(key) as T
97
+ return
98
+ }
94
99
  const fixed = config.args ? config.args(key) : {}
95
100
  const task = rpc(config.method, { ...fixed, ...extra, sessionId, force: force || undefined } as RpcRequestMap[M])
96
101
  .then((res) => {
@@ -374,6 +374,25 @@ function normalizeSlashes(path: string | null | undefined): string {
374
374
  return String(path ?? '').replace(/\\/g, '/')
375
375
  }
376
376
 
377
+ /**
378
+ * 路径改写(资源管理器右键重命名后页签/脏标同步用):
379
+ * 目标本身替换为新路径,其子树按前缀跟随改写,无关路径原样返回。
380
+ * @author ddj 2026年09月22号
381
+ * @param path 待改写路径
382
+ * @param from 原路径
383
+ * @param to 新路径
384
+ * @returns 改写后的路径
385
+ */
386
+ export function remapPathOf(path: string, from: string, to: string): string {
387
+ const text = normalizeSlashes(path)
388
+ const src = normalizeSlashes(from).replace(/\/+$/, '')
389
+ const dst = normalizeSlashes(to).replace(/\/+$/, '')
390
+ if (!src || !dst || src === dst) return text
391
+ if (text === src) return dst
392
+ if (text.startsWith(src + '/')) return dst + text.slice(src.length)
393
+ return text
394
+ }
395
+
377
396
  /**
378
397
  * 文件路径的祖先目录(由浅到深):`a/b/c.ts` → `['a', 'a/b']`。
379
398
  * 根级文件、绝对路径与含 `..` 的路径返回空数组。
@@ -392,14 +411,7 @@ export function ancestorDirsOf(path: string): string[] {
392
411
  return out
393
412
  }
394
413
 
395
- /**
396
- * 文件名(页签/状态栏展示与 tooltip 用)。
397
- * @author ddj 2026年09月11号
398
- * @param path 路径
399
- * @returns 末段文件名;空路径返回空串
400
- */
401
- export function baseNameOf(path: string): string {
402
- const text = normalizeSlashes(path)
403
- return text.split('/').filter(Boolean).pop() ?? ''
404
- }
414
+ // baseNameOf(取末段文件名)已收敛到 shared/fsNames.ts(host 与 client 共用同一份语义),
415
+ // 此处转出口保持既有引用不动。
416
+ export { baseNameOf } from '../shared/fsNames.js'
405
417
  // --endregion
@@ -11,10 +11,12 @@ import React from 'react'
11
11
  import { dbg, rpc } from '../rpc.js'
12
12
  import { emitFileChanged, emitRefresh } from '../events.js'
13
13
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
14
- import { dataUrlOf, isImagePath, isSvgPath } from '../imagePreview.js'
14
+ import { clampZoom, clearZoomMem, dataUrlOf, isImagePath, isSvgPath, recallZoom, rememberZoom, zoomKeyOf, zoomStepOf } from '../imagePreview.js'
15
15
  import { isMarkdownPath } from '../markdownPreview.js'
16
16
  import { MarkdownPanel } from '../md/mdPanel.js'
17
- import { base64ToBytes, isPdfPath } from '../pdfPreview.js'
17
+ import { base64ToBytes, bytesToBase64, isPdfPath } from '../pdfPreview.js'
18
+ import { inNativePath, tryNativeOpen } from '../nativeOpenStore.js'
19
+ import { readBinaryPreview } from '../../shared/rpc.js'
18
20
  import {
19
21
  clearBaseline,
20
22
  clearReadVersion,
@@ -50,7 +52,7 @@ import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybi
50
52
  import { getSidebarMinWidth } from '../sidebarMin.js'
51
53
  import { navHistoryFor } from '../navHistory.js'
52
54
  import { statusOfAdd } from '../addToConversation.js'
53
- import { setSearchSeed } from '../searchSeed.js'
55
+ import { setSearchScope, setSearchSeed } from '../searchSeed.js'
54
56
  import { CACHE_KEY } from '../paths.js'
55
57
  import { runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
56
58
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
@@ -62,7 +64,7 @@ import { setupLaunchJson, prefetchSnippets } from '../dap/launchSnippetProvider.
62
64
  import { findArrayPos, LAUNCH_JSON_RE } from '../dap/launchInsert.js'
63
65
  import {
64
66
  absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
65
- evictPlan, insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
67
+ evictPlan, insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, remapPathOf, tabPathOf, togglePin,
66
68
  } from '../tabActions.js'
67
69
  import { getMaxOpenEditors } from '../editorLimit.js'
68
70
  import { buildTabMenu } from '../tabMenu.js'
@@ -75,6 +77,9 @@ import { isSvnDiffable } from '../../shared/svn.js'
75
77
  import type { SvnAction } from '../../shared/svn.js'
76
78
  import { createSaveTimer } from '../saveDebounce.js'
77
79
  import { ContextMenu } from './ContextMenu.js'
80
+ import { promptName } from './PromptDialog.js'
81
+ import { openWithDialog } from '../openWithDialog.js'
82
+ import { copyText as copyClipText } from '../copyText.js'
78
83
  import { SvnLogDialog } from './SvnLogDialog.js'
79
84
  import { SvnPatchDialog } from './SvnPatchDialog.js'
80
85
  import { SvnSumDialog } from './SvnSumDialog.js'
@@ -118,6 +123,22 @@ function dirOfRel(relPath) {
118
123
  /** 跳转目标高亮的保留时长(LSP/搜索跳转落地后给用户的位置提示,到期自动清除)。 */
119
124
  const NAV_FLASH_MS = 1200
120
125
 
126
+ /** 二进制通道回退日志的一次性闸(整页仅报一次,避免每次读图/读 PDF 刷屏)。 */
127
+ let binFallbackLogged = false
128
+
129
+ /**
130
+ * 二进制通道回退的一次性诊断日志:readBinaryPreview 本次尝试过新通道并失败时上报。
131
+ * 有效期:随页面生命周期仅一条;dbg 本身受诊断开关控制(关=零输出零落盘)。
132
+ * @author ddj 2026年09月22号
133
+ * @param sid 会话 id
134
+ * @param out readBinaryPreview 统一结果(带 probed 才说明本次尝试过新通道)
135
+ */
136
+ const noteBinFallback = (sid, out) => {
137
+ if (binFallbackLogged || !out?.probed) return
138
+ binFallbackLogged = true
139
+ dbg(sid, 'edrv.readBinary 不可用(host 未接线/旧版或网络失败),已回退 base64', 'debug')
140
+ }
141
+
121
142
  /**
122
143
  * 从 Monaco 语言目录取可选语言 id 列表(代码片段新建时的语言下拉来源)。
123
144
  * 旧版 Monaco / 目录不可读时返回 undefined,由调用方回落 shared 的内置常量。
@@ -194,6 +215,8 @@ export function EditorView(props) {
194
215
  const [imageSrc, setImageSrc] = React.useState(null) // 图片预览 data URL(图片 tab 专用,文本 tab 恒为 null)
195
216
  const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
196
217
  const [imgBroken, setImgBroken] = React.useState(false) // 图片解码失败(onError),显示占位与重试
218
+ const [imgZoom, setImgZoom] = React.useState(null) // 图片缩放比例(null=适应宽度;会话+路径级记忆,见 imagePreview.zoomKeyOf)
219
+ const imgElRef = React.useRef(null) // 预览 <img> 元素(适应宽度态下步进取实测缩放基准)
197
220
  const svgTextRef = React.useRef(new Set()) // 强制以文本打开的 SVG 路径集合(toggleSvgText 维护)
198
221
  const mdPreviewRef = React.useRef(new Set()) // 处于预览态的 Markdown 路径集合(toggleMdPreview 维护;不持久化)
199
222
  const [pdfBytes, setPdfBytes] = React.useState(null) // 当前 PDF tab 的原始字节(null=加载中/非 PDF)
@@ -630,6 +653,59 @@ export function EditorView(props) {
630
653
  setStatus(label + '(' + closing.length + ' 个)')
631
654
  }
632
655
 
656
+ /**
657
+ * 删除落盘后的页签收尾:关闭目标及其子树页签。
658
+ * 不走 closeTab 的「先落盘」:文件已删除,落盘会把删除的文件写回磁盘。
659
+ * @author ddj 2026年09月22号
660
+ * @param path 已删除的工作区相对路径
661
+ */
662
+ const dropDeletedTabs = (path) => {
663
+ const doomed = tabsRef.current.filter((t) => t.path === path || t.path.startsWith(path + '/'))
664
+ if (!doomed.length) return
665
+ for (const tab of doomed) releaseTab(tab.path)
666
+ commitClose(applyClose(tabsRef.current, new Set(doomed.map((t) => t.path)), activeRef.current))
667
+ }
668
+
669
+ /**
670
+ * 重命名落盘后的页签改写:目标及其子树页签路径整体改写(含脏标与活动页签)。
671
+ * @author ddj 2026年09月22号
672
+ * @param from 原路径
673
+ * @param to 新路径
674
+ */
675
+ const remapRenamedTabs = (from, to) => {
676
+ setTabs((prev) => prev.map((tab) => ({ ...tab, path: remapPathOf(tab.path, from, to) })))
677
+ setDirtyMap((prev) => {
678
+ const next = {}
679
+ for (const key of Object.keys(prev)) next[remapPathOf(key, from, to)] = prev[key]
680
+ return next
681
+ })
682
+ setActive((prev) => (prev ? remapPathOf(prev, from, to) : prev))
683
+ }
684
+
685
+ // 文件树右键的重命名/删除 → 页签同步(动作在 sidebar/menuItems.ts,经窗口事件桥接;
686
+ // 经 ref 读最新闭包,避免空依赖 effect 捕获陈旧状态)
687
+ const tabFsSyncRef = React.useRef({ rename: () => {}, drop: () => {} })
688
+ tabFsSyncRef.current = { rename: remapRenamedTabs, drop: dropDeletedTabs }
689
+ React.useEffect(() => {
690
+ const onRenamed = (event) => {
691
+ const from = event?.detail?.from
692
+ const to = event?.detail?.to
693
+ if (typeof from !== 'string' || !from || typeof to !== 'string' || !to || from === to) return
694
+ tabFsSyncRef.current.rename(from, to)
695
+ }
696
+ const onDeleted = (event) => {
697
+ const path = event?.detail?.path
698
+ if (typeof path !== 'string' || !path) return
699
+ tabFsSyncRef.current.drop(path)
700
+ }
701
+ window.addEventListener('edrv:path-renamed', onRenamed)
702
+ window.addEventListener('edrv:path-deleted', onDeleted)
703
+ return () => {
704
+ window.removeEventListener('edrv:path-renamed', onRenamed)
705
+ window.removeEventListener('edrv:path-deleted', onDeleted)
706
+ }
707
+ }, [])
708
+
633
709
  /**
634
710
  * 在已打开页签间循环切换(文件分页归编辑器自带页签栏)。
635
711
  * @author ddj 2026年09月10号
@@ -772,26 +848,36 @@ export function EditorView(props) {
772
848
  setImgSize(null)
773
849
  setImgBroken(false)
774
850
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取图片…' })
775
- rpc('edrv.read', { sessionId: sid, path, encoding: 'base64' }).then((res) => {
851
+ readBinaryPreview(rpc, { sessionId: sid, path }).then((out) => {
776
852
  if (seq !== loadSeqRef.current || path !== active) return
777
- if (res && res.ok && res.encoding === 'base64') {
778
- if (!res.mime) {
853
+ if (out.ok && out.via === 'binary') {
854
+ // 新通道:原始字节本地组 data URL(线上省 base64,本地编码为 img src 所需不可省)
855
+ recordBaseline(scope, path, out.version || version)
856
+ clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
857
+ setImageSrc(dataUrlOf(bytesToBase64(out.bytes), out.mime))
858
+ setLoadStage({ progress: 100, message: '图片已就绪' })
859
+ setStatus('已加载')
860
+ return
861
+ }
862
+ noteBinFallback(sid, out)
863
+ if (out.ok && out.via === 'base64') {
864
+ if (!out.mime) {
779
865
  setLoadError('host 版本过旧:图片响应缺少 mime')
780
866
  setError('host 版本过旧:图片响应缺少 mime')
781
867
  setStatus('读取失败')
782
868
  return
783
869
  }
784
- const imgVersion = res.version ?? version
870
+ const imgVersion = out.version ?? version
785
871
  recordBaseline(scope, path, imgVersion)
786
872
  clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
787
- setImageSrc(dataUrlOf(res.content, res.mime))
873
+ setImageSrc(dataUrlOf(out.content, out.mime))
788
874
  setLoadStage({ progress: 100, message: '图片已就绪' })
789
875
  setStatus('已加载')
790
876
  return
791
877
  }
792
- const base = res?.error ? String(res.error) : '读取失败'
878
+ const base = out.error ? String(out.error) : '读取失败'
793
879
  // 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
794
- const message = res?.resolvedPath ? base + ':' + String(res.resolvedPath) : base
880
+ const message = out.resolvedPath ? base + ':' + String(out.resolvedPath) : base
795
881
  setLoadError(message)
796
882
  setError(message)
797
883
  setStatus('读取失败')
@@ -828,21 +914,34 @@ export function EditorView(props) {
828
914
  setStatus('已加载')
829
915
  return
830
916
  }
831
- rpc('edrv.read', { sessionId: sid, path, encoding: 'base64' }).then((res) => {
917
+ readBinaryPreview(rpc, { sessionId: sid, path }).then((out) => {
832
918
  if (seq !== loadSeqRef.current || path !== active) return
833
- if (res && res.ok && res.encoding === 'base64') {
834
- const pdfVersion = res.version ?? version
919
+ if (out.ok && out.via === 'binary') {
920
+ const pdfVersion = out.version || version
835
921
  recordBaseline(scope, path, pdfVersion)
836
922
  clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
837
- cache.set(path, res.content)
923
+ // 缓存仍存 base64(pdfB64CacheRef 形状不变,命中路径零改动);字节直通面板省一次解码
924
+ cache.set(path, bytesToBase64(out.bytes))
838
925
  while (cache.size > 6) cache.delete(cache.keys().next().value)
839
- setPdfBytes(base64ToBytes(res.content))
926
+ setPdfBytes(out.bytes)
840
927
  setLoadStage({ progress: 100, message: 'PDF 已就绪' })
841
928
  setStatus('已加载')
842
929
  return
843
930
  }
844
- const base = res?.error ? String(res.error) : '读取失败'
845
- const message = res?.resolvedPath ? base + ':' + String(res.resolvedPath) : base
931
+ noteBinFallback(sid, out)
932
+ if (out.ok && out.via === 'base64') {
933
+ const pdfVersion = out.version ?? version
934
+ recordBaseline(scope, path, pdfVersion)
935
+ clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
936
+ cache.set(path, out.content)
937
+ while (cache.size > 6) cache.delete(cache.keys().next().value)
938
+ setPdfBytes(base64ToBytes(out.content))
939
+ setLoadStage({ progress: 100, message: 'PDF 已就绪' })
940
+ setStatus('已加载')
941
+ return
942
+ }
943
+ const base = out.error ? String(out.error) : '读取失败'
944
+ const message = out.resolvedPath ? base + ':' + String(out.resolvedPath) : base
846
945
  setLoadError(message)
847
946
  setError(message)
848
947
  setStatus('读取失败')
@@ -1313,6 +1412,10 @@ export function EditorView(props) {
1313
1412
  if (!event?.detail?.scope || event.detail.scope === scope) setSvnStatus(getSvnStatus(scope))
1314
1413
  }
1315
1414
  window.addEventListener('edrv:svn-status', onSvnStatus)
1415
+ // 缓存命中时 ensure 直接 return、不广播事件,须同步读回当前 scope 的载荷:
1416
+ // 否则从非 SVN 工作区切回 SVN 工作区后,状态残留上一个工作区的 managed:false,
1417
+ // 右键菜单/页签菜单的 SVN 组会整组消失(与下方变更清单 effect 的同步语句同理)。
1418
+ setSvnStatus(getSvnStatus(scope))
1316
1419
  return () => window.removeEventListener('edrv:svn-status', onSvnStatus)
1317
1420
  }, [sessionId, scope])
1318
1421
 
@@ -1453,6 +1556,11 @@ export function EditorView(props) {
1453
1556
  ['edrv.command.svnTortoiseDiff', () => runSvnTortoise('diff', activeRef.current)],
1454
1557
  ['edrv.command.svnTortoiseBlame', () => runSvnTortoise('blame', activeRef.current)],
1455
1558
  ['edrv.command.svnTortoiseRevert', () => runSvnTortoise('revert', activeRef.current)],
1559
+ // 转到行:转发 Monaco 原生 action(widget 本体/占位/跳转全原生,插件只补键位与命令栏入口)
1560
+ ['edrv.command.goToLine', () => {
1561
+ const ed = editorRef.current
1562
+ if (ed?.getModel?.()) ed.trigger('edrv-goto', 'editor.action.gotoLine', null)
1563
+ }],
1456
1564
  // 诊断日志弹窗(同片段选择器:执行一条命令即关命令栏,避免残浮层遮挡)
1457
1565
  ['edrv.command.showLogs', () => { closeCommandPalette(); setDlogOpen(true) }],
1458
1566
  ]
@@ -1847,6 +1955,7 @@ export function EditorView(props) {
1847
1955
  // model 不在此销毁:跨挂载缓存按作用域存活(modelCache 切作用域时统一释放)
1848
1956
  for (const ctl of pdfCtlRef.current.values()) ctl.destroy()
1849
1957
  pdfCtlRef.current.clear()
1958
+ clearZoomMem() // 图片缩放记忆随 EditorView 卸载清空(会话级生命周期,不持久化)
1850
1959
  const root = editorRef.current && editorRef.current.getDomNode ? editorRef.current.getDomNode() : null
1851
1960
  if (root) {
1852
1961
  const ov = root.querySelector('.edrv-minus-overlay')
@@ -2340,15 +2449,58 @@ export function EditorView(props) {
2340
2449
  }
2341
2450
  toggleMdPreviewRef.current = toggleMdPreview
2342
2451
 
2452
+ /** 当前图片的会话+路径级缩放记忆键(页签切换回来保留同一缩放)。 */
2453
+ const imgZoomKey = zoomKeyOf(sessionId, active)
2454
+
2455
+ // 页签/会话切换时恢复该图片的记忆缩放(无记忆 = 初始适应宽度,等价 PDF page-width)
2456
+ React.useEffect(() => { setImgZoom(recallZoom(imgZoomKey)) }, [imgZoomKey])
2457
+
2343
2458
  /**
2344
- * 图片预览面板:工具条(尺寸 meta / SVG 文本切换 / 刷新)+ 棋盘底自适应图片。
2345
- * @author ddj 2026年09月08号
2459
+ * 应用图片缩放并写入页签记忆(null = 适应宽度;数值经 clampZoom 收敛 25%–400%)。
2460
+ * @author ddj 2026年09月22号
2461
+ * @param next 目标缩放(null = 适应宽度)
2462
+ */
2463
+ const applyImgZoom = (next) => {
2464
+ const value = next === null ? null : clampZoom(next)
2465
+ setImgZoom(value)
2466
+ rememberZoom(imgZoomKey, value)
2467
+ }
2468
+
2469
+ /**
2470
+ * 图片缩放步进(10%/档,clamp 25%–400%);适应宽度态先按渲染尺寸实测当前缩放作基准。
2471
+ * @author ddj 2026年09月22号
2472
+ * @param dir 方向:1 放大 / -1 缩小
2473
+ */
2474
+ const stepImgZoom = (dir) => {
2475
+ const el = imgElRef.current
2476
+ const measured = el && el.naturalWidth ? el.clientWidth / el.naturalWidth : 1
2477
+ applyImgZoom(zoomStepOf(imgZoom ?? measured, dir))
2478
+ }
2479
+
2480
+ /**
2481
+ * 图片预览面板:工具条(尺寸 meta / 缩放 −+适应宽度 / SVG 文本切换 / 刷新)+ 棋盘底图片。
2482
+ * 初始适应宽度(等价 PDF page-width,0.1.7 官方统一缩放);按钮样式与 PDF 工具条同款。
2483
+ * @author ddj 2026年09月08号 / 2026年09月22号
2346
2484
  */
2347
2485
  const imagePanel = () => React.createElement('div', { className: 'edrv-imgview' },
2348
2486
  React.createElement('div', { className: 'edrv-imgview-bar' },
2349
2487
  React.createElement('span', { className: 'edrv-imgview-meta' },
2350
2488
  imgBroken ? '图片无法显示' : (imgSize ? imgSize.w + '×' + imgSize.h : '…')),
2351
2489
  React.createElement('span', { style: { flex: 1 } }),
2490
+ React.createElement('span', { className: 'edrv-pdf-page' },
2491
+ imgZoom === null ? '适应' : Math.round(imgZoom * 100) + '%'),
2492
+ React.createElement('button', {
2493
+ className: 'edrv-pill edrv-pill-ghost edrv-pdf-zoom', title: '缩小(10%/档)',
2494
+ onClick: () => stepImgZoom(-1),
2495
+ }, '−'),
2496
+ React.createElement('button', {
2497
+ className: 'edrv-pill edrv-pill-ghost edrv-pdf-zoom', title: '放大(10%/档)',
2498
+ onClick: () => stepImgZoom(1),
2499
+ }, '+'),
2500
+ React.createElement('button', {
2501
+ className: 'edrv-pill edrv-pill-ghost', title: '恢复适应宽度',
2502
+ disabled: imgZoom === null, onClick: () => applyImgZoom(null),
2503
+ }, '适应宽度'),
2352
2504
  (isSvgPath(active) ? React.createElement('button', {
2353
2505
  className: 'edrv-pill edrv-pill-ghost', title: '以文本方式查看/编辑(SVG 为文本格式)',
2354
2506
  onClick: () => toggleSvgText(active),
@@ -2361,8 +2513,12 @@ export function EditorView(props) {
2361
2513
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => reloadFile() }, '重试'))
2362
2514
  : React.createElement('img', {
2363
2515
  className: 'edrv-imgview-img',
2516
+ ref: imgElRef,
2364
2517
  src: imageSrc,
2365
2518
  alt: active || '',
2519
+ style: (imgZoom !== null && imgSize)
2520
+ ? { width: Math.round(imgSize.w * imgZoom) + 'px', maxWidth: 'none', maxHeight: 'none' }
2521
+ : undefined,
2366
2522
  onLoad: (e) => { setImgBroken(false); setImgSize({ w: e.target.naturalWidth, h: e.target.naturalHeight }) },
2367
2523
  onError: () => setImgBroken(true),
2368
2524
  }))))
@@ -2798,17 +2954,15 @@ export function EditorView(props) {
2798
2954
 
2799
2955
  /**
2800
2956
  * 复制文本到剪贴板(状态栏反馈;浏览器拒绝时提示,不抛异常)。
2801
- * @author ddj 2026年09月11号
2957
+ * 剪贴板写入与降级文案收敛到 client/copyText.ts(文件树菜单同源共用)。
2958
+ * @author ddj 2026年09月11号 / 2026年09月22号
2802
2959
  * @param text 待复制文本
2803
2960
  * @param okText 成功文案
2804
2961
  */
2805
2962
  const copyText = (text, okText) => {
2806
2963
  const value = String(text ?? '')
2807
2964
  if (!value) { setStatus('无可复制内容'); return }
2808
- if (!navigator.clipboard?.writeText) { setStatus('剪贴板不可用'); return }
2809
- navigator.clipboard.writeText(value)
2810
- .then(() => setStatus(okText + ':' + value))
2811
- .catch(() => setStatus('复制失败(浏览器拒绝剪贴板写入)'))
2965
+ void copyClipText(value, okText + ':' + value, setStatus)
2812
2966
  }
2813
2967
 
2814
2968
  /**
@@ -2937,6 +3091,10 @@ export function EditorView(props) {
2937
3091
 
2938
3092
  const openFile = (path, focusDiff) => {
2939
3093
  if (!path) return
3094
+ // 原生打开范围(nativeOpenExts 设置):命中且非差异跳转 → 走官方渲染器
3095
+ //(focusDiff=true 跳过:差异审查对象必为本插件编辑的文本文件,审查优先);
3096
+ // handler 缺失/失败(旧版无 openResource / seat 未挂)→ 回退现状页签打开,不阻断
3097
+ if (!focusDiff && inNativePath(path) && tryNativeOpen(path)) return
2940
3098
  // 打开文件即离开基线差异审阅态(差异视图是「当前文件」的临时视图)
2941
3099
  setSvnDiff(null)
2942
3100
  recordNav()
@@ -3393,6 +3551,21 @@ export function EditorView(props) {
3393
3551
  openSvnLog: (p) => openSvnLog(p),
3394
3552
  confirm: (message) => (typeof window === 'undefined' ? false : window.confirm(message)),
3395
3553
  notify: (message) => setStatus(message),
3554
+ // 名称输入弹窗(资源管理器右键的新建文件/新建文件夹/重命名)
3555
+ prompt: promptName,
3556
+ // 「打开方式…」(资源管理器右键、文件目标):打开器选择弹窗(注册表由 index 装配传入)
3557
+ openWith: (p) => {
3558
+ const openers = props.fileOpeners
3559
+ if (!openers) return
3560
+ openWithDialog(openers, { sessionId, cwd: cwd ?? undefined }, p, (opener) => {
3561
+ if (opener) setStatus('已用「' + opener.label + '」打开:' + baseNameOf(p))
3562
+ })
3563
+ },
3564
+ // 「在文件夹中查找…」(资源管理器右键、目录目标):目录过滤种子 + 跳搜索面板
3565
+ searchInFolder: (dir) => {
3566
+ setSearchScope(dir)
3567
+ openSearchPanel()
3568
+ },
3396
3569
  }
3397
3570
 
3398
3571
  // 调试工具条(VS Code 浮动条紧凑版,图2 形态):配置下拉 + 启动/继续 + 单步组 + 停止 + 相位。
@@ -13,6 +13,7 @@ import { SettingsContext } from '../settingsContext.js'
13
13
  import { normalizeSidebarMinWidth, SIDEBAR_MIN_DEFAULT } from '../sidebarMin.js'
14
14
  import { EDITOR_LIMIT_CEIL, EDITOR_LIMIT_DEFAULT, normalizeMaxOpenEditors } from '../../shared/editorLimit.js'
15
15
  import { TORTOISE_DIR_DEFAULT } from '../../shared/svn.js'
16
+ import { DEFAULT_NATIVE_CSV } from '../../shared/nativeOpen.js'
16
17
  import { KeybindingsPanel } from './KeybindingsPanel.js'
17
18
  import { LspSettings } from './LspSettings.js'
18
19
  import { PerfSettings } from './PerfSettings.js'
@@ -139,6 +140,8 @@ function GeneralSettings({ registry }) {
139
140
  const [minDraft, setMinDraft] = React.useState(null) // 输入草稿(null=跟随已保存值;blur/Enter 提交)
140
141
  const [limit, setLimit] = React.useState(EDITOR_LIMIT_DEFAULT)
141
142
  const [limitDraft, setLimitDraft] = React.useState(null) // 页签上限草稿(同上提交语义)
143
+ const [nativeSaved, setNativeSaved] = React.useState(DEFAULT_NATIVE_CSV) // 原生打开范围已保存值
144
+ const [nativeDraft, setNativeDraft] = React.useState(null) // 原生打开范围草稿(同上提交语义)
142
145
  const settings = React.useContext(SettingsContext)
143
146
  const snapshot = settings?.getSnapshot?.()
144
147
  const loading = !snapshot || snapshot.status === 'loading'
@@ -151,6 +154,8 @@ function GeneralSettings({ registry }) {
151
154
  if (typeof next === 'string') setTool(next)
152
155
  setMinW(normalizeSidebarMinWidth(snap?.value?.sidebarMinWidth))
153
156
  setLimit(normalizeMaxOpenEditors(snap?.value?.maxOpenEditors))
157
+ const native = snap?.value?.nativeOpenExts
158
+ setNativeSaved(typeof native === 'string' && native.trim() ? native : DEFAULT_NATIVE_CSV)
154
159
  }
155
160
  onChange()
156
161
  return settings?.subscribe?.(onChange)
@@ -191,6 +196,18 @@ function GeneralSettings({ registry }) {
191
196
  setLimitDraft(null)
192
197
  saveLimit(limitDraft)
193
198
  }
199
+ /** 提交原生打开范围(逗号分隔后缀;空串回落默认让位清单后持久化)。 */
200
+ const saveNative = (raw) => {
201
+ const next = String(raw ?? '').trim() || DEFAULT_NATIVE_CSV
202
+ setNativeSaved(next); setNativeDraft(null); setBusy(true); setError('')
203
+ if (!settings?.set) { setError('设置服务不可用'); setBusy(false); return }
204
+ settings.set('nativeOpenExts', next).catch((e) => setError(String(e))).finally(() => setBusy(false))
205
+ }
206
+ /** 结束输入(blur/Enter)时提交原生打开范围草稿。 */
207
+ const commitNative = () => {
208
+ if (nativeDraft === null) return
209
+ saveNative(nativeDraft)
210
+ }
194
211
  const closeDevForm = () => {
195
212
  if (!window.confirm('关闭开发形态:插件将切换为正式版安装(版本依赖 + 删除工作区链接),pnpm 装配后需重启 DSH 生效。确认关闭?')) return
196
213
  setDevBusy(true)
@@ -249,6 +266,19 @@ function GeneralSettings({ registry }) {
249
266
  onKeyDown: (event) => { if (event.key === 'Enter') commitLimit() },
250
267
  }),
251
268
  React.createElement('small', null, '0 = 不限制;超限时关闭最久未使用的页签(固定页签除外)')),
269
+ React.createElement('label', { className: 'vsm-general-row' },
270
+ React.createElement('span', null, '原生打开范围'),
271
+ React.createElement('input', {
272
+ type: 'text',
273
+ value: nativeDraft ?? nativeSaved,
274
+ disabled: loading || unavailable || notReady || busy || snapshot?.writable === false,
275
+ title: '逗号分隔后缀(如 doc,xlsx):文件树点击命中时用 DSH 官方预览原生打开;留空 = 默认让位清单(Office + 不可预览二进制)',
276
+ placeholder: 'doc,xlsx,…(留空恢复默认)',
277
+ onChange: (event) => setNativeDraft(event.target.value),
278
+ onBlur: commitNative,
279
+ onKeyDown: (event) => { if (event.key === 'Enter') commitNative() },
280
+ }),
281
+ React.createElement('small', null, '逗号分隔;命中的文件用 DSH 原生预览打开,留空恢复默认(Office + 二进制,csv/tsv 需显式加入)')),
252
282
  ),
253
283
  ),
254
284
  React.createElement(SvnSettingsSection, null),
@@ -86,6 +86,7 @@ export function OfficialSideTab(props) {
86
86
  sidebarPanels: props?.sidebarPanels,
87
87
  outlineSources: props?.outlineSources,
88
88
  fileMenuItems: props?.fileMenuItems,
89
+ fileOpeners: props?.fileOpeners,
89
90
  sessions: props?.sessions,
90
91
  })
91
92
  }
@@ -1,7 +1,7 @@
1
1
  // @ts-nocheck
2
2
  /**
3
3
  * dsh-vscode-mode client — PerfSettings:「性能优化」设置子页(会话卫生 + 压缩调优)。
4
- * - 会话盘点:全工作区/每会话体积、活跃标记,按体积排序;
4
+ * - 会话盘点:全工作区/每会话体积、活跃标记、官方归档/置顶徽标,按体积排序;
5
5
  * - 移出到归档(先规划→确认→执行→可恢复)与归档清除;
6
6
  * - DSH 内置压缩调优:写 profile patch(compaction-basic / tool-result-pruner 更低阈值,
7
7
  * 带备份与撤销,需重启生效);
@@ -67,6 +67,9 @@ function SessionRow({ row, checked, disabled, onToggle }) {
67
67
  React.createElement('span', { className: 'vsm-perf-cell vsm-perf-size ' + (row.bytes >= 1024 * 1024 ? 'big' : '') }, formatBytes(row.bytes)),
68
68
  React.createElement('span', { className: 'vsm-perf-cell vsm-perf-mtime', title: new Date(row.mtime).toLocaleString() }, when),
69
69
  row.active ? React.createElement('span', { className: 'vsm-perf-cell vsm-perf-active' }, '活跃') : null,
70
+ // 官方侧栏归档/置顶状态徽标(旧 host 不回传字段 → undefined 按 false,不渲染;样式复用面板现有 vsm-perf-active 徽标)
71
+ row.archived ? React.createElement('span', { className: 'vsm-perf-cell vsm-perf-active', title: '官方侧栏已归档(持久标志位,未搬目录)' }, '已归档') : null,
72
+ row.pinned ? React.createElement('span', { className: 'vsm-perf-cell vsm-perf-active', title: '官方侧栏已置顶' }, '已置顶') : null,
70
73
  )
71
74
  }
72
75