dsh-vscode-mode 0.14.13 → 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.
- package/README.md +5 -3
- package/lib/client.js +2849 -1466
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/paths.ts +2 -0
- package/src/client/state/documentStore.ts +148 -0
- package/src/client/state/editorLayout.ts +179 -0
- package/src/client/state/modelCache.ts +16 -7
- package/src/client/styles/editor.css +29 -3
- package/src/client/tabDrag.ts +137 -0
- package/src/client/tabMenu.ts +15 -3
- package/src/client/ui/EditorPane.ts +130 -0
- package/src/client/ui/EditorView.ts +358 -97
- package/src/client/ui/FloatingEditor.ts +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
|
|
5
5
|
"icon": "assets/icon.svg",
|
|
6
6
|
"keywords": [
|
package/src/client/paths.ts
CHANGED
|
@@ -16,6 +16,8 @@ export const CACHE_KEY = {
|
|
|
16
16
|
viewstate: 'edrv.cache.viewstate.v1.',
|
|
17
17
|
/** 编辑器页签/活动文件(EditorView v3:页签带 pinned 固定标记)。 */
|
|
18
18
|
editor: 'edrv.editor.v3.',
|
|
19
|
+
/** Multi-group split tree and floating window geometry. */
|
|
20
|
+
editorLayout: 'edrv.editorLayout.v1.',
|
|
19
21
|
/** 旧编辑器页签键(v2:页签为纯路径数组)——仅用于一次性回读迁移,不再写入。 */
|
|
20
22
|
editorLegacy: 'edrv.editor.v2.',
|
|
21
23
|
/** 侧边栏状态(EditorView;按布局 side/central 追加段)。 */
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** One save owner per document, independent of group placement. @author ddj 2026年10月14号 */
|
|
2
|
+
export interface TextModel { getValue(): string; isDisposed?(): boolean; getVersionId?(): number }
|
|
3
|
+
export interface SaveResult { ok: boolean; rev?: string; conflict?: boolean; error?: string }
|
|
4
|
+
export interface DocumentState {
|
|
5
|
+
model: TextModel | null
|
|
6
|
+
rev?: string
|
|
7
|
+
dirty: boolean
|
|
8
|
+
conflict: boolean
|
|
9
|
+
pending: ReturnType<typeof setTimeout> | null
|
|
10
|
+
saving: Promise<SaveResult> | null
|
|
11
|
+
again: boolean
|
|
12
|
+
generation: number
|
|
13
|
+
}
|
|
14
|
+
export interface SaveRequest { path: string; content: string; rev?: string }
|
|
15
|
+
export interface DocumentDeps {
|
|
16
|
+
save(request: SaveRequest): Promise<SaveResult>
|
|
17
|
+
changed?(path: string, state: DocumentState): void
|
|
18
|
+
saved?(path: string, content: string, model: TextModel, result: SaveResult): void
|
|
19
|
+
failed?(path: string, result: SaveResult): void
|
|
20
|
+
blocked?(path: string): boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @public @author ddj 2026年10月14号 @param deps IO/version guard and view notifications. @returns Shared lifecycle without model disposal. */
|
|
24
|
+
export function createDocStore(deps: DocumentDeps) {
|
|
25
|
+
const documents = new Map<string, DocumentState>()
|
|
26
|
+
let disposed = false
|
|
27
|
+
|
|
28
|
+
/** @public @author ddj 2026年10月14号 @param path Canonical file path. @returns Document state, lazily created. */
|
|
29
|
+
function state(path: string): DocumentState {
|
|
30
|
+
let item = documents.get(path)
|
|
31
|
+
if (!item) {
|
|
32
|
+
item = { model: null, dirty: false, conflict: false, pending: null, saving: null, again: false, generation: 0 }
|
|
33
|
+
documents.set(path, item)
|
|
34
|
+
}
|
|
35
|
+
return item
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @private @author ddj 2026年10月14号 @param path Canonical path. @param item Current state. Publish change to all views. */
|
|
39
|
+
function notify(path: string, item: DocumentState): void { deps.changed?.(path, item) }
|
|
40
|
+
|
|
41
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param model Existing model. @param rev Optional disk revision. Bind without erasing edits. */
|
|
42
|
+
function bind(path: string, model: TextModel, rev?: string): void {
|
|
43
|
+
const item = state(path)
|
|
44
|
+
if (item.model && item.model !== model) item.generation += 1
|
|
45
|
+
item.model = model
|
|
46
|
+
if (rev) item.rev = rev
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param rev Disk token. @param conflict Optional conflict marker. Update disk baseline only. */
|
|
50
|
+
function baseline(path: string, rev?: string, conflict?: boolean): void {
|
|
51
|
+
const item = state(path)
|
|
52
|
+
if (rev) item.rev = rev
|
|
53
|
+
if (conflict !== undefined) item.conflict = conflict
|
|
54
|
+
notify(path, item)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @private @author ddj 2026年10月14号 @param item Document state. Cancel delayed save, not in-flight IO. */
|
|
58
|
+
function cancel(item: DocumentState): void {
|
|
59
|
+
if (item.pending !== null) clearTimeout(item.pending)
|
|
60
|
+
item.pending = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @private @author ddj 2026年10月14号 @param path Saved path. @param item Owner state. @param model Captured model. @param content Saved snapshot. @param version Captured version. @param generation Owner generation. @param result IO result. Accept only current content. */
|
|
64
|
+
function accept(path: string, item: DocumentState, model: TextModel, content: string, version: number | undefined, generation: number, result: SaveResult): void {
|
|
65
|
+
if (documents.get(path) !== item || item.generation !== generation || item.model !== model) return
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
item.conflict = result.conflict === true || item.conflict
|
|
68
|
+
deps.failed?.(path, result)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (result.rev) item.rev = result.rev
|
|
72
|
+
if (!model.isDisposed?.() && model.getValue() === content && (version === undefined || version === model.getVersionId?.())) item.dirty = false
|
|
73
|
+
deps.saved?.(path, content, model, result)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param manual Explicit user save may invoke version guard despite conflict. @returns Deduplicated save completion; failures retain dirty state. */
|
|
77
|
+
function save(path: string, manual = false): Promise<SaveResult> {
|
|
78
|
+
const item = state(path)
|
|
79
|
+
cancel(item)
|
|
80
|
+
if (disposed || !item.model || item.model.isDisposed?.()) return Promise.resolve({ ok: false, error: '文件模型不可用' })
|
|
81
|
+
if (!manual && (item.conflict || deps.blocked?.(path))) return Promise.resolve({ ok: false, conflict: true })
|
|
82
|
+
if (item.saving) { item.again = item.dirty; return item.saving }
|
|
83
|
+
const model = item.model, content = model.getValue(), version = model.getVersionId?.(), generation = item.generation
|
|
84
|
+
const request = { path, content, rev: item.rev }
|
|
85
|
+
/** @private @author ddj 2026年10月14号 @param result IO result. @returns Original result after guarded acceptance. */
|
|
86
|
+
function success(result: SaveResult): SaveResult { accept(path, item, model, content, version, generation, result); return result }
|
|
87
|
+
/** @private @author ddj 2026年10月14号 @param error IO failure. @returns Failure without discarding content. */
|
|
88
|
+
function failure(error: unknown): SaveResult {
|
|
89
|
+
const result = { ok: false, error: String(error) }
|
|
90
|
+
if (documents.get(path) === item) deps.failed?.(path, result)
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
/** @private @author ddj 2026年10月14号 Complete one request before scheduling another changed snapshot. */
|
|
94
|
+
function settled(): void {
|
|
95
|
+
item.saving = null
|
|
96
|
+
notify(path, item)
|
|
97
|
+
if (documents.get(path) !== item || disposed || !item.again) return
|
|
98
|
+
item.again = false
|
|
99
|
+
if (item.dirty && !item.conflict) edit(path)
|
|
100
|
+
}
|
|
101
|
+
item.saving = Promise.resolve().then(() => deps.save(request)).then(success, failure).finally(settled)
|
|
102
|
+
notify(path, item)
|
|
103
|
+
return item.saving
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param delay Debounce milliseconds. Mark dirty and schedule a document-owned save. */
|
|
107
|
+
function edit(path: string, delay = 700): void {
|
|
108
|
+
if (disposed) return
|
|
109
|
+
const item = state(path)
|
|
110
|
+
item.dirty = true
|
|
111
|
+
cancel(item)
|
|
112
|
+
if (!item.conflict && !deps.blocked?.(path)) {
|
|
113
|
+
/** @private @author ddj 2026年10月14号 Save the same document even after group/focus changes. */
|
|
114
|
+
function delayed(): void { item.pending = null; void save(path) }
|
|
115
|
+
item.pending = setTimeout(delayed, delay)
|
|
116
|
+
}
|
|
117
|
+
notify(path, item)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @public @author ddj 2026年10月14号 @param path Optional document. @returns Pending saves submitted; moving groups need not call this. */
|
|
121
|
+
function flush(path?: string): Promise<SaveResult[]> {
|
|
122
|
+
const work: Promise<SaveResult>[] = []
|
|
123
|
+
for (const [key, item] of documents) {
|
|
124
|
+
if (path && path !== key) continue
|
|
125
|
+
if (item.pending !== null || (item.dirty && !item.saving)) work.push(save(key))
|
|
126
|
+
else if (item.saving) work.push(item.saving)
|
|
127
|
+
}
|
|
128
|
+
return Promise.all(work)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @public @author ddj 2026年10月14号 @param path File removed from tracking. @returns False while unsafe; never destroys a model. */
|
|
132
|
+
function release(path: string): boolean {
|
|
133
|
+
const item = documents.get(path)
|
|
134
|
+
if (!item) return true
|
|
135
|
+
if (item.dirty || item.saving) return false
|
|
136
|
+
cancel(item)
|
|
137
|
+
documents.delete(path)
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @public @author ddj 2026年10月14号 @returns Whether dirty or in-flight content prevents reload. */
|
|
142
|
+
function unsafe(): boolean { return [...documents.values()].some((item) => item.dirty || item.pending !== null || !!item.saving) }
|
|
143
|
+
|
|
144
|
+
/** @public @author ddj 2026年10月14号 Cancel timers on teardown; retained models and in-flight save guards survive. */
|
|
145
|
+
function dispose(): void { disposed = true; for (const item of documents.values()) cancel(item) }
|
|
146
|
+
return { state, bind, baseline, edit, save, flush, release, unsafe, dispose }
|
|
147
|
+
}
|
|
148
|
+
export type DocumentStore = ReturnType<typeof createDocStore>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Workbench group layout without file-content ownership. @author ddj 2026年10月14号 */
|
|
2
|
+
import { normalizeTabs, pickActive, tabPathOf, type TabLike } from '../tabActions.js'
|
|
3
|
+
|
|
4
|
+
export const GROUP_LIMIT = 8
|
|
5
|
+
export const MAIN_GROUP = 'main'
|
|
6
|
+
export type Edge = 'left' | 'right' | 'top' | 'bottom'
|
|
7
|
+
export interface Group { id: string; tabs: TabLike[]; active: string | null }
|
|
8
|
+
export type LayoutNode = { group: string } | { axis: 'horizontal' | 'vertical'; ratio: number; first: LayoutNode; second: LayoutNode }
|
|
9
|
+
export interface FloatRect { left: number; top: number; width: number; height: number }
|
|
10
|
+
export interface FloatGroup extends FloatRect { group: string }
|
|
11
|
+
export interface EditorLayout { v: 1; tree: LayoutNode; groups: Group[]; floats: FloatGroup[]; focused: string; serial: number }
|
|
12
|
+
export type DropTarget = { kind: 'group'; group: string; index?: number } | { kind: 'split'; group: string; edge: Edge } | { kind: 'float'; rect: FloatRect }
|
|
13
|
+
|
|
14
|
+
// --region Layout transactions
|
|
15
|
+
/** @public @author ddj 2026年10月14号 @param tabs Legacy tabs. @param active Legacy selection. @returns Single-group layout. */
|
|
16
|
+
export function createLayout(tabs: TabLike[] = [], active: string | null = null): EditorLayout {
|
|
17
|
+
return { v: 1, tree: { group: MAIN_GROUP }, groups: [{ id: MAIN_GROUP, tabs, active: pickActive(tabs, active) }], floats: [], focused: MAIN_GROUP, serial: 0 }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param path Normalized path. @returns Unique owning group. */
|
|
21
|
+
export function ownerOf(layout: EditorLayout, path: string): Group | undefined {
|
|
22
|
+
return layout.groups.find((group) => group.tabs.some((tab) => tab.path === path))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param group Group id. @param path Optional tab selection. @returns Focused layout. */
|
|
26
|
+
export function focusGroup(layout: EditorLayout, group: string, path?: string): EditorLayout {
|
|
27
|
+
const found = layout.groups.find((item) => item.id === group)
|
|
28
|
+
if (!found) return layout
|
|
29
|
+
const active = path && found.tabs.some((tab) => tab.path === path) ? path : found.active
|
|
30
|
+
if (layout.focused === group && active === found.active) return layout
|
|
31
|
+
return { ...layout, focused: group, groups: layout.groups.map((item) => item.id === group ? { ...item, active } : item) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @private @author ddj 2026年10月14号 @param node Split subtree. @param group Leaf to replace. @param next Replacement. @returns Rewritten tree. */
|
|
35
|
+
function replaceLeaf(node: LayoutNode, group: string, next: LayoutNode): LayoutNode {
|
|
36
|
+
if ('group' in node) return node.group === group ? next : node
|
|
37
|
+
return { ...node, first: replaceLeaf(node.first, group, next), second: replaceLeaf(node.second, group, next) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @private @author ddj 2026年10月14号 @param node Split subtree. @param alive Surviving ids. @returns Collapsed tree or null. */
|
|
41
|
+
function pruneTree(node: LayoutNode, alive: Set<string>): LayoutNode | null {
|
|
42
|
+
if ('group' in node) return alive.has(node.group) ? node : null
|
|
43
|
+
const first = pruneTree(node.first, alive)
|
|
44
|
+
const second = pruneTree(node.second, alive)
|
|
45
|
+
return first && second ? { ...node, first, second } : first ?? second
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @private @author ddj 2026年10月14号 @param layout Changed layout. @returns Empty auxiliary groups removed; primary group retained. */
|
|
49
|
+
function compact(layout: EditorLayout): EditorLayout {
|
|
50
|
+
const groups = layout.groups.filter((group) => group.id === MAIN_GROUP || group.tabs.length)
|
|
51
|
+
const alive = new Set(groups.map((group) => group.id))
|
|
52
|
+
return { ...layout, groups, tree: pruneTree(layout.tree, alive) ?? { group: MAIN_GROUP }, floats: layout.floats.filter((item) => alive.has(item.group)), focused: alive.has(layout.focused) ? layout.focused : MAIN_GROUP }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @private @author ddj 2026年10月14号 @param tabs Target tabs without moved tab. @param tab Moved record. @param index Requested insertion slot. @returns Pinned-partition-safe insertion. */
|
|
56
|
+
function insertAt(tabs: TabLike[], tab: TabLike, index = tabs.length): TabLike[] {
|
|
57
|
+
const pins = tabs.filter((item) => item.pinned).length
|
|
58
|
+
const at = tab.pinned ? Math.max(0, Math.min(pins, index)) : Math.max(pins, Math.min(tabs.length, index))
|
|
59
|
+
return [...tabs.slice(0, at), tab, ...tabs.slice(at)]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param path Existing normalized file. @param target Accepted destination. @returns Atomic move; invalid target leaves original unchanged. */
|
|
63
|
+
export function moveTab(layout: EditorLayout, path: string, target: DropTarget): EditorLayout {
|
|
64
|
+
const source = ownerOf(layout, path)
|
|
65
|
+
const tab = source?.tabs.find((item) => item.path === path)
|
|
66
|
+
if (!source || !tab) return layout
|
|
67
|
+
if (target.kind !== 'group' && layout.groups.length >= GROUP_LIMIT) return layout
|
|
68
|
+
const destination = target.kind !== 'float' ? layout.groups.find((item) => item.id === target.group) : null
|
|
69
|
+
if (target.kind !== 'float' && !destination) return layout
|
|
70
|
+
if (target.kind === 'split' && layout.floats.some((item) => item.group === target.group)) return layout
|
|
71
|
+
const id = target.kind === 'group' ? target.group : 'group-' + (layout.serial + 1)
|
|
72
|
+
let index = target.kind === 'group' ? target.index : undefined
|
|
73
|
+
const oldIndex = source.tabs.findIndex((item) => item.path === path)
|
|
74
|
+
if (source.id === id && index !== undefined && oldIndex < index) index -= 1
|
|
75
|
+
let groups = layout.groups.map((group) => {
|
|
76
|
+
const tabs = group.tabs.filter((item) => item.path !== path)
|
|
77
|
+
if (group.id === id) return { ...group, tabs: insertAt(tabs, tab, index), active: path }
|
|
78
|
+
return { ...group, tabs, active: pickActive(tabs, group.active) }
|
|
79
|
+
})
|
|
80
|
+
let tree = layout.tree
|
|
81
|
+
let floats = layout.floats
|
|
82
|
+
if (target.kind !== 'group') groups = [...groups, { id, tabs: [tab], active: path }]
|
|
83
|
+
if (target.kind === 'float') floats = [...floats, { ...target.rect, group: id }]
|
|
84
|
+
if (target.kind === 'split') {
|
|
85
|
+
const before = target.edge === 'left' || target.edge === 'top'
|
|
86
|
+
const old: LayoutNode = { group: target.group }
|
|
87
|
+
const added: LayoutNode = { group: id }
|
|
88
|
+
tree = replaceLeaf(tree, target.group, { axis: target.edge === 'left' || target.edge === 'right' ? 'horizontal' : 'vertical', ratio: 0.5, first: before ? added : old, second: before ? old : added })
|
|
89
|
+
}
|
|
90
|
+
return compact({ ...layout, groups, tree, floats, focused: id, serial: target.kind === 'group' ? layout.serial : layout.serial + 1 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param group Auxiliary group. @returns All tabs returned to main without closing documents. */
|
|
94
|
+
export function returnGroup(layout: EditorLayout, group: string): EditorLayout {
|
|
95
|
+
const found = layout.groups.find((item) => item.id === group)
|
|
96
|
+
if (!found || group === MAIN_GROUP) return layout
|
|
97
|
+
let next = layout
|
|
98
|
+
for (const tab of found.tabs) next = moveTab(next, tab.path, { kind: 'group', group: MAIN_GROUP })
|
|
99
|
+
return next
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param tabs Global file records. @param active Optional newly selected file. @returns Layout reconciled with opens/closes/renames. */
|
|
103
|
+
export function syncTabs(layout: EditorLayout, tabs: TabLike[], active?: string | null): EditorLayout {
|
|
104
|
+
const byPath = new Map(tabs.map((tab) => [tab.path, tab]))
|
|
105
|
+
const groups = layout.groups.map((group) => {
|
|
106
|
+
const kept = group.tabs.flatMap((tab) => byPath.has(tab.path) ? [byPath.get(tab.path)!] : [])
|
|
107
|
+
return { ...group, tabs: kept, active: pickActive(kept, group.active) }
|
|
108
|
+
})
|
|
109
|
+
const seen = new Set(groups.flatMap((group) => group.tabs.map((tab) => tab.path)))
|
|
110
|
+
const focused = groups.find((group) => group.id === layout.focused) ?? groups[0]!
|
|
111
|
+
for (const tab of tabs) if (!seen.has(tab.path)) focused.tabs = insertAt(focused.tabs, tab)
|
|
112
|
+
let next = compact({ ...layout, groups })
|
|
113
|
+
if (active) {
|
|
114
|
+
const owner = ownerOf(next, active)
|
|
115
|
+
if (owner) next = focusGroup(next, owner.id, active)
|
|
116
|
+
}
|
|
117
|
+
return next
|
|
118
|
+
}
|
|
119
|
+
// --endregion
|
|
120
|
+
|
|
121
|
+
// --region Persistence and geometry
|
|
122
|
+
/** @public @author ddj 2026年10月14号 @param rect Floating bounds. @param width Viewport width. @param height Viewport height. @returns Visible, finite rectangle. */
|
|
123
|
+
export function clampFloat(rect: FloatRect, width: number, height: number): FloatRect {
|
|
124
|
+
const w = Math.max(1, width), h = Math.max(1, height)
|
|
125
|
+
const sizeW = Math.min(w, Math.max(Math.min(360, w), Number.isFinite(rect.width) ? rect.width : 600))
|
|
126
|
+
const sizeH = Math.min(h, Math.max(Math.min(240, h), Number.isFinite(rect.height) ? rect.height : 400))
|
|
127
|
+
return { width: sizeW, height: sizeH, left: Math.max(0, Math.min(w - sizeW, Number.isFinite(rect.left) ? rect.left : 0)), top: Math.max(0, Math.min(h - sizeH, Number.isFinite(rect.top) ? rect.top : 0)) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @private @author ddj 2026年10月14号 @param raw Unknown tree. @param ids Available nonfloating groups. @param seen Leaf uniqueness guard. @param depth Recursion budget. @returns Valid tree or null. */
|
|
131
|
+
function parseTree(raw: unknown, ids: Set<string>, seen: Set<string>, depth = 0): LayoutNode | null {
|
|
132
|
+
if (!raw || typeof raw !== 'object' || depth > GROUP_LIMIT) return null
|
|
133
|
+
const node = raw as Record<string, unknown>
|
|
134
|
+
if (typeof node.group === 'string') {
|
|
135
|
+
if (!ids.has(node.group) || seen.has(node.group)) return null
|
|
136
|
+
seen.add(node.group)
|
|
137
|
+
return { group: node.group }
|
|
138
|
+
}
|
|
139
|
+
if (node.axis !== 'horizontal' && node.axis !== 'vertical') return null
|
|
140
|
+
if (typeof node.ratio !== 'number' || !Number.isFinite(node.ratio)) return null
|
|
141
|
+
const first = parseTree(node.first, ids, seen, depth + 1)
|
|
142
|
+
const second = parseTree(node.second, ids, seen, depth + 1)
|
|
143
|
+
return first && second ? { axis: node.axis, ratio: Math.max(0.1, Math.min(0.9, node.ratio)), first, second } : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @public @author ddj 2026年10月14号 @param text Saved layout JSON. @param cwd Workspace path normalization. @returns Valid bounded layout or null for legacy fallback. */
|
|
147
|
+
export function parseLayout(text: string | null, cwd?: string | null): EditorLayout | null {
|
|
148
|
+
if (!text) return null
|
|
149
|
+
try {
|
|
150
|
+
const raw = JSON.parse(text)
|
|
151
|
+
if (raw?.v !== 1 || !Array.isArray(raw.groups) || !Array.isArray(raw.floats)) return null
|
|
152
|
+
if (!raw.groups.length || raw.groups.length > GROUP_LIMIT) return null
|
|
153
|
+
const seen = new Set<string>(), paths = new Set<string>()
|
|
154
|
+
const groups: Group[] = []
|
|
155
|
+
for (const item of raw.groups) {
|
|
156
|
+
if (typeof item?.id !== 'string' || !item.id || seen.has(item.id)) return null
|
|
157
|
+
seen.add(item.id)
|
|
158
|
+
const tabs = normalizeTabs(item.tabs, cwd)
|
|
159
|
+
if (tabs.some((tab) => paths.has(tab.path))) return null
|
|
160
|
+
for (const tab of tabs) paths.add(tab.path)
|
|
161
|
+
groups.push({ id: item.id, tabs, active: pickActive(tabs, typeof item.active === 'string' ? tabPathOf(item.active, cwd) : null) })
|
|
162
|
+
}
|
|
163
|
+
if (!seen.has(MAIN_GROUP)) return null
|
|
164
|
+
const floats: FloatGroup[] = [], floating = new Set<string>()
|
|
165
|
+
for (const item of raw.floats) {
|
|
166
|
+
if (!seen.has(item?.group) || item.group === MAIN_GROUP || floating.has(item.group)) return null
|
|
167
|
+
if (![item.left, item.top, item.width, item.height].every(Number.isFinite)) return null
|
|
168
|
+
floating.add(item.group)
|
|
169
|
+
floats.push({ group: item.group, ...clampFloat(item, 100000, 100000) })
|
|
170
|
+
}
|
|
171
|
+
const leaves = new Set<string>(), ids = new Set([...seen].filter((id) => !floating.has(id)))
|
|
172
|
+
const tree = parseTree(raw.tree, ids, leaves)
|
|
173
|
+
if (!tree || leaves.size !== ids.size) return null
|
|
174
|
+
const serial = Number.isSafeInteger(raw.serial) && raw.serial >= 0 ? raw.serial : 0
|
|
175
|
+
if (groups.some((group) => group.id !== MAIN_GROUP && (!/^group-\d+$/.test(group.id) || Number(group.id.slice(6)) > serial))) return null
|
|
176
|
+
return compact({ v: 1, tree, groups, floats, focused: seen.has(raw.focused) ? raw.focused : MAIN_GROUP, serial })
|
|
177
|
+
} catch { return null }
|
|
178
|
+
}
|
|
179
|
+
// --endregion
|
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
if (oldest
|
|
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
|
+
}
|
package/src/client/tabMenu.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* EditorView 只把结果映射成 ContextMenu 的 entries 并派发动作。
|
|
5
5
|
*
|
|
6
6
|
* 与参考图(VS Code / CodeBuddy 页签右键菜单)的对齐口径:
|
|
7
|
-
* 保留其分组顺序(对话 / 关闭族 / 路径 / 定位 /
|
|
8
|
-
*
|
|
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-
|
|
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 },
|