dsh-vscode-mode 0.14.12 → 0.15.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.
@@ -15,6 +15,13 @@ export const MODEL_CACHE_CAP = 40
15
15
 
16
16
  /** scope →(path → model)缓存表。 */
17
17
  const caches = new Map<string, Map<string, unknown>>()
18
+ /** Open/dirty documents must not be evicted while another group is active. */
19
+ const retained = new WeakMap<Map<string, unknown>, Set<string>>()
20
+
21
+ /** @public @author ddj 2026年10月14号 @param map Workspace model cache. @param paths Open or otherwise unsafe documents. Protect models without changing ownership. */
22
+ export function retainModels(map: Map<string, unknown>, paths: Iterable<string>): void {
23
+ retained.set(map, new Set(paths))
24
+ }
18
25
  /** 当前激活作用域(切换时释放上一个)。 */
19
26
  let activeScope: string | null = null
20
27
  // --endregion
@@ -44,9 +51,12 @@ export function modelsForScope(scope: string): Map<string, unknown> {
44
51
  if (activeScope !== null && activeScope !== scope) {
45
52
  const prev = caches.get(activeScope)
46
53
  if (prev) {
47
- for (const model of prev.values()) disposeIfFree(model)
48
- prev.clear()
49
- caches.delete(activeScope)
54
+ for (const [path, model] of prev) {
55
+ if (retained.get(prev)?.has(path) || isAttached(model)) continue
56
+ disposeIfFree(model)
57
+ prev.delete(path)
58
+ }
59
+ if (!prev.size) caches.delete(activeScope)
50
60
  }
51
61
  }
52
62
  activeScope = scope
@@ -67,10 +77,9 @@ export function rememberModel(map: Map<string, unknown>, path: string, model: un
67
77
  if (!path || !model) return
68
78
  map.delete(path)
69
79
  map.set(path, model)
70
- while (map.size > MODEL_CACHE_CAP) {
71
- const oldest = map.keys().next().value
72
- if (oldest === undefined) break
73
- const evicted = map.get(oldest)
80
+ for (const [oldest, evicted] of map) {
81
+ if (map.size <= MODEL_CACHE_CAP) break
82
+ if (retained.get(map)?.has(oldest) || isAttached(evicted)) continue
74
83
  map.delete(oldest)
75
84
  disposeIfFree(evicted)
76
85
  }
@@ -6,11 +6,12 @@
6
6
  [data-edrv-view] { box-sizing: border-box; --edrv-tab-h: 34px; }
7
7
 
8
8
  [data-edrv-view] .edrv-tabs { display: flex; align-items: center; gap: 2px; padding: 2px 8px 0; background: var(--dsw-alias-bg-layer-1, transparent); overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; flex-shrink: 0; overscroll-behavior-x: contain; } /* overscroll-behavior-x: 横滚到边界不把滚动链传给页面(滚轮横滚接管更可控)@author ddj 2026年09月18号 */
9
- [data-edrv-view] .edrv-tab { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 12px; border: none; border-radius: 6px 6px 0 0; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
9
+ [data-edrv-view] .edrv-tab { user-select: none; -webkit-user-select: none; display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 12px; border: none; border-radius: 6px 6px 0 0; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
10
10
  [data-edrv-view] .edrv-tab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
11
11
  [data-edrv-view] .edrv-tab.edrv-tab-active { background: var(--dsw-alias-bg-base, #111); color: var(--dsw-alias-label-primary, #eee); box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary, #4f8cff); }
12
12
  [data-edrv-view] .edrv-tab .edrv-tab-star { color: var(--dsw-alias-brand-primary, #4f8cff); font-size: 13px; font-weight: 700; line-height: 1; flex-shrink: 0; } /* 未保存修改星号 @author ddj 2026年09月09号 */
13
- [data-edrv-view] .edrv-tab .edrv-tab-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 4px; color: var(--dsw-alias-label-tertiary, #888); font-size: 11px; }
13
+ [data-edrv-view] .edrv-tab .edrv-tab-x { appearance: none; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 4px; background: transparent; box-shadow: none; color: var(--dsw-alias-label-tertiary, #888); font-family: inherit; font-size: 11px; line-height: 1; cursor: pointer; }
14
+ [data-edrv-view] .edrv-tab .edrv-tab-x:focus-visible { outline: 1px solid var(--dsw-alias-state-info-primary, #4d6bfe); outline-offset: 1px; }
14
15
  [data-edrv-view] .edrv-tab .edrv-tab-x:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.12)); color: var(--dsw-alias-label-primary, #eee); }
15
16
  /* 固定页签:📌 标记 + 免关闭按钮(固定页签不渲染 ×,仅可经右键菜单取消固定) */
16
17
  [data-edrv-view] .edrv-tab .edrv-tab-pin { flex-shrink: 0; font-size: 10px; line-height: 1; opacity: .85; }
@@ -971,4 +972,29 @@
971
972
  /* Alt 提示层:由 hoverTree 注入到 .monaco-hover(滚动区之外)贴底常驻;容器预留同高内边距避免遮挡末行。
972
973
  不再用 sticky / `:has()`:滚动链在 .monaco-hover-content 上,sticky 会被滚走。 */
973
974
  [data-edrv-view] .monaco-hover[data-edrv-hint-host="1"] { padding-bottom: 22px; }
974
- [data-edrv-view] [data-edrv-hint="1"] { position: absolute; left: 0; right: 0; bottom: 0; box-sizing: border-box; height: 22px; padding: 2px 8px; font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #8a949b); background: var(--dsw-alias-bg-overlay, #ffffff); border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
975
+ [data-edrv-view] [data-edrv-hint="1"] { position: absolute; left: 0; right: 0; bottom: 0; box-sizing: border-box; height: 22px; padding: 2px 8px; font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #8a949b); background: var(--dsw-alias-bg-overlay, #ffffff); border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
976
+
977
+ /* Workbench panes: existing theme, action-driven drop previews. ddj 2026年10月14号 */
978
+ [data-edrv-view] .edrv-group, [data-edrv-view] .edrv-group-body,
979
+ [data-edrv-view] .edrv-full-slot, [data-edrv-view] .edrv-full-view,
980
+ [data-edrv-view] .edrv-passive-view { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
981
+ [data-edrv-view] .edrv-group { border: 0; }
982
+ [data-edrv-view] .edrv-group .edrv-tabs { flex: 0 0 auto; touch-action: none; }
983
+ [data-edrv-view] .edrv-group-tab-name { overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
984
+ [data-edrv-view] .edrv-workbench-tools { flex: 0 0 auto; }
985
+ [data-edrv-view] .edrv-split { display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
986
+ [data-edrv-view] .edrv-split-vertical { flex-direction: column; }
987
+ [data-edrv-view] .edrv-split-child { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
988
+ [data-edrv-view] .edrv-split-handle { flex: 0 0 5px; background: var(--dsw-alias-border-l1, #e0e6e8); cursor: col-resize; touch-action: none; }
989
+ [data-edrv-view] .edrv-split-vertical > .edrv-split-handle { cursor: row-resize; }
990
+ [data-edrv-view] .edrv-split-handle:hover, [data-edrv-view] .edrv-split-handle:focus-visible { background: var(--dsw-alias-state-info-primary, #4d6bfe); }
991
+ [data-edrv-view].edrv-floating { position: fixed; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2, #cdd6da); background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #1f2933); box-shadow: 0 8px 28px rgba(0,0,0,.22); }
992
+ [data-edrv-view] .edrv-floating-title { display: flex; align-items: center; gap: 8px; flex: 0 0 32px; padding: 0 8px; background: var(--dsw-alias-bg-layer-1, #f7f9fa); cursor: move; touch-action: none; user-select: none; }
993
+ [data-edrv-view] .edrv-floating-title > span { flex: 1; }
994
+ [data-edrv-view] .edrv-floating-title > button { border: 0; background: transparent; color: inherit; cursor: pointer; }
995
+ [data-edrv-view] .edrv-floating-grip { position: absolute; width: 16px; height: 16px; bottom: 0; right: 0; cursor: nwse-resize; touch-action: none; border-right: 3px solid var(--dsw-alias-border-l2, #cdd6da); border-bottom: 3px solid var(--dsw-alias-border-l2, #cdd6da); }
996
+ .edrv-drag-overlay { position: fixed; z-index: 150; pointer-events: none; box-sizing: border-box; border: 0; background: color-mix(in srgb, var(--dsw-alias-state-info-primary, #4d6bfe) 25%, transparent); }
997
+ .edrv-drop-label, .edrv-drag-ghost { position: fixed; z-index: 151; pointer-events: none; padding: 4px 8px; background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, #1f2933); border: 1px solid var(--dsw-alias-border-l2, #cdd6da); font-size: 12px; white-space: nowrap; }
998
+ .edrv-drop-label { position: absolute; left: 4px; top: 4px; }
999
+ [data-edrv-view] .edrv-tab:focus-visible { outline: 2px solid var(--dsw-alias-state-info-primary, #4d6bfe); outline-offset: -2px; }
1000
+ @media (prefers-reduced-motion: reduce) { .edrv-drag-overlay { transition: none; } }
@@ -0,0 +1,137 @@
1
+ /** Internal pointer drag: never converts external text into file paths. @author ddj 2026年10月14号 */
2
+ import { clampFloat, type DropTarget, type Edge } from './state/editorLayout.js'
3
+
4
+ export interface Rect { left: number; top: number; width: number; height: number }
5
+ export interface Point { x: number; y: number }
6
+ export type TabDrop = DropTarget | { kind: 'reference' }
7
+ export interface HitRegion { group: string; rect: Rect; tabs?: { path: string; rect: Rect }[]; tabRect?: Rect; floating?: boolean }
8
+ export interface DragHitInput {
9
+ point: Point
10
+ viewport: { width: number; height: number }
11
+ composer?: Rect | null
12
+ regions: HitRegion[]
13
+ blank: boolean
14
+ allowCreate: boolean
15
+ }
16
+ export interface DragPreview { path: string; point: Point; target: TabDrop | null; rect: Rect | null; label: string }
17
+
18
+ /** @public @author ddj 2026年10月14号 @param point Pointer position. @param rect View bounds. @returns Containment. */
19
+ export function contains(point: Point, rect: Rect): boolean {
20
+ return point.x >= rect.left && point.x <= rect.left + rect.width && point.y >= rect.top && point.y <= rect.top + rect.height
21
+ }
22
+
23
+ /** @public @author ddj 2026年10月14号 @param from Press origin. @param to Current pointer. @returns Whether movement exceeds the drag threshold. */
24
+ export function dragStarted(from: Point, to: Point): boolean { return Math.hypot(from.x - to.x, from.y - to.y) >= 6 }
25
+
26
+ /** @public @author ddj 2026年10月14号 @param point Pointer. @param rect Editor bounds. @returns Nearest edge in a 22% band, or null. */
27
+ export function edgeAt(point: Point, rect: Rect): Edge | null {
28
+ const x = (point.x - rect.left) / rect.width, y = (point.y - rect.top) / rect.height
29
+ const distances: [Edge, number][] = [['left', x], ['right', 1 - x], ['top', y], ['bottom', 1 - y]]
30
+ distances.sort((a, b) => a[1] - b[1])
31
+ return distances[0]![1] < 0.22 ? distances[0]![0] : null
32
+ }
33
+
34
+ /** @public @author ddj 2026年10月14号 @param rect Editor bounds. @param edge Split direction. @returns Preview half rectangle. */
35
+ export function splitRect(rect: Rect, edge: Edge): Rect {
36
+ const { left, top, width, height } = rect
37
+ if (edge === 'left' || edge === 'right') return { left: left + (edge === 'right' ? width / 2 : 0), top, width: width / 2, height }
38
+ return { left, top: top + (edge === 'bottom' ? height / 2 : 0), width, height: height / 2 }
39
+ }
40
+
41
+ /** @public @author ddj 2026年10月14号 @param input Trusted geometry and blank classification. @returns Drop proposal; no state is mutated. */
42
+ export function hitTarget(input: DragHitInput): { target: TabDrop; rect: Rect; label: string } | null {
43
+ const { point, viewport, composer, regions } = input
44
+ if (!contains(point, { left: 0, top: 0, ...viewport })) return null
45
+ if (composer && contains(point, composer)) return { target: { kind: 'reference' }, rect: composer, label: '添加文件引用(不发送)' }
46
+ for (const region of regions) {
47
+ if (!contains(point, region.rect)) continue
48
+ if (region.tabRect && contains(point, region.tabRect)) {
49
+ const tabs = region.tabs ?? []
50
+ const index = tabs.findIndex((tab) => point.x < tab.rect.left + tab.rect.width / 2)
51
+ const at = index < 0 ? tabs.length : index
52
+ const left = tabs[at]?.rect.left ?? (tabs.length ? tabs[tabs.length - 1]!.rect.left + tabs[tabs.length - 1]!.rect.width : region.tabRect.left)
53
+ return { target: { kind: 'group', group: region.group, index: at }, rect: { left, top: region.tabRect.top, width: 3, height: region.tabRect.height }, label: '移动到此处' }
54
+ }
55
+ const edge = edgeAt(point, region.rect)
56
+ const fits = edge === 'left' || edge === 'right' ? region.rect.width >= 480 : region.rect.height >= 320
57
+ if (edge && !region.floating && input.allowCreate && fits) return { target: { kind: 'split', group: region.group, edge }, rect: splitRect(region.rect, edge), label: '分屏编辑' }
58
+ return { target: { kind: 'group', group: region.group }, rect: region.rect, label: '移入编辑组' }
59
+ }
60
+ if (!input.blank || !input.allowCreate) return null
61
+ const rect = clampFloat({ left: point.x - 120, top: point.y - 18, width: 600, height: 400 }, viewport.width, viewport.height)
62
+ return { target: { kind: 'float', rect }, rect, label: '独立浮窗' }
63
+ }
64
+
65
+ export interface DragDeps {
66
+ hit(point: Point): ReturnType<typeof hitTarget>
67
+ preview(value: DragPreview | null): void
68
+ drop(path: string, target: TabDrop): void
69
+ }
70
+
71
+ /** @public @author ddj 2026年10月14号 @param deps DOM geometry/readback callbacks. @param host Browser window. @returns Single internal drag controller with cleanup. */
72
+ export function createTabDrag(deps: DragDeps, host: Window) {
73
+ let session: { path: string; origin: Point; point: Point; pointerId: number; dragging: boolean; capture: Element | null } | null = null
74
+ let frame: number | null = null
75
+ let suppressClick = false
76
+
77
+ /** @private @author ddj 2026年10月14号 Render only once per animation frame. */
78
+ function paint(): void {
79
+ frame = null
80
+ if (!session?.dragging) return
81
+ const hit = deps.hit(session.point)
82
+ // DOMRect properties live on its prototype; React style needs own enumerable geometry.
83
+ const rect = hit ? { left: hit.rect.left, top: hit.rect.top, width: hit.rect.width, height: hit.rect.height } : null
84
+ deps.preview({ path: session.path, point: session.point, target: hit?.target ?? null, rect, label: hit?.label ?? '无法放置' })
85
+ }
86
+ /** @public @author ddj 2026年10月14号 Cancel without changing file/layout ownership. */
87
+ function cancel(): void {
88
+ if (frame !== null) host.cancelAnimationFrame(frame)
89
+ frame = null
90
+ const previous = session
91
+ session = null
92
+ if (previous?.dragging) suppressClick = true
93
+ try { previous?.capture?.releasePointerCapture(previous.pointerId) } catch { /* Pointer may already be released. */ }
94
+ host.removeEventListener('pointermove', move, true)
95
+ host.removeEventListener('pointerup', up, true)
96
+ host.removeEventListener('pointercancel', cancel, true)
97
+ host.removeEventListener('keydown', key, true)
98
+ host.removeEventListener('blur', cancel)
99
+ deps.preview(null)
100
+ }
101
+ /** @private @author ddj 2026年10月14号 @param event Pointer move. Update geometry, not layout. */
102
+ function move(event: PointerEvent): void {
103
+ if (!session || event.pointerId !== session.pointerId) return
104
+ session.point = { x: event.clientX, y: event.clientY }
105
+ session.dragging ||= dragStarted(session.origin, session.point)
106
+ if (!session.dragging) return
107
+ event.preventDefault()
108
+ if (frame === null) frame = host.requestAnimationFrame(paint)
109
+ }
110
+ /** @private @author ddj 2026年10月14号 @param event Pointer release. Commit only a currently valid drop. */
111
+ function up(event: PointerEvent): void {
112
+ if (!session || event.pointerId !== session.pointerId) return
113
+ const previous = session
114
+ const hit = previous.dragging ? deps.hit({ x: event.clientX, y: event.clientY }) : null
115
+ cancel()
116
+ if (hit) deps.drop(previous.path, hit.target)
117
+ }
118
+ /** @private @author ddj 2026年10月14号 @param event Keyboard event. Escape cancels this drag only. */
119
+ function key(event: KeyboardEvent): void { if (event.key === 'Escape' && session) { event.preventDefault(); event.stopPropagation(); cancel() } }
120
+ /** @public @author ddj 2026年10月14号 @param path Trusted disk tab. @param event Primary pointer press. Start observation, not a move. */
121
+ function start(path: string, event: PointerEvent): void {
122
+ if (event.button !== 0 || !event.isPrimary || !path) return
123
+ cancel()
124
+ suppressClick = false
125
+ const origin = { x: event.clientX, y: event.clientY }
126
+ session = { path, origin, point: origin, pointerId: event.pointerId, dragging: false, capture: event.currentTarget instanceof Element ? event.currentTarget : null }
127
+ try { session.capture?.setPointerCapture(event.pointerId) } catch { /* Global listeners cover older platforms. */ }
128
+ host.addEventListener('pointermove', move, true)
129
+ host.addEventListener('pointerup', up, true)
130
+ host.addEventListener('pointercancel', cancel, true)
131
+ host.addEventListener('keydown', key, true)
132
+ host.addEventListener('blur', cancel)
133
+ }
134
+ /** @public @author ddj 2026年10月14号 @returns Whether to consume the synthetic post-drag click. */
135
+ function consumeClick(): boolean { const result = suppressClick; suppressClick = false; return result }
136
+ return { start, cancel, consumeClick }
137
+ }
@@ -4,8 +4,8 @@
4
4
  * EditorView 只把结果映射成 ContextMenu 的 entries 并派发动作。
5
5
  *
6
6
  * 与参考图(VS Code / CodeBuddy 页签右键菜单)的对齐口径:
7
- * 保留其分组顺序(对话 / 关闭族 / 路径 / 定位 / 固定),**省略**本架构无法实现的
8
- * 「向右拆分 / Split & Move / 移动到新窗口 / 复制到新窗口」(浏览器内单编辑器实例)。
7
+ * 保留其分组顺序(对话 / 关闭族 / 分组 / 路径 / 定位 / 固定)。
8
+ * 分屏与页面内浮窗由 workbench 能力显式控制,不伪造系统窗口或复制编辑器能力。
9
9
  * 只显示**真实已绑定**的键位;参考图里的两步弦(Ctrl+K W 等)引擎不支持,故不伪造。
10
10
  *
11
11
  * 「固定 = 保护」:关闭其他 / 关闭右侧 / 关闭已保存 / 全部关闭 一律不关固定页签,
@@ -44,6 +44,10 @@ export interface TabMenuState {
44
44
  svnStatusOfTarget?: SvnItemStatus
45
45
  /** 「关闭」项的键位提示(缺省无提示;由调用方读 chordOf 注入)。 */
46
46
  closeChord?: string | null
47
+ /** Real workbench capabilities; omitted for legacy consumers. */
48
+ canSplit?: boolean
49
+ canFloat?: boolean
50
+ canReturn?: boolean
47
51
  }
48
52
 
49
53
  /** 一条页签菜单项(ContextMenu 的展示形状 + 动作 id)。 */
@@ -62,7 +66,7 @@ export interface TabMenuEntry {
62
66
  export const CLOSE_MENU_IDS = ['close', 'close-others', 'close-right', 'close-saved', 'close-all'] as const
63
67
 
64
68
  /** 本架构不支持的条目 id(参考图有、浏览器单编辑器实例无法实现)——显式登记以防误加。 */
65
- export const UNSUPPORTED_MENU_IDS = ['split-right', 'split-move', 'move-new-window', 'copy-new-window'] as const
69
+ export const UNSUPPORTED_MENU_IDS = ['split-move', 'copy-new-window'] as const
66
70
 
67
71
  /**
68
72
  * 构建 SVN 组条目(动作清单来自 shared/svnActions.ts,与树菜单/命令栏同一份规则)。
@@ -123,6 +127,14 @@ export function buildTabMenu(state: TabMenuState): TabMenuEntry[] {
123
127
  { id: 'close-right', label: '关闭右侧标签页', disabled: nothingToClose(closeRight(tabs, path, active), tabs) },
124
128
  { id: 'close-saved', label: '关闭已保存', disabled: nothingToClose(closeSaved(tabs, dirty, active), tabs) },
125
129
  { id: 'close-all', label: '全部关闭', disabled: nothingToClose(closeAll(tabs, active), tabs) },
130
+ ...(state.canSplit !== undefined ? [
131
+ { id: 'split-left', label: '向左分屏', separator: true, disabled: !state.canSplit },
132
+ { id: 'split-right', label: '向右分屏', disabled: !state.canSplit },
133
+ { id: 'split-top', label: '向上分屏', disabled: !state.canSplit },
134
+ { id: 'split-bottom', label: '向下分屏', disabled: !state.canSplit },
135
+ { id: 'move-new-window', label: '移到页面内浮窗', disabled: !state.canFloat },
136
+ { id: 'return-main', label: '移回主编辑区', disabled: !state.canReturn },
137
+ ] : []),
126
138
  { id: 'copy-path', label: '复制路径', separator: true },
127
139
  { id: 'copy-relative-path', label: '复制相对路径', disabled: !hasCwd(cwd) },
128
140
  { id: 'reveal-in-os', label: '在文件资源管理器中显示', separator: true, disabled: !hasSession },
@@ -0,0 +1,130 @@
1
+ /** Independent group views; workbench owns documents, saves and commands. @author ddj 2026年10月14号 */
2
+ import React from 'react'
3
+ import { createViewKeeper, type ViewEditor } from '../state/editorViewState.js'
4
+ import type { Group } from '../state/editorLayout.js'
5
+ import type { TabLike } from '../tabActions.js'
6
+
7
+ export interface PaneProps {
8
+ group: Group
9
+ focused: boolean
10
+ dirty: Record<string, boolean>
11
+ onSelect(group: string, path?: string): void
12
+ onClose(path: string): void
13
+ onMenu(path: string, event: React.MouseEvent): void
14
+ onDrag(path: string, event: React.PointerEvent): void
15
+ consumeClick(): boolean
16
+ children?: React.ReactNode
17
+ extras?: React.ReactNode
18
+ }
19
+
20
+ /** @public @author ddj 2026年10月14号 @param props Group callbacks and file view. @returns Accessible tab strip and drop target. */
21
+ export function EditorPane(props: PaneProps): React.ReactElement {
22
+ const { group } = props
23
+ /** @private @author ddj 2026年10月14号 @param tab File record. @returns Draggable tab with isolated close control. */
24
+ function renderTab(tab: TabLike): React.ReactElement {
25
+ /** @private @author ddj 2026年10月14号 @param event Primary press. Start trusted file drag. */
26
+ function press(event: React.PointerEvent): void { props.onDrag(tab.path, event) }
27
+ /** @private @author ddj 2026年10月14号 Select unless release completed a drag. */
28
+ function select(): void { if (!props.consumeClick()) props.onSelect(group.id, tab.path) }
29
+ /** @private @author ddj 2026年10月14号 @param event Context event. Open file actions. */
30
+ function menu(event: React.MouseEvent): void { event.preventDefault(); event.stopPropagation(); props.onMenu(tab.path, event) }
31
+ /** @private @author ddj 2026年10月14号 @param event Close activation. Never select tab. */
32
+ function close(event: React.MouseEvent): void { event.stopPropagation(); props.onClose(tab.path) }
33
+ /** @private @author ddj 2026年10月14号 @param event Close press. Never start a drag. */
34
+ function stop(event: React.PointerEvent): void { event.stopPropagation() }
35
+ /** @private @author ddj 2026年10月14号 @param event Keyboard activation. */
36
+ function key(event: React.KeyboardEvent): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) } }
37
+ return React.createElement('div', {
38
+ key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': tab.path === group.active,
39
+ className: 'edrv-tab' + (tab.path === group.active ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
40
+ title: tab.path + (tab.pinned ? '(已固定)' : ''), onPointerDown: press, onClick: select, onContextMenu: menu, onKeyDown: key,
41
+ }, tab.pinned ? React.createElement('span', { className: 'edrv-tab-pin' }, '📌') : null,
42
+ React.createElement('span', { className: 'edrv-group-tab-name' }, tab.path.split(/[\\/]/).pop()),
43
+ props.dirty[tab.path] ? React.createElement('span', { className: 'edrv-tab-star', title: '未保存修改' }, '*') : null,
44
+ tab.pinned ? null : React.createElement('button', { className: 'edrv-tab-x', type: 'button', 'aria-label': '关闭 ' + tab.path, title: '关闭', onPointerDown: stop, onClick: close }, '×'))
45
+ }
46
+ /** @private @author ddj 2026年10月14号 @param event Group background press. Allow an empty main group to receive new files. */
47
+ function focusEmpty(event: React.PointerEvent): void {
48
+ if (!group.active && !props.focused && event.button === 0) props.onSelect(group.id)
49
+ }
50
+ return React.createElement('section', { className: 'edrv-group' + (props.focused ? ' edrv-group-focused' : ''), 'data-edrv-group': group.id, onPointerDown: focusEmpty },
51
+ React.createElement('div', { className: 'edrv-tabs', role: 'tablist', 'data-edrv-group-tabs': group.id }, group.tabs.map(renderTab), props.extras),
52
+ React.createElement('div', { className: 'edrv-group-body', role: 'tabpanel' }, props.children))
53
+ }
54
+
55
+ /** @public @author ddj 2026年10月14号 @param editor View instance. @param scope Workspace. @param path Canonical path. @param model Shared content. @returns Keeper whose disposal never disposes the model. */
56
+ export function bindPaneView(editor: ViewEditor, scope: string, path: string, model: unknown) {
57
+ const keeper = createViewKeeper(editor)
58
+ keeper.bind(scope, path, model)
59
+ return keeper
60
+ }
61
+
62
+ export interface PassiveProps {
63
+ path: string | null
64
+ mount(host: HTMLDivElement, path: string): (() => void) | undefined
65
+ }
66
+
67
+ /** @public @author ddj 2026年10月14号 @param props Workbench-owned mounting contract. @returns Independent visible file view without global command listeners. */
68
+ export function PassiveEditor(props: PassiveProps): React.ReactElement {
69
+ const host = React.useRef<HTMLDivElement>(null)
70
+ React.useLayoutEffect(() => {
71
+ if (host.current && props.path) return props.mount(host.current, props.path)
72
+ }, [props.path, props.mount])
73
+ return React.createElement('div', { ref: host, className: 'edrv-passive-view' })
74
+ }
75
+
76
+ export interface SplitProps {
77
+ axis: 'horizontal' | 'vertical'
78
+ ratio: number
79
+ onRatio(ratio: number): void
80
+ first: React.ReactNode
81
+ second: React.ReactNode
82
+ }
83
+
84
+ /** @public @author ddj 2026年10月14号 @param props Split geometry. @returns Resizable split with keyboard equivalents. */
85
+ export function EditorSplit(props: SplitProps): React.ReactElement {
86
+ const host = React.useRef<HTMLDivElement>(null)
87
+ const clean = React.useRef<(() => void) | null>(null)
88
+ const latest = React.useRef(props)
89
+ latest.current = props
90
+ React.useEffect(() => () => clean.current?.(), [])
91
+ /** @private @author ddj 2026年10月14号 @param event Separator press. Resize only this branch. */
92
+ function start(event: React.PointerEvent): void {
93
+ if (event.button !== 0 || !host.current) return
94
+ clean.current?.()
95
+ event.preventDefault()
96
+ const rect = host.current.getBoundingClientRect(), pointer = event.pointerId
97
+ /** @private @author ddj 2026年10月14号 @param value Pointer movement. Keep both leaves usable. */
98
+ function move(value: PointerEvent): void {
99
+ if (value.pointerId !== pointer) return
100
+ const horizontal = latest.current.axis === 'horizontal'
101
+ const size = horizontal ? rect.width : rect.height
102
+ const min = Math.min(0.45, (horizontal ? 240 : 160) / size)
103
+ const at = horizontal ? value.clientX - rect.left : value.clientY - rect.top
104
+ latest.current.onRatio(Math.max(min, Math.min(1 - min, at / size)))
105
+ }
106
+ /** @private @author ddj 2026年10月14号 Remove resize listeners. */
107
+ function stop(): void {
108
+ window.removeEventListener('pointermove', move)
109
+ window.removeEventListener('pointerup', stop)
110
+ window.removeEventListener('pointercancel', stop)
111
+ window.removeEventListener('blur', stop)
112
+ clean.current = null
113
+ }
114
+ clean.current = stop
115
+ window.addEventListener('pointermove', move)
116
+ window.addEventListener('pointerup', stop)
117
+ window.addEventListener('pointercancel', stop)
118
+ window.addEventListener('blur', stop)
119
+ }
120
+ /** @private @author ddj 2026年10月14号 @param event Keyboard resizing. Arrow keys change ratio. */
121
+ function key(event: React.KeyboardEvent): void {
122
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
123
+ event.preventDefault()
124
+ props.onRatio(Math.max(0.1, Math.min(0.9, props.ratio + (event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -0.05 : 0.05))))
125
+ }
126
+ return React.createElement('div', { ref: host, className: 'edrv-split edrv-split-' + props.axis },
127
+ React.createElement('div', { className: 'edrv-split-child', style: { flex: props.ratio } }, props.first),
128
+ React.createElement('div', { className: 'edrv-split-handle', role: 'separator', tabIndex: 0, 'aria-orientation': props.axis === 'horizontal' ? 'vertical' : 'horizontal', 'aria-valuenow': Math.round(props.ratio * 100), 'aria-valuemin': 10, 'aria-valuemax': 90, onPointerDown: start, onKeyDown: key }),
129
+ React.createElement('div', { className: 'edrv-split-child', style: { flex: 1 - props.ratio } }, props.second))
130
+ }