Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)`。
Expand Down Expand Up @@ -74,7 +83,7 @@ Git Log 的当前提交选择与已打开的历史比较分开,上一文件/

根编辑区使用稳定的局部状态持有落点几何;不能在每次父视图重建时重新创建被观察对象,否则几何未改变时不会再发布偏好,真实预览会丢失落点。
每个标签组只安装一个原生鼠标监听,完整处理标签按下、拖动和松开,避免 SwiftUI 进入局部鼠标跟踪后漏接跨原生编辑器的快速拖动。
关闭按钮、Control 点击和终端原生拖放仍交给原控件;普通点击、辅助功能动作和原右键菜单继续走现有选择动作。
关闭按钮、Control 点击和终端标签的 AppKit 原生拖放仍交给原控件;普通点击、辅助功能动作和原右键菜单继续走现有选择动作。
鼠标监听按原生 `visibleRect`(经祖先与滚动视口裁剪后的可见区域)命中,隐藏标签不能截获相邻分组点击。分组记录实际标签栏矩形,完整排除第二、第三行再检测正文边缘,不能假设单行 36pt 高度。正文焦点进入时调用完整文档选择动作,清除终端/数据库活动标志;保留的文档 ID 不能代替当前活动项判断。
预览几何在拖动开始冻结,鼠标移动只移动原生面板;目标改变才发布临时半区。
Esc、来源卸载和取消均清理面板;无效落点不改布局。焦点进入另一组文件时,菜单命令同步作用于该文件。
Expand Down Expand Up @@ -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`
Expand All @@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>) -> EditorGroupLayout? {
switch self {
case .group(let id): return ids.contains(id) ? self : nil
Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,17 +144,47 @@ 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()
focus(item)
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]
Expand Down
15 changes: 15 additions & 0 deletions macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
22 changes: 7 additions & 15 deletions macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions macos/Sources/Lithe/Views/Editor/EditorGroupContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 37 additions & 14 deletions macos/Sources/Lithe/Views/Terminal/TerminalTabDragPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,31 @@ 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? {
String(data: data, encoding: .utf8).flatMap(UUID.init(uuidString:))
}

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
Expand Down Expand Up @@ -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?
Expand All @@ -83,4 +100,10 @@ private final class ActiveTerminalTabDrag: @unchecked Sendable {
storedSessionID = sessionID
lock.unlock()
}

func clear() {
lock.lock()
storedSessionID = nil
lock.unlock()
}
}
Loading
Loading