dsh-vscode-mode 0.1.23 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,13 +51,16 @@
51
51
  "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
52
52
  "cordis": ">=4.0.0-rc <5",
53
53
  "react": "^18.2.0",
54
+ "react-dom": "^18.2.0",
54
55
  "schemastery": "^3.18.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
58
59
  "@types/node": "^24.13.3",
59
60
  "@types/react": "^18.3.1",
61
+ "@types/react-dom": "^18.3.5",
60
62
  "react": "^18.3.1",
63
+ "react-dom": "^18.3.1",
61
64
  "schemastery": "3.18.0",
62
65
  "tsdown": "^0.22.14",
63
66
  "typescript": "^5.9.0",
@@ -21,6 +21,9 @@ export function createDiffRenderer(log) {
21
21
  * @param sessionId 会话 id
22
22
  */
23
23
  const render = (monaco, editor, pendingRegions, sessionId) => {
24
+ // 空渲染且无残留(5s 轮询 setRecords 常触发):无需清理时直接跳过,
25
+ // 不打 begin 日志、不做无谓 DOM 操作;有 zone/decoration 残留时仍需走清理。
26
+ if (!pendingRegions.length && viewZoneIds.length === 0 && decorations.length === 0) return
24
27
  const renderT0 = Date.now()
25
28
  const callIdAttr = (callId, idx) => String(callId) + ':' + String(idx)
26
29
  log(sessionId, '[diff-render] begin regs=' + pendingRegions.length + ' ' + pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + '(-' + (r.oldLines ? r.oldLines.length : 0) + '/+' + (r.newLines ? r.newLines.length : 0) + ')').join('|'))
@@ -10,6 +10,7 @@
10
10
  import React from 'react'
11
11
  import { rpc } from '../../rpc.js'
12
12
  import { ContextMenu } from '../../ui/ContextMenu.js'
13
+ import { rowMenuPosition } from '../../ui/menuPosition.js'
13
14
  import { buildTreeMenu } from '../contextMenu.js'
14
15
  import type { SidebarCtx } from '../types.js'
15
16
 
@@ -74,6 +75,7 @@ export function FileExplorer(props) {
74
75
  const refresh = () => {
75
76
  const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
76
77
  tokensRef.current = {}
78
+ setMenu(null)
77
79
  setDirs({})
78
80
  setLoading({})
79
81
  setError(null)
@@ -94,6 +96,7 @@ export function FileExplorer(props) {
94
96
  setExpanded({})
95
97
  setLoading({})
96
98
  setError(null)
99
+ setMenu(null)
97
100
  setRoot(null)
98
101
  void loadDir('')
99
102
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -111,17 +114,22 @@ export function FileExplorer(props) {
111
114
  const rowEl = (e, depth, isDir, isOpen) => {
112
115
  const pending = isDir ? 0 : (pendingByPath[e.path] ?? 0)
113
116
  const active = !isDir && e.path === activePath
117
+ const contextTarget = Boolean(menu && menuEntries.length && menu.target.path === e.path)
114
118
  const dim = e.type === 'other'
115
119
  return React.createElement('div', {
116
120
  key: e.path,
117
- className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : ''),
121
+ className: 'edrv-tree-row'
122
+ + (active ? ' edrv-tree-active' : '')
123
+ + (contextTarget ? ' edrv-tree-context' : ''),
118
124
  title: e.path,
119
125
  style: { paddingLeft: 6 + depth * 14 },
120
126
  onClick: () => { if (isDir) toggle(e.path); else openFile(e.path) },
121
127
  onContextMenu: (ev) => {
122
128
  ev.preventDefault()
123
129
  ev.stopPropagation()
124
- setMenu({ x: ev.clientX, y: ev.clientY, target: { path: e.path, type: e.type } })
130
+ const rect = ev.currentTarget?.getBoundingClientRect?.()
131
+ const position = rowMenuPosition(rect, ev.clientX, ev.clientY)
132
+ setMenu({ x: position.left, y: position.top, target: { path: e.path, type: e.type } })
125
133
  },
126
134
  },
127
135
  React.createElement('span', { className: 'edrv-tree-chev' },
@@ -158,6 +166,8 @@ export function FileExplorer(props) {
158
166
  // 面板区右键(空白处)→ 工作区根目录菜单;行内右键已在 rowEl 阻止冒泡。
159
167
  const onPanelContext = (ev) => {
160
168
  ev.preventDefault()
169
+ ev.stopPropagation()
170
+ if (!ev.currentTarget.contains(ev.target)) return
161
171
  setMenu({ x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
162
172
  }
163
173
  // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
@@ -213,6 +213,7 @@
213
213
  [data-edrv-view] .edrv-tree-row { display: flex; align-items: center; gap: 4px; height: 22px; padding-right: 6px; border-radius: 5px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; white-space: nowrap; }
214
214
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
215
215
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
216
+ [data-edrv-view] .edrv-tree-row.edrv-tree-context { box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary, #0f9d58); background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.12)); }
216
217
  [data-edrv-view] .edrv-tree-chev { width: 12px; flex-shrink: 0; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 10px; text-align: center; }
217
218
  [data-edrv-view] .edrv-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
218
219
  [data-edrv-view] .edrv-tree-name.edrv-tree-dim { color: var(--dsw-alias-label-tertiary, #9aa5b1); }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 通用浮动右键菜单(createElement 风格、类型化)。
3
- * 全屏 backdrop(点击/右键/Esc 关闭)+ 固定定位(viewport clamp 防越界),
3
+ * 非阻塞式关闭:window capture 监听(点击菜单外/右键菜单外/Esc 关闭),
4
+ * 不拦截菜单外事件,允许同一事件内由发起方切换/重开菜单。
4
5
  * 条目支持 danger/disabled/separator,复用既有 edrv-ctxmenu* 样式类。
5
6
  * 作者 ddj 2026-08-27
6
7
  */
7
8
  import React from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import { clampMenuPosition } from './menuPosition.js'
8
11
 
9
12
  /** 单条菜单项(展示层形状;业务侧由 buildTreeMenu 映射而来)。 */
10
13
  export interface ContextMenuEntry {
@@ -37,17 +40,43 @@ const MENU_H = 176
37
40
  */
38
41
  export function ContextMenu(props: ContextMenuProps): React.ReactElement {
39
42
  const { x, y, entries, onClose } = props
43
+ const menuRef = React.useRef<HTMLDivElement | null>(null)
40
44
 
41
45
  React.useEffect(() => {
42
46
  const onKey = (e: KeyboardEvent): void => {
43
47
  if (e.key === 'Escape') onClose()
44
48
  }
49
+ const onPointerDown = (e: PointerEvent): void => {
50
+ if (e.button !== 0) return
51
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) return
52
+ onClose()
53
+ }
54
+ const onContextMenu = (e: MouseEvent): void => {
55
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) {
56
+ e.preventDefault()
57
+ }
58
+ onClose()
59
+ }
45
60
  window.addEventListener('keydown', onKey, true)
46
- return () => window.removeEventListener('keydown', onKey, true)
61
+ window.addEventListener('pointerdown', onPointerDown, true)
62
+ window.addEventListener('contextmenu', onContextMenu, true)
63
+ return () => {
64
+ window.removeEventListener('keydown', onKey, true)
65
+ window.removeEventListener('pointerdown', onPointerDown, true)
66
+ window.removeEventListener('contextmenu', onContextMenu, true)
67
+ }
47
68
  }, [onClose])
48
69
 
49
- const left = Math.max(4, Math.min(x, (window.innerWidth || 800) - MENU_W))
50
- const top = Math.max(4, Math.min(y, (window.innerHeight || 600) - MENU_H))
70
+ const safeX = Number.isFinite(x) ? x : 4
71
+ const safeY = Number.isFinite(y) ? y : 4
72
+ const position = clampMenuPosition(
73
+ safeX,
74
+ safeY,
75
+ window.innerWidth || 800,
76
+ window.innerHeight || 600,
77
+ MENU_W,
78
+ MENU_H,
79
+ )
51
80
 
52
81
  const children: React.ReactNode[] = []
53
82
  for (const entry of entries) {
@@ -71,12 +100,8 @@ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
71
100
  }, entry.label))
72
101
  }
73
102
 
74
- return React.createElement(React.Fragment, null,
75
- React.createElement('div', {
76
- className: 'edrv-ctxmenu-backdrop',
77
- style: { position: 'fixed', inset: 0, zIndex: 70 },
78
- onClick: onClose,
79
- onContextMenu: (e: React.MouseEvent) => { e.preventDefault(); onClose() },
80
- }),
81
- React.createElement('div', { className: 'edrv-ctxmenu', style: { left, top } }, ...children))
103
+ const overlay = React.createElement('div', { 'data-edrv-view': '1' },
104
+ React.createElement('div', { className: 'edrv-ctxmenu', ref: menuRef, style: position }, ...children))
105
+
106
+ return typeof document === 'undefined' ? overlay : createPortal(overlay, document.body)
82
107
  }
@@ -131,6 +131,25 @@ export function EditorView(props) {
131
131
  })
132
132
  }
133
133
 
134
+ /**
135
+ * 轮询去重:前后记录内容一致时返回原引用,React 跳过重渲染,
136
+ * 阻断 5s 轮询 → memo 链 → diff 重渲染/日志 的空转。
137
+ * @author ddj 2026年08月26号
138
+ * @param prev 当前 records
139
+ * @param next 轮询新结果
140
+ * @returns 内容一致返回 true
141
+ */
142
+ const sameRecords = (prev, next) => {
143
+ const pk = Object.keys(prev)
144
+ const nk = Object.keys(next)
145
+ if (pk.length !== nk.length) return false
146
+ for (const k of pk) {
147
+ if (!(k in next)) return false
148
+ if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return false
149
+ }
150
+ return true
151
+ }
152
+
134
153
  const refreshRecords = (skipStale) => {
135
154
  if (!sessionId) return
136
155
  rpc('edrv.list', { sessionId, ...(skipStale ? { skipStale: true } : {}) }).then((res) => {
@@ -140,7 +159,7 @@ export function EditorView(props) {
140
159
  if (r.archived === true) continue
141
160
  map[r.callId] = r
142
161
  }
143
- setRecords(map)
162
+ setRecords((prev) => (sameRecords(prev, map) ? prev : map))
144
163
  }).catch((e) => setError('list异常:' + String(e)))
145
164
  }
146
165
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * dsh-vscode-mode client — 右键菜单 viewport 定位纯函数。
3
+ * 将目标行锚点与视口边界计算从 React 渲染层拆出,便于复用和单测。
4
+ * 作者 ddj 2026-08-28
5
+ */
6
+
7
+ export interface MenuPosition {
8
+ left: number
9
+ top: number
10
+ }
11
+
12
+ export interface MenuAnchorRect {
13
+ top: number
14
+ right: number
15
+ width: number
16
+ height: number
17
+ }
18
+
19
+ /**
20
+ * 将菜单坐标限制在 viewport 内。
21
+ * @author ddj 2026年08月28号
22
+ * @param x 期望的 viewport x 坐标
23
+ * @param y 期望的 viewport y 坐标
24
+ * @param viewportWidth viewport 宽度
25
+ * @param viewportHeight viewport 高度
26
+ * @param menuWidth 菜单估算宽度
27
+ * @param menuHeight 菜单估算高度
28
+ * @returns 限制后的菜单位置
29
+ */
30
+ export function clampMenuPosition(
31
+ x: number,
32
+ y: number,
33
+ viewportWidth: number,
34
+ viewportHeight: number,
35
+ menuWidth: number,
36
+ menuHeight: number,
37
+ ): MenuPosition {
38
+ const maxLeft = Math.max(4, viewportWidth - menuWidth)
39
+ const maxTop = Math.max(4, viewportHeight - menuHeight)
40
+ return {
41
+ left: Math.max(4, Math.min(x, maxLeft)),
42
+ top: Math.max(4, Math.min(y, maxTop)),
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 将文件树行右键菜单锚定到目标行右侧;无法测量时回退到鼠标坐标。
48
+ * @author ddj 2026年08月28号
49
+ * @param rect 目标行的布局矩形
50
+ * @param fallbackX 鼠标 viewport x 坐标
51
+ * @param fallbackY 鼠标 viewport y 坐标
52
+ * @returns 菜单初始位置
53
+ */
54
+ export function rowMenuPosition(
55
+ rect: MenuAnchorRect | null | undefined,
56
+ fallbackX: number,
57
+ fallbackY: number,
58
+ ): MenuPosition {
59
+ if (!rect || rect.width <= 0 || rect.height <= 0) {
60
+ return { left: fallbackX, top: fallbackY }
61
+ }
62
+ return { left: rect.right + 4, top: rect.top }
63
+ }
package/src/rpc.ts CHANGED
@@ -34,6 +34,8 @@ import { revealInExplorer } from './reveal.js'
34
34
 
35
35
  /** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
36
36
  const debugWriteQueues = new Map<string, Promise<void>>()
37
+ /** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
38
+ const DEBUG_LOG_CAP = 512 * 1024
37
39
 
38
40
  /** 记录 → 客户端视图(不含 before 全文,仅长度)。 */
39
41
  function recView(record: DiffRecord): RecordView {
@@ -300,7 +302,11 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
300
302
  const target = await fs.resolve('.dsh-edit-review-debug.log', { cwd: sc.cwd })
301
303
  const old = await fs.readText(target).catch(() => '')
302
304
  const line = new Date().toISOString() + ' ' + text + '\n'
303
- await fs.writeText(target, (old || '') + line, void 0, void 0, policyOf(ctx, sc.session))
305
+ // 超上限时截断保留尾部:单文件体积有界,每次追加的读改写成本不随会话时长增长
306
+ const next = old.length + line.length > DEBUG_LOG_CAP
307
+ ? old.slice(-Math.floor(DEBUG_LOG_CAP / 2)) + line
308
+ : old + line
309
+ await fs.writeText(target, next, void 0, void 0, policyOf(ctx, sc.session))
304
310
  } catch (e) { /* 写日志失败忽略 */ }
305
311
  })
306
312
  debugWriteQueues.set(sc.cwd, task)