From f4a75300fe0a556a4f1eadac0ce710c0d5c37913 Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Fri, 18 Sep 2026 15:00:36 +0800 Subject: [PATCH 1/7] fix(tabs): restore tab hover and scrolling Co-authored-by: Codex --- mac/zshell/AppKitSessionTabsView.swift | 235 ++++++++++++++++++++----- mac/zshell/WorkspaceChromeViews.swift | 23 ++- 2 files changed, 213 insertions(+), 45 deletions(-) diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift index 0d7da8b..b71e405 100644 --- a/mac/zshell/AppKitSessionTabsView.swift +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -195,7 +195,12 @@ final class MainHeaderNSView: NSView { } let available = max(0, right - left - 32) let stripWidth = min(strip.preferredWidth, available) - strip.frame = NSRect(x: left, y: 2, width: stripWidth, height: max(0, bounds.height - 4)) + strip.frame = NSRect( + x: left, + y: 2, + width: stripWidth, + height: max(0, bounds.height - 4) + ) windowDrag.frame = NSRect(x: 0, y: 0, width: bounds.width, height: bounds.height) } @@ -221,19 +226,93 @@ private final class SessionStripScrollView: NSScrollView { } override func scrollWheel(with event: NSEvent) { - guard abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) else { - super.scrollWheel(with: event) - return - } + let dominantDelta = abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) + ? event.scrollingDeltaX : event.scrollingDeltaY + guard dominantDelta != 0 else { return } let maximum = max(0, (documentView?.bounds.width ?? 0) - contentSize.width) - let delta = event.scrollingDeltaY * (event.hasPreciseScrollingDeltas ? 1 : 12) + let delta = dominantDelta * (event.hasPreciseScrollingDeltas ? 1 : 12) contentView.scroll(to: NSPoint(x: min(max(0, contentView.bounds.minX - delta), maximum), y: 0)) reflectScrolledClipView(contentView) } } private final class SessionStripDocumentView: NSView { + weak var scrollView: SessionStripScrollView? + override var isFlipped: Bool { true } + + // Tab hit testing makes a row the initial responder, so forward its wheel + // event to the strip rather than relying on AppKit's responder traversal. + override func scrollWheel(with event: NSEvent) { scrollView?.scrollWheel(with: event) } +} + +private final class SessionStripOverlayScroller: NSView { + var onScroll: ((CGFloat) -> Void)? + var onDragEnded: (() -> Void)? + private(set) var isDragging = false + private var position: CGFloat = 0 + private var proportion: CGFloat = 1 + private var dragOffset: CGFloat = 0 + + override var isFlipped: Bool { true } + + func update(position: CGFloat, viewportWidth: CGFloat, contentWidth: CGFloat, visible: Bool) { + let hasOverflow = contentWidth > viewportWidth + 0.5 + if !isDragging { self.position = min(max(position, 0), 1) } + proportion = min(max(viewportWidth / max(contentWidth, 1), 0), 1) + isHidden = !visible || !hasOverflow + needsDisplay = true + } + + private var trackRect: NSRect { + NSRect(x: 4, y: floor(bounds.midY), width: max(0, bounds.width - 8), height: 1) + } + + private var thumbRect: NSRect { + let track = trackRect + let length = min(track.width, max(20, track.width * proportion)) + let x = track.minX + (track.width - length) * position + return NSRect(x: x, y: track.minY, width: length, height: track.height) + } + + override func draw(_ dirtyRect: NSRect) { + let thumb = thumbRect + NSColor.labelColor.withAlphaComponent(isDragging ? 0.35 : 0.2).setFill() + NSBezierPath(roundedRect: thumb, xRadius: 1, yRadius: 1).fill() + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard !isHidden, thumbRect.insetBy(dx: -4, dy: -5).contains(point) else { return nil } + return self + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + let thumb = thumbRect + dragOffset = min(max(point.x - thumb.minX, 0), thumb.width) + isDragging = true + moveThumb(to: point.x) + } + + override func mouseDragged(with event: NSEvent) { + guard isDragging else { return } + moveThumb(to: convert(event.locationInWindow, from: nil).x) + } + + override func mouseUp(with event: NSEvent) { + isDragging = false + needsDisplay = true + onDragEnded?() + } + + private func moveThumb(to x: CGFloat) { + let track = trackRect + let travel = track.width - thumbRect.width + guard travel > 0 else { return } + position = min(max((x - dragOffset - track.minX) / travel, 0), 1) + needsDisplay = true + onScroll?(position) + } } private final class SessionStripProjectDropTargetView: NSView { @@ -256,16 +335,17 @@ final class SessionTabsNSView: NSView { private weak var tabDrag: TabSplitDragCoordinator? private let scrollView = SessionStripScrollView() private let document = SessionStripDocumentView() + private let overlayScroller = SessionStripOverlayScroller() private let addButton = WorkspaceChromeButton(symbol: "plus", label: AppCommand.newSession.title) - private let groupButton = WorkspaceChromeButton(symbol: "rectangle.3.group", label: String(localized: "New Tab Group")) - private let leftButton = WorkspaceChromeButton(symbol: "chevron.left", label: String(localized: "Scroll Tabs Left")) - private let rightButton = WorkspaceChromeButton(symbol: "chevron.right", label: String(localized: "Scroll Tabs Right")) private let projectDropTarget = SessionStripProjectDropTargetView(frame: .zero) private var rows: [Item: WorkspaceItemView] = [:] private var order: [Item] = [] private var contentObservations: [UUID: AnyCancellable] = [:] private var scrollObservation: AnyCancellable? private var projectDragObservation: AnyCancellable? + private var pointerTrackingArea: NSTrackingArea? + private var scrollWheelMonitor: Any? + private var isPointerInsideStrip = false private var refreshScheduled = false private var contentWidth: CGFloat = 0 private var lastViewportWidth: CGFloat = 0 @@ -285,22 +365,34 @@ final class SessionTabsNSView: NSView { scrollView.verticalScrollElasticity = .none scrollView.contentView.postsBoundsChangedNotifications = true scrollView.documentView = document - for view in [scrollView, addButton, groupButton, leftButton, rightButton] { addSubview(view) } + document.scrollView = scrollView + for view in [scrollView, overlayScroller, addButton] { addSubview(view) } projectDropTarget.isHidden = true addSubview(projectDropTarget) addButton.onAction = { [weak self] in self?.project?.newSession() } - groupButton.onAction = { [weak self] in self?.createGroup() } - leftButton.onAction = { [weak self] in self?.scroll(by: -160) } - rightButton.onAction = { [weak self] in self?.scroll(by: 160) } + overlayScroller.onScroll = { [weak self] position in self?.scroll(to: position) } + overlayScroller.onDragEnded = { [weak self] in self?.updateOverlayScroller() } scrollObservation = NotificationCenter.default.publisher( for: NSView.boundsDidChangeNotification, object: scrollView.contentView - ).sink { [weak self] _ in self?.updateScrollButtons() } + ).sink { [weak self] _ in self?.updateOverlayScroller() } setAccessibilityElement(false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - var preferredWidth: CGFloat { contentWidth + controlWidth * 2 + 8 } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installScrollWheelMonitor() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow !== window { removeScrollWheelMonitor() } + super.viewWillMove(toWindow: newWindow) + } + + deinit { removeScrollWheelMonitor() } + + var preferredWidth: CGFloat { contentWidth + controlWidth + 4 } private var scale: CGFloat { CGFloat(AppSettings.shared.interfaceScale) } private var controlWidth: CGFloat { min(34, max(24, 24 * scale)) } // Keep an ungrouped release target reachable when the tab strip overflows. @@ -371,6 +463,7 @@ final class SessionTabsNSView: NSView { row.onDragEnded = { [weak self] event in self?.finishDrag(item: item, event: event) } row.onDragCancelled = { [weak self] in self?.cancelDrag() } row.onNavigate = { [weak self] key in self?.navigate(from: item, key: key) } + row.onScrollWheel = { [weak self] event in self?.scrollWheel(with: event) } row.frame = NSRect(x: width, y: 0, width: row.preferredWidth, height: bounds.height) width += row.preferredWidth + 3 } @@ -379,7 +472,6 @@ final class SessionTabsNSView: NSView { if lastSelection != project.selectedTabID { lastSelection = project.selectedTabID; revealSelection = true } observeContent(in: project) addButton.configure(symbol: "plus", command: .newSession, pointSize: 11 * scale) - groupButton.configure(symbol: "rectangle.3.group", label: String(localized: "New Tab Group"), pointSize: 11 * scale) needsLayout = true } @@ -401,6 +493,7 @@ final class SessionTabsNSView: NSView { pinned: tab.isPinned, marker: groupColor ?? tab.markerColor, count: tab.allPanes.count > 1 ? tab.allPanes.count : nil, rollup: tab.agentRollup, dirty: content?.isDirty == true, scale: scale, + tabStrip: true, action: { [weak project, weak tab] in if let tab { project?.close(tab) } }) row.toolTip = content?.fileIconPath ?? tab.displayTitle row.onSelect = { [weak project, weak tab] in if let tab { project?.selectedTabID = tab.id } } @@ -491,27 +584,21 @@ final class SessionTabsNSView: NSView { override func layout() { super.layout() - let controls = controlWidth * 2 + 8 - let available = max(0, bounds.width - controls) - let overflow = contentWidth + trailingDropWidth > available + 0.5 - let arrowWidth: CGFloat = overflow ? 20 : 0 - leftButton.isHidden = !overflow - rightButton.isHidden = !overflow - leftButton.frame = NSRect(x: 0, y: 0, width: arrowWidth, height: bounds.height) - let viewportWidth = max(0, available - arrowWidth * 2) - scrollView.frame = NSRect(x: arrowWidth, y: 0, width: viewportWidth, height: bounds.height) - rightButton.frame = NSRect(x: arrowWidth + viewportWidth, y: 0, width: arrowWidth, height: bounds.height) + let available = max(0, bounds.width - controlWidth - 4) + scrollView.frame = NSRect(x: 0, y: 0, width: available, height: bounds.height) + overlayScroller.frame = NSRect(x: 0, y: max(0, bounds.height - 10), width: available, height: 10) addButton.frame = NSRect(x: available + 4, y: 0, width: controlWidth, height: bounds.height) - groupButton.frame = NSRect(x: available + 4 + controlWidth, y: 0, width: controlWidth, height: bounds.height) document.frame = NSRect( x: 0, y: 0, - width: max(viewportWidth, contentWidth + trailingDropWidth), + width: max(available, contentWidth + trailingDropWidth), height: bounds.height ) + updatePointerPresence() + updateOverlayScroller() projectDropTarget.frame = scrollView.frame let projectDropBounds = scrollView.frame - .insetBy(dx: overflow ? 0 : -4 * scale, dy: -2 * scale) + .insetBy(dx: -4 * scale, dy: -2 * scale) .intersection(bounds) let projectDropScreenFrame = project == nil ? nil @@ -519,13 +606,12 @@ final class SessionTabsNSView: NSView { tabDrag?.updateTabStripFrame(projectID: project?.id, screenFrame: projectDropScreenFrame) updateProjectDropTarget() for row in rows.values { row.setFrameSize(NSSize(width: row.frame.width, height: bounds.height)) } - if lastViewportWidth != viewportWidth { lastViewportWidth = viewportWidth; revealSelection = true } + if lastViewportWidth != available { lastViewportWidth = available; revealSelection = true } scroll(by: 0) if revealSelection && draggedItem == nil { revealSelection = false revealSelectedRow() } - updateScrollButtons() } private func revealSelectedRow() { @@ -541,11 +627,84 @@ final class SessionTabsNSView: NSView { let x = min(max(0, scrollView.contentView.bounds.minX + delta), maximum) scrollView.contentView.scroll(to: NSPoint(x: x, y: 0)) scrollView.reflectScrolledClipView(scrollView.contentView) + updateOverlayScroller() } - private func updateScrollButtons() { - leftButton.isEnabled = scrollView.contentView.bounds.minX > 0.5 - rightButton.isEnabled = scrollView.contentView.bounds.maxX < document.bounds.width - 0.5 + private func scroll(to position: CGFloat) { + let maximum = max(0, document.bounds.width - scrollView.contentSize.width) + scroll(by: min(max(position, 0), 1) * maximum - scrollView.contentView.bounds.minX) + } + + override func scrollWheel(with event: NSEvent) { + isPointerInsideStrip = true + scrollView.scrollWheel(with: event) + } + + private func installScrollWheelMonitor() { + guard scrollWheelMonitor == nil, window != nil else { return } + scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, self.handlesScrollWheel(event) else { return event } + self.scrollWheel(with: event) + return nil + } + } + + private func removeScrollWheelMonitor() { + if let scrollWheelMonitor { NSEvent.removeMonitor(scrollWheelMonitor) } + scrollWheelMonitor = nil + } + + private func handlesScrollWheel(_ event: NSEvent) -> Bool { + guard let window, + event.window === window, + let contentView = window.contentView + else { return false } + let point = contentView.convert(event.locationInWindow, from: nil) + guard let hitView = contentView.hitTest(point) else { return false } + return hitView === scrollView || hitView.isDescendant(of: scrollView) + || hitView === overlayScroller || hitView.isDescendant(of: overlayScroller) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let pointerTrackingArea { removeTrackingArea(pointerTrackingArea) } + let pointerTrackingArea = NSTrackingArea( + rect: .zero, + options: [.activeAlways, .mouseEnteredAndExited, .mouseMoved, .inVisibleRect], + owner: self + ) + addTrackingArea(pointerTrackingArea) + self.pointerTrackingArea = pointerTrackingArea + } + + override func mouseEntered(with event: NSEvent) { updatePointerPresence(at: event.locationInWindow) } + override func mouseMoved(with event: NSEvent) { updatePointerPresence(at: event.locationInWindow) } + + override func mouseExited(with event: NSEvent) { + isPointerInsideStrip = false + updateOverlayScroller() + } + + private func updatePointerPresence() { + guard let window else { return } + updatePointerPresence(at: window.mouseLocationOutsideOfEventStream) + } + + private func updatePointerPresence(at locationInWindow: NSPoint) { + isPointerInsideStrip = scrollView.frame.contains(convert(locationInWindow, from: nil)) + updateOverlayScroller() + } + + private func updateOverlayScroller() { + let viewportWidth = scrollView.contentSize.width + let maximum = max(0, document.bounds.width - viewportWidth) + let position = maximum > 0 ? scrollView.contentView.bounds.minX / maximum : 0 + overlayScroller.update( + position: position, + viewportWidth: viewportWidth, + contentWidth: document.bounds.width, + visible: isPointerInsideStrip || overlayScroller.isDragging + ) } private func updateProjectDropTarget() { @@ -556,14 +715,6 @@ final class SessionTabsNSView: NSView { projectDropTarget.isHidden = !isTarget } - private func createGroup() { - guard let project else { return } - let group = project.createTabGroup(containing: project.selectedTab) - refresh() - layoutSubtreeIfNeeded() - rows[.group(group.id)]?.onRename?() - } - private func documentPoint(at event: NSEvent) -> NSPoint? { let point = convert(event.locationInWindow, from: nil) guard scrollView.frame.contains(point) else { return nil } diff --git a/mac/zshell/WorkspaceChromeViews.swift b/mac/zshell/WorkspaceChromeViews.swift index eb0109c..4af7862 100644 --- a/mac/zshell/WorkspaceChromeViews.swift +++ b/mac/zshell/WorkspaceChromeViews.swift @@ -126,6 +126,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { var onDragEnded: ((NSEvent) -> Void)? var onDragCancelled: (() -> Void)? var onNavigate: ((UInt16) -> Void)? + var onScrollWheel: ((NSEvent) -> Void)? var menuItems: (() -> [AppKitContextMenuItem])? private var renameCommit: ((String) -> Void)? private weak var renamePreviousResponder: NSResponder? @@ -142,6 +143,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { private var isGrouped = false private var isDirty = false private var isSidebar = false + private var usesTabStripHoverTracking = false private var indent: CGFloat = 0 private var scale: CGFloat = 1 private var groupControlScale: CGFloat = 1 @@ -194,6 +196,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { sidebar: Bool = false, compactGroup: Bool = false, fillsGroupRow: Bool = false, showsGroupTitle: Bool = false, indent: CGFloat = 0, scale: CGFloat = 1, groupControlScale: CGFloat? = nil, + tabStrip: Bool = false, shortcut: String? = nil, actionSymbol: String = "xmark", actionLabel: String = String(localized: "Close"), action: (() -> Void)? = nil ) { @@ -205,6 +208,10 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { self.isGrouped = grouped self.isDirty = dirty self.isSidebar = sidebar + if usesTabStripHoverTracking != tabStrip { + usesTabStripHoverTracking = tabStrip + updateTrackingAreas() + } self.indent = indent self.scale = scale self.groupControlScale = groupControlScale ?? scale @@ -406,6 +413,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { override func draw(_ dirtyRect: NSRect) { let usesGroupControlBackground = isCompactGroup || fillsGroupRow + let drawsItemBackground = isDropTarget || isSelected || isHovered || isGroup let shapeBounds = usesGroupControlBackground ? groupControlFrame : bounds.insetBy(dx: 0.5, dy: 1) let cornerRadius = min( (isCompactGroup ? 6 * compactGroupControlScale : (isSidebar ? 6 : 12) * scale), @@ -420,7 +428,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { (isDropTarget ? Theme.accent.withAlphaComponent(0.15) : NSColor.white.withAlphaComponent(isSelected ? 0.16 : 0.08)).setFill() shape.fill() - } else if !usesGroupControlBackground && (isDropTarget || isSelected || isHovered || isGroup) { + } else if !usesGroupControlBackground && drawsItemBackground { (isDropTarget ? Theme.accent.withAlphaComponent(0.15) : NSColor.labelColor.withAlphaComponent(isSelected ? 0.09 : (isHovered ? 0.05 : 0.025))).setFill() shape.fill() @@ -452,7 +460,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { NSColor.secondaryLabelColor.setFill() NSBezierPath(ovalIn: NSRect(x: actionButton.frame.midX - 2.5, y: bounds.midY - 2.5, width: 5, height: 5)).fill() } - if window?.firstResponder === self { + if window?.firstResponder === self, !usesTabStripHoverTracking { NSColor.keyboardFocusIndicatorColor.setStroke() shape.lineWidth = 2 shape.stroke() @@ -466,13 +474,22 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { override func updateTrackingAreas() { super.updateTrackingAreas() trackingAreas.forEach(removeTrackingArea) + let activeOption: NSTrackingArea.Options = usesTabStripHoverTracking ? .activeAlways : .activeInKeyWindow addTrackingArea(NSTrackingArea(rect: .zero, - options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self)) + options: [activeOption, .mouseEnteredAndExited, .inVisibleRect], owner: self)) } override func mouseEntered(with event: NSEvent) { isHovered = true; updateActionVisibility(); needsDisplay = true } override func mouseExited(with event: NSEvent) { isHovered = false; updateActionVisibility(); needsDisplay = true } + override func scrollWheel(with event: NSEvent) { + if let onScrollWheel { + onScrollWheel(event) + } else { + super.scrollWheel(with: event) + } + } + private func updateActionVisibility() { actionButton.isHidden = !hasAction || isRenaming shortcutLabel.isHidden = isRenaming || !actionButton.isHidden || isDirty From e5b9c583b714f089a3679ae720aaf4ce6755e7fc Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Fri, 18 Sep 2026 19:10:10 +0800 Subject: [PATCH 2/7] fix(tabs): restore scrolling and pane transfers Co-authored-by: Codex --- mac/zshell/AppKitSessionTabsView.swift | 70 +++----- mac/zshell/ContentView.swift | 215 +++++++++++++++++++++++-- mac/zshell/PaneLayoutView.swift | 19 ++- mac/zshell/Project.swift | 42 +++++ mac/zshell/WorkspaceChromeViews.swift | 7 +- 5 files changed, 292 insertions(+), 61 deletions(-) diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift index b71e405..de43cb5 100644 --- a/mac/zshell/AppKitSessionTabsView.swift +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -217,6 +217,14 @@ final class MainHeaderNSView: NSView { } private final class SessionStripScrollView: NSScrollView { + override func tile() { + super.tile() + // AppKit only scrolls horizontal gestures when a scroller is enabled; + // keep its mechanics while the lightweight overlay owns the visuals. + contentView.frame = bounds + horizontalScroller?.frame = .zero + } + override func hitTest(_ point: NSPoint) -> NSView? { guard let documentView else { return super.hitTest(point) } let clipPoint = contentView.convert(point, from: self) @@ -226,11 +234,13 @@ private final class SessionStripScrollView: NSScrollView { } override func scrollWheel(with event: NSEvent) { - let dominantDelta = abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) - ? event.scrollingDeltaX : event.scrollingDeltaY - guard dominantDelta != 0 else { return } + if abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) { + super.scrollWheel(with: event) + return + } + guard event.scrollingDeltaY != 0 else { return } let maximum = max(0, (documentView?.bounds.width ?? 0) - contentSize.width) - let delta = dominantDelta * (event.hasPreciseScrollingDeltas ? 1 : 12) + let delta = event.scrollingDeltaY * (event.hasPreciseScrollingDeltas ? 1 : 12) contentView.scroll(to: NSPoint(x: min(max(0, contentView.bounds.minX - delta), maximum), y: 0)) reflectScrolledClipView(contentView) } @@ -265,7 +275,7 @@ private final class SessionStripOverlayScroller: NSView { } private var trackRect: NSRect { - NSRect(x: 4, y: floor(bounds.midY), width: max(0, bounds.width - 8), height: 1) + NSRect(x: 4, y: max(0, bounds.maxY - 2), width: max(0, bounds.width - 8), height: 1) } private var thumbRect: NSRect { @@ -344,7 +354,6 @@ final class SessionTabsNSView: NSView { private var scrollObservation: AnyCancellable? private var projectDragObservation: AnyCancellable? private var pointerTrackingArea: NSTrackingArea? - private var scrollWheelMonitor: Any? private var isPointerInsideStrip = false private var refreshScheduled = false private var contentWidth: CGFloat = 0 @@ -359,7 +368,7 @@ final class SessionTabsNSView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) scrollView.drawsBackground = false - scrollView.hasHorizontalScroller = false + scrollView.hasHorizontalScroller = true scrollView.hasVerticalScroller = false scrollView.horizontalScrollElasticity = .none scrollView.verticalScrollElasticity = .none @@ -380,18 +389,6 @@ final class SessionTabsNSView: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - installScrollWheelMonitor() - } - - override func viewWillMove(toWindow newWindow: NSWindow?) { - if newWindow !== window { removeScrollWheelMonitor() } - super.viewWillMove(toWindow: newWindow) - } - - deinit { removeScrollWheelMonitor() } - var preferredWidth: CGFloat { contentWidth + controlWidth + 4 } private var scale: CGFloat { CGFloat(AppSettings.shared.interfaceScale) } private var controlWidth: CGFloat { min(34, max(24, 24 * scale)) } @@ -418,7 +415,7 @@ final class SessionTabsNSView: NSView { self.project = project if self.tabDrag !== tabDrag { self.tabDrag = tabDrag - projectDragObservation = tabDrag.$projectDrag + projectDragObservation = tabDrag.$projectDrag.combineLatest(tabDrag.$paneDrag) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateProjectDropTarget() } } @@ -603,7 +600,11 @@ final class SessionTabsNSView: NSView { let projectDropScreenFrame = project == nil ? nil : workspaceScreenRect(projectDropBounds) - tabDrag?.updateTabStripFrame(projectID: project?.id, screenFrame: projectDropScreenFrame) + tabDrag?.updateTabStripFrame( + projectID: project?.id, + screenFrame: projectDropScreenFrame, + workspaceFrame: project == nil ? nil : workspaceGlobalRect(projectDropBounds) + ) updateProjectDropTarget() for row in rows.values { row.setFrameSize(NSSize(width: row.frame.width, height: bounds.height)) } if lastViewportWidth != available { lastViewportWidth = available; revealSelection = true } @@ -640,31 +641,6 @@ final class SessionTabsNSView: NSView { scrollView.scrollWheel(with: event) } - private func installScrollWheelMonitor() { - guard scrollWheelMonitor == nil, window != nil else { return } - scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - guard let self, self.handlesScrollWheel(event) else { return event } - self.scrollWheel(with: event) - return nil - } - } - - private func removeScrollWheelMonitor() { - if let scrollWheelMonitor { NSEvent.removeMonitor(scrollWheelMonitor) } - scrollWheelMonitor = nil - } - - private func handlesScrollWheel(_ event: NSEvent) -> Bool { - guard let window, - event.window === window, - let contentView = window.contentView - else { return false } - let point = contentView.convert(event.locationInWindow, from: nil) - guard let hitView = contentView.hitTest(point) else { return false } - return hitView === scrollView || hitView.isDescendant(of: scrollView) - || hitView === overlayScroller || hitView.isDescendant(of: overlayScroller) - } - override func updateTrackingAreas() { super.updateTrackingAreas() if let pointerTrackingArea { removeTrackingArea(pointerTrackingArea) } @@ -710,6 +686,8 @@ final class SessionTabsNSView: NSView { private func updateProjectDropTarget() { let isTarget = project.map { tabDrag?.projectDrag?.targetProjectID == $0.id + || (tabDrag?.paneDrag?.targetsTabStrip == true + && tabDrag?.paneDrag?.sourceProjectID == $0.id) } ?? false guard projectDropTarget.isHidden == !isTarget else { return } projectDropTarget.isHidden = !isTarget diff --git a/mac/zshell/ContentView.swift b/mac/zshell/ContentView.swift index b03eb5a..9927239 100644 --- a/mac/zshell/ContentView.swift +++ b/mac/zshell/ContentView.swift @@ -48,7 +48,22 @@ final class TabSplitDragCoordinator: ObservableObject { let targetProjectID: UUID? } + struct PaneDrag { + let sourcePaneID: UUID + let sourceTabID: UUID + let sourceProjectID: UUID + let location: CGPoint + let targetsTabStrip: Bool + let sidebarTarget: TabSidebarDropTarget? + let sidebarHighlight: TabSidebarDropHighlight? + + var hasExternalTarget: Bool { + targetsTabStrip || sidebarTarget != nil + } + } + @Published private(set) var drag: Drag? + @Published private(set) var paneDrag: PaneDrag? @Published private(set) var projectDrag: ProjectDrag? @Published private(set) var sidebarDropHighlight: TabSidebarDropHighlight? @@ -65,6 +80,7 @@ final class TabSplitDragCoordinator: ObservableObject { private var sidebarUngroupedFooterFrame: CGRect? private var tabStripProjectID: UUID? private var tabStripScreenFrame: CGRect? + private var tabStripWorkspaceFrame: CGRect? func update( sourceTabID: UUID, @@ -72,6 +88,7 @@ final class TabSplitDragCoordinator: ObservableObject { in project: Project, manager: TerminalManager ) { + if paneDrag != nil { cancelPaneDrag() } self.project = project self.manager = manager if projectDragSourceProjectID != nil { cancelProjectDrag() } @@ -101,13 +118,45 @@ final class TabSplitDragCoordinator: ObservableObject { sidebarGroupFrames = groups sidebarUngroupedFrame = ungrouped sidebarUngroupedFooterFrame = ungroupedFooter - guard changed, let drag, let project else { return } - let resolved = resolvedDrag( - sourceTabID: drag.sourceTabID, - location: drag.location, + guard changed, let project else { return } + if let drag { + let resolved = resolvedDrag( + sourceTabID: drag.sourceTabID, + location: drag.location, + in: project + ) + self.drag = resolved + updateSidebarDropHighlight(resolved.sidebarHighlight) + } else if let paneDrag { + let resolved = resolvedPaneDrag( + sourcePaneID: paneDrag.sourcePaneID, + sourceTabID: paneDrag.sourceTabID, + location: paneDrag.location, + in: project + ) + self.paneDrag = resolved + updateSidebarDropHighlight(resolved.sidebarHighlight) + } + } + + func updatePaneDrag( + sourcePaneID: UUID, + sourceTabID: UUID, + location: CGPoint, + in project: Project, + manager: TerminalManager + ) { + if drag != nil { drag = nil } + if projectDragSourceProjectID != nil { cancelProjectDrag() } + self.project = project + self.manager = manager + let resolved = resolvedPaneDrag( + sourcePaneID: sourcePaneID, + sourceTabID: sourceTabID, + location: location, in: project ) - self.drag = resolved + paneDrag = resolved updateSidebarDropHighlight(resolved.sidebarHighlight) } @@ -120,6 +169,7 @@ final class TabSplitDragCoordinator: ObservableObject { || projectDragManager !== manager if beginsNewDrag { if drag != nil { drag = nil } + if paneDrag != nil { cancelPaneDrag() } project = nil self.manager = nil } @@ -129,12 +179,31 @@ final class TabSplitDragCoordinator: ObservableObject { publishProjectDrag() } - func updateTabStripFrame(projectID: UUID?, screenFrame: CGRect?) { - let changed = tabStripProjectID != projectID || tabStripScreenFrame != screenFrame + func updateTabStripFrame( + projectID: UUID?, + screenFrame: CGRect?, + workspaceFrame: CGRect? = nil + ) { + let changed = tabStripProjectID != projectID + || tabStripScreenFrame != screenFrame + || tabStripWorkspaceFrame != workspaceFrame tabStripProjectID = projectID tabStripScreenFrame = screenFrame - guard changed, projectDragSourceProjectID != nil, projectDragScreenLocation != nil else { return } - publishProjectDrag() + tabStripWorkspaceFrame = workspaceFrame + guard changed else { return } + if projectDragSourceProjectID != nil, projectDragScreenLocation != nil { + publishProjectDrag() + } + if let paneDrag, let project { + let resolved = resolvedPaneDrag( + sourcePaneID: paneDrag.sourcePaneID, + sourceTabID: paneDrag.sourceTabID, + location: paneDrag.location, + in: project + ) + self.paneDrag = resolved + updateSidebarDropHighlight(resolved.sidebarHighlight) + } } /// Pane frames are reported by the currently mounted layout, including a @@ -204,6 +273,70 @@ final class TabSplitDragCoordinator: ObservableObject { cancel() } + /// Commits a pane only when it is over an external destination. Returning + /// false lets PaneLayoutView preserve its existing in-tab rearrangement. + @discardableResult + func commitPaneDrag() -> Bool { + guard let paneDrag, let project, let manager else { + cancelPaneDrag() + return false + } + let resolved = resolvedPaneDrag( + sourcePaneID: paneDrag.sourcePaneID, + sourceTabID: paneDrag.sourceTabID, + location: paneDrag.location, + in: project + ) + guard resolved.hasExternalTarget else { + cancelPaneDrag() + return false + } + + if case .project(let destinationProjectID) = resolved.sidebarTarget, + let failure = paneMoveFailure( + resolved, + to: destinationProjectID, + from: project, + manager: manager + ) { + presentMoveFailure(failure) + cancelPaneDrag() + return true + } + + guard let detached = project.extractPaneAsAdjacentTab( + resolved.sourcePaneID, + from: resolved.sourceTabID + ) else { + cancelPaneDrag() + return true + } + + let result: TerminalManager.TabMoveResult? + switch resolved.sidebarTarget { + case .project(let destinationProjectID): + result = manager.moveTab( + id: detached.id, + from: resolved.sourceProjectID, + to: destinationProjectID, + in: ObjectIdentifier(manager) + ) + case .newProject(let groupID): + result = manager.moveTabToNewProject( + id: detached.id, + from: resolved.sourceProjectID, + in: ProjectGroupStore.shared.group(id: groupID) + ) + case nil: + result = nil + } + if let failure = result?.failure { + presentMoveFailure(failure) + } + cancelPaneDrag() + return true + } + func commitProjectDrag() { guard let sourceProjectID = projectDragSourceProjectID, let screenLocation = projectDragScreenLocation, @@ -232,12 +365,21 @@ final class TabSplitDragCoordinator: ObservableObject { func cancel() { if drag != nil { drag = nil } + if paneDrag != nil { paneDrag = nil } project = nil manager = nil updateSidebarDropHighlight(nil) cancelProjectDrag() } + func cancelPaneDrag() { + guard paneDrag != nil else { return } + paneDrag = nil + project = nil + manager = nil + updateSidebarDropHighlight(nil) + } + func cancelProjectDrag() { if projectDrag != nil { projectDrag = nil } projectDragManager = nil @@ -307,6 +449,57 @@ final class TabSplitDragCoordinator: ObservableObject { ) } + private func resolvedPaneDrag( + sourcePaneID: UUID, + sourceTabID: UUID, + location: CGPoint, + in project: Project + ) -> PaneDrag { + let sourceTab = project.tabs.first { $0.id == sourceTabID } + let canDetach = sourceTab?.hasMultiplePanes == true + && sourceTab?.allPanes.first(where: { $0.id == sourcePaneID })?.content.isDiff == false + let sidebarDrop: ( + target: TabSidebarDropTarget?, + highlight: TabSidebarDropHighlight? + ) = canDetach + ? resolvedSidebarDrop(at: location, excluding: project.id) + : (nil, nil) + let targetsTabStrip = canDetach + && sidebarDrop.target == nil + && tabStripProjectID == project.id + && tabStripWorkspaceFrame?.contains(location) == true + return PaneDrag( + sourcePaneID: sourcePaneID, + sourceTabID: sourceTabID, + sourceProjectID: project.id, + location: location, + targetsTabStrip: targetsTabStrip, + sidebarTarget: sidebarDrop.target, + sidebarHighlight: sidebarDrop.highlight + ) + } + + /// Validate before extracting the pane so a rejected cross-project move + /// leaves the source split exactly as it was. + private func paneMoveFailure( + _ drag: PaneDrag, + to destinationProjectID: UUID, + from source: Project, + manager: TerminalManager + ) -> TerminalManager.TabMoveFailure? { + guard let destination = manager.projects.first(where: { $0.id == destinationProjectID }), + let sourceTab = source.tabs.first(where: { $0.id == drag.sourceTabID }), + let sourcePane = sourceTab.allPanes.first(where: { $0.id == drag.sourcePaneID }) + else { return .unavailable } + guard source.location == destination.location else { return .incompatibleLocation } + guard !sourcePane.content.isDiff else { return .containsDiff } + guard case .session(let session) = sourcePane.content, + let alias = session.agentStatus?.alias + else { return nil } + let aliases = Set(destination.sessions.compactMap { $0.agentStatus?.alias }) + return aliases.contains(alias) ? .agentAliasConflict(alias) : nil + } + private func resolvedSidebarDrop( at location: CGPoint, excluding sourceProjectID: UUID @@ -461,9 +654,11 @@ struct ContentView: View { } } Group { - if let tab = manager.selectedProject?.selectedTab { + if let project = manager.selectedProject, + let tab = project.selectedTab { PaneLayoutView( manager: manager, + project: project, tab: tab, tabSplitDrag: tabSplitDrag, onSplit: { manager.split(toward: $0) }, diff --git a/mac/zshell/PaneLayoutView.swift b/mac/zshell/PaneLayoutView.swift index 3c6bcf4..8f54c54 100644 --- a/mac/zshell/PaneLayoutView.swift +++ b/mac/zshell/PaneLayoutView.swift @@ -11,6 +11,7 @@ import SwiftUI /// combined freely. Only the selected tab's layout is ever mounted. struct PaneLayoutView: View { let manager: TerminalManager + let project: Project @ObservedObject var tab: PaneTab @ObservedObject var tabSplitDrag: TabSplitDragCoordinator @ObservedObject private var themeChanges = Theme.changes @@ -100,6 +101,7 @@ struct PaneLayoutView: View { } .onDisappear { tabSplitDrag.clearPaneFrames(for: tab.id) + tabSplitDrag.cancelPaneDrag() } // A divider or pane-move drag can't deliver its ending callback once // toggling zoom unmounts its view — drop any in-flight drag state so a @@ -109,6 +111,7 @@ struct PaneLayoutView: View { dragLayout = nil paneDrag = nil dragThumbnail = nil + tabSplitDrag.cancelPaneDrag() } } @@ -126,7 +129,7 @@ struct PaneLayoutView: View { tab: tab, pane: placement.pane, showSplitChrome: tab.hasMultiplePanes, - allowsMove: true, + allowsMove: tab.hasMultiplePanes && !placement.pane.content.isDiff, isMoveSource: paneDrag?.sourceID == placement.pane.id, dropEdge: dropEdge(for: placement.pane.id), onMove: { @@ -251,19 +254,29 @@ struct PaneLayoutView: View { if paneDrag == nil { dragThumbnail = thumbnail(for: source) } + tabSplitDrag.updatePaneDrag( + sourcePaneID: source, + sourceTabID: tab.id, + location: location, + in: project, + manager: manager + ) if let (targetID, frame) = paneFrames.first(where: { $0.key != source && $0.value.contains(location) }) { paneDrag = PaneMove(sourceID: source, location: location, targetID: targetID, edge: dropEdge(at: location, in: frame)) NSCursor.closedHand.set() } else { paneDrag = PaneMove(sourceID: source, location: location, targetID: nil, edge: nil) - NSCursor.operationNotAllowed.set() + (tabSplitDrag.paneDrag?.hasExternalTarget == true + ? NSCursor.closedHand : NSCursor.operationNotAllowed).set() } } /// Commits a pane-move on release: splits the target on the chosen edge and /// drops the carried pane there. private func commitPaneMove() { - if let paneDrag, let target = paneDrag.targetID, let edge = paneDrag.edge { + let committedExternally = tabSplitDrag.commitPaneDrag() + if !committedExternally, + let paneDrag, let target = paneDrag.targetID, let edge = paneDrag.edge { tab.movePane(paneDrag.sourceID, edge, of: target) } paneDrag = nil diff --git a/mac/zshell/Project.swift b/mac/zshell/Project.swift index be7b051..cc17c0a 100644 --- a/mac/zshell/Project.swift +++ b/mac/zshell/Project.swift @@ -1002,6 +1002,48 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { revealSelectedTabGroup() } + /// Pulls one live pane out of a split tab and places it in an adjacent tab. + /// The existing pane and content objects move intact so terminal surfaces, + /// editor state, and browser state are never torn down during the transfer. + @discardableResult + func extractPaneAsAdjacentTab(_ paneID: UUID, from sourceTabID: UUID) -> PaneTab? { + guard let sourceIndex = tabs.firstIndex(where: { $0.id == sourceTabID }) else { + return nil + } + let source = tabs[sourceIndex] + let panesBefore = source.allPanes + guard panesBefore.count > 1, + let paneIndex = panesBefore.firstIndex(where: { $0.id == paneID }), + !panesBefore[paneIndex].content.isDiff + else { return nil } + + let result = source.layout.removingPane(paneID) + guard let movedPane = result.pane, let remainingLayout = result.node else { + return nil + } + + let detached = PaneTab( + layout: .pane(movedPane), + focusedPaneID: movedPane.id, + isPinned: source.isPinned + ) + detached.tabGroupID = source.tabGroupID + detached.launchSettingsOverride = source.launchSettingsOverride + detached.contextSession = source.contextSession ?? source.sessions.first + + source.layout = remainingLayout + if source.focusedPaneID == paneID { + let survivors = source.allPanes + source.focusedPaneID = survivors[min(paneIndex, survivors.count - 1)].id + } + source.isZoomed = false + + register(detached) + tabs.insert(detached, at: sourceIndex + 1) + selectedTabID = detached.id + return detached + } + /// Moves a tab into another tab's pane tree at the indicated drop edge. /// The source layout is grafted intact, so dragging a tab that already has /// splits preserves those panes and their proportions. Diff tabs stay diff --git a/mac/zshell/WorkspaceChromeViews.swift b/mac/zshell/WorkspaceChromeViews.swift index 4af7862..9503c09 100644 --- a/mac/zshell/WorkspaceChromeViews.swift +++ b/mac/zshell/WorkspaceChromeViews.swift @@ -414,9 +414,12 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { override func draw(_ dirtyRect: NSRect) { let usesGroupControlBackground = isCompactGroup || fillsGroupRow let drawsItemBackground = isDropTarget || isSelected || isHovered || isGroup - let shapeBounds = usesGroupControlBackground ? groupControlFrame : bounds.insetBy(dx: 0.5, dy: 1) + let shapeBounds = usesGroupControlBackground + ? groupControlFrame + : bounds.insetBy(dx: 0.5, dy: usesTabStripHoverTracking ? 6 : 1) let cornerRadius = min( - (isCompactGroup ? 6 * compactGroupControlScale : (isSidebar ? 6 : 12) * scale), + (isCompactGroup ? 6 * compactGroupControlScale + : (usesTabStripHoverTracking ? 7 : (isSidebar ? 6 : 12) * scale)), min(shapeBounds.width, shapeBounds.height) / 2 ) let shape = NSBezierPath(roundedRect: shapeBounds, xRadius: cornerRadius, yRadius: cornerRadius) From 6cde33bee3c3b22802a291c9abdc48eddd9d93dd Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Fri, 18 Sep 2026 19:47:06 +0800 Subject: [PATCH 3/7] fix(tabs): expand pane drop target and scrolling --- mac/zshell/AppKitSessionTabsView.swift | 173 +++++++++++++++++-------- 1 file changed, 118 insertions(+), 55 deletions(-) diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift index de43cb5..3fc1e5c 100644 --- a/mac/zshell/AppKitSessionTabsView.swift +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -34,6 +34,7 @@ final class MainHeaderNSView: NSView { private let leftButton = WorkspaceChromeButton(symbol: "sidebar.left", label: AppCommand.toggleLeftSidebar.title) private let rightButton = WorkspaceChromeButton(symbol: "sidebar.right", label: AppCommand.toggleRightSidebar.title) private let zoomButton = WorkspaceChromeButton(symbol: "arrow.down.forward.and.arrow.up.backward", label: String(localized: "Exit Pane Zoom (⇧⌘↩)")) + private let dropTarget = HeaderDropTargetView(frame: .zero) private var observations: [AnyCancellable] = [] private var refreshScheduled = false private let presentsWindowOverlay: Bool @@ -49,6 +50,7 @@ final class MainHeaderNSView: NSView { super.init(frame: .zero) addSubview(windowDrag) for view in [strip, leftButton, rightButton, zoomButton] { addSubview(view) } + addSubview(dropTarget) leftButton.onAction = { [weak manager] in manager?.toggleLeftSidebar() } rightButton.onAction = { [weak manager] in manager?.toggleSidebar() } zoomButton.onAction = { [weak manager] in manager?.togglePaneZoom() } @@ -59,6 +61,10 @@ final class MainHeaderNSView: NSView { publisher.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.scheduleRefresh() } .store(in: &observations) } + tabDrag.$projectDrag.combineLatest(tabDrag.$paneDrag) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.updateDropTarget() } + .store(in: &observations) setAccessibilityElement(false) refresh() } @@ -202,6 +208,14 @@ final class MainHeaderNSView: NSView { height: max(0, bounds.height - 4) ) windowDrag.frame = NSRect(x: 0, y: 0, width: bounds.width, height: bounds.height) + dropTarget.frame = bounds + let project = manager.selectedProject + tabDrag.updateTabStripFrame( + projectID: project?.id, + screenFrame: project == nil ? nil : workspaceScreenRect(bounds), + workspaceFrame: project == nil ? nil : workspaceGlobalRect(bounds) + ) + updateDropTarget(animated: false) } override func draw(_ dirtyRect: NSRect) { @@ -213,18 +227,19 @@ final class MainHeaderNSView: NSView { override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); refresh() } + private func updateDropTarget(animated: Bool = true) { + let isTarget = manager.selectedProject.map { + tabDrag.projectDrag?.targetProjectID == $0.id + || (tabDrag.paneDrag?.targetsTabStrip == true + && tabDrag.paneDrag?.sourceProjectID == $0.id) + } ?? false + dropTarget.setActive(isTarget, animated: animated) + } + deinit { removeWindowOverlay() } } private final class SessionStripScrollView: NSScrollView { - override func tile() { - super.tile() - // AppKit only scrolls horizontal gestures when a scroller is enabled; - // keep its mechanics while the lightweight overlay owns the visuals. - contentView.frame = bounds - horizontalScroller?.frame = .zero - } - override func hitTest(_ point: NSPoint) -> NSView? { guard let documentView else { return super.hitTest(point) } let clipPoint = contentView.convert(point, from: self) @@ -234,13 +249,11 @@ private final class SessionStripScrollView: NSScrollView { } override func scrollWheel(with event: NSEvent) { - if abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) { - super.scrollWheel(with: event) - return - } - guard event.scrollingDeltaY != 0 else { return } + let dominantDelta = abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) + ? event.scrollingDeltaX : event.scrollingDeltaY + guard dominantDelta != 0 else { return } let maximum = max(0, (documentView?.bounds.width ?? 0) - contentSize.width) - let delta = event.scrollingDeltaY * (event.hasPreciseScrollingDeltas ? 1 : 12) + let delta = dominantDelta * (event.hasPreciseScrollingDeltas ? 1 : 12) contentView.scroll(to: NSPoint(x: min(max(0, contentView.bounds.minX - delta), maximum), y: 0)) reflectScrolledClipView(contentView) } @@ -292,14 +305,16 @@ private final class SessionStripOverlayScroller: NSView { } override func hitTest(_ point: NSPoint) -> NSView? { - guard !isHidden, thumbRect.insetBy(dx: -4, dy: -5).contains(point) else { return nil } + guard !isHidden, trackRect.insetBy(dx: 0, dy: -5).contains(point) else { return nil } return self } override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) let thumb = thumbRect - dragOffset = min(max(point.x - thumb.minX, 0), thumb.width) + dragOffset = thumb.insetBy(dx: -4, dy: -5).contains(point) + ? min(max(point.x - thumb.minX, 0), thumb.width) + : thumb.width / 2 isDragging = true moveThumb(to: point.x) } @@ -325,15 +340,57 @@ private final class SessionStripOverlayScroller: NSView { } } -private final class SessionStripProjectDropTargetView: NSView { +private final class HeaderDropTargetView: NSView { + private var isActive = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + alphaValue = 0 + isHidden = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + func setActive(_ active: Bool, animated: Bool) { + guard isActive != active else { return } + isActive = active + let shouldAnimate = animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + if active { + isHidden = false + needsDisplay = true + if !shouldAnimate { + alphaValue = 1 + return + } + alphaValue = 0 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + animator().alphaValue = 1 + } + } else if shouldAnimate { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self, !self.isActive else { return } + self.isHidden = true + } + } else { + alphaValue = 0 + isHidden = true + } + } + override func draw(_ dirtyRect: NSRect) { - let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6) - Theme.accent.withAlphaComponent(0.18).setFill() + let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 2), xRadius: 7, yRadius: 7) + Theme.accent.withAlphaComponent(0.10).setFill() path.fill() - Theme.accent.setStroke() - path.lineWidth = 1.5 + Theme.accent.withAlphaComponent(0.7).setStroke() + path.lineWidth = 1 path.stroke() } } @@ -347,13 +404,12 @@ final class SessionTabsNSView: NSView { private let document = SessionStripDocumentView() private let overlayScroller = SessionStripOverlayScroller() private let addButton = WorkspaceChromeButton(symbol: "plus", label: AppCommand.newSession.title) - private let projectDropTarget = SessionStripProjectDropTargetView(frame: .zero) private var rows: [Item: WorkspaceItemView] = [:] private var order: [Item] = [] private var contentObservations: [UUID: AnyCancellable] = [:] private var scrollObservation: AnyCancellable? - private var projectDragObservation: AnyCancellable? private var pointerTrackingArea: NSTrackingArea? + private var scrollWheelMonitor: Any? private var isPointerInsideStrip = false private var refreshScheduled = false private var contentWidth: CGFloat = 0 @@ -368,7 +424,7 @@ final class SessionTabsNSView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) scrollView.drawsBackground = false - scrollView.hasHorizontalScroller = true + scrollView.hasHorizontalScroller = false scrollView.hasVerticalScroller = false scrollView.horizontalScrollElasticity = .none scrollView.verticalScrollElasticity = .none @@ -376,8 +432,6 @@ final class SessionTabsNSView: NSView { scrollView.documentView = document document.scrollView = scrollView for view in [scrollView, overlayScroller, addButton] { addSubview(view) } - projectDropTarget.isHidden = true - addSubview(projectDropTarget) addButton.onAction = { [weak self] in self?.project?.newSession() } overlayScroller.onScroll = { [weak self] position in self?.scroll(to: position) } overlayScroller.onDragEnded = { [weak self] in self?.updateOverlayScroller() } @@ -389,6 +443,18 @@ final class SessionTabsNSView: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installScrollWheelMonitor() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow !== window { removeScrollWheelMonitor() } + super.viewWillMove(toWindow: newWindow) + } + + deinit { removeScrollWheelMonitor() } + var preferredWidth: CGFloat { contentWidth + controlWidth + 4 } private var scale: CGFloat { CGFloat(AppSettings.shared.interfaceScale) } private var controlWidth: CGFloat { min(34, max(24, 24 * scale)) } @@ -413,12 +479,7 @@ final class SessionTabsNSView: NSView { } self.manager = manager self.project = project - if self.tabDrag !== tabDrag { - self.tabDrag = tabDrag - projectDragObservation = tabDrag.$projectDrag.combineLatest(tabDrag.$paneDrag) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in self?.updateProjectDropTarget() } - } + self.tabDrag = tabDrag refresh() } @@ -593,19 +654,6 @@ final class SessionTabsNSView: NSView { ) updatePointerPresence() updateOverlayScroller() - projectDropTarget.frame = scrollView.frame - let projectDropBounds = scrollView.frame - .insetBy(dx: -4 * scale, dy: -2 * scale) - .intersection(bounds) - let projectDropScreenFrame = project == nil - ? nil - : workspaceScreenRect(projectDropBounds) - tabDrag?.updateTabStripFrame( - projectID: project?.id, - screenFrame: projectDropScreenFrame, - workspaceFrame: project == nil ? nil : workspaceGlobalRect(projectDropBounds) - ) - updateProjectDropTarget() for row in rows.values { row.setFrameSize(NSSize(width: row.frame.width, height: bounds.height)) } if lastViewportWidth != available { lastViewportWidth = available; revealSelection = true } scroll(by: 0) @@ -641,6 +689,31 @@ final class SessionTabsNSView: NSView { scrollView.scrollWheel(with: event) } + private func installScrollWheelMonitor() { + guard scrollWheelMonitor == nil, window != nil else { return } + scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, self.handlesScrollWheel(event) else { return event } + self.scrollWheel(with: event) + return nil + } + } + + private func removeScrollWheelMonitor() { + if let scrollWheelMonitor { NSEvent.removeMonitor(scrollWheelMonitor) } + scrollWheelMonitor = nil + } + + private func handlesScrollWheel(_ event: NSEvent) -> Bool { + guard let window, + event.window === window, + let contentView = window.contentView + else { return false } + let point = contentView.convert(event.locationInWindow, from: nil) + guard let hitView = contentView.hitTest(point) else { return false } + return hitView === scrollView || hitView.isDescendant(of: scrollView) + || hitView === overlayScroller || hitView.isDescendant(of: overlayScroller) + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let pointerTrackingArea { removeTrackingArea(pointerTrackingArea) } @@ -683,16 +756,6 @@ final class SessionTabsNSView: NSView { ) } - private func updateProjectDropTarget() { - let isTarget = project.map { - tabDrag?.projectDrag?.targetProjectID == $0.id - || (tabDrag?.paneDrag?.targetsTabStrip == true - && tabDrag?.paneDrag?.sourceProjectID == $0.id) - } ?? false - guard projectDropTarget.isHidden == !isTarget else { return } - projectDropTarget.isHidden = !isTarget - } - private func documentPoint(at event: NSEvent) -> NSPoint? { let point = convert(event.locationInWindow, from: nil) guard scrollView.frame.contains(point) else { return nil } From 5d525165913ec8aea40a809d5e81da41be0bcc9d Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Sat, 19 Sep 2026 10:10:51 +0800 Subject: [PATCH 4/7] fix(tabs): correct scrollbar hit testing and hover state Keep the native header outside NSHostingView hit testing, use parent-relative scrollbar hit coordinates, and retain the grab area independently of hover painting. Add offscreen AppKit regression coverage for the event-routing and scrolling failures. Co-authored-by: Codex --- mac/tests/test_tab_strip_scrolling.py | 248 +++++++++++++++++++++++++ mac/zshell/AppKitSessionTabsView.swift | 45 +++-- 2 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 mac/tests/test_tab_strip_scrolling.py diff --git a/mac/tests/test_tab_strip_scrolling.py b/mac/tests/test_tab_strip_scrolling.py new file mode 100644 index 0000000..7c25c7b --- /dev/null +++ b/mac/tests/test_tab_strip_scrolling.py @@ -0,0 +1,248 @@ +"""Exercise native tab hit testing beside a real SwiftUI hosting view. + +Run: python3 mac/tests/test_tab_strip_scrolling.py [--source-ref ] +The offscreen fixture never sends input to the user's desktop. +""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = "mac/zshell/AppKitSessionTabsView.swift" + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--source-ref", help="Run the same regression against an earlier revision") +args = parser.parse_args() +source = (subprocess.check_output(["git", "show", f"{args.source_ref}:{SOURCE}"], cwd=ROOT, text=True) + if args.source_ref else (ROOT / SOURCE).read_text()) + + +def section(start, end): + return source[source.index(start):source.index(end)] + + +native_scroll_views = section("private final class SessionStripScrollView:", + "private final class HeaderDropTargetView:") +overlay_lifecycle = section(" private func installWindowOverlay()", " override func viewWillMove(") +wheel_hit_test = section(" private func handlesScrollWheel(", " override func updateTrackingAreas()") +scroll_configuration = section(" scrollView.drawsBackground =", " scrollView.contentView.postsBoundsChangedNotifications") +tab_wheel_handler = section(" override func scrollWheel(with event: NSEvent) {\n isPointerInsideStrip", + " private func installScrollWheelMonitor()") +scroller_update = section(" private func updateOverlayScroller()", " private func documentPoint(") + +fixture = r''' +private final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + +private final class WindowDragFixture: NSView { + weak var dragWindow: NSWindow? +} + +private final class MainHeaderNSView: NSView { + let manager: Int + let tabDrag: Int + let presentsWindowOverlay: Bool + let windowDrag = WindowDragFixture() + weak var overlayHeader: MainHeaderNSView? + var overlayObservers: [NSObjectProtocol] = [] + let scrollView = SessionStripScrollView() + let document = SessionStripDocumentView() + let overlayScroller = SessionStripOverlayScroller() + let firstRow = FlippedView() + let secondRow = FlippedView() + let closeButton = NSButton() + var isPointerInsideStrip = false + override var isFlipped: Bool { true } + + init(manager: Int = 0, tabDrag: Int = 0, presentsWindowOverlay: Bool = true) { + self.manager = manager + self.tabDrag = tabDrag + self.presentsWindowOverlay = presentsWindowOverlay + super.init(frame: .zero) + __SCROLL_CONFIGURATION__ + scrollView.documentView = document + document.scrollView = scrollView + addSubview(scrollView) + addSubview(overlayScroller) + document.addSubview(firstRow) + document.addSubview(secondRow) + firstRow.addSubview(closeButton) + overlayScroller.onScroll = { [weak self] position in + guard let self else { return } + let maximum = self.document.bounds.width - self.scrollView.contentSize.width + self.scrollView.contentView.scroll(to: NSPoint(x: maximum * position, y: 0)) + self.scrollView.reflectScrolledClipView(self.scrollView.contentView) + } + } + + required init?(coder: NSCoder) { fatalError() } + + override func layout() { + super.layout() + scrollView.frame = NSRect(x: 0, y: 0, width: bounds.width, height: 34) + document.frame = NSRect(x: 0, y: 0, width: 1200, height: 34) + overlayScroller.frame = NSRect(x: 0, y: 24, width: bounds.width, height: 10) + firstRow.frame = NSRect(x: 0, y: 0, width: 200, height: 34) + secondRow.frame = NSRect(x: 200, y: 0, width: 200, height: 34) + closeButton.frame = NSRect(x: 170, y: 5, width: 24, height: 24) + showScroller(true) + } + + func showScroller(_ visible: Bool) { + overlayScroller.update(position: scrollView.contentView.bounds.minX / 740, + viewportWidth: bounds.width, contentWidth: 1200, visible: visible) + } + + func attach() { installWindowOverlay() } + func sync() { syncWindowOverlay() } + func detach() { removeWindowOverlay() } + func handles(_ event: NSEvent) -> Bool { handlesScrollWheel(event) } + + // Compile the production attachment, cleanup and wheel-routing methods. + __OVERLAY_LIFECYCLE__ + __WHEEL_HIT_TEST__ + __TAB_WHEEL_HANDLER__ + __SCROLLER_UPDATE__ +} + +@main +struct TabStripRegression { + static func main() { + NSApplication.shared.setActivationPolicy(.prohibited) + var failures = 0 + var checks = 0 + func check(_ value: Bool, _ name: String) { + checks += 1 + print("\(value ? "PASS" : "FAIL") \(name)") + if !value { failures += 1 } + } + for titled in [false, true] { + let style: NSWindow.StyleMask = titled ? [.titled, .closable, .resizable, .fullSizeContentView] : [.borderless] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: style, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + let hostingView = NSHostingView(rootView: Color.clear) + window.contentView = hostingView + let host = FlippedView(frame: NSRect(x: 100, y: 0, width: 460, height: 34)) + hostingView.addSubview(host) + let placeholder = MainHeaderNSView() + placeholder.frame = host.bounds + host.addSubview(placeholder) + placeholder.attach() + let header = placeholder.overlayHeader! + header.layoutSubtreeIfNeeded() + let root = window.contentView!.superview! + + func point(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + header.convert(NSPoint(x: x, y: y), to: nil) + } + func hit(_ x: CGFloat, _ y: CGFloat) -> NSView? { + root.hitTest(point(x, y)) + } + func mouse(_ type: NSEvent.EventType, _ x: CGFloat, _ y: CGFloat) -> NSEvent { + NSEvent.mouseEvent(with: type, location: point(x, y), modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)! + } + func wheel(_ x: Int32, _ y: Int32, units: CGScrollEventUnit = .pixel) { + let cg = CGEvent(scrollWheelEvent2Source: nil, units: units, wheelCount: 2, + wheel1: y, wheel2: x, wheel3: 0)! + let location = mouse(.mouseMoved, 100, 17) + if placeholder.handles(location) { + placeholder.scrollWheel(with: NSEvent(cgEvent: cg)!) + } else if header.handles(location) { + header.scrollWheel(with: NSEvent(cgEvent: cg)!) + } + } + func paintedThumbPixels() -> Int { + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 460, pixelsHigh: 10, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + let context = NSGraphicsContext(bitmapImageRep: bitmap)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(header.overlayScroller.bounds) + header.overlayScroller.draw(header.overlayScroller.bounds) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + return (0..<10).reduce(0) { total, y in + total + (0..<460).filter { bitmap.colorAt(x: $0, y: y)!.alphaComponent > 0 }.count + } + } + + check(header.superview === root, "header is outside NSHostingView (titled=\(titled))") + check(header.visibleRect == header.bounds, "hover tracking is limited to the header bounds") + check(hit(100, 32) === header.overlayScroller, "painted scrollbar receives clicks") + check(hit(100, 29) === header.overlayScroller, "thin scrollbar has a larger hit area") + check(hit(100, 17) === header.firstRow, "tab body remains clickable") + check(hit(180, 17) === header.closeButton, "close button remains clickable") + check(header.handles(mouse(.mouseMoved, 100, 17)), "visible header owns wheel input") + check(!placeholder.handles(mouse(.mouseMoved, 100, 17)), "covered header cannot consume wheel input") + check(!header.handles(mouse(.mouseMoved, 100, 80)), "terminal area is not intercepted") + + wheel(-100, 0) + check(header.scrollView.contentView.bounds.minX == 100, "horizontal pixel wheel moves right") + wheel(60, 0) + check(header.scrollView.contentView.bounds.minX == 40, "horizontal pixel wheel moves left") + wheel(0, -2, units: .line) + check(header.scrollView.contentView.bounds.minX > 40, "vertical mouse wheel maps to horizontal") + wheel(-5000, 0) + check(header.scrollView.contentView.bounds.minX == 740, "scroll clamps at the right edge") + wheel(5000, 0) + check(header.scrollView.contentView.bounds.minX == 0, "scroll clamps at the left edge") + + let dragTarget = hit(70, 32) + dragTarget?.mouseDown(with: mouse(.leftMouseDown, 70, 32)) + dragTarget?.mouseDragged(with: mouse(.leftMouseDragged, 260, 32)) + dragTarget?.mouseUp(with: mouse(.leftMouseUp, 260, 32)) + check(header.scrollView.contentView.bounds.minX > 400, "thumb drag moves the visible document") + let offset = header.scrollView.contentView.bounds.minX + header.needsLayout = true + header.layoutSubtreeIfNeeded() + check(header.scrollView.contentView.bounds.minX == offset, "layout preserves manual scrolling") + + header.scrollView.contentView.scroll(to: NSPoint(x: 200, y: 0)) + check(hit(100, 17) === header.secondRow, "hit testing follows the scrolled document") + header.showScroller(false) + check(hit(100, 32) === header.overlayScroller, + "scrollbar can be grabbed before hover callbacks arrive") + check(paintedThumbPixels() == 0, "idle scrollbar stays visually hidden") + let coldTarget = hit(100, 32) + coldTarget?.mouseDown(with: mouse(.leftMouseDown, 100, 32)) + coldTarget?.mouseDragged(with: mouse(.leftMouseDragged, 200, 32)) + check(header.scrollView.contentView.bounds.minX > 200, + "drag works without a preceding hover event") + check(paintedThumbPixels() > 0, "dragging reveals the thumb") + coldTarget?.mouseUp(with: mouse(.leftMouseUp, 200, 32)) + wheel(0, 0) + check(paintedThumbPixels() > 0, "zero-delta wheel phase still reveals the scrollbar") + header.showScroller(true) + header.overlayScroller.update(position: 0, viewportWidth: 1200, contentWidth: 1200, visible: true) + check(header.overlayScroller.isHidden, "no overflow means no scrollbar") + + host.setFrameOrigin(NSPoint(x: 140, y: 130)) + placeholder.sync() + check(header.frame == host.convert(host.bounds, to: root), "header follows host geometry") + placeholder.detach() + check(header.superview == nil && placeholder.overlayHeader == nil, "detaching removes the overlay") + window.close() + } + print("Tab strip regression: \(checks - failures) passed, \(failures) failed") + exit(failures == 0 ? 0 : 1) + } +} +''' +fixture = fixture.replace("__OVERLAY_LIFECYCLE__", overlay_lifecycle).replace("__WHEEL_HIT_TEST__", wheel_hit_test) +fixture = fixture.replace("__SCROLL_CONFIGURATION__", scroll_configuration) +fixture = fixture.replace("__TAB_WHEEL_HANDLER__", tab_wheel_handler).replace("__SCROLLER_UPDATE__", scroller_update) + +with tempfile.TemporaryDirectory(prefix="zshell-tab-scroll-tests-") as directory: + helper = Path(directory) / "TabStripRegression.swift" + helper.write_text("import AppKit\nimport SwiftUI\n" + native_scroll_views + fixture) + executable = Path(directory) / "tab-strip-tests" + subprocess.run(["xcrun", "swiftc", "-parse-as-library", str(helper), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift index 3fc1e5c..c99b7b3 100644 --- a/mac/zshell/AppKitSessionTabsView.swift +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -93,6 +93,7 @@ final class MainHeaderNSView: NSView { guard presentsWindowOverlay, let window, let contentView = window.contentView, + let container = contentView.superview, overlayHeader == nil else { return } @@ -102,8 +103,9 @@ final class MainHeaderNSView: NSView { presentsWindowOverlay: false ) header.windowDrag.dragWindow = window - // A view overlay cannot be left behind on another Space or display. - contentView.addSubview(header, positioned: .above, relativeTo: nil) + // NSHostingView skips manually added children during hit testing. Keep + // the native header beside it, below the system title-bar controls. + container.addSubview(header, positioned: .above, relativeTo: contentView) overlayHeader = header let names: [Notification.Name] = [ @@ -131,13 +133,14 @@ final class MainHeaderNSView: NSView { let window, let header = overlayHeader, let host = superview, - let contentView = window.contentView + let contentView = window.contentView, + let container = contentView.superview else { return } - let frame = host.convert(host.bounds, to: contentView) + let frame = host.convert(host.bounds, to: container) guard frame.width > 0, frame.height > 0 else { return } - if header.superview !== contentView { - contentView.addSubview(header, positioned: .above, relativeTo: nil) + if header.superview !== container { + container.addSubview(header, positioned: .above, relativeTo: contentView) } header.frame = frame } @@ -240,14 +243,6 @@ final class MainHeaderNSView: NSView { } private final class SessionStripScrollView: NSScrollView { - override func hitTest(_ point: NSPoint) -> NSView? { - guard let documentView else { return super.hitTest(point) } - let clipPoint = contentView.convert(point, from: self) - guard contentView.bounds.contains(clipPoint) else { return super.hitTest(point) } - let documentPoint = documentView.convert(clipPoint, from: contentView) - return documentView.hitTest(documentPoint) ?? super.hitTest(point) - } - override func scrollWheel(with event: NSEvent) { let dominantDelta = abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) ? event.scrollingDeltaX : event.scrollingDeltaY @@ -276,6 +271,7 @@ private final class SessionStripOverlayScroller: NSView { private var position: CGFloat = 0 private var proportion: CGFloat = 1 private var dragOffset: CGFloat = 0 + private var showsThumb = false override var isFlipped: Bool { true } @@ -283,7 +279,10 @@ private final class SessionStripOverlayScroller: NSView { let hasOverflow = contentWidth > viewportWidth + 0.5 if !isDragging { self.position = min(max(position, 0), 1) } proportion = min(max(viewportWidth / max(contentWidth, 1), 0), 1) - isHidden = !visible || !hasOverflow + showsThumb = visible + // Keep the grab area alive even if hover tracking has not caught up + // with the mouse-down event; only the painted thumb fades out. + isHidden = !hasOverflow needsDisplay = true } @@ -299,13 +298,18 @@ private final class SessionStripOverlayScroller: NSView { } override func draw(_ dirtyRect: NSRect) { + guard showsThumb || isDragging else { return } let thumb = thumbRect NSColor.labelColor.withAlphaComponent(isDragging ? 0.35 : 0.2).setFill() NSBezierPath(roundedRect: thumb, xRadius: 1, yRadius: 1).fill() } override func hitTest(_ point: NSPoint) -> NSView? { - guard !isHidden, trackRect.insetBy(dx: 0, dy: -5).contains(point) else { return nil } + let localPoint = convert(point, from: superview) + guard !isHidden, + bounds.contains(localPoint), + trackRect.insetBy(dx: 0, dy: -5).contains(localPoint) + else { return nil } return self } @@ -424,6 +428,8 @@ final class SessionTabsNSView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) scrollView.drawsBackground = false + clipsToBounds = true + scrollView.automaticallyAdjustsContentInsets = false scrollView.hasHorizontalScroller = false scrollView.hasVerticalScroller = false scrollView.horizontalScrollElasticity = .none @@ -687,6 +693,7 @@ final class SessionTabsNSView: NSView { override func scrollWheel(with event: NSEvent) { isPointerInsideStrip = true scrollView.scrollWheel(with: event) + updateOverlayScroller() } private func installScrollWheelMonitor() { @@ -706,10 +713,10 @@ final class SessionTabsNSView: NSView { private func handlesScrollWheel(_ event: NSEvent) -> Bool { guard let window, event.window === window, - let contentView = window.contentView + let rootView = window.contentView?.superview else { return false } - let point = contentView.convert(event.locationInWindow, from: nil) - guard let hitView = contentView.hitTest(point) else { return false } + let point = rootView.superview?.convert(event.locationInWindow, from: nil) ?? event.locationInWindow + guard let hitView = rootView.hitTest(point) else { return false } return hitView === scrollView || hitView.isDescendant(of: scrollView) || hitView === overlayScroller || hitView.isDescendant(of: overlayScroller) } From db2631bff809db4acae1d1026152a523320b7282 Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Sat, 19 Sep 2026 10:54:15 +0800 Subject: [PATCH 5/7] fix(workspace): bound hover regions and narrow split panes Restrict tracking areas to control bounds instead of unclipped visible rectangles. Let prompt-queue content yield its trailing constraint so its minimum width cannot expand narrow panes, including during repeated resize updates. Preserve existing selection drawing and live terminal surfaces. Co-authored-by: Codex --- mac/tests/test_pane_bounds.py | 266 ++++++++++++++++++++++++++ mac/tests/test_workspace_hover.py | 160 ++++++++++++++++ mac/zshell/PromptQueueBarView.swift | 10 +- mac/zshell/WorkspaceChromeViews.swift | 9 +- 4 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 mac/tests/test_pane_bounds.py create mode 100644 mac/tests/test_workspace_hover.py diff --git a/mac/tests/test_pane_bounds.py b/mac/tests/test_pane_bounds.py new file mode 100644 index 0000000..51c7ef1 --- /dev/null +++ b/mac/tests/test_pane_bounds.py @@ -0,0 +1,266 @@ +"""Exercise production pane chrome beside a native content surface at narrow widths. + +Run: python3 mac/tests/test_pane_bounds.py [--source-ref ] +The offscreen fixture never sends input to the user's desktop. +""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = "mac/zshell/PaneLayoutView.swift" + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--source-ref", help="Run the same regression against an earlier revision") +args = parser.parse_args() + + +def read_source(path): + return (subprocess.check_output(["git", "show", f"{args.source_ref}:{path}"], cwd=ROOT, text=True) + if args.source_ref else (ROOT / path).read_text()) + + +source = read_source(SOURCE) +terminal_source = read_source("mac/zshell/TerminalHostView.swift") +queue_source = read_source("mac/zshell/PromptQueueBarView.swift") + + +def section(start, end): + return source[source.index(start):source.index(end)] + + +pane_view = section("private struct PaneView:", "/// Compact chrome for a pane") +content_start = pane_view.index(" @ViewBuilder\n private var content:") +content_end = pane_view.index(" @ViewBuilder\n private var dropHighlight:") +pane_view = (pane_view[:content_start] + + " private var content: some View { SurfaceFixture(id: pane.id) }\n\n" + + pane_view[content_end:]) +header = section("private struct PaneHeaderView:", "/// Mounts a session's find bar") +reporter = section("private struct PaneFramePreferenceKey:", "private extension NSView") +terminal_container = terminal_source[terminal_source.index("private final class TerminalContainerView:"):] +queue_metrics = queue_source[queue_source.index(" private enum Metrics {"):queue_source.index(" private var queue:")] +queue_build = queue_source[queue_source.index(" private func buildView() {"):queue_source.index(" // MARK: - State")] + +fixture = r''' +enum FixtureState { static var showsQueue = false } +final class ThemeChanges: ObservableObject {} +enum Theme { + static let changes = ThemeChanges() + static let background = NSColor.white + static let accent = NSColor.blue +} +final class AppSettings: ObservableObject { + static let shared = AppSettings() + let isTerminalBackgroundBlurActive = false +} +final class TerminalManager {} +final class PaneTab: ObservableObject { + @Published var focusedPaneID = UUID() +} +final class TerminalSession: ObservableObject { + let title = "wzz@MacBook-Pro:~/a-very-long-project-path" + let agentRollup: ZshellAgentRollup? = nil +} +final class FileTab: ObservableObject { + let name = "long-file-name.swift" + let path = "/fixture/long-file-name.swift" + let isDirty = true +} +final class BrowserTab: ObservableObject { + let title = "Fixture browser" + let urlString = "about:blank" +} +struct DiffTab { + let title = "Fixture diff" + let path = "/fixture/file.swift" +} +enum PaneContent { + case session(TerminalSession), file(FileTab), browser(BrowserTab), diff(DiffTab) +} +struct Pane: Identifiable { + let id = UUID() + let content: PaneContent +} +enum PaneDropEdge { case left, right, top, bottom } +struct ZshellAgentRollup {} +struct AgentStatusBadgeRepresentable: View { + let rollup: ZshellAgentRollup + var body: some View { Color.blue.frame(width: 14, height: 14) } +} +struct BrowserFaviconView: View { + let browser: BrowserTab + let size: CGFloat + var body: some View { Color.blue.frame(width: size, height: size) } +} +struct MaterialFileIconView: View { + let path: String + let size: CGFloat + var opacity: Double = 1 + var body: some View { Color.blue.frame(width: size, height: size) } +} +struct PaneFocusRing: View { + let isFocused: Bool + var body: some View { Rectangle().stroke(Color.blue) } +} +enum TooltipEdge { case below } +enum TooltipAlignment { case trailing } +extension View { + func tooltip(_ text: LocalizedStringKey, edge: TooltipEdge, alignment: TooltipAlignment) -> some View { self } +} +protocol TerminalBackendSurface: NSView { func setSurfaceVisible(_ visible: Bool) } +enum OverlayScrollbarView { static let stripWidth: CGFloat = 10 } +final class SurfaceView: NSView, TerminalBackendSurface { + let id: UUID + override var isFlipped: Bool { true } + init(id: UUID) { + self.id = id + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.red.cgColor + } + required init?(coder: NSCoder) { fatalError() } + func setSurfaceVisible(_ visible: Bool) {} +} +final class PromptQueueFixture: NSView, NSTextFieldDelegate { + __QUEUE_METRICS__ + private lazy var heightConstraint = heightAnchor.constraint(equalToConstant: 0) + private lazy var listHeightConstraint = listScroll.heightAnchor.constraint(equalToConstant: 0) + private let inputField = NSTextField(string: "") + private let addButton = NSButton(title: "", target: nil, action: nil) + private let closeButton = NSButton(title: "", target: nil, action: nil) + private let countLabel = NSTextField(labelWithString: "") + private let listScroll = NSScrollView() + private let listStack = NSStackView() + private let inputRow = NSStackView(views: []) + private let contentStack = NSStackView() + override var isFlipped: Bool { true } + init() { + super.init(frame: .zero) + buildView() + isHidden = !FixtureState.showsQueue + heightConstraint.constant = FixtureState.showsQueue ? Metrics.padding * 2 + Metrics.inputRowHeight : 0 + } + required init?(coder: NSCoder) { fatalError() } + @objc private func addToQueue() {} + @objc private func closeClicked() {} + __QUEUE_BUILD__ +} +struct SurfaceFixture: NSViewRepresentable { + let id: UUID + func makeNSView(context: Context) -> NSView { + let container = TerminalContainerView() + container.focusOnAppear = false + container.mount(SurfaceView(id: id), scrollbar: NSView(), queueBar: PromptQueueFixture()) + return container + } + func updateNSView(_ view: NSView, context: Context) {} +} +func descendants(_ root: NSView) -> [NSView] { + [root] + root.subviews.flatMap(descendants) +} +struct LayoutFixture: View { + let panes: [Pane] + let width: CGFloat + let height: CGFloat + let tab = PaneTab() + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(Array(panes.enumerated()), id: \.element.id) { index, pane in + PaneView(manager: TerminalManager(), tab: tab, pane: pane, + showSplitChrome: true, allowsMove: true, isMoveSource: false, + dropEdge: nil, onMove: { _ in }, onMoveEnded: {}, onSplit: { _ in }, + onNewBrowserTab: { _ in }, onNewBrowserPane: { _ in }, + onNewFileTab: { _ in }, onNewFilePane: { _ in }) + .frame(width: width, height: height) + .offset(x: CGFloat(index) * (width + 10), y: 0) + } + } + .frame(width: CGFloat(panes.count) * (width + 10), height: height, alignment: .topLeading) + } +} +@main +struct PaneBoundsRegression { + static func main() { + NSApplication.shared.setActivationPolicy(.prohibited) + var checks = 0 + var failures = 0 + func check(_ value: Bool, _ name: String) { + checks += 1 + print("\(value ? "PASS" : "FAIL") \(name)") + if !value { failures += 1 } + } + for (width, height, showsQueue): (CGFloat, CGFloat, Bool) in [ + (360, 240, false), (160, 240, false), (80, 240, false), (32, 240, false), + (360, 90, true), (160, 90, true), (80, 90, true), (32, 90, true), + ] { + FixtureState.showsQueue = showsQueue + let panes = (0..<4).map { _ in Pane(content: .session(TerminalSession())) } + let hosting = NSHostingView(rootView: LayoutFixture(panes: panes, width: width, height: height)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1600, height: 360), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let root = NSView(frame: NSRect(x: 0, y: 0, width: 1600, height: 360)) + window.contentView = root + hosting.frame = NSRect(x: 80, y: 40, width: CGFloat(panes.count) * (width + 10), height: height) + root.addSubview(hosting) + hosting.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.04)) + hosting.layoutSubtreeIfNeeded() + let surfaces = descendants(hosting).compactMap { $0 as? SurfaceView } + check(surfaces.count == panes.count, "all panes mount at width \(width), queue open=\(showsQueue)") + for (index, pane) in panes.enumerated() { + guard let surface = surfaces.first(where: { $0.id == pane.id }) else { continue } + let frame = surface.convert(surface.bounds, to: hosting) + let expectedMinX = CGFloat(index) * (width + 10) + check(frame.minX >= expectedMinX - 0.5 && frame.maxX <= expectedMinX + width + 0.5, + "pane \(index) stays in allocated width \(width): x=\(frame.minX), width=\(frame.width)") + check(frame.minY >= -0.5 && frame.maxY <= height + 0.5, + "pane \(index) stays in allocated height \(height)") + let bar = surface.superview!.subviews.compactMap { $0 as? PromptQueueFixture }.first! + let barFrame = bar.convert(bar.bounds, to: hosting) + check(barFrame.minX >= expectedMinX - 0.5 && barFrame.maxX <= expectedMinX + width + 0.5, + "queue bar stays inside pane \(index)") + check(bar.layer?.masksToBounds == true, "narrow queue content remains clipped inside its bar") + if width == 360 { + let stack = bar.subviews.compactMap { $0 as? NSStackView }.first! + check(abs(stack.frame.maxX - (bar.bounds.width - 10)) < 0.5, + "queue controls fill the available width when it fits") + } + } + if width == 360 { + // Reuse the same live surfaces while width changes, as it does + // on every divider-drag update; static initial layout is not enough. + for resizedWidth: CGFloat in [240, 160, 80, 32, 80, 160, 360] { + hosting.rootView = LayoutFixture(panes: panes, width: resizedWidth, height: height) + hosting.setFrameSize(NSSize(width: CGFloat(panes.count) * (resizedWidth + 10), height: height)) + hosting.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.04)) + hosting.layoutSubtreeIfNeeded() + let resized = descendants(hosting).compactMap { $0 as? SurfaceView } + check(Set(resized.map(ObjectIdentifier.init)) == Set(surfaces.map(ObjectIdentifier.init)), + "resizing to \(resizedWidth) preserves live terminal surfaces") + for (index, pane) in panes.enumerated() { + guard let surface = resized.first(where: { $0.id == pane.id }) else { continue } + let frame = surface.convert(surface.bounds, to: hosting) + let expectedMinX = CGFloat(index) * (resizedWidth + 10) + check(frame.minX >= expectedMinX - 0.5 && frame.maxX <= expectedMinX + resizedWidth + 0.5, + "resize to \(resizedWidth) keeps pane \(index) aligned with its allocated edges") + } + } + } + window.close() + } + print("Pane bounds regression: \(checks - failures) passed, \(failures) failed") + exit(failures == 0 ? 0 : 1) + } +} +''' +fixture = fixture.replace("__QUEUE_METRICS__", queue_metrics).replace("__QUEUE_BUILD__", queue_build) + +with tempfile.TemporaryDirectory(prefix="zshell-pane-bounds-tests-") as directory: + helper = Path(directory) / "PaneBoundsRegression.swift" + helper.write_text("import AppKit\nimport SwiftUI\n" + fixture + pane_view + header + reporter + terminal_container) + executable = Path(directory) / "pane-bounds-tests" + subprocess.run(["xcrun", "swiftc", "-parse-as-library", str(helper), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) diff --git a/mac/tests/test_workspace_hover.py b/mac/tests/test_workspace_hover.py new file mode 100644 index 0000000..7a5aba6 --- /dev/null +++ b/mac/tests/test_workspace_hover.py @@ -0,0 +1,160 @@ +"""Check the real workspace controls' hover regions and background painting. + +Run: python3 mac/tests/test_workspace_hover.py [--source-ref ] +The offscreen fixture resolves tracking regions without sending desktop input. +""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = "mac/zshell/WorkspaceChromeViews.swift" +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--source-ref") +args = parser.parse_args() +source = (subprocess.check_output(["git", "show", f"{args.source_ref}:{SOURCE}"], cwd=ROOT, text=True) + if args.source_ref else (ROOT / SOURCE).read_text()) + +# Only dependencies unrelated to hover/layout/painting are stubbed. Both +# WorkspaceItemView and WorkspaceChromeButton compile unchanged in full. +fixture = r''' +enum Theme { static let accent = NSColor.systemBlue } +struct AppCommand { var title = "Command" } +struct Shortcut { var displayString = "" } +struct AppSettings { + static let shared = AppSettings() + func commandShortcut(for command: AppCommand) -> Shortcut { Shortcut() } +} +struct ProjectTabMarkerColor { + static let defaultColor = ProjectTabMarkerColor() + var nsColor: NSColor { .systemBlue } + var displayValue: String { "Blue" } +} +struct ZshellAgentRollup { var phase = 0; var count = 0 } +final class AgentStatusBadgeView: NSView { + func apply(phase: Int, count: Int) {} +} +enum AppKitContextMenuItem {} +final class AppKitContextMenuMonitorView: NSView { + func popUp(items: [AppKitContextMenuItem], at: NSPoint, in: NSView) {} +} +extension NSWindow { func performTitlebarDoubleClickAction() {} } + +final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + +@main +struct HoverRegression { + static func main() { + NSApplication.shared.setActivationPolicy(.prohibited) + var failures = 0 + var checks = 0 + func check(_ condition: Bool, _ name: String) { + checks += 1 + print("\(condition ? "PASS" : "FAIL") \(name)") + if !condition { failures += 1 } + } + for tabStrip in [true, false] { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + let root = FlippedView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) + root.clipsToBounds = true + window.contentView = root + let clip = NSClipView(frame: NSRect(x: 90, y: 20, width: 600, height: 34)) + clip.clipsToBounds = true + let document = FlippedView(frame: NSRect(x: 0, y: 0, width: 900, height: 34)) + clip.documentView = document + root.addSubview(clip) + let rows = (0..<3).map { index -> WorkspaceItemView in + let row = WorkspaceItemView(frame: NSRect(x: CGFloat(index) * 200, y: 0, width: 197, height: 34)) + document.addSubview(row) + row.apply(title: "Session", icon: nil, selected: index == 2, + sidebar: !tabStrip, tabStrip: tabStrip, action: {}) + row.layoutSubtreeIfNeeded() + return row + } + let buttons = rows.map { $0.subviews.compactMap { $0 as? WorkspaceChromeButton }.first! } + let views: [NSView] = rows + buttons + views.forEach { $0.updateTrackingAreas() } + + func trackingRect(_ view: NSView) -> NSRect { + let area = view.trackingAreas.first { ($0.owner as? NSView) === view }! + return area.options.contains(.inVisibleRect) ? view.visibleRect : area.rect + } + func contains(_ view: NSView, _ location: NSPoint) -> Bool { + !view.isHiddenOrHasHiddenAncestor && trackingRect(view).contains(view.convert(location, from: nil)) + } + func location(_ view: NSView, x: CGFloat, y: CGFloat) -> NSPoint { + view.convert(NSPoint(x: x, y: y), to: nil) + } + var entered = Set() + func move(_ point: NSPoint) { + for view in views { + let id = ObjectIdentifier(view) + let inside = contains(view, point) + if inside != entered.contains(id) { + let type: NSEvent.EventType = inside ? .mouseEntered : .mouseExited + let event = NSEvent.enterExitEvent(with: type, location: point, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, trackingNumber: 0, userData: nil)! + if inside { entered.insert(id); view.mouseEntered(with: event) } + else { entered.remove(id); view.mouseExited(with: event) } + } + } + } + func alpha(_ view: NSView, x: Int = 10, y: Int = 17) -> CGFloat { + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(view.bounds.width), + pixelsHigh: Int(view.bounds.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + let context = NSGraphicsContext(bitmapImageRep: bitmap)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(view.bounds) + view.draw(view.bounds) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + return bitmap.colorAt(x: x, y: y)!.alphaComponent + } + + check(trackingRect(rows[0]) == rows[0].bounds, "row hover is bounded (tabStrip=\(tabStrip))") + check(trackingRect(buttons[0]) == buttons[0].bounds, "close-button hover is bounded") + let first = location(rows[0], x: 60, y: 17) + check(!contains(rows[1], first), "neighbor row cannot enter hover") + check(!contains(buttons[0], first), "tab body cannot hover its close button") + move(first) + check(alpha(rows[0]) > 0, "pointer over a tab paints hover") + check(alpha(rows[1]) == 0, "non-hovered unselected tab has no background") + check(alpha(buttons[0], x: 3, y: 12) == 0, "non-hovered close button has no background") + move(location(buttons[0], x: 12, y: 12)) + check(alpha(buttons[0], x: 3, y: 12) > 0, "close button paints its own hover") + move(location(rows[1], x: 60, y: 17)) + check(alpha(rows[0]) == 0, "leaving a tab clears its hover background") + check(alpha(buttons[0], x: 3, y: 12) == 0, "leaving a close button clears its hover") + move(root.convert(NSPoint(x: 50, y: 150), to: nil)) + check(alpha(rows[1]) == 0, "leaving the strip clears hover") + check(alpha(rows[2]) > 0, "selected tab retains its background without hover") + if tabStrip { + check(alpha(rows[2], y: 3) == 0, "selected tab keeps the compact vertical inset") + } + clip.scroll(to: NSPoint(x: 50, y: 0)) + views.forEach { $0.updateTrackingAreas() } + check(!contains(rows[0], location(rows[0], x: 10, y: 17)), "clipped tab portion cannot hover") + check(contains(rows[0], location(rows[0], x: 100, y: 17)), "visible tab portion can hover after scrolling") + window.close() + } + print("Workspace hover regression: \(checks - failures) passed, \(failures) failed") + exit(failures == 0 ? 0 : 1) + } +} +''' + +with tempfile.TemporaryDirectory(prefix="zshell-hover-tests-") as directory: + helper = Path(directory) / "HoverRegression.swift" + helper.write_text(source + fixture) + executable = Path(directory) / "hover-tests" + subprocess.run(["xcrun", "swiftc", "-parse-as-library", str(helper), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) diff --git a/mac/zshell/PromptQueueBarView.swift b/mac/zshell/PromptQueueBarView.swift index 9d1673f..070ecd2 100644 --- a/mac/zshell/PromptQueueBarView.swift +++ b/mac/zshell/PromptQueueBarView.swift @@ -205,13 +205,17 @@ final class PromptQueueBarView: NSView { contentStack.addArrangedSubview(listScroll) addSubview(contentStack) + let contentTrailing = contentStack.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -Metrics.horizontalPadding + ) + // Let the clipped bar retain its controls' minimum width without + // imposing that width on the terminal's split pane. + contentTrailing.priority = .defaultLow NSLayoutConstraint.activate([ contentStack.leadingAnchor.constraint( equalTo: leadingAnchor, constant: Metrics.horizontalPadding ), - contentStack.trailingAnchor.constraint( - equalTo: trailingAnchor, constant: -Metrics.horizontalPadding - ), + contentTrailing, contentStack.topAnchor.constraint( equalTo: topAnchor, constant: Metrics.padding ), diff --git a/mac/zshell/WorkspaceChromeViews.swift b/mac/zshell/WorkspaceChromeViews.swift index 9503c09..fc24948 100644 --- a/mac/zshell/WorkspaceChromeViews.swift +++ b/mac/zshell/WorkspaceChromeViews.swift @@ -43,9 +43,10 @@ final class WorkspaceChromeButton: NSButton { override func updateTrackingAreas() { super.updateTrackingAreas() trackingAreas.forEach(removeTrackingArea) + // Unclipped views can report a visibleRect larger than their bounds. addTrackingArea(NSTrackingArea( - rect: .zero, - options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], + rect: NSIntersectionRect(bounds, visibleRect), + options: [.activeInKeyWindow, .mouseEnteredAndExited], owner: self )) } @@ -478,8 +479,8 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { super.updateTrackingAreas() trackingAreas.forEach(removeTrackingArea) let activeOption: NSTrackingArea.Options = usesTabStripHoverTracking ? .activeAlways : .activeInKeyWindow - addTrackingArea(NSTrackingArea(rect: .zero, - options: [activeOption, .mouseEnteredAndExited, .inVisibleRect], owner: self)) + addTrackingArea(NSTrackingArea(rect: NSIntersectionRect(bounds, visibleRect), + options: [activeOption, .mouseEnteredAndExited], owner: self)) } override func mouseEntered(with event: NSEvent) { isHovered = true; updateActionVisibility(); needsDisplay = true } From 026a35ec8ff4adf7eb9974502365077e79fe3dd6 Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Sat, 19 Sep 2026 12:53:52 +0800 Subject: [PATCH 6/7] fix(tabs): simplify tab group controls Co-authored-by: Codex --- mac/tests/test_workspace_hover.py | 83 ++++++++- mac/zshell/AppKitSessionTabsView.swift | 18 +- mac/zshell/AppSettings.swift | 12 -- mac/zshell/Localizable.xcstrings | 16 -- mac/zshell/ProjectTabMarkerColor.swift | 170 +++++++++++++++++- .../Settings/SettingsAppearancePane.swift | 6 - mac/zshell/WorkspaceChromeViews.swift | 40 ++++- web/content/docs/configuration.mdx | 10 -- web/content/docs/configuration.zh.mdx | 8 - web/content/docs/projects.mdx | 23 +-- web/content/docs/projects.zh.mdx | 7 +- 11 files changed, 301 insertions(+), 92 deletions(-) diff --git a/mac/tests/test_workspace_hover.py b/mac/tests/test_workspace_hover.py index 7a5aba6..dab2795 100644 --- a/mac/tests/test_workspace_hover.py +++ b/mac/tests/test_workspace_hover.py @@ -17,7 +17,8 @@ if args.source_ref else (ROOT / SOURCE).read_text()) # Only dependencies unrelated to hover/layout/painting are stubbed. Both -# WorkspaceItemView and WorkspaceChromeButton compile unchanged in full. +# WorkspaceItemView and WorkspaceChromeButton compile unchanged in full; +# the fixture window supplies pointer/key state without moving the desktop mouse. fixture = r''' enum Theme { static let accent = NSColor.systemBlue } struct AppCommand { var title = "Command" } @@ -45,6 +46,13 @@ override var isFlipped: Bool { true } } +final class HoverWindow: NSWindow { + var pointerLocation = NSPoint(x: -100, y: -100) + var hasKeyStatus = true + override var mouseLocationOutsideOfEventStream: NSPoint { pointerLocation } + override var isKeyWindow: Bool { hasKeyStatus } +} + @main struct HoverRegression { static func main() { @@ -56,9 +64,14 @@ print("\(condition ? "PASS" : "FAIL") \(name)") if !condition { failures += 1 } } + let compactGroup = WorkspaceItemView(frame: NSRect(x: 0, y: 0, width: 34, height: 34)) + compactGroup.apply(title: "Group", icon: nil, selected: false, group: true, + collapsed: true, grouped: true, marker: .defaultColor, + compactGroup: true, tabStrip: true) + check(compactGroup.preferredWidth == 17, "compact tab group is half its previous width") for tabStrip in [true, false] { - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), - styleMask: [.borderless], backing: .buffered, defer: false) + let window = HoverWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.borderless], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.appearance = NSAppearance(named: .aqua) let root = FlippedView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) @@ -81,8 +94,11 @@ let views: [NSView] = rows + buttons views.forEach { $0.updateTrackingAreas() } + func trackingArea(_ view: NSView) -> NSTrackingArea { + view.trackingAreas.first { ($0.owner as? NSView) === view }! + } func trackingRect(_ view: NSView) -> NSRect { - let area = view.trackingAreas.first { ($0.owner as? NSView) === view }! + let area = trackingArea(view) return area.options.contains(.inVisibleRect) ? view.visibleRect : area.rect } func contains(_ view: NSView, _ location: NSPoint) -> Bool { @@ -93,7 +109,11 @@ } var entered = Set() func move(_ point: NSPoint) { + window.pointerLocation = point for view in views { + let options = trackingArea(view).options + guard options.contains(.activeAlways) + || (options.contains(.activeInKeyWindow) && window.isKeyWindow) else { continue } let id = ObjectIdentifier(view) let inside = contains(view, point) if inside != entered.contains(id) { @@ -140,6 +160,61 @@ if tabStrip { check(alpha(rows[2], y: 3) == 0, "selected tab keeps the compact vertical inset") } + + let outside = root.convert(NSPoint(x: 50, y: 150), to: nil) + move(location(buttons[0], x: 12, y: 12)) + buttons[0].updateTrackingAreas() + check(alpha(buttons[0], x: 3, y: 12) > 0, "tracking refresh preserves hover under a stationary pointer") + let originalFrame = rows[0].frame + rows[0].setFrameOrigin(NSPoint(x: 40, y: 0)) + buttons[0].updateTrackingAreas() + check(alpha(buttons[0], x: 3, y: 12) == 0, "moving a tab away clears close hover without an exit event") + rows[0].frame = originalFrame + buttons[0].updateTrackingAreas() + check(alpha(buttons[0], x: 3, y: 12) > 0, "moving the close button under a stationary pointer restores hover") + clip.scroll(to: NSPoint(x: 50, y: 0)) + buttons[0].updateTrackingAreas() + check(alpha(buttons[0], x: 3, y: 12) == 0, "scrolling the close button away clears hover without an exit event") + clip.scroll(to: .zero) + buttons[0].updateTrackingAreas() + check(alpha(buttons[0], x: 3, y: 12) > 0, "scrolling the close button back under the pointer restores hover") + clip.setFrameSize(NSSize(width: 174, height: 34)) + buttons[0].updateTrackingAreas() + check(alpha(buttons[0], x: 3, y: 12) == 0, "a clipped part of the close button cannot retain hover") + clip.setFrameSize(NSSize(width: 600, height: 34)) + buttons[0].updateTrackingAreas() + move(outside) + + move(location(buttons[0], x: 12, y: 12)) + buttons[0].isHidden = true + window.pointerLocation = outside + buttons[0].isHidden = false + check(alpha(buttons[0], x: 3, y: 12) == 0, "showing a close button again does not restore stale hover") + move(outside) + move(location(buttons[0], x: 12, y: 12)) + rows[0].isHidden = true + window.pointerLocation = outside + rows[0].isHidden = false + check(alpha(buttons[0], x: 3, y: 12) == 0, "showing a tab again does not restore its close button's stale hover") + move(outside) + move(location(buttons[0], x: 12, y: 12)) + rows[0].isHidden = true + rows[0].isHidden = false + check(alpha(buttons[0], x: 3, y: 12) > 0, "showing a tab under a stationary pointer restores real close hover") + rows[0].removeFromSuperview() + window.pointerLocation = outside + document.addSubview(rows[0]) + check(alpha(buttons[0], x: 3, y: 12) == 0, "reattaching a tab does not restore its close button's stale hover") + move(outside) + + move(location(buttons[0], x: 12, y: 12)) + window.hasKeyStatus = false + move(outside) + check(alpha(buttons[0], x: 3, y: 12) == 0, "leaving the close button after window deactivation clears hover") + window.hasKeyStatus = true + move(outside) + check(alpha(rows[2]) > 0, "hover lifecycle changes preserve the selected tab background") + clip.scroll(to: NSPoint(x: 50, y: 0)) views.forEach { $0.updateTrackingAreas() } check(!contains(rows[0], location(rows[0], x: 10, y: 17)), "clipped tab portion cannot hover") diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift index c99b7b3..1291a5d 100644 --- a/mac/zshell/AppKitSessionTabsView.swift +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -577,25 +577,18 @@ final class SessionTabsNSView: NSView { row.apply(title: group.name, icon: nil, selected: project.selectedTab?.tabGroupID == group.id, group: true, collapsed: group.isCollapsed, grouped: true, - marker: group.markerColor ?? .defaultColor, compactGroup: true, - showsGroupTitle: AppSettings.shared.showTabGroupNames, scale: scale) - row.toolTip = group.name + marker: group.markerColor ?? .defaultColor, compactGroup: true, scale: scale, + tabStrip: true) + row.toolTip = String(localized: group.isCollapsed ? "Expand Group" : "Collapse Group") row.onSelect = { [weak project] in guard let current = project?.tabGroup(id: group.id) else { return } project?.setTabGroupCollapsed(!current.isCollapsed, id: group.id) } - row.onRename = { [weak self, weak row, weak project] in - guard let project, let current = project.tabGroup(id: group.id) else { return } - row?.beginRename(value: current.name) { [weak self, weak project] name in - project?.renameTabGroup(group.id, to: name) - self?.scheduleRefresh() - } - } + row.onRename = nil row.menuItems = { [weak self, weak row, weak project] in guard let project, let current = project.tabGroup(id: group.id) else { return [] } var items: [AppKitContextMenuItem] = [ .action(title: String(localized: "New Session in Group")) { project.newSession(inTabGroup: group.id) }, - .action(title: String(localized: "Rename…")) { row?.onRename?() }, .action(title: String(localized: current.isCollapsed ? "Expand Group" : "Collapse Group")) { project.setTabGroupCollapsed(!current.isCollapsed, id: group.id) }, @@ -858,9 +851,8 @@ final class SessionTabsNSView: NSView { if tab.customName != nil { items.append(.action(title: String(localized: "Use Automatic Title")) { tab.customName = nil }) } var groupItems: [AppKitContextMenuItem] = [ .action(title: String(localized: "New Tab Group")) { [weak self] in - let group = project.createTabGroup(containing: tab) + project.createTabGroup(containing: tab) self?.refresh() - DispatchQueue.main.async { [weak self] in self?.rows[.group(group.id)]?.onRename?() } }, ] if !project.tabGroups.isEmpty { groupItems.append(.separator) } diff --git a/mac/zshell/AppSettings.swift b/mac/zshell/AppSettings.swift index 4a3e055..b90bfce 100644 --- a/mac/zshell/AppSettings.swift +++ b/mac/zshell/AppSettings.swift @@ -183,7 +183,6 @@ final class AppSettings: nonisolated ObservableObject { static let sidebarFontSizeRange: ClosedRange = 9...24 static let defaultInterfaceScale: Double = 1 static let interfaceScaleRange: ClosedRange = 0.9...1.5 - static let defaultShowTabGroupNames = true static let defaultToolbarVisibility: ToolbarVisibility = .hide static let defaultTerminalBackgroundOpacity: Double = 1 static let terminalBackgroundOpacityRange: ClosedRange = 0.2...1 @@ -284,11 +283,6 @@ final class AppSettings: nonisolated ObservableObject { didSet { save() } } - /// Whether tab-group controls include their name or stay icon-only. - @Published var showTabGroupNames: Bool { - didSet { save() } - } - /// `auto` shows the toolbar only for Git projects; `always` keeps its Git /// panel entry point visible in every project; `hide` suppresses it. @Published var toolbarVisibility: ToolbarVisibility { @@ -494,7 +488,6 @@ final class AppSettings: nonisolated ObservableObject { self.interfaceScale = Self.interfaceScaleRange.contains(interfaceScale) ? interfaceScale : Self.defaultInterfaceScale - showTabGroupNames = toml["tabs.show-group-names"]?.bool ?? Self.defaultShowTabGroupNames toolbarVisibility = ToolbarVisibility( rawValue: toml["toolbar.visibility"]?.string ?? "" ) ?? Self.defaultToolbarVisibility @@ -634,7 +627,6 @@ final class AppSettings: nonisolated ObservableObject { && fontSize == Self.defaultFontSize && sidebarFontSize == Self.defaultSidebarFontSize && interfaceScale == Self.defaultInterfaceScale - && showTabGroupNames == Self.defaultShowTabGroupNames && !fontThicken && fontThickenStrength == Self.defaultFontThickenStrength && terminalLineHeight == Self.defaultTerminalLineHeight @@ -675,7 +667,6 @@ final class AppSettings: nonisolated ObservableObject { themeDark = Theme.defaultDarkThemeName themeLight = Theme.defaultLightThemeName terminalThemeOnly = false - showTabGroupNames = Self.defaultShowTabGroupNames toolbarVisibility = Self.defaultToolbarVisibility cursorShape = .block cursorBlinking = true @@ -928,9 +919,6 @@ final class AppSettings: nonisolated ObservableObject { if interfaceScale != Self.defaultInterfaceScale { lines.append("interface.scale = \(TOML.number(interfaceScale))") } - if showTabGroupNames != Self.defaultShowTabGroupNames { - lines.append("tabs.show-group-names = false") - } if toolbarVisibility != Self.defaultToolbarVisibility { lines.append("toolbar.visibility = \(TOML.quote(toolbarVisibility.rawValue))") } diff --git a/mac/zshell/Localizable.xcstrings b/mac/zshell/Localizable.xcstrings index b6aabe8..c62b99f 100644 --- a/mac/zshell/Localizable.xcstrings +++ b/mac/zshell/Localizable.xcstrings @@ -9378,22 +9378,6 @@ } } }, - "Show tab group names": { - "localizations": { - "ja": { - "stringUnit": { - "state": "translated", - "value": "タブグループ名を表示" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "显示标签页分组名称" - } - } - } - }, "Showing the last local snapshot. %@": { "localizations": { "ja": { diff --git a/mac/zshell/ProjectTabMarkerColor.swift b/mac/zshell/ProjectTabMarkerColor.swift index 1b93bdd..be717fb 100644 --- a/mac/zshell/ProjectTabMarkerColor.swift +++ b/mac/zshell/ProjectTabMarkerColor.swift @@ -11,6 +11,17 @@ import Foundation /// archive formats. struct ProjectTabMarkerColor: Equatable, Sendable { static let defaultColor = ProjectTabMarkerColor(hex: "0A84FF")! + static let chromePresetColors = [ + ProjectTabMarkerColor(hex: "5F6369")!, + ProjectTabMarkerColor(hex: "1A74E8")!, + ProjectTabMarkerColor(hex: "D93025")!, + ProjectTabMarkerColor(hex: "F9AC02")!, + ProjectTabMarkerColor(hex: "1A8039")!, + ProjectTabMarkerColor(hex: "D01784")!, + ProjectTabMarkerColor(hex: "A142F5")!, + ProjectTabMarkerColor(hex: "027B84")!, + ProjectTabMarkerColor(hex: "FA903E")!, + ] let hex: String @@ -48,6 +59,9 @@ final class ProjectTabColorPanelController: NSObject { static let shared = ProjectTabColorPanelController() private var applyColor: ((ProjectTabMarkerColor) -> Void)? + private lazy var chromePalette = ChromeColorPaletteView { [weak self] color in + self?.selectChromeColor(color) + } func present(project: Project, hostWindow: NSWindow? = nil) { present( @@ -55,7 +69,8 @@ final class ProjectTabColorPanelController: NSObject { apply: { [weak project] color in project?.markerColor = color }, - hostWindow: hostWindow + hostWindow: hostWindow, + showsChromePresets: false ) } @@ -65,7 +80,8 @@ final class ProjectTabColorPanelController: NSObject { apply: { [weak tab] color in tab?.markerColor = color }, - hostWindow: hostWindow + hostWindow: hostWindow, + showsChromePresets: false ) } @@ -74,7 +90,12 @@ final class ProjectTabColorPanelController: NSObject { apply: @escaping (ProjectTabMarkerColor) -> Void, hostWindow: NSWindow? = nil ) { - present(markerColor: group.markerColor, apply: apply, hostWindow: hostWindow) + present( + markerColor: group.markerColor, + apply: apply, + hostWindow: hostWindow, + showsChromePresets: true + ) } func present( @@ -82,13 +103,19 @@ final class ProjectTabColorPanelController: NSObject { apply: @escaping (ProjectTabMarkerColor) -> Void, hostWindow: NSWindow? = nil ) { - present(markerColor: tabGroup.markerColor, apply: apply, hostWindow: hostWindow) + present( + markerColor: tabGroup.markerColor, + apply: apply, + hostWindow: hostWindow, + showsChromePresets: true + ) } private func present( markerColor: ProjectTabMarkerColor?, apply: @escaping (ProjectTabMarkerColor) -> Void, - hostWindow: NSWindow? + hostWindow: NSWindow?, + showsChromePresets: Bool ) { let initialColor = markerColor ?? .defaultColor guard let host = AppWindowPresentation.hostWindow(relativeTo: hostWindow) else { return } @@ -98,6 +125,19 @@ final class ProjectTabColorPanelController: NSObject { panel.showsAlpha = false panel.isContinuous = true panel.color = initialColor.nsColor + if showsChromePresets { + chromePalette.select(initialColor) + panel.accessoryView = chromePalette + let contentSize = panel.contentView?.bounds.size ?? .zero + if contentSize.width < chromePalette.intrinsicContentSize.width { + panel.setContentSize(NSSize( + width: chromePalette.intrinsicContentSize.width, + height: contentSize.height + )) + } + } else { + panel.accessoryView = nil + } panel.setTarget(self) panel.setAction(#selector(colorDidChange(_:))) AppWindowPresentation.attach(panel, to: host, placement: .centered) @@ -106,6 +146,126 @@ final class ProjectTabColorPanelController: NSObject { @objc private func colorDidChange(_ sender: NSColorPanel) { guard let color = ProjectTabMarkerColor(nsColor: sender.color) else { return } + chromePalette.select(color) applyColor?(color) } + + private func selectChromeColor(_ color: ProjectTabMarkerColor) { + let panel = NSColorPanel.shared + panel.color = color.nsColor + chromePalette.select(color) + applyColor?(color) + } +} + +private final class ChromeColorPaletteView: NSView { + private static let buttonSize: CGFloat = 40 + private static let spacing: CGFloat = 8 + private static let horizontalInset: CGFloat = 12 + + private let buttons: [ChromeColorSwatchButton] + + init(onSelect: @escaping (ProjectTabMarkerColor) -> Void) { + buttons = ProjectTabMarkerColor.chromePresetColors.map { color in + ChromeColorSwatchButton(color: color, action: onSelect) + } + super.init(frame: .zero) + frame.size = intrinsicContentSize + for button in buttons { addSubview(button) } + layout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize( + width: Self.horizontalInset * 2 + + CGFloat(buttons.count) * Self.buttonSize + + CGFloat(max(0, buttons.count - 1)) * Self.spacing, + height: Self.buttonSize + ) + } + + override func layout() { + super.layout() + var x = Self.horizontalInset + for button in buttons { + button.frame = NSRect(x: x, y: 0, width: Self.buttonSize, height: Self.buttonSize) + x += Self.buttonSize + Self.spacing + } + } + + func select(_ color: ProjectTabMarkerColor) { + for button in buttons { + button.isSelectedSwatch = button.color == color + } + } +} + +private final class ChromeColorSwatchButton: NSButton { + let color: ProjectTabMarkerColor + var isSelectedSwatch = false { + didSet { + guard oldValue != isSelectedSwatch else { return } + needsDisplay = true + setAccessibilityValue(isSelectedSwatch ? String(localized: "Selected") : "") + } + } + + init(color: ProjectTabMarkerColor, action: @escaping (ProjectTabMarkerColor) -> Void) { + self.color = color + onSelect = action + super.init(frame: .zero) + isBordered = false + focusRingType = .none + setButtonType(.momentaryChange) + target = self + self.action = #selector(invokeAction) + toolTip = color.displayValue + setAccessibilityLabel(color.displayValue) + setAccessibilityRole(.button) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var onSelect: (ProjectTabMarkerColor) -> Void + + @objc private func invokeAction() { + onSelect(color) + } + + override func draw(_ dirtyRect: NSRect) { + let center = bounds.midX + let diameter = min(bounds.width, bounds.height) + if isSelectedSwatch { + let outer = NSRect( + x: center - diameter / 2 + 2, + y: bounds.midY - diameter / 2 + 2, + width: diameter - 4, + height: diameter - 4 + ) + color.nsColor.setStroke() + let outerRing = NSBezierPath(ovalIn: outer) + outerRing.lineWidth = 2 + outerRing.stroke() + + let halo = outer.insetBy(dx: 3, dy: 3) + NSColor.white.setStroke() + let haloRing = NSBezierPath(ovalIn: halo) + haloRing.lineWidth = 2 + haloRing.stroke() + + color.nsColor.setFill() + NSBezierPath(ovalIn: halo.insetBy(dx: 2, dy: 2)).fill() + } else { + color.nsColor.setFill() + NSBezierPath(ovalIn: bounds.insetBy(dx: 5, dy: 5)).fill() + } + } } diff --git a/mac/zshell/Settings/SettingsAppearancePane.swift b/mac/zshell/Settings/SettingsAppearancePane.swift index 6c1016e..1247e89 100644 --- a/mac/zshell/Settings/SettingsAppearancePane.swift +++ b/mac/zshell/Settings/SettingsAppearancePane.swift @@ -80,10 +80,6 @@ final class SettingsAppearancePane: SettingsPaneViewController { onChange: { AppSettings.shared.interfaceScale = $0 } ) - private let tabGroupNamesSwitch = SettingsSwitch { - AppSettings.shared.showTabGroupNames = $0 - } - private let thickenSwitch = SettingsSwitch { AppSettings.shared.fontThicken = $0 } private let thickenStrengthRow = SettingsSliderRow( @@ -157,7 +153,6 @@ final class SettingsAppearancePane: SettingsPaneViewController { SettingsGroup(header: String(localized: "Interface"), rows: [ interfaceScaleRow, sidebarFontSizeRow, - SettingsRow(title: String(localized: "Show tab group names"), control: tabGroupNamesSwitch), ]), SettingsGroup(header: String(localized: "Panes"), rows: [ SettingsRow( @@ -186,7 +181,6 @@ final class SettingsAppearancePane: SettingsPaneViewController { fontSizeRow.setValue(settings.fontSize) sidebarFontSizeRow.setValue(settings.sidebarFontSize) interfaceScaleRow.setValue(settings.interfaceScale) - tabGroupNamesSwitch.isOn = settings.showTabGroupNames thickenSwitch.isOn = settings.fontThicken thickenStrengthRow.setValue(Double(settings.fontThickenStrength)) thickenStrengthRow.setEnabled(settings.fontThicken) diff --git a/mac/zshell/WorkspaceChromeViews.swift b/mac/zshell/WorkspaceChromeViews.swift index fc24948..36be52e 100644 --- a/mac/zshell/WorkspaceChromeViews.swift +++ b/mac/zshell/WorkspaceChromeViews.swift @@ -46,13 +46,45 @@ final class WorkspaceChromeButton: NSButton { // Unclipped views can report a visibleRect larger than their bounds. addTrackingArea(NSTrackingArea( rect: NSIntersectionRect(bounds, visibleRect), - options: [.activeInKeyWindow, .mouseEnteredAndExited], + options: [.activeAlways, .mouseEnteredAndExited], owner: self )) + synchronizeHoverWithMouse() } - override func mouseEntered(with event: NSEvent) { isHovered = true; needsDisplay = true } - override func mouseExited(with event: NSEvent) { isHovered = false; needsDisplay = true } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + synchronizeHoverWithMouse() + } + + override func viewDidHide() { + super.viewDidHide() + setHovered(false) + } + + override func viewDidUnhide() { + super.viewDidUnhide() + synchronizeHoverWithMouse() + } + + override func mouseEntered(with event: NSEvent) { setHovered(true) } + override func mouseExited(with event: NSEvent) { setHovered(false) } + + private func synchronizeHoverWithMouse() { + guard let window, !isHiddenOrHasHiddenAncestor else { + setHovered(false) + return + } + // Layout and visibility changes can move the button without a mouse exit. + let point = convert(window.mouseLocationOutsideOfEventStream, from: nil) + setHovered(NSIntersectionRect(bounds, visibleRect).contains(point)) + } + + private func setHovered(_ hovered: Bool) { + guard isHovered != hovered else { return } + isHovered = hovered + needsDisplay = true + } override func draw(_ dirtyRect: NSRect) { if isHovered || isHighlighted { @@ -298,7 +330,7 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { ? min(28, max(24, ceil(titlePointSize + 8))) : min(26, max(22, 24 * controlScale)) guard showsCompactGroupTitle else { - return NSSize(width: max(34 * controlScale, height + 9 * controlScale), height: height) + return NSSize(width: 17 * controlScale, height: height) } let horizontalPadding = 8 * controlScale let title = min(titleWidth, 68 * controlScale) diff --git a/web/content/docs/configuration.mdx b/web/content/docs/configuration.mdx index b11d535..540dcbb 100644 --- a/web/content/docs/configuration.mdx +++ b/web/content/docs/configuration.mdx @@ -66,16 +66,6 @@ Controls the compact toolbar below the active tab: See [The Git toolbar](/docs/git#the-git-toolbar) for what it shows. -## Tabs - -### `tabs.show-group-names` - -boolean — default `true` - -Shows each tab group's name beside its colored disclosure control in the tab -bar. Set it to `false` to use the short disclosure control only; sidebar group -names always remain visible. - ## Text ### `font-family` diff --git a/web/content/docs/configuration.zh.mdx b/web/content/docs/configuration.zh.mdx index dba55c3..a160bf3 100644 --- a/web/content/docs/configuration.zh.mdx +++ b/web/content/docs/configuration.zh.mdx @@ -49,14 +49,6 @@ Zshell 大多数持久化设置都在一个文件里: 它具体展示什么,见 [Git 工具栏](/zh/docs/git#git-工具栏)。 -## 标签页 - -### `tabs.show-group-names` - -布尔值——默认 `true` - -控制标签栏是否在彩色展开按钮旁显示分组名称。设为 `false` 后只显示短的展开按钮;侧边栏分组名称始终显示。 - ## 文字 ### `font-family` diff --git a/web/content/docs/projects.mdx b/web/content/docs/projects.mdx index dba2f85..71957d0 100644 --- a/web/content/docs/projects.mdx +++ b/web/content/docs/projects.mdx @@ -98,18 +98,18 @@ the current session's directory and falls back to your home directory. ### Tab groups -Use **New Tab Group** beside the new-session button, or right-click a tab and -choose **Move to Group → New Tab Group**. The current tab joins the new group, -whose name can be edited immediately. New sessions opened from a grouped tab -stay in that group; the group's **+** button creates a session directly inside it. - -Drag a tab onto a group header or another tab to move it. Its context menu also -lists groups and **Remove from Group**. Click a group header to collapse or -expand it, and double-click to rename it. Collapsing a group keeps the active +Right-click a tab and choose **Move to Group → New Tab Group**. That tab joins +the new group, shown as a colored arrow without a name. New sessions opened +from a grouped tab stay in that group; right-click the group's arrow and choose +**New Session in Group** to create a session directly inside it. + +Drag a tab onto a group's arrow or another tab to move it. Its context menu also +lists groups and **Remove from Group**. Click the arrow to collapse or +expand the group. Collapsing a group keeps the active terminal running and visible; switching to a hidden member expands the group. Removing a group keeps its tabs and sessions open. Pinning a grouped tab moves -it to the fixed section outside the groups. Right-click a group header to choose -**Set Color Marker…** or **Remove Color Marker**. Group names, colors, and +it to the fixed section outside the groups. Right-click a group's arrow to choose +**Set Color Marker…** or **Remove Color Marker**. Group colors and collapse state are restored after relaunching Zshell. A tab can also be dropped onto a sidebar project, or onto a sidebar group header @@ -144,7 +144,8 @@ Zshell snapshots your layout as you work, so quitting and reopening gives you ba - every project, in order - every tab, including custom names, pinning, and color markers -- sidebar groups and tab groups, including names, color markers, order, membership, and collapse state +- sidebar groups, including names, color markers, order, membership, and collapse state +- tab groups, including color markers, order, membership, and collapse state - pinned project directories and empty projects - the pane layout inside each tab - which sidebars were open and which right panel was selected diff --git a/web/content/docs/projects.zh.mdx b/web/content/docs/projects.zh.mdx index 13fe276..668cfad 100644 --- a/web/content/docs/projects.zh.mdx +++ b/web/content/docs/projects.zh.mdx @@ -66,9 +66,9 @@ Directory** 则交还自动判断。 ### 标签页分组 -点击新建会话按钮旁的 **新建标签页分组**,或右键标签页选择 **移动到分组 → 新建标签页分组**。当前标签会加入新组,并立即进入名称编辑。选中组内标签后新建的会话会留在该组;组标题上的 **+** 可以直接在组内创建会话。 +右键标签页选择 **移动到分组 → 新建标签页分组**。该标签会加入新组,分组在标签栏中仅显示彩色箭头,不显示名称。选中组内标签后新建的会话会留在该组;右键分组箭头选择 **在分组中新建会话**,也可以直接在组内创建会话。 -把标签拖到组标题或另一个标签上即可移动。右键菜单也可以选择目标分组,或 **移出分组**。单击组标题折叠、展开,双击重命名。折叠时当前终端仍会运行并继续显示;切换到隐藏的标签会自动展开其分组。移除分组会保留所有标签与会话。固定组内标签时,该标签会移到分组之外的固定区。右键组标题可以 **设置颜色标记…** 或 **移除颜色标记**。侧栏分组和标签页分组的名称、颜色、折叠状态都会在重启后恢复。 +把标签拖到分组箭头或另一个标签上即可移动。右键菜单也可以选择目标分组,或 **移出分组**。单击分组箭头折叠、展开。折叠时当前终端仍会运行并继续显示;切换到隐藏的标签会自动展开其分组。移除分组会保留所有标签与会话。固定组内标签时,该标签会移到分组之外的固定区。右键分组箭头可以 **设置颜色标记…** 或 **移除颜色标记**。标签页分组的颜色、折叠状态都会在重启后恢复。 标签还可以拖到侧栏已有项目,或拖到侧栏分组标题,在该分组内创建新项目。拖到侧边栏底部的 **+(新建项目)** 会创建不属于任何侧栏组的新项目。右键菜单的 **移动标签页到项目** 也支持其他窗口。移动会保留运行中的终端和窗格布局。目标项目必须具有相同的本地或 SSH 位置;从标签创建新项目时会继承来源位置。包含 Diff 的标签保留在原项目中。 @@ -90,7 +90,8 @@ Zshell 会随着你的操作持续快照布局,所以退出再打开会拿回 - 每个项目,顺序不变 - 每个标签页,包括自定义名称、固定状态和颜色标记 -- 侧边栏分组与标签页分组的名称、颜色标记、顺序、成员和折叠状态 +- 侧边栏分组的名称、颜色标记、顺序、成员和折叠状态 +- 标签页分组的颜色标记、顺序、成员和折叠状态 - 固定的项目目录和空项目 - 每个标签页里的窗格布局 - 哪些侧边栏是打开的、右侧选中的是哪个面板 From 0a1ed45ca8eaa8bb84929b42d5b9fcc06a6c8779 Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Sat, 19 Sep 2026 13:18:47 +0800 Subject: [PATCH 7/7] feat(git): ignore AI dir --- .gitignore | 4 + .vscode/settings.json | 3 - .workbuddy/branches-pr-map.html | 461 -------------------------------- .workbuddy/memory/2026-09-11.md | 22 -- .workbuddy/memory/2026-09-13.md | 35 --- .workbuddy/memory/MEMORY.md | 20 -- 6 files changed, 4 insertions(+), 541 deletions(-) delete mode 100644 .vscode/settings.json delete mode 100644 .workbuddy/branches-pr-map.html delete mode 100644 .workbuddy/memory/2026-09-11.md delete mode 100644 .workbuddy/memory/2026-09-13.md delete mode 100644 .workbuddy/memory/MEMORY.md diff --git a/.gitignore b/.gitignore index 3b6300b..bdbb5a9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ playground.xcworkspace # it would be a second copy to keep in sync. Symlink one here if a tool needs it. .claude/ CLAUDE.md + +.vscode/ +.idea/ +.workbuddy/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 082b194..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "makefile.configureOnOpen": false -} \ No newline at end of file diff --git a/.workbuddy/branches-pr-map.html b/.workbuddy/branches-pr-map.html deleted file mode 100644 index dbccfd4..0000000 --- a/.workbuddy/branches-pr-map.html +++ /dev/null @@ -1,461 +0,0 @@ - - -zshell PR 总览 · 80 个 -
-

zshell PR 总览

-
wzz6423/zshell · 全部收口:35 个合并 + #58 关闭 · main @ d3b73ac · 点击编号跳转 GitHub
-
-
35
今日合并
-
76
累计 MERGED
-
0
仍开放
-
4
已关闭
-
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PR状态分支功能合并日期
#81MERGEDfix/terminal-link-at-clickfix(terminal): resolve terminal links at click position2026-09-13
#80MERGEDfeat/prompt-queuefeat(terminal): add a per-session prompt queue2026-09-13
#79MERGEDfeat/settings-import-exportfeat(settings): add settings import and export2026-09-13
#78MERGEDfeat/search-file-contentsfeat(files): search file contents across the project2026-09-13
#77MERGEDfeat/reopen-closed-sessionsfeat(tabs): reopen closed sessions2026-09-13
#76MERGEDfeat/notify-command-finishfeat(terminal): notify when long commands finish or fail2026-09-13
#75MERGEDfeat/quick-launchfeat(terminal): add Quick Launch for commands and SSH connections2026-09-13
#74MERGEDfeat/kero-custom-keybindingsfeat(settings): add command shortcut remapping2026-09-13
#73MERGEDfeat/kero-remote-ssh-projectsfeat(projects): add remote SSH projects2026-09-13
#72MERGEDfeat/kero-ui-scalingfeat(settings): add interface scale preference2026-09-13
#71MERGEDfeat/kero-alternate-app-iconsfeat(settings): add alternate app icons2026-09-13
#70MERGEDfeat/kero-global-fuzzy-searchfeat(palette): search files across all projects2026-09-13
#69MERGEDfeat/kero-cross-project-tab-movefeat(tabs): move tabs across projects and windows2026-09-13
#68MERGEDfeat/kero-project-tab-environmentfeat(terminal): add project environment and init commands2026-09-13
#67MERGEDfeat/kero-code-review-workflowfeat(git): add code review workflow2026-09-13
#66MERGEDfeat/kero-agent-usage-limitsfeat(automation): show agent usage and limits2026-09-13
#65MERGEDfix/kero-automation-enterfix(terminal): commit automated commands with enter2026-09-13
#64MERGEDfeat/kero-sidebar-font-rangefeat(sidebar): widen the sidebar font size range2026-09-13
#63MERGEDfeat/kero-tab-mouse-actionsfeat(tabs): rename on double-click and close on middle-click2026-09-13
#62MERGEDfeat/kero-quick-command-presetsfeat(terminal): add quick command presets2026-09-13
#61MERGEDfeat/kero-terminal-only-themefeat(terminal): add terminal-only theme scope2026-09-13
#60MERGEDfeat/kero-agent-palettefeat(palette): add running agent palette2026-09-13
#59CLOSEDfix/kero-herdr-sidebar-integrationfix(terminal): pass right-clicks through to mouse-aware apps
#58CLOSEDfeat/kero-swap-sidebarsfeat(layout): allow swapping sidebars
按维护者决策关闭:不做交换侧栏功能(2026-09-13)
#57MERGEDfeat/kero-editor-auto-indentfeat(editor): auto-indent on newline2026-09-13
#56MERGEDfeat/kero-file-tree-cmd-openfeat(sidebar): open files with Cmd-click in default app2026-09-13
#55MERGEDfeat/kero-terminal-font-metricsfeat(terminal): add font-thicken strength, line height, and CJK fallback2026-09-13
#54MERGEDperf/kero-file-diff-performanceperf(file): avoid redundant initial reload and content hash collisions2026-09-13
#53MERGEDfeat/kero-terminal-multiplexerfeat(terminal): add built-in terminal multiplexer2026-09-13
#52MERGEDfeat/kero-pane-focus-ringfeat(panes): add focus ring toggle and opacity2026-09-13
#51MERGEDfeat/kero-project-tab-colorsfeat(tabs): add project and tab color markers2026-09-13
#50MERGEDfeat/kero-window-transparencyfeat(terminal): add terminal background opacity and blur2026-09-13
#49MERGEDfeat/kero-pin-project-tabsfeat(tabs): pin project tabs2026-09-13
#48MERGEDfix/kero-resize-text-jitterfix(terminal): stabilize resize text metrics2026-09-12
#47MERGEDfix/kero-editor-preview-refreshfix(editor): refresh Alacritty prompt previews2026-09-12
#46MERGEDperf/kero-memory-leak-soakperf(memory): add a repeatable macOS soak sampler2026-09-12
#45MERGEDfix/kero-window-screen-clampfix(window): constrain windows after display changes2026-09-12
#44MERGEDperf/kero-large-file-editorperf(editor): reduce large file highlighting work2026-09-13
#43MERGEDfeat/kero-replace-file-tabfeat(editor): reuse file preview tabs2026-09-12
#42MERGEDfeat/kero-startup-shell-profilefeat(terminal): configure terminal startup program2026-09-12
#41MERGEDfeat/kero-shift-enter-newlinefeat(terminal): configure Shift-Enter newline2026-09-12
#40MERGEDfix/kero-alacritty-ime-preeditfix(terminal): render Alacritty IME preedit2026-09-13
#39MERGEDfeat/kero-markdown-previewfeat(editor): add native Markdown preview2026-09-12
#38MERGEDfeat/kero-sidebar-folder-dropfeat(sidebar): open folders dropped from Finder2026-09-13
#37MERGEDfix/kero-alacritty-ime-candidate-positionfix(terminal): position Alacritty IME candidates2026-09-13
#36MERGEDfeat/kero-configurable-editorsfeat(editor): configure external editors2026-09-12
#35MERGEDfix/kero-dark-cli-contrastfix(terminal): preserve dark CLI text contrast2026-09-12
#34MERGEDfix/project-status-bucketfix(ci): route project status from PR type2026-09-12
#33MERGEDfix/kero-selection-autoscrollfix(terminal): autoscroll selection at viewport edges2026-09-12
#32MERGEDfeat/kero-terminal-bell-togglefeat(terminal): add a terminal bell setting2026-09-12
#31MERGEDfix/kero-long-running-tabsfix(automation): bound background session polling2026-09-12
#30MERGEDfix/kero-terminal-context-menufix(terminal): pass right-clicks to mouse-aware apps2026-09-12
#29MERGEDfix/kero-sidecar-window-movefix(window): restore system display destinations2026-09-12
#28MERGEDfix/worktree-sidebar-scrollfix(git): constrain worktree list height2026-09-12
#27MERGEDtest/kero-git-status-regressionstest(git): cover duplicate porcelain status paths2026-09-12
#26MERGEDdocs/release-v0.1.3docs(release): update website for 0.1.32026-09-09
#25MERGEDfix/release-publish-draftsfix(release): publish existing GitHub drafts2026-09-09
#24MERGEDbuild/release-v0.1.3build(release): prepare zshell 0.1.32026-09-09
#23MERGEDfeat/git-worktree-browserfeat(git): browse repository worktrees2026-09-09
#22MERGEDfix-git-status-duplicate-pathfix(git): handle duplicate status paths2026-09-08
#21MERGEDfix/tighten-header-drag-spacefix(header): tighten trailing drag space2026-09-08
#20MERGEDdocs/release-0.1.1-linksdocs(web): update release links for 0.1.12026-09-07
#19MERGEDdependabot/bun/web/bun-dependencies-2ce7ac118bchore(deps): bump the bun-dependencies group in /web with 3 updates2026-09-06
#18MERGEDfix/terminal-click-escape-sequencesfix(terminal): prevent click control sequences from leaking into input2026-09-06
#17MERGEDpublish-v0.1.0build(release): prepare zshell 0.1.02026-09-06
#16MERGEDrefactor/settings-categoriesrefactor(settings): rebuild Settings as AppKit category panes2026-09-06
#15MERGEDchore/drop-submodule-instructionschore: stop treating vendored packages as submodules2026-09-05
#14MERGEDci/release-feed-verificationci: verify a published release reaches installed copies2026-09-05
#13MERGEDci/skill-validationci: validate every SKILL.md in CI2026-09-05
#12MERGEDdocs/note-the-gitee-mirrordocs: point the Gitee mirror's readers at GitHub2026-09-05
#11MERGEDchore/untrack-agent-instructionschore: keep agent instructions to AGENTS.md2026-09-05
#10MERGEDfix/web-search-multilingual-tokenizerfix(web): search with the engine's multilingual tokenizer2026-09-05
#9MERGEDfix/release-origin-github-releasesfix(release): publish downloads and the Sparkle feed from GitHub Releases2026-09-05
#8MERGEDdocs/align-bilingual-docsdocs: add the Simplified Chinese docs and a Code of Conduct2026-09-05
#7MERGEDdependabot/bun/web/bun-dependencies-9644855b67chore(deps): bump the bun-dependencies group in /web with 16 updates2026-09-05
#6CLOSEDdependabot/cargo/mac/Vendor/tree-sitter/rand-0.8.6chore(deps): bump rand from 0.8.5 to 0.8.6 in /mac/Vendor/tree-sitter
#5MERGEDfix/website-pages-pathfix(web): serve the website from its GitHub Pages project path2026-09-05
#4CLOSEDtest/verify-ci-gateschore: verify the CI gates on the migrated repository
#2MERGEDci/repair-migrated-workflowsci: repair the pull request workflows2026-09-05
#1MERGEDdependabot/cargo/mac/Vendor/alacritty-bridge/cargo-dependencies-cd19caac8dchore(deps): bump the cargo-dependencies group across 1 directory with 2 updates2026-09-05
-
无匹配项
-
\ No newline at end of file diff --git a/.workbuddy/memory/2026-09-11.md b/.workbuddy/memory/2026-09-11.md deleted file mode 100644 index ff09fc5..0000000 --- a/.workbuddy/memory/2026-09-11.md +++ /dev/null @@ -1,22 +0,0 @@ -# 2026-09-11 - -## Kero 任务收尾(接手 VS Code 面板编排会话) - -- 背景:用户在 Claude Code VS Code 面板(会话标题「Zshell Kero 功能缺口」,id `d6bd3981-4328-4065-9776-57d1878271a9`)跑了一个编排会话:读 Codex 线程的功能缺口对照表,为 50 个 Kero 缺口各建 worktree(`.claude/worktrees/<名>`)+ Claude 任务会话(只实现不提交),由编排会话验证后提交/推送/开 PR。14:45 前后 API 额度耗尽(403,余额 -$0.175)全部中断;此前已产出 PR #27–#49。 -- 本会话接手:定位会话(`claude agents --json --all` 有 50 个 kero-* 会话状态)、盘点 54 个 worktree(23 个有未提交改动且无 PR)。 -- 已交付 PR(子代理补完 + 静态校验 + 提交推送):#50 window-transparency、#51 project-tab-colors、#52 pane-focus-ring、#53 terminal-multiplexer(补了两处并发修正:shutdown 响应截断、create 绕过 128 上限)、#54 file-diff-performance(64 位 FNV-1a 指纹 + 初载去重)、#55 terminal-font-metrics、#56 file-tree-cmd-open(Cmd-click 默认应用打开)、#57 editor-auto-indent(补全了缺失的 `registerSelectionUndo` 多光标 undo/redo 选区往返)。 -- 剩余 15 个任务已建立一次性自动化(id `ccff45d8-6f66-43d1-a967-a690d3fce4ae`,2026-09-11 19:35 触发,子代理额度 19:28 重置后接力),提示词含全部任务规格、会话记录位置、环境约束与 PR 模板。 - -## 环境约束(重要,跨会话有效) - -- **WorkBuddy 沙箱内无法运行 xcodebuild**:SwiftPM 包解析阶段 `sandbox-exec: sandbox_apply: Operation not permitted`,`dangerouslyDisableSandbox` 也一样;`launchctl` IPC 也被挡(`launchctl list` 空输出)。替代验证:任务会话早先的真实构建报告 + `swiftc -parse` 逐文件 + `xcrun xcstringstool compile --dry-run` + `git diff --check`。 -- **git HTTPS 推送被代理拦**(CONNECT 502),但 SSH 到 github.com 可用:`git push git@github.com:wzz6423/zshell.git
:
`。 -- `gh pr create` 需要 `--head wzz6423:`(分支非本地跟踪时)。 -- Claude CLI(用户额度)当前 403 余额负数,`claude agents` 会话无法自行续跑。 -- 会话记录位置:Claude Code 任务会话在 `~/.claude/projects/-Users-wzz----code-zshell--claude-worktrees-<名>/.jsonl`;编排会话在 `~/.claude/projects/-Users-wzz----code-zshell/`。 - -## Kero 积压任务最终收口(本轮) -- 用 zlm-5.3-flash 子代理跑完最后 11 个任务并全部 PR:#64 sidebar-font-range、#65 automation-enter(sendEnter 语义提交,Closes #127)、#66 agent-usage-limits、#67 code-review-workflow(此前误报 499 取消,实际已提交 707 行)、#68 project-tab-environment、#69 cross-project-tab-move、#70 global-fuzzy-search、#71 alternate-app-icons、#72 ui-scaling、#73 remote-ssh-projects(含 25 项单测)、#74 custom-keybindings(12 命令重映射,40 项单测)。 -- 50 任务终态:46 个 kero PR(#27–#74,其中 #28/#34 非 kero)+ 3 个核实无需改动(automation-pane-access、ghostty-screen-export-fd、voice-input-popover)+ app-live-smoke 无法在本沙箱完成(需真实启动 app 跑 AX 冒烟)。 -- 结论:除 app-live-smoke 外全部交付;所有 PR 仅静态校验(swiftc -parse/-typecheck、xcstrings、plutil),合并前需按 CONTRIBUTING 用 make run 实机走查。 -- 已删除 19:35 的冗余一次性自动化(ccff45d8),任务改为主会话即时执行。 diff --git a/.workbuddy/memory/2026-09-13.md b/.workbuddy/memory/2026-09-13.md deleted file mode 100644 index e4145b7..0000000 --- a/.workbuddy/memory/2026-09-13.md +++ /dev/null @@ -1,35 +0,0 @@ -# 2026-09-13 - -## 分支与 PR 映射盘点 -- 应需求梳理 wzz6423/zshell 全部分支与 PR 的功能映射:80 个 PR(36 开放 / 41 已合并 / 3 已关闭),36 个活跃远程特性分支与开放 PR 一一对应;已合并分支远程已删,本地留有陈旧 origin/* 跟踪引用(可 `git remote prune origin` 清理)。 -- 产出:`.workbuddy/branches-pr-map.html`(可搜索/按状态筛选的映射表页面)。 -- 数据来源:`git ls-remote --heads`(SSH)+ `gh pr list --state all --limit 200 --json`。 - -## PR 批量调试与合并(36 开放 PR 收口) -- 结果:33/36 合并(squash + `--admin` 绕过 CI,用户授权),3 个留给用户:#44(大文件编辑器需移植到 main 新 SourceEditorController)、#58(实现与标题意图不符:固定重排≠动态交换)、#81(空分支零 diff)。 -- 修复后合并:#53 PTYMux 守护进程挂起(3 处 close listener 前补 `shutdown(listener, SHUT_RDWR)` 唤醒阻塞 accept)、#54 xcstrings 3 个重复 JSON 键去重、#63 补实现双击重命名(TabItemChrome 的 Button 本体挂 onTapGesture(count:2),挂祖先视图会被 Button 消费)、#73 远程文件树接线、#78 Stop Search 本地化键、#80 queueBar 适配 mount() 新签名。 -- 多轮冲突收敛:合并会推进 main 导致其余分支再冲突,共 3 轮 rebase;xcstrings 一律 union 合并(键并集、分支侧优先、sort_keys 输出)。 -- 关键坑(见 zshell-pr-pipeline skill): - 1. `xcstringstool compile --dry-run` 现在必须带 `--output-directory`,否则报 Missing expected argument。 - 2. union 脚本若接收相对路径并以 cwd 写文件,会写歪到主仓库(本会话曾写脏主仓库 xcstrings,已恢复);必须 `os.path.join(repo, path)`。 - 3. rebase 中对重排格式后的 JSON 做第 2 个提交的文本合并会产生带冲突标记的"成功"结果——多提交分支改用 squash 重建(worktree --detach 到 main → merge --squash 分支 → union → commit → branch -f → push)更稳。 - 4. 主仓库工作区可能停在旧分支上,`fetch main:main` 不更新工作区;验证前先 `checkout main`。 -- 收尾:删除 33 个本地已合并分支(squash 需 -D),保留 3 个开放 PR 分支;`update-ref refs/remotes/origin/main` 修正陈旧跟踪引用;最终 main `6d9c53f`:xcstrings 732 键零重复、四语言编译通过、49 个变更 swift 文件全部 swiftc -parse 通过、diff --check 干净。 -- 环境限制再次确认:xcodebuild 在 WorkBuddy 会话内因 SwiftPM 嵌套沙箱 EPERM 不可用(5 种绕过均失败),无法真实运行调试,只能 review + 静态验证,已向用户披露。 - -## #81 落地:终端裸域名链接识别(已合并 91c2e94) -- 用户澄清 #81 需求:识别 `https://` 之外的裸域名链接(www.baidu.com / baidu.com)。实现两层: - 1. `mac/Vendor/alacritty-bridge/src/lib.rs` LINK_REGEX 加裸域名分支((?:www.)?host.tld + localhost[:port]),TLD 白名单约 50 个防误伤 main.swift/1.2.3/config.yaml;CJK 全角标点(。,、;:?!)排除出 URL 字符类——中文无空格,否则后续文字粘进链接;`cargo test` 31/31 过(新增 3 个测试)。 - 2. `TerminalSession.swift` 加 `bareWebURL(from:)`:无 scheme 值按同一 TLD 白名单匹配,localhost 用 http、其余 https;`terminalLinkTarget` 三段式(file → scheme URL → bare web)。16/16 行为测试过。 -- 层级事实:检测在 Alacritty=自研 bridge(可改)/ Ghostty=预编译 libghostty(不可改,只能改解析层兜底);解析层 TerminalSession.terminalLinkTarget 是两后端共用拦截点。 -- 又一个大坑:主仓库工作区 checkout main 之后 `fetch URL main:main` 报 "refusing to fetch into branch"(此前被 2>/dev/null 掩盖),需 `git pull --ff-only $SSH main`。已写入 zshell-pr-pipeline skill。 -- 剩余开放 PR:#44、#58。#44 冲突仅 SourceTextEditor.swift 单文件(main 已重构为 SourceEditorController,分支还挂在旧 Coordinator/NSViewRepresentable 上);#58 冲突 ContentView/RightSidebarView/SidebarView/xcstrings 四文件。 - -## PR #44 重做 + #58 关闭(下午续) - -- 用户指令:#44 基于最新 main 重做,#58 关闭不做。已执行:#58 关闭并删远程分支;#44 全新移植后合并(main → `d3b73ac`)。至此 80 个 PR 全部收口,开放数 0。 -- #44 移植要点:旧实现挂在已删除的 SourceTextEditor(NSViewRepresentable+Coordinator) 上;新家是 SourceEditorController(同为 STTextViewDelegate,will/didChangeTextIn 委托方法直接可挂)。三大件:FocusReportingTextView 文本快照(granular 回写 file.text)、UTF16LineIndex 行索引(二分+增量)、didChangeContent(to:) 快照喂 parser。 -- 关键语义发现:vendored TreeSitterClient 的 InputEdit 用 `startByte: location*2` 字节约定,旧 PR 的列号 `*2` 与之配套且修正了原 TextKit 列号不一致的隐患——移植时保留。 -- 验证:UTF16LineIndex 独立行为测试(CJK/emoji/CRLF 样本 + 3000 次 fuzz,granular vs 全量重建零漂移)全过;swiftc -parse 两文件干净;merge-tree 预检干净。 -- 注意 `range.max` 是 Rearrange 的扩展,独立测试环境需用 `location+length` 等价替换。 -- 本地分支已全部清空(只剩 main);/tmp 测试产物已清理。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md deleted file mode 100644 index db85d0b..0000000 --- a/.workbuddy/memory/MEMORY.md +++ /dev/null @@ -1,20 +0,0 @@ -# Zshell 项目长期备忘 - -## 环境硬约束(WorkBuddy 会话内) - -- xcodebuild 不可运行:SwiftPM 嵌套沙箱 `sandbox_apply: EPERM`(disableSandbox 亦然),launchctl IPC 也被挡。编译验证替代方案:实现会话的构建报告 + `swiftc -parse` + `xcstringstool compile --dry-run` + `git diff --check`。 -- git HTTPS 被代理拦(CONNECT 502);推送走 SSH:`git push git@github.com:wzz6423/zshell.git
:
`。`gh` 可用,PR 用 `--head wzz6423:`。 -- 用户的 Claude CLI 额度可能为负(403 用户额度不足),`claude agents` 任务会话会集体 blocked。 - -## 构建验证标准命令 - -- xcodebuild 在 WorkBuddy 会话内不可运行(SwiftPM 嵌套沙箱 EPERM),替代方案:实现会话构建报告 + `swiftc -parse` + `xcrun xcstringstool compile --output-directory --dry-run`(必须带 --output-directory)+ `git diff --check` + `git merge-tree --write-tree`。 -- 本地 main 可能落后于远程且工作区停在旧分支:一律 `git fetch git@github.com:wzz6423/zshell.git main:main --force`(SSH),验证前先 checkout main。 -- xcstrings 冲突 union 合并脚本必须用 `os.path.join(repo, path)` 定位输出(相对路径会写歪到 cwd);多提交分支 rebase 卡 JSON 时改用 squash 重建。 -- PR 合并循环:SSH fetch main+分支 → `git merge-tree --write-tree` 预检 → `gh pr merge --repo wzz6423/zshell --squash --delete-branch --admin`(CI 被绕过需 --admin,用户已授权此模式)。合并会推进 main 使其余分支再冲突,需多轮收敛。 - -## 项目工作流 - -- Kero 功能收口模式:编排会话(Claude Code)为每个缺口建 `.claude/worktrees/<名>` + 任务会话(实现不提交),验证后由编排方提交推送开 PR(conventional commits 英文标题 + 固定 PR 模板,含 Summary / GitHub Project: zshell Development / PR Type / Validation / Risk and Rollback)。 -- 构建验证标准命令:`xcodebuild -project mac/zshell.xcodeproj -scheme zshell -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build`(仅用户本机可用);本地化校验必须带输出目录 `xcrun xcstringstool compile mac/zshell/Localizable.xcstrings --output-directory --dry-run`。 -- 任务会话转录:`~/.claude/projects/-Users-wzz----code-zshell--claude-worktrees-<名>/.jsonl`。