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