diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index e2d6b8f406..aa720b49e4 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -508,6 +508,16 @@ Lithe 使用 `BranchPopupRowView` 包装原有 SwiftUI 行,保留共享 `Lithe 直接调用 cell 的检查通过,但用户实测仍未出现展开,不能继续把它当作可靠入口。 子窗口接收该行点击并转交原动作,键盘焦点留在父弹窗;滚轮转交原行滚动路径。 离开、打开动作菜单、禁用、脱离窗口及父窗口关闭都清理浮层及父窗口关闭观察器。 +触控板手势绑定开始时的窗口,不能收到首个滚动事件就关闭展开层,否则后续手势会丢失。 +展开层在真实滚动时隐藏但继续接收事件,将后续手势转交原生滚动区;只是放上手指时保持名称显示。 +手势接收对象不依赖旧行存活,因此懒列表滚走并移除原行后仍可继续滚动。它自己拥有父窗口关闭监听, +父弹窗关闭时立即取消回收任务并移除展开层,不等待结束事件或兜底计时。回收闭包不强持有面板, +避免面板、内容视图和闭包互相持有。 +隐藏层上的点击必须按屏幕坐标转换后,重新交给父窗口的原生事件派发(由当前命中控件处理); +左键、右键、拖动和松开属于同一次鼠标操作,隐藏层在松开后回收,父窗口提前关闭则立即回收。 +例如滚动后原位置已经是分支 B,点击应交给 B,而不是调用已滚走分支 A 的回调,也不能只关闭隐藏层吞掉点击。 +指针超出父窗口内容时,不猜测其他应用或全局窗口的目标,不使用全局事件注入。 +代价是手势移交期间短暂保留透明子窗口,并需要转换鼠标事件;丢失结束事件仍由有界计时兜底。 只测量当前悬停行,不给空闲行登记全局悬浮几何;展开使用当前分组里的本地名称, 不额外插入 namespace。 行点击仍通过 `LitheDropdownPopover` 打开动作菜单,无障碍按下和键盘按下保留。 @@ -533,6 +543,9 @@ Stash/Shelf 复用同一边界,独立展开宿主继续测量完整名称。 `BranchSwitcherPopoverBehaviorTests` 检查深浅选中底色、尺寸、无须展开的行、 动作和禁用行为;悬停必须实际创建可见子窗口,检查完整内容、原行位置、 绘制底色、点击转交、焦点及父窗口关闭清理,不能再直接调用展开或绘制方法证明触发。 +手势回归还要覆盖移除旧行后完整滚动转发、隐藏层左/右键的按下/拖动/松开转交当前内容、 +事件坐标与修饰键保留,以及父窗口关闭时无需执行定时任务就取消回收和解除父子关系。 +通过窗口派发的组件事件不能替代完整产品中的真实触控板与惯性滚动验收。 完整预览已核对分支列表、长名称截断和搜索,原生悬浮层与实际菜单的组合 尚未取得可靠的实机截图,不能用组件截图代替最终视觉验收。 diff --git a/macos/Sources/Lithe/Views/Git/BranchPopupRowView.swift b/macos/Sources/Lithe/Views/Git/BranchPopupRowView.swift index 6fc413ecb3..971a321ba0 100644 --- a/macos/Sources/Lithe/Views/Git/BranchPopupRowView.swift +++ b/macos/Sources/Lithe/Views/Git/BranchPopupRowView.swift @@ -271,7 +271,9 @@ final class BranchPopupRowControl: NSControl { if isGesture, let panel = detachExpansion() { // The panel outlives this row, which a lazy list may drop mid-gesture. overlay.handOffScrollGesture(to: enclosingScrollView ?? nextResponder, - schedule: scheduleExpansionRetirement) { Self.dismiss(panel) } + schedule: scheduleExpansionRetirement) { [weak panel] in + if let panel { Self.dismiss(panel) } + } } isHovered = false refresh() @@ -338,8 +340,8 @@ final class BranchPopupRowControl: NSControl { private final class BranchPopupExpansionView: NSView { /// Momentum follows lifted fingers almost immediately, but fingers may rest /// mid-gesture for as long as they like and retiring then loses the gesture. - /// The long delay only bounds a gesture whose end never arrives; a click on - /// the invisible panel retires it sooner. + /// The long delay only bounds a gesture whose end never arrives. A click + /// transfers its full mouse sequence to the current parent content. private enum RetirementDelay { static let afterFingersLift: TimeInterval = 0.15 static let whileGestureActive: TimeInterval = 10 @@ -357,6 +359,13 @@ private final class BranchPopupExpansionView: NSView { var onScroll: ((NSEvent) -> Void)? private var handoff: ScrollGestureHandoff? private var pendingRetirement: DispatchWorkItem? + private var parentCloseObserver: NSObjectProtocol? + private weak var mouseWindow: NSWindow? + + deinit { + pendingRetirement?.cancel() + if let parentCloseObserver { NotificationCenter.default.removeObserver(parentCloseObserver) } + } /// Hides the panel but keeps it receiving the gesture. An alpha 0 window is /// click-through by default, which loses the gesture just like closing it, @@ -366,27 +375,64 @@ private final class BranchPopupExpansionView: NSView { onPress = nil onSecondaryPress = nil onExit = nil + onScroll = nil window?.ignoresMouseEvents = false window?.alphaValue = 0 handoff = ScrollGestureHandoff(target: target, schedule: schedule, retire: retire) + if let parent = window?.parent { + parentCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: parent, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.retireHandoff() } } + } } private func forward(_ event: NSEvent, using handoff: ScrollGestureHandoff) { handoff.target?.scrollWheel(with: event) + guard self.handoff != nil, mouseWindow == nil else { return } + let finished: [NSEvent.Phase] = [.ended, .cancelled] + let isActive = finished.contains(event.momentumPhase) ? false + : !event.momentumPhase.isEmpty || !finished.contains(event.phase) + scheduleRetirement(after: isActive ? RetirementDelay.whileGestureActive : RetirementDelay.afterFingersLift) + } + + private func scheduleRetirement(after delay: TimeInterval) { pendingRetirement?.cancel() let work = DispatchWorkItem { [weak self] in MainActor.assumeIsolated { self?.retireHandoff() } } pendingRetirement = work - let finished: [NSEvent.Phase] = [.ended, .cancelled] - let isActive = finished.contains(event.momentumPhase) ? false - : !event.momentumPhase.isEmpty || !finished.contains(event.phase) - handoff.schedule(isActive ? RetirementDelay.whileGestureActive : RetirementDelay.afterFingersLift, work) + handoff?.schedule(delay, work) + } + + /// Re-enter window dispatch so the current hit view, focus and native mouse + /// tracking own the click, not the row that scrolled away. Keep the hidden + /// panel until mouse-up because AppKit may bind this sequence to it too. + private func forwardMouse(_ event: NSEvent, startsSequence: Bool = false, endsSequence: Bool = false) { + guard handoff != nil, let source = window else { return } + if startsSequence { + mouseWindow = source.parent + scheduleRetirement(after: RetirementDelay.whileGestureActive) + } + defer { if endsSequence { retireHandoff() } } + guard let destination = mouseWindow, destination.isVisible else { retireHandoff(); return } + let point = destination.convertPoint(fromScreen: source.convertPoint(toScreen: event.locationInWindow)) + if startsSequence { + guard let content = destination.contentView, + content.bounds.contains(content.convert(point, from: nil)) else { retireHandoff(); return } + } + guard let forwarded = NSEvent.mouseEvent(with: event.type, location: point, + modifierFlags: event.modifierFlags, timestamp: event.timestamp, + windowNumber: destination.windowNumber, context: nil, eventNumber: event.eventNumber, + clickCount: event.clickCount, pressure: event.pressure) else { retireHandoff(); return } + NSApp.sendEvent(forwarded) } private func retireHandoff() { pendingRetirement?.cancel() pendingRetirement = nil + if let parentCloseObserver { NotificationCenter.default.removeObserver(parentCloseObserver) } + parentCloseObserver = nil + mouseWindow = nil let retiring = handoff handoff = nil retiring?.retire() @@ -400,13 +446,16 @@ private final class BranchPopupExpansionView: NSView { owner: self, userInfo: nil)) } override func mouseDown(with event: NSEvent) { - retireHandoff() - onPress?() + if handoff != nil { forwardMouse(event, startsSequence: true) } else { onPress?() } } override func rightMouseDown(with event: NSEvent) { - retireHandoff() - onSecondaryPress?(event.window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation) + if handoff != nil { forwardMouse(event, startsSequence: true) } + else { onSecondaryPress?(event.window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation) } } + override func mouseDragged(with event: NSEvent) { forwardMouse(event) } + override func mouseUp(with event: NSEvent) { forwardMouse(event, endsSequence: true) } + override func rightMouseDragged(with event: NSEvent) { forwardMouse(event) } + override func rightMouseUp(with event: NSEvent) { forwardMouse(event, endsSequence: true) } override func mouseExited(with event: NSEvent) { onExit?() } override func scrollWheel(with event: NSEvent) { if let handoff { forward(event, using: handoff) } else { onScroll?(event) } diff --git a/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift index 61cf4df5f6..83addf698f 100644 --- a/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift +++ b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift @@ -225,25 +225,79 @@ struct BranchSwitcherPopoverBehaviorTests { #expect(harness.window.childWindows?.contains(panel) != true) } - @Test + @Test(arguments: [false, true]) @MainActor - func clickingTheHiddenExpansionRetiresItImmediately() throws { + func clickingTheHiddenExpansionReachesTheCurrentContent(rightClick: Bool) throws { let harness = try BranchExpansionScrollHarness() defer { harness.close() } var scheduled: [DispatchWorkItem] = [] harness.row.scheduleExpansionRetirement = { scheduled.append($1) } + harness.row.onPress = { Issue.record("A hidden expansion must not activate its old row") } + harness.row.onSecondaryPress = { _ in Issue.record("A hidden expansion must not open its old row's menu") } let panel = harness.panel let overlay = try #require(panel.contentView) overlay.scrollWheel(with: try harness.event(phase: .began)) - #expect(panel.isVisible) - overlay.mouseDown(with: try #require(NSEvent.mouseEvent(with: .leftMouseDown, location: .zero, - modifierFlags: [], timestamp: 0, windowNumber: panel.windowNumber, context: nil, - eventNumber: 0, clickCount: 1, pressure: 1))) - #expect(!panel.isVisible, "A gesture that never ended must not keep an invisible panel over the list") + // Replace the source row, just as scrolling a lazy list can do. + harness.row.removeFromSuperview() + let current = BranchExpansionClickRecorder(frame: harness.scroll.bounds) + harness.scroll.documentView = current + panel.setFrameOrigin(NSPoint(x: panel.frame.minX + 12, y: panel.frame.minY + 3)) + let point = NSPoint(x: 20, y: 12) + let expectedPoint = harness.window.convertPoint(fromScreen: panel.convertPoint(toScreen: point)) + let types: [NSEvent.EventType] = rightClick + ? [.rightMouseDown, .rightMouseDragged, .rightMouseUp] + : [.leftMouseDown, .leftMouseDragged, .leftMouseUp] + for type in types { + panel.sendEvent(try #require(NSEvent.mouseEvent(with: type, location: point, + modifierFlags: [.shift], timestamp: 42, windowNumber: panel.windowNumber, context: nil, + eventNumber: 7, clickCount: 2, pressure: 0.5))) + if type == types.first { #expect(panel.isVisible, "Keep receiving the native mouse sequence until mouse-up") } + } + #expect(current.events.map(\.type) == types) + #expect(current.events.allSatisfy { $0.window === harness.window && $0.locationInWindow == expectedPoint }) + #expect(current.events.allSatisfy { $0.modifierFlags.contains(.shift) && $0.clickCount == 2 && $0.timestamp == 42 }) + #expect(!panel.isVisible) + #expect(harness.window.childWindows?.contains(panel) != true) #expect(scheduled.allSatisfy { $0.isCancelled }) } + @Test + @MainActor + func clickingOutsideTheParentContentOnlyRetiresTheHiddenExpansion() throws { + let harness = try BranchExpansionScrollHarness() + defer { harness.close() } + harness.row.scheduleExpansionRetirement = { _, _ in } + let panel = harness.panel + try #require(panel.contentView).scrollWheel(with: try harness.event(phase: .began)) + harness.row.removeFromSuperview() + let current = BranchExpansionClickRecorder(frame: harness.scroll.bounds) + harness.scroll.documentView = current + panel.sendEvent(try #require(NSEvent.mouseEvent(with: .leftMouseDown, + location: NSPoint(x: harness.window.frame.width + 20, y: 12), modifierFlags: [], timestamp: 0, + windowNumber: panel.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))) + #expect(current.events.isEmpty) + #expect(!panel.isVisible && panel.parent == nil) + } + + @Test + @MainActor + func closingTheParentRetiresTheDetachedExpansionWithoutWaitingForItsTimer() throws { + let harness = try BranchExpansionScrollHarness() + defer { harness.close() } + var scheduled: [DispatchWorkItem] = [] + harness.row.scheduleExpansionRetirement = { scheduled.append($1) } + let panel = harness.panel + try #require(panel.contentView).scrollWheel(with: try harness.event(phase: .began)) + harness.row.removeFromSuperview() + + harness.window.close() + + #expect(!panel.isVisible) + #expect(panel.parent == nil) + #expect(scheduled.count == 1 && scheduled.allSatisfy { $0.isCancelled }) + } + @Test @MainActor func mouseWheelOverAnExpansionClosesItAndScrollsTheList() throws { @@ -694,6 +748,16 @@ private final class BranchExpansionScrollHarness { } } +private final class BranchExpansionClickRecorder: NSView { + var events: [NSEvent] = [] + override func mouseDown(with event: NSEvent) { events.append(event) } + override func mouseDragged(with event: NSEvent) { events.append(event) } + override func mouseUp(with event: NSEvent) { events.append(event) } + override func rightMouseDown(with event: NSEvent) { events.append(event) } + override func rightMouseDragged(with event: NSEvent) { events.append(event) } + override func rightMouseUp(with event: NSEvent) { events.append(event) } +} + private final class BranchHoverTestWindow: NSWindow { var pointer = NSPoint.zero override var mouseLocationOutsideOfEventStream: NSPoint { pointer } diff --git a/shared/platform-feature-matrix/features/git-branches-remotes.json b/shared/platform-feature-matrix/features/git-branches-remotes.json index f34ad24e32..7be56196fc 100644 --- a/shared/platform-feature-matrix/features/git-branches-remotes.json +++ b/shared/platform-feature-matrix/features/git-branches-remotes.json @@ -97,6 +97,6 @@ "verificationStatus": "pending" }, "owner": "Git", - "verification": "创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。 分支跟踪名称不同的远端分支(例如从 origin/main 新建分支后手动设置 upstream)时:Windows 推送弹窗必须先让用户在“发布到同名分支”和“推送到 upstream”之间选择,选择后弹窗展示的目标分支和提交列表与最终远端一致;“提交并推送”在写入提交前一次性询问所有异名仓库,取消后不产生提交;普通同名推送不出现任何询问。macOS 每次推送(分支列表与顶部分支弹窗)都先打开推送弹窗,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交、右侧显示所选提交的改动文件,点击“推送”前不写远端;异名上游时“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择,选择后目的地与提交列表随之更新,远端 main 不变;弹窗打开后仓库又有新提交时推送被拒绝并给出原因。macOS “提交并推送”始终先出审阅单列出每个仓库的目的地,异名仓库在审阅单内选择,未选择时“继续”不可用,取消后不产生提交。核对中英文、明暗主题与窄窗口。 macOS 指针停在被截断的分支行上时,用触控板双指滚动,确认列表持续跟随手势(含抬指后的惯性滚动),展开层在滚动时隐藏且手势结束后不残留;手指只放在触控板上不滚动时展开层保持显示。滚动中途手指停顿超过 1 秒再继续移动,确认列表仍跟随手势;原行滚出列表后手势不中断;滚动结束后点击同一位置,确认没有隐藏层拦截后续操作。", - "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。 2026-10-17:issue #1272 推送目标修复。Core 与 Windows 本地自动化已覆盖:从 origin/main 新建分支不继承 upstream;异名目标未确认时拒绝推送;预览与推送共用同一个按选择解析的目标,确认后 upstream 被改指其他分支时已审阅的推送被判过期;--force-with-lease 作用在所选 upstream 分支;提交并推送在写入提交前由 Core 按仓库提问,重试时恢复并重新校验;省略 targetKind 的旧调用方仍可推送。 2026-10-22:macOS 补上界面。每次推送先打开 IDEA 式推送弹窗(目的地、将推送的提交、所选提交的改动文件),异名上游需先选择;“提交并推送”先出审阅单并就地选择。原生层与真实 Core 的衔接由 GitPushReviewBridgeTests 覆盖,相关套件经 scripts/macos/test/test-macos.sh 串行运行通过;完整套件在本机并行运行时有与本变更无关的超时类失败。SwiftUI 窗口本身尚未在真机上目视验证。两端真实 Windows/macOS 交互验收仍为 pending。 2026-10-31:issue #1621 修复悬停展开层关闭后丢失触控板手势后续事件。自动化测试已覆盖手势全程转发、行中途被移除和鼠标滚轮路径;尚未用真实触控板完成 macOS 实机验收,验证状态保持 pending。" + "verification": "创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。 分支跟踪名称不同的远端分支(例如从 origin/main 新建分支后手动设置 upstream)时:Windows 推送弹窗必须先让用户在“发布到同名分支”和“推送到 upstream”之间选择,选择后弹窗展示的目标分支和提交列表与最终远端一致;“提交并推送”在写入提交前一次性询问所有异名仓库,取消后不产生提交;普通同名推送不出现任何询问。macOS 每次推送(分支列表与顶部分支弹窗)都先打开推送弹窗,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交、右侧显示所选提交的改动文件,点击“推送”前不写远端;异名上游时“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择,选择后目的地与提交列表随之更新,远端 main 不变;弹窗打开后仓库又有新提交时推送被拒绝并给出原因。macOS “提交并推送”始终先出审阅单列出每个仓库的目的地,异名仓库在审阅单内选择,未选择时“继续”不可用,取消后不产生提交。核对中英文、明暗主题与窄窗口。 macOS 指针停在被截断的分支行上时,用触控板双指滚动,确认列表持续跟随手势(含抬指后的惯性滚动),展开层在滚动时隐藏且手势结束后不残留;手指只放在触控板上不滚动时展开层保持显示。滚动中途手指停顿超过 1 秒再继续移动,确认列表仍跟随手势;原行滚出列表后手势不中断;滚动结束后点击同一位置,确认没有隐藏层拦截后续操作。 macOS 触控板滚动隐藏名称展开层后,立即在原位置左键/右键并拖动、松开,确认当前可见行收到完整操作,修饰键与点击次数不变,不触发滚走的旧行;滚动中按 Esc 关闭父弹窗后立即重开,确认隐藏层和回收任务已清理、无残留点击拦截。", + "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。 2026-10-17:issue #1272 推送目标修复。Core 与 Windows 本地自动化已覆盖:从 origin/main 新建分支不继承 upstream;异名目标未确认时拒绝推送;预览与推送共用同一个按选择解析的目标,确认后 upstream 被改指其他分支时已审阅的推送被判过期;--force-with-lease 作用在所选 upstream 分支;提交并推送在写入提交前由 Core 按仓库提问,重试时恢复并重新校验;省略 targetKind 的旧调用方仍可推送。 2026-10-22:macOS 补上界面。每次推送先打开 IDEA 式推送弹窗(目的地、将推送的提交、所选提交的改动文件),异名上游需先选择;“提交并推送”先出审阅单并就地选择。原生层与真实 Core 的衔接由 GitPushReviewBridgeTests 覆盖,相关套件经 scripts/macos/test/test-macos.sh 串行运行通过;完整套件在本机并行运行时有与本变更无关的超时类失败。SwiftUI 窗口本身尚未在真机上目视验证。两端真实 Windows/macOS 交互验收仍为 pending。 2026-10-31:issue #1621 修复悬停展开层关闭后丢失触控板手势后续事件。自动化测试已覆盖手势全程转发、行中途被移除和鼠标滚轮路径;尚未用真实触控板完成 macOS 实机验收,验证状态保持 pending。 2026-10-31:PR #1625 后续修复将隐藏展开层的完整鼠标操作交给当前父窗口命中控件,并由移交对象在父窗口关闭时立即清理;不依赖旧行存活或兜底计时,真实触控板验收仍为 pending。" }