diff --git a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md index bafa911043..93af4d3ef6 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md @@ -30,6 +30,15 @@ LSP 日志同样是普通编辑器标签,不登记底部工具窗口。单个 文件通过功能模型直接打开时,也必须让日志标签让出活动选择;工作区重置移除日志标签。 与数据库 PR #17 集成后,数据库标签继续使用其共享工作区和查询状态,统一保留在同一个编辑分组,只允许组内重排;不能直接开放跨组移动,否则两个分组会显示同一数据库上下文。数据库工具窗口及 Services 工具窗口仍支持停靠分组。 +### 终端标签使用独立的 AppKit 拖放 + +终端标签不能继续使用 SwiftUI `.onDrag`。Monaco 所在的 `WKWebView` 会消费拖放目标,祖先视图的 `.onDrop` 收不到终端 payload;带文本兜底的 `NSItemProvider` 还会把 “Terminal” 当文本插入编辑器。 +拖拽源因此改为 AppKit `NSDraggingSession`,pasteboard 只写私有 `com.lithe.terminal-tab`;编辑器工作区、空编辑器和工具窗正文各放一个只在终端拖拽期间可命中的 AppKit drop overlay。工具窗覆盖层只包住终端正文,不覆盖工具栏,让拖回已有标签左侧或右侧的操作继续使用 SwiftUI 的前后插入逻辑;不能整窗截获后统一追加到末尾。编辑器内继续复用 `EditorGroupDragState` 的分栏预览,标签栏仍交给 SwiftUI 重排。所有预览目标通过同一个去重入口发布,相同落点和重复清空不通知整个编辑分组布局。 +外部标签通过 `EditorTabOrderFeatureModel.insert` 加入布局:边缘总是新建分组,中心加入现有分组;工具窗与编辑器之间只移动 placement,不重建终端会话或 PTY。 + +正确做法:把工具窗终端拖到编辑器底边得到上下分屏,再把同一标签拖回工具窗,shell 提示符和 scrollback 保持连续。 +不要这样做:为拖拽重建终端会话,或让工作区继续依赖 Monaco 不会转发的 SwiftUI `.onDrop` payload。 + ### Git Diff 也属于普通编辑标签 所有仓库比较入口通过 `AppModel` 的共享标签动作进入 `EditorTabItem.repositoryDiff(UUID)`。 @@ -74,7 +83,7 @@ Git Log 的当前提交选择与已打开的历史比较分开,上一文件/ 根编辑区使用稳定的局部状态持有落点几何;不能在每次父视图重建时重新创建被观察对象,否则几何未改变时不会再发布偏好,真实预览会丢失落点。 每个标签组只安装一个原生鼠标监听,完整处理标签按下、拖动和松开,避免 SwiftUI 进入局部鼠标跟踪后漏接跨原生编辑器的快速拖动。 -关闭按钮、Control 点击和终端原生拖放仍交给原控件;普通点击、辅助功能动作和原右键菜单继续走现有选择动作。 +关闭按钮、Control 点击和终端标签的 AppKit 原生拖放仍交给原控件;普通点击、辅助功能动作和原右键菜单继续走现有选择动作。 鼠标监听按原生 `visibleRect`(经祖先与滚动视口裁剪后的可见区域)命中,隐藏标签不能截获相邻分组点击。分组记录实际标签栏矩形,完整排除第二、第三行再检测正文边缘,不能假设单行 36pt 高度。正文焦点进入时调用完整文档选择动作,清除终端/数据库活动标志;保留的文档 ID 不能代替当前活动项判断。 预览几何在拖动开始冻结,鼠标移动只移动原生面板;目标改变才发布临时半区。 Esc、来源卸载和取消均清理面板;无效落点不改布局。焦点进入另一组文件时,菜单命令同步作用于该文件。 @@ -108,6 +117,8 @@ Windows 的既有分栏实现不受此次 macOS 呈现改动影响,完整两 - `./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh` - `./scripts/agent-notes/verify-agent-notes.sh` - 原生事件检查右侧、底部半区、释放提交、Esc 取消、重排、关闭与焦点;实机预览另检查真实字体资源、代码输入、灰色浮动标签及分隔器调整。 +- 终端标签拖放:聚焦测试覆盖外部插入/边缘分屏、私有 pasteboard 与拖拽状态、工具窗终端拖入编辑器不重建进程;真实窗口录屏确认工具窗→底边分屏→右侧分屏→工具窗往返中 PTY 未重建。 +- `TerminalPlacementFeatureModelTests.toolTerminalDropOverlayLeavesTheTabBarAvailable` 在两种面板高度挂载真实工具窗视图,检查正文覆盖层不命中标签栏;`EditorTabOrderFeatureModelTests.editorGroupDropPreviewPublishesOnlyChangedTargets` 检查相同落点、目标切换和重复清空的发布次数。实机拖放仍需检查拖回已有标签前后的位置,组件命中验证不代替系统拖放验收。 - `EditorTabOrderFeatureModelTests` 对工作区、目录、分支/引用、历史、保存更改、冲突与行标记入口在深浅主题下挂载原生编辑分组,检查普通标签、切换保留、分屏与共享关闭;`GitModuleTests` 检查独立会话并发加载、关闭一个会话、刷新及迟到结果隔离。组件验证不替代完整产品与 Windows 实机验收。 - `EditorTabOrderFeatureModelTests.explicitNewDiffTabKeepsAnIndependentSessionWhileOrdinaryOpenReusesItsPreview` 验证同文件显式新标签与普通去重共存;`VcsProviderAppModelTests.providerDiffTabsOwnTheirPendingRequestsWithoutStealingFocus` @@ -118,8 +129,14 @@ Windows 的既有分栏实现不受此次 macOS 呈现改动影响,完整两 - `macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift` - `macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift` - `macos/Sources/Lithe/Models/AppModel/AppModel+EditorGroups.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift` - `macos/Sources/Lithe/Views/Editor/EditorAreaView.swift` - `macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift` - `macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift` +- `macos/Sources/Lithe/Views/Terminal/TerminalTabInteraction.swift` +- `macos/Sources/Lithe/Views/Terminal/TerminalTabDragPayload.swift` +- `macos/Sources/Lithe/Views/Terminal/TerminalView.swift` - `macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift` +- `macos/Tests/LitheTests/EditorTabLayoutTests.swift` +- `macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift` - `macos/Tests/LitheTests/MonacoWorkbenchSessionLifetimeTests.swift` diff --git a/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift b/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift index bc83433c71..a69ec06b78 100644 --- a/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift +++ b/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift @@ -20,6 +20,14 @@ indirect enum EditorGroupLayout: Equatable { } } + /// Splits `id` along `side`; a leading edge puts the new group first. + func splitting(_ id: UUID, by side: EditorGroupDropSide, newGroup: UUID) -> EditorGroupLayout { + let new = Self.group(newGroup), old = Self.group(id) + let leading = side == .left || side == .top + let layout = Self.split(id: UUID(), vertical: side.isVertical, first: leading ? new : old, second: leading ? old : new) + return replacing(id, with: layout) + } + func keeping(_ ids: Set) -> EditorGroupLayout? { switch self { case .group(let id): return ids.contains(id) ? self : nil @@ -31,4 +39,8 @@ indirect enum EditorGroupLayout: Equatable { } } -enum EditorGroupDropSide: CaseIterable { case center, left, right, top, bottom } +enum EditorGroupDropSide: CaseIterable { + case center, left, right, top, bottom + + var isVertical: Bool { self == .top || self == .bottom } +} diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index 8b6e9b2e9e..7be829f774 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -64,6 +64,9 @@ final class EditorTabOrderFeatureModel: ObservableObject { items.filter { placements[$0] == group } } + /// Group presenting `item`, or nil while it is outside the editor (for example in the tool window). + func group(containing item: EditorTabItem) -> UUID? { placements[item] } + /// Close menus operate on visible group order and leave mixed non-document tabs open. func documentCloseTargets(for id: UUID, in group: UUID?) -> (other: [UUID], left: [UUID], right: [UUID]) { let tabs = group.map { items(in: $0) } ?? items @@ -141,10 +144,7 @@ final class EditorTabOrderFeatureModel: ObservableObject { if ordered != items { items = ordered } } else { let created = UUID() - let new = EditorGroupLayout.group(created), old = EditorGroupLayout.group(group) - let leading = side == .left || side == .top - groupLayout = groupLayout.replacing(group, with: .split(id: UUID(), - vertical: side == .top || side == .bottom, first: leading ? new : old, second: leading ? old : new)) + groupLayout = groupLayout.splitting(group, by: side, newGroup: created) placements[item] = created } reconcileGroups() @@ -152,6 +152,39 @@ final class EditorTabOrderFeatureModel: ObservableObject { return true } + /// Adds a tab from outside the editor; an edge always splits because the target group does not own it yet. + @discardableResult + func insert(_ item: EditorTabItem, into group: UUID?, side: EditorGroupDropSide) -> Bool { + guard !items.contains(item) else { return false } + let groups = groupLayout.groups + guard let group else { + let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] + placements[item] = destination + var next = items + next.append(item) + items = next + focus(item) + return true + } + guard groups.contains(group) else { return false } + if side == .center { + placements[item] = group + var next = items + let last = next.lastIndex { placements[$0] == group } + next.insert(item, at: last.map { $0 + 1 } ?? next.count) + items = next + } else { + let created = UUID() + groupLayout = groupLayout.splitting(group, by: side, newGroup: created) + placements[item] = created + var next = items + next.append(item) + items = next + } + focus(item) + return true + } + private func reconcileGroups() { let groups = groupLayout.groups let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index e3618035fc..a4578f0546 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -286,6 +286,21 @@ extension AppModel { _ = terminalFeature?.selectSession(session) } + /// Moves a terminal into an editor group. It joins the tab order first so an edge drop commits the split. + /// A nil group targets the focused editor group. + @discardableResult + func moveTerminalToEditor(_ sessionID: UUID, into group: UUID?, side: EditorGroupDropSide) -> Bool { + guard terminalSessions.contains(where: { $0.id == sessionID }) else { return false } + let item = EditorTabItem.terminal(sessionID) + if editorTabOrderFeature.contains(item) { + guard let group, editorTabOrderFeature.drop(item, into: group, side: side) else { return false } + } else { + guard editorTabOrderFeature.insert(item, into: group, side: side) else { return false } + } + selectEditorTab(item) + return true + } + func moveTerminalToEditor(_ sessionID: UUID, before targetSessionID: UUID) { moveEditorTab(.terminal(sessionID), before: .terminal(targetSessionID)) } diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index bee37358fd..436be6c9a7 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -625,20 +625,11 @@ struct EditorAreaView: View { .padding(.leading, 8) .frame(height: 28) .contentShape(Rectangle()) - .onTapGesture { - model.selectEditorTerminalSession(session) - session.focus() - } - // Terminal sessions retain their native cross-container transfer. - .onDrag { - TerminalTabDragPayload.provider(for: session.id) - } preview: { - Image(systemName: "terminal") - .font(LitheTheme.uiFont(size: 10, weight: .medium)) - .foregroundStyle(LitheTheme.accent) - .frame(width: 20, height: 20) - .background(LitheTheme.activeTabBackground) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay { + TerminalTabInteraction(sessionID: session.id, title: model.terminalTitle(for: session)) { + model.selectEditorTerminalSession(session) + session.focus() + } } .accessibilityElement(children: .combine) .accessibilityLabel(model.terminalTitle(for: session)) @@ -876,7 +867,7 @@ struct EditorAreaView: View { tabDragPreview.move(by: translation) if let groupID { let destination = drag.destination(at: location, sourceGroup: groupID) - if drag.target != destination { drag.target = destination } + drag.updateTarget(destination) } guard tabReorderTarget != plan.target else { return } tabReorderTarget = plan.target @@ -1391,6 +1382,7 @@ struct EditorAreaView: View { .offset(y: geometry.size.height * 0.375 - LitheTheme.uiNSFont(size: 16).ascender) } .clipped() + .overlay { TerminalEmptyEditorDropOverlay(model: model) } } @ViewBuilder diff --git a/macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift b/macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift index 6e179185de..b2ca1933e0 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift @@ -13,11 +13,16 @@ final class EditorGroupDragState: ObservableObject { var tabFrames: [UUID: CGRect] = [:] private var dragTabFrames: [UUID: CGRect] = [:] private var dragFrames: [UUID: CGRect] = [:] - @Published var target: Target? - func begin() { dragFrames = frames; dragTabFrames = tabFrames; target = nil } - func finish() { target = nil; dragFrames = [:]; dragTabFrames = [:] } + @Published private(set) var target: Target? + func begin() { dragFrames = frames; dragTabFrames = tabFrames; updateTarget(nil) } + func finish() { updateTarget(nil); dragFrames = [:]; dragTabFrames = [:] } - func destination(at point: CGPoint, sourceGroup: UUID) -> Target? { + func updateTarget(_ target: Target?) { + guard self.target != target else { return } + self.target = target + } + + func destination(at point: CGPoint, sourceGroup: UUID?) -> Target? { guard let (group, fullFrame) = dragFrames.first(where: { $0.value.contains(point) }) else { return nil } // The tab strip remains a reorder/move target, never a split edge. guard let tabFrame = dragTabFrames[group] else { return nil } @@ -48,11 +53,13 @@ final class EditorGroupDragState: ObservableObject { struct EditorGroupContainer: View { @ObservedObject var feature: EditorTabOrderFeatureModel @ObservedObject var drag: EditorGroupDragState + @EnvironmentObject private var model: AppModel var body: some View { pane(feature.groupLayout) .coordinateSpace(name: editorWorkspaceCoordinateSpaceName) .onPreferenceChange(EditorGroupFramePreference.self) { drag.frames = $0 } .onPreferenceChange(EditorGroupTabFramePreference.self) { drag.tabFrames = $0 } + .overlay { TerminalEditorDropOverlay(drag: drag, model: model) } .overlay(alignment: .topLeading) { if let target = drag.target { Rectangle().fill(LitheTheme.editorTabDropBackground) diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalTabDragPayload.swift b/macos/Sources/Lithe/Views/Terminal/TerminalTabDragPayload.swift index 0fe426db1a..798308acd2 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalTabDragPayload.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalTabDragPayload.swift @@ -5,24 +5,20 @@ enum TerminalTabDragPayload { static let type = UTType(exportedAs: "com.lithe.terminal-tab") static let pasteboardType = NSPasteboard.PasteboardType(type.identifier) private static let activeDrag = ActiveTerminalTabDrag() + private static let dragState = TerminalTabDragState() static var activeSessionID: UUID? { activeDrag.sessionID } + static var isDragging: Bool { dragState.isDragging } - static func provider(for sessionID: UUID) -> NSItemProvider { + /// AppKit drags own the session for the gesture; drops read the pasteboard and fall back here. + static func beginDrag(_ sessionID: UUID) { activeDrag.store(sessionID) - let data = Data(sessionID.uuidString.utf8) - // AppKit needs a concrete standard representation to establish the - // native drag session; the private representation identifies terminal - // tabs to the receiving workbench. - let provider = NSItemProvider(object: "Terminal" as NSString) - provider.registerDataRepresentation( - forTypeIdentifier: type.identifier, - visibility: .ownProcess - ) { completion in - completion(data, nil) - return nil - } - return provider + dragState.set(true) + } + + static func endDrag() { + dragState.set(false) + activeDrag.clear() } static func sessionID(from data: Data) -> UUID? { @@ -30,6 +26,10 @@ enum TerminalTabDragPayload { } static func sessionID(from pasteboard: NSPasteboard) -> UUID? { + if let value = pasteboard.string(forType: pasteboardType), + let sessionID = UUID(uuidString: value) { + return sessionID + } if let data = pasteboard.data(forType: pasteboardType), let sessionID = sessionID(from: data) { return sessionID @@ -68,6 +68,23 @@ enum TerminalTabDragPayload { } } +private final class TerminalTabDragState: @unchecked Sendable { + private let lock = NSLock() + private var dragging = false + + var isDragging: Bool { + lock.lock() + defer { lock.unlock() } + return dragging + } + + func set(_ value: Bool) { + lock.lock() + dragging = value + lock.unlock() + } +} + private final class ActiveTerminalTabDrag: @unchecked Sendable { private let lock = NSLock() private var storedSessionID: UUID? @@ -83,4 +100,10 @@ private final class ActiveTerminalTabDrag: @unchecked Sendable { storedSessionID = sessionID lock.unlock() } + + func clear() { + lock.lock() + storedSessionID = nil + lock.unlock() + } } diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalTabInteraction.swift b/macos/Sources/Lithe/Views/Terminal/TerminalTabInteraction.swift new file mode 100644 index 0000000000..3a2ea66fde --- /dev/null +++ b/macos/Sources/Lithe/Views/Terminal/TerminalTabInteraction.swift @@ -0,0 +1,300 @@ +import AppKit +import SwiftUI + +/// AppKit drag source for terminal tabs; SwiftUI's public payload would be inserted as editor text. +struct TerminalTabInteraction: NSViewRepresentable { + let sessionID: UUID + let title: String + let activate: () -> Void + + func makeNSView(context: Context) -> TerminalTabInteractionView { + let view = TerminalTabInteractionView() + view.sessionID = sessionID + view.title = title + view.activate = activate + return view + } + + func updateNSView(_ view: TerminalTabInteractionView, context: Context) { + view.sessionID = sessionID + view.title = title + view.activate = activate + view.toolTip = title + } +} + +final class TerminalTabInteractionView: NSView, NSDraggingSource { + var sessionID: UUID? + var title = "" + var activate: (() -> Void)? + private var mouseDownEvent: NSEvent? + + /// Only left-button gestures belong to the tab; hover and context menus stay with SwiftUI underneath. + override func hitTest(_ point: NSPoint) -> NSView? { + switch NSApp.currentEvent?.type { + case .leftMouseDown, .leftMouseDragged, .leftMouseUp: + return super.hitTest(point) + default: + return nil + } + } + + override func mouseDown(with event: NSEvent) { + mouseDownEvent = event + } + + override func mouseUp(with event: NSEvent) { + guard mouseDownEvent != nil else { return } + mouseDownEvent = nil + activate?() + } + + override func mouseDragged(with event: NSEvent) { + guard let start = mouseDownEvent, let sessionID, window != nil else { return } + let dx = event.locationInWindow.x - start.locationInWindow.x + let dy = event.locationInWindow.y - start.locationInWindow.y + guard hypot(dx, dy) >= 4 else { return } + mouseDownEvent = nil + + let item = NSPasteboardItem() + item.setString(sessionID.uuidString, forType: TerminalTabDragPayload.pasteboardType) + let draggingItem = NSDraggingItem(pasteboardWriter: item) + let location = convert(event.locationInWindow, from: nil) + let fallback = NSImage(size: NSSize(width: 24, height: 24)) + let image = NSImage(systemSymbolName: "terminal", accessibilityDescription: nil) ?? fallback + let frame = NSRect(x: location.x - 12, y: location.y - 12, width: 24, height: 24) + draggingItem.setDraggingFrame(frame, contents: image) + TerminalTabDragPayload.beginDrag(sessionID) + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + session.animatesToStartingPositionsOnCancelOrFail = true + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + context == .withinApplication ? .move : [] + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + TerminalTabDragPayload.endDrag() + } +} + +/// Covers only the tool canvas; the toolbar retains its per-tab insertion targets. +struct TerminalToolDropOverlay: NSViewRepresentable { + let model: AppModel + + func makeNSView(context: Context) -> TerminalToolDropOverlayView { + let view = TerminalToolDropOverlayView() + view.model = model + return view + } + + func updateNSView(_ view: TerminalToolDropOverlayView, context: Context) { + view.model = model + } +} + +final class TerminalToolDropOverlayView: NSView { + var model: AppModel? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([TerminalTabDragPayload.pasteboardType]) + } + + required init?(coder: NSCoder) { nil } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard TerminalTabDragPayload.isDragging, + let sessionID = TerminalTabDragPayload.activeSessionID, + model?.editorTabOrderFeature.contains(.terminal(sessionID)) == true else { return nil } + return super.hitTest(point) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + isEditorTerminal(sender) ? .move : [] + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + isEditorTerminal(sender) ? .move : [] + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + isEditorTerminal(sender) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let sessionID = TerminalTabDragPayload.sessionID(from: sender.draggingPasteboard), + let model, model.editorTabOrderFeature.contains(.terminal(sessionID)) else { return false } + model.moveTerminalToTool(sessionID) + return true + } + + private func isEditorTerminal(_ sender: NSDraggingInfo) -> Bool { + guard let sessionID = TerminalTabDragPayload.sessionID(from: sender.draggingPasteboard), + let model else { return false } + return model.editorTabOrderFeature.contains(.terminal(sessionID)) + } +} + +/// Empty editor area needs its own destination before any group container exists. +struct TerminalEmptyEditorDropOverlay: NSViewRepresentable { + let model: AppModel + + func makeNSView(context: Context) -> TerminalEmptyEditorDropOverlayView { + let view = TerminalEmptyEditorDropOverlayView() + view.model = model + return view + } + + func updateNSView(_ view: TerminalEmptyEditorDropOverlayView, context: Context) { + view.model = model + } +} + +final class TerminalEmptyEditorDropOverlayView: NSView { + var model: AppModel? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([TerminalTabDragPayload.pasteboardType]) + } + + required init?(coder: NSCoder) { nil } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard TerminalTabDragPayload.isDragging, + let sessionID = TerminalTabDragPayload.activeSessionID, + model?.editorTabOrderFeature.contains(.terminal(sessionID)) == false else { return nil } + return super.hitTest(point) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + isToolTerminal(sender) ? .move : [] + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + isToolTerminal(sender) ? .move : [] + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + isToolTerminal(sender) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let sessionID = TerminalTabDragPayload.sessionID(from: sender.draggingPasteboard), + let model, !model.editorTabOrderFeature.contains(.terminal(sessionID)) else { return false } + return model.moveTerminalToEditor(sessionID, into: nil, side: .center) + } + + private func isToolTerminal(_ sender: NSDraggingInfo) -> Bool { + guard let sessionID = TerminalTabDragPayload.sessionID(from: sender.draggingPasteboard), + let model else { return false } + return !model.editorTabOrderFeature.contains(.terminal(sessionID)) + } +} + +/// AppKit drop target above the editor surface. Monaco consumes drags inside its WebView. +/// The view is hit-testable only during a terminal drag and defers tab strips to SwiftUI. +struct TerminalEditorDropOverlay: NSViewRepresentable { + let drag: EditorGroupDragState + let model: AppModel + + func makeNSView(context: Context) -> TerminalEditorDropOverlayView { + let view = TerminalEditorDropOverlayView() + view.drag = drag + view.model = model + return view + } + + func updateNSView(_ view: TerminalEditorDropOverlayView, context: Context) { + view.drag = drag + view.model = model + } +} + +final class TerminalEditorDropOverlayView: NSView { + var drag: EditorGroupDragState? + var model: AppModel? + private var currentTarget: EditorGroupDragState.Target? + + /// SwiftUI records group and tab frames in a top-left coordinate space. + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([TerminalTabDragPayload.pasteboardType]) + } + + required init?(coder: NSCoder) { nil } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard TerminalTabDragPayload.isDragging else { return nil } + // Tab strips keep their own reorder/move targets. + if let drag, let superview { + let local = convert(point, from: superview) + if drag.tabFrames.values.contains(where: { $0.contains(local) }) { + return nil + } + } + return super.hitTest(point) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard sessionID(from: sender) != nil else { return [] } + drag?.begin() + return update(sender) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + update(sender) + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + currentTarget = nil + drag?.finish() + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + currentTarget != nil + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let sessionID = sessionID(from: sender), let target = currentTarget else { + drag?.finish() + return false + } + currentTarget = nil + drag?.finish() + return model?.moveTerminalToEditor(sessionID, into: target.group, side: target.side) ?? false + } + + override func concludeDragOperation(_ sender: NSDraggingInfo?) { + currentTarget = nil + drag?.finish() + } + + private func sessionID(from sender: NSDraggingInfo) -> UUID? { + TerminalTabDragPayload.sessionID(from: sender.draggingPasteboard) + } + + private func update(_ sender: NSDraggingInfo) -> NSDragOperation { + guard let sessionID = sessionID(from: sender), let drag, let model else { return [] } + let point = convert(sender.draggingLocation, from: nil) + let item = EditorTabItem.terminal(sessionID) + let source = model.editorTabOrderFeature.group(containing: item) + guard let target = drag.destination(at: point, sourceGroup: source) else { + drag.updateTarget(nil) + currentTarget = nil + return [] + } + if model.editorTabOrderFeature.contains(item), + !model.editorTabOrderFeature.canDrop(item, into: target.group, side: target.side) { + drag.updateTarget(nil) + currentTarget = nil + return [] + } + drag.updateTarget(target) + currentTarget = target + return .move + } +} diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index 97122eceee..d9e268411f 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -11,6 +11,7 @@ struct TerminalView: View { VStack(spacing: 0) { terminalToolbar terminalCanvas + .overlay { TerminalToolDropOverlay(model: model) } } .contentShape(Rectangle()) .onDrop( @@ -122,18 +123,11 @@ struct TerminalView: View { .padding(.trailing, 3) .frame(height: 28) .contentShape(Rectangle()) - .contentShape( - .dragPreview, - RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) - ) - .onTapGesture { - model.selectTerminalSession(session) - session.focus() - } - .onDrag { - TerminalTabDragPayload.provider(for: session.id) - } preview: { - terminalTabDragPreview(session) + .overlay { + TerminalTabInteraction(sessionID: session.id, title: title) { + model.selectTerminalSession(session) + session.focus() + } } .accessibilityElement(children: .combine) .accessibilityLabel(title) @@ -207,23 +201,6 @@ struct TerminalView: View { } } - private func terminalTabDragPreview(_ session: TerminalSession) -> some View { - HStack(spacing: 7) { - Image(systemName: "terminal") - .font(LitheTheme.uiFont(size: 11, weight: .medium)) - .foregroundStyle(LitheTheme.accent) - Text(terminalTabTitle(for: session)) - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - } - .padding(.horizontal, 10) - .frame(height: LitheTheme.Metrics.tabHeight) - .background(LitheTheme.activeTabBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) - .shadow(color: .black.opacity(0.42), radius: 10, y: 6) - } - private func shellLabel(for path: String) -> String { let name = URL(fileURLWithPath: path).lastPathComponent return path == "/bin/\(name)" ? name : "\(name) (\(path))" diff --git a/macos/Tests/LitheTests/EditorTabLayoutTests.swift b/macos/Tests/LitheTests/EditorTabLayoutTests.swift index 6af0d5a9fd..b4512cf9da 100644 --- a/macos/Tests/LitheTests/EditorTabLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorTabLayoutTests.swift @@ -120,18 +120,6 @@ struct EditorTabLayoutTests { #expect(provider.registeredTypeIdentifiers.contains(UTType.utf8PlainText.identifier)) } - @Test - func terminalTabDragPayloadOffersAConcreteItemAndPrivateSessionIdentifier() throws { - let sessionID = UUID() - let provider = TerminalTabDragPayload.provider(for: sessionID) - let encoded = Data(sessionID.uuidString.utf8) - - #expect(!provider.registeredTypeIdentifiers.contains(EditorTabDragPayload.type.identifier)) - #expect(provider.registeredTypeIdentifiers.contains(TerminalTabDragPayload.type.identifier)) - #expect(provider.registeredTypeIdentifiers.contains(UTType.utf8PlainText.identifier)) - #expect(TerminalTabDragPayload.sessionID(from: encoded) == sessionID) - } - @Test func terminalDragUsesTheSharedEditorTabState() { var state = EditorTabDragState.idle @@ -180,7 +168,8 @@ struct EditorTabLayoutTests { @Test func terminalTabPasteboardUsesTheActiveDragWhenPromisedDataIsNotReady() { let sessionID = UUID() - _ = TerminalTabDragPayload.provider(for: sessionID) + TerminalTabDragPayload.beginDrag(sessionID) + defer { TerminalTabDragPayload.endDrag() } let pasteboard = NSPasteboard( name: .init("lithe-promised-terminal-tab-\(UUID().uuidString)") ) diff --git a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift index 9bdb34eb79..fe5a924359 100644 --- a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift +++ b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift @@ -1,5 +1,6 @@ import Foundation import AppKit +import Combine import SwiftUI @testable import LitheGitModule import Testing @@ -99,6 +100,75 @@ struct EditorTabOrderFeatureModelTests { #expect(feature.groupLayout.groups.count == 1) } + @Test func insertingAnExternalTabCanSplitOrJoinAGroup() throws { + let feature = EditorTabOrderFeatureModel() + let document = EditorTabItem.document(UUID()) + feature.moveToEnd(document) + let group = try #require(feature.groupLayout.groups.first) + let terminal = EditorTabItem.terminal(UUID()) + + #expect(feature.insert(terminal, into: group, side: .bottom)) + let terminalGroup = try #require(feature.group(containing: terminal)) + #expect(terminalGroup != group) + #expect(feature.groupLayout.groups.count == 2) + #expect(feature.items(in: group) == [document]) + #expect(feature.items(in: terminalGroup) == [terminal]) + #expect(feature.focusedItem == terminal) + + let second = EditorTabItem.terminal(UUID()) + #expect(feature.insert(second, into: group, side: .center)) + #expect(feature.items(in: group) == [document, second]) + #expect(!feature.insert(second, into: group, side: .center)) + + let third = EditorTabItem.terminal(UUID()) + #expect(feature.insert(third, into: nil, side: .right)) + #expect(feature.group(containing: third) == feature.group(containing: second)) + } + + @Test func terminalWorkspaceDropResolvesPaneEdgesAndKeepsTabStripsAsCenter() { + let drag = EditorGroupDragState() + let first = UUID(), second = UUID() + drag.frames = [ + first: CGRect(x: 0, y: 0, width: 400, height: 300), + second: CGRect(x: 400, y: 0, width: 400, height: 300) + ] + drag.tabFrames = [ + first: CGRect(x: 0, y: 0, width: 400, height: 36), + second: CGRect(x: 400, y: 0, width: 400, height: 36) + ] + drag.begin() + + #expect(drag.destination(at: CGPoint(x: 5, y: 150), sourceGroup: nil)?.side == .left) + #expect(drag.destination(at: CGPoint(x: 200, y: 295), sourceGroup: nil)?.side == .bottom) + #expect(drag.destination(at: CGPoint(x: 600, y: 150), sourceGroup: nil)?.side == .center) + #expect(drag.destination(at: CGPoint(x: 200, y: 20), sourceGroup: nil)?.side == .center) + #expect(drag.destination(at: CGPoint(x: 200, y: 150), sourceGroup: first) == nil) + #expect(drag.destination(at: CGPoint(x: 600, y: 150), sourceGroup: first)?.side == .center) + #expect(drag.destination(at: CGPoint(x: 405, y: 150), sourceGroup: first)?.side == .left) + } + + @Test func editorGroupDropPreviewPublishesOnlyChangedTargets() throws { + let drag = EditorGroupDragState(), group = UUID() + drag.frames = [group: CGRect(x: 0, y: 0, width: 400, height: 300)] + drag.tabFrames = [group: CGRect(x: 0, y: 0, width: 400, height: 36)] + var publications: [EditorGroupDragState.Target?] = [] + let observation = drag.$target.dropFirst().sink { publications.append($0) } + defer { observation.cancel() } + drag.begin() + + let left = try #require(drag.destination(at: CGPoint(x: 5, y: 150), sourceGroup: nil)) + let right = try #require(drag.destination(at: CGPoint(x: 395, y: 150), sourceGroup: nil)) + for _ in 0..<30 { drag.updateTarget(left) } + #expect(publications == [left]) + for _ in 0..<30 { drag.updateTarget(right) } + #expect(publications == [left, right]) + for _ in 0..<30 { drag.updateTarget(nil) } + drag.finish() + drag.finish() + #expect(publications == [left, right, nil]) + #expect(drag.target == nil) + } + @Test func agentTabsPresentOneSessionEachAndForgetClosedTabs() { let feature = EditorTabOrderFeatureModel() let first = UUID(), second = UUID() diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 0c54111d53..6570d53cc4 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -525,6 +525,97 @@ struct TerminalPlacementFeatureModelTests { return (model, session, transport) } + @Test func droppingAToolWindowTerminalIntoAnEditorEdgeCreatesASplitWithoutRestartingTheProcess() async throws { + let context = makeTerminalCloseContext() + let model = context.model + do { + let url = URL(string: "lithe-test://terminal-split/A.swift")! + model.documentFeature.openVirtualDocument(url, text: "A", displayPath: nil) + model.moveTerminalToTool(context.session.id) + let group = try #require(model.editorTabOrderFeature.groupLayout.groups.first) + let nativeViewID = ObjectIdentifier(context.session.nativeView) + + #expect(model.moveTerminalToEditor(context.session.id, into: group, side: .bottom)) + + let terminalItem = EditorTabItem.terminal(context.session.id) + let terminalGroup = try #require(model.editorTabOrderFeature.group(containing: terminalItem)) + #expect(terminalGroup != group) + #expect(model.editorTabOrderFeature.groupLayout.groups.count == 2) + #expect(model.editorTabOrderFeature.items(in: terminalGroup) == [terminalItem]) + #expect(model.editorTerminalSessions.contains { $0.id == context.session.id }) + #expect(model.toolTerminalSessions.isEmpty) + #expect(context.transport.startCount == 1) + #expect(context.transport.stopCount == 0) + #expect(ObjectIdentifier(context.session.nativeView) == nativeViewID) + } catch { + await model.shutdownProjectSession() + throw error + } + await model.shutdownProjectSession() + } + + @Test + func terminalDragPasteboardKeepsTheSessionPrivateAndTracksTheGesture() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("lithe-terminal-drag-\(UUID())")) + pasteboard.clearContents() + let sessionID = UUID() + let item = NSPasteboardItem() + item.setString(sessionID.uuidString, forType: TerminalTabDragPayload.pasteboardType) + #expect(pasteboard.writeObjects([item])) + #expect(TerminalTabDragPayload.sessionID(from: pasteboard) == sessionID) + // A text representation would be inserted by the embedded editor. + #expect(pasteboard.string(forType: .string) == nil) + + #expect(!TerminalTabDragPayload.isDragging) + TerminalTabDragPayload.beginDrag(sessionID) + #expect(TerminalTabDragPayload.isDragging) + #expect(TerminalTabDragPayload.activeSessionID == sessionID) + TerminalTabDragPayload.endDrag() + #expect(!TerminalTabDragPayload.isDragging) + #expect(TerminalTabDragPayload.activeSessionID == nil) + } + + @Test(arguments: [CGFloat(180), 420]) + func toolTerminalDropOverlayLeavesTheTabBarAvailable(height: CGFloat) async throws { + let context = makeTerminalCloseContext(), model = context.model + do { + let feature = try #require(model.terminalFeature) + model.moveTerminalToEditor(context.session.id) + let toolSession = feature.createSession(in: URL(fileURLWithPath: "/workspace/drop-routing"), shellPath: "/bin/zsh") + model.terminalPlacementFeature.registerSession(toolSession.id) + let host = NSHostingView(rootView: TerminalView(feature: feature) + .environmentObject(model).environmentObject(model.settings)) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: height), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.contentView = nil; window.close() } + func overlay(in view: NSView) -> TerminalToolDropOverlayView? { + if let view = view as? TerminalToolDropOverlayView { return view } + return view.subviews.lazy.compactMap { overlay(in: $0) }.first + } + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(3)) + while (overlay(in: host)?.bounds.height ?? 0) == 0 && clock.now < deadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + let target = try #require(overlay(in: host)) + let parent = try #require(target.superview) + #expect(abs(target.bounds.height - (height - 41)) < 1) + let toolbarPoint = NSPoint(x: host.bounds.midX, + y: host.isFlipped ? host.bounds.minY + 20 : host.bounds.maxY - 20) + let canvasPoint = NSPoint(x: target.bounds.midX, y: target.bounds.midY) + + TerminalTabDragPayload.beginDrag(context.session.id) + defer { TerminalTabDragPayload.endDrag() } + #expect(target.hitTest(parent.convert(toolbarPoint, from: host)) == nil, + "The native canvas must not intercept the toolbar's before/after drop targets") + #expect(target.hitTest(target.convert(canvasPoint, to: parent)) === target) + } catch { await model.shutdownProjectSession(); throw error } + await model.shutdownProjectSession() + } + @Test func movingPresentationNeverStopsOrRecreatesTheTerminalTransport() { let transport = PlacementTestTerminalTransport() diff --git a/shared/platform-feature-matrix/features/terminal-sessions.json b/shared/platform-feature-matrix/features/terminal-sessions.json index 97e9795982..80a0eac136 100644 --- a/shared/platform-feature-matrix/features/terminal-sessions.json +++ b/shared/platform-feature-matrix/features/terminal-sessions.json @@ -2,7 +2,7 @@ "id": "terminal-sessions", "area": "工作台", "group": "终端", - "capability": "多会话与标签;Windows 文件夹右键与全局入口统一在底部终端面板创建会话", + "capability": "多会话与标签;macOS 终端标签边缘分屏和工具窗往返保留 PTY;Windows 文件夹右键与全局入口统一在底部终端面板创建会话", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Terminal", @@ -11,7 +11,10 @@ "macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift", "macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift", "macos/Resources/IDEAIcons/expui/general/chevronDownLarge.svg", - "macos/Resources/IDEAIcons/expui/general/chevronDownLarge_dark.svg" + "macos/Resources/IDEAIcons/expui/general/chevronDownLarge_dark.svg", + "macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift", + "macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift", + "macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -28,5 +31,5 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;macOS 默认标签依次显示 Local、Local (2)、Local (3),从下拉框选择 zsh 后依次显示 zsh、zsh (2),不同 Shell 分别编号;终端与 Git Log 标签悬停有反馈,点击各自区域时选中标签背景显示活动色、点击外部时显示非活动色,文字始终保持相同颜色与常规字重;macOS 终端标题栏的加号、下拉箭头、更多操作和收起图标在明暗主题分别使用 IDEA 的 #6C707E 和 #CED0D6 颜色,按钮动作及悬停效果保持一致;macOS 关闭终端会话直接停止进程,最后一个底部终端关闭后收起面板,编辑区的其他终端会话继续运行;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。 Terminal 失焦通知必须清除标签活动态,包含键盘与程序化切焦。 Windows 隐藏底部终端后,右键含空格的子目录打开终端,确认只创建一个底部会话、工作目录为所选目录、编辑器标签与分栏不变;已有终端时再次从文件夹和工具栏打开,均留在底部面板。自定义 profile 的 Shell 与启动命令继续生效,但文件夹入口显式目录优先于 profile 的启动目录;全局入口仍保留 profile 默认目录。WSL/本地目录相互切换时按最终工作目录选取 Shell;拖动终端到编辑区与拖回底部的既有操作仍可用。 终端全屏后用菜单或快捷键隐藏,再从文件夹或全局入口创建终端,验证监听器保持挂载、面板恢复显示且每次只创建一个会话。 macOS 交互式终端的 shell 自然退出(Ctrl+D 或 exit)后自动关闭标签并重选相邻会话,最后一个底部终端关闭后收起面板;宿主主动关闭、重启或启动失败不触发自动关闭,调试与运行终端进程结束后保留输出(TerminalModuleTests、TerminalPlacementFeatureModelTests)。 Windows 窗口关闭必须回收已有终端,拒绝关闭后迟到的连接登记并关闭其新建进程;其他窗口会话不受影响。" + "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;macOS 默认标签依次显示 Local、Local (2)、Local (3),从下拉框选择 zsh 后依次显示 zsh、zsh (2),不同 Shell 分别编号;终端与 Git Log 标签悬停有反馈,点击各自区域时选中标签背景显示活动色、点击外部时显示非活动色,文字始终保持相同颜色与常规字重;macOS 终端标题栏的加号、下拉箭头、更多操作和收起图标在明暗主题分别使用 IDEA 的 #6C707E 和 #CED0D6 颜色,按钮动作及悬停效果保持一致;macOS 关闭终端会话直接停止进程,最后一个底部终端关闭后收起面板,编辑区的其他终端会话继续运行;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。 Terminal 失焦通知必须清除标签活动态,包含键盘与程序化切焦。 Windows 隐藏底部终端后,右键含空格的子目录打开终端,确认只创建一个底部会话、工作目录为所选目录、编辑器标签与分栏不变;已有终端时再次从文件夹和工具栏打开,均留在底部面板。自定义 profile 的 Shell 与启动命令继续生效,但文件夹入口显式目录优先于 profile 的启动目录;全局入口仍保留 profile 默认目录。WSL/本地目录相互切换时按最终工作目录选取 Shell;拖动终端到编辑区与拖回底部的既有操作仍可用。 终端全屏后用菜单或快捷键隐藏,再从文件夹或全局入口创建终端,验证监听器保持挂载、面板恢复显示且每次只创建一个会话。 macOS 交互式终端的 shell 自然退出(Ctrl+D 或 exit)后自动关闭标签并重选相邻会话,最后一个底部终端关闭后收起面板;宿主主动关闭、重启或启动失败不触发自动关闭,调试与运行终端进程结束后保留输出(TerminalModuleTests、TerminalPlacementFeatureModelTests)。 Windows 窗口关闭必须回收已有终端,拒绝关闭后迟到的连接登记并关闭其新建进程;其他窗口会话不受影响。 macOS 将工具窗终端拖到编辑分组中心和四边,检查加入标签或上下/左右分屏;拖入空编辑区后再拖回工具窗正文,保留同一会话、PTY、输入和 scrollback。工具窗已有 A、B 时,将编辑器终端 C 分别拖到 A 左半区及 B 右半区,检查前后插入顺序;工具窗内重排、关闭按钮和右键菜单保持可用。调整工具窗高度后,原生正文覆盖层不截获标签栏;同一编辑分屏落点内连续移动或重复经过无效落点,仅在目标变化时发布预览更新。聚焦组件测试不替代系统原生拖放和 Windows 实机验收,verificationStatus 保持 pending。" }