From b0502c15fc254325d4a8cadceb7880e4b843aa5c Mon Sep 17 00:00:00 2001 From: zhengru Date: Wed, 22 Jul 2026 10:29:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20middle-click=20to=20close=20wo?= =?UTF-8?q?rkspace=20/=20=E4=B8=AD=E9=94=AE=E5=85=B3=E9=97=AD=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a MiddleClickCatcher NSViewRepresentable overlay on workspace cards that fires deleteWorkspace on middle-mouse press. Transparent to all other input (hitTest returns nil unless button 2 is down) so left/right click, drag, hover, and context menu are unaffected. Gated behind a new setting (Settings → General → Workspace → "Middle-click closes workspace"), persisted as glint.middleClickClosesWorkspace, default on. zh-Hans localization included. --- Glint/Chrome/SettingsView.swift | 6 ++++ Glint/Chrome/SidebarView.swift | 10 ++++++ Glint/Chrome/VisualEffectBackground.swift | 44 +++++++++++++++++++++++ Glint/Resources/Localizable.xcstrings | 22 ++++++++++++ Glint/Workspace/WorkspaceStore.swift | 8 +++++ 5 files changed, 90 insertions(+) diff --git a/Glint/Chrome/SettingsView.swift b/Glint/Chrome/SettingsView.swift index 604b762..ae85236 100644 --- a/Glint/Chrome/SettingsView.swift +++ b/Glint/Chrome/SettingsView.swift @@ -421,6 +421,12 @@ private struct GeneralPane: View { Toggle("", isOn: $store.revealAtRepoRoot) .toggleStyle(.switch).labelsHidden() } + SettingsDivider() + SettingsRow("Middle-click closes workspace", + subtitle: "Click a workspace card with the middle mouse button to close it. Same as the context menu's “Close Workspace”.") { + Toggle("", isOn: $store.middleClickClosesWorkspace) + .toggleStyle(.switch).labelsHidden() + } } SettingsCard("New terminals") { diff --git a/Glint/Chrome/SidebarView.swift b/Glint/Chrome/SidebarView.swift index f1c2198..339e05e 100644 --- a/Glint/Chrome/SidebarView.swift +++ b/Glint/Chrome/SidebarView.swift @@ -811,6 +811,16 @@ private struct WorkspaceCard: View { // than removing it so the call-site stays a single chain. including: archived ? .subviews : .all ) + // Middle-click closes the workspace — same path as the context menu's + // "Close Workspace" (confirm dialog included when panes are busy). + // Disabled while renaming and on archived cards (which expose + // Unarchive/Delete instead). Catcher is transparent to left/right/hover + // so existing gestures, popover, and menu are untouched. + .overlay { + if !archived && !isEditing && store.middleClickClosesWorkspace { + MiddleClickCatcher { store.deleteWorkspace(ws.id) } + } + } } private func startEditing() { diff --git a/Glint/Chrome/VisualEffectBackground.swift b/Glint/Chrome/VisualEffectBackground.swift index 8806728..86afb32 100644 --- a/Glint/Chrome/VisualEffectBackground.swift +++ b/Glint/Chrome/VisualEffectBackground.swift @@ -102,6 +102,50 @@ struct NoDragSurface: NSViewRepresentable { } } +/// Invisible overlay that fires `onMiddleClick` only on a middle-mouse +/// button press. Left/right clicks, drags, hover, and scroll pass through +/// untouched (the view returns nil from `hitTest` unless the middle button +/// is down), so any SwiftUI gesture / popover / menu underneath keeps +/// working. Mirror of the `WindowDragSurface` / `NoDragSurface` pattern. +struct MiddleClickCatcher: NSViewRepresentable { + let onMiddleClick: () -> Void + + func makeNSView(context: Context) -> NSView { CatcherView(onMiddleClick: onMiddleClick) } + func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? CatcherView)?.onMiddleClick = onMiddleClick + } + + private final class CatcherView: NSView { + var onMiddleClick: () -> Void + + init(onMiddleClick: @escaping () -> Void) { + self.onMiddleClick = onMiddleClick + super.init(frame: .zero) + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + + // Only claim hits when the middle button is pressed — every other + // interaction (left/right click, drag, hover, scroll) falls through + // to the SwiftUI view underneath. `hitTest` is the gate AppKit + // consults before routing any mouse event, so returning nil here + // makes this view genuinely transparent to non-middle input. + override func hitTest(_ point: NSPoint) -> NSView? { + NSEvent.pressedMouseButtons & (1 << 2) != 0 ? self : nil + } + + override func otherMouseDown(with event: NSEvent) { + // buttonNumber 2 == middle. Other "other" buttons (back/forward) + // pass through to the responder chain. + guard event.buttonNumber == 2 else { super.otherMouseDown(with: event); return } + onMiddleClick() + } + + // Never let the overlay drag the window or steal cursor focus. + override var mouseDownCanMoveWindow: Bool { false } + override var acceptsFirstResponder: Bool { false } + } +} + // MARK: - Liquid Glass (macOS 26) /// Whether the OS can render Liquid Glass at all. Call sites that keep a diff --git a/Glint/Resources/Localizable.xcstrings b/Glint/Resources/Localizable.xcstrings index edb5a1a..e11364c 100644 --- a/Glint/Resources/Localizable.xcstrings +++ b/Glint/Resources/Localizable.xcstrings @@ -2715,6 +2715,28 @@ } } }, + "Middle-click closes workspace": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中键关闭工作区" + } + } + } + }, + "Click a workspace card with the middle mouse button to close it. Same as the context menu's \u201cClose Workspace\u201d.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用鼠标中键点击工作区卡片即可关闭,等同于右键菜单的「关闭工作区」。" + } + } + } + }, "Missing": { "extractionState": "manual", "localizations": { diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index dd7301f..ddc1a18 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -1251,6 +1251,14 @@ final class WorkspaceStore: ObservableObject { didSet { UserDefaults.standard.set(sortCompletedFirst, forKey: "glint.sortCompletedFirst") } } + /// Middle-click on a workspace card in the sidebar closes it (same path + /// as the context menu's "Close Workspace"). Defaults to on so the + /// behaviour stays available without configuration; users who find it + /// surprising can disable it here. + @Published var middleClickClosesWorkspace: Bool = (UserDefaults.standard.object(forKey: "glint.middleClickClosesWorkspace") as? Bool) ?? true { + didSet { UserDefaults.standard.set(middleClickClosesWorkspace, forKey: "glint.middleClickClosesWorkspace") } + } + /// Show the "Paste potentially unsafe text?" confirm dialog when the /// clipboard contains newlines or control characters. The underlying /// default (`glint.skipUnsafePasteConfirmation`) is inverted so the