diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42d77a90..aa536d08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,19 +101,26 @@ jobs: fi APP_PATH="build/Build/Products/Release/cmux.app" ZIP_SUBMIT="cmux-notary.zip" - ZIP_RELEASE="cmux-macos.zip" + DMG_RELEASE="cmux-macos.dmg" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT" xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait xcrun stapler staple "$APP_PATH" xcrun stapler validate "$APP_PATH" spctl -a -vv --type execute "$APP_PATH" - rm -f "$ZIP_RELEASE" - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_RELEASE" + rm -f "$ZIP_SUBMIT" + STAGING_DIR="$(mktemp -d)" + cp -R "$APP_PATH" "$STAGING_DIR/cmux.app" + ln -s /Applications "$STAGING_DIR/Applications" + hdiutil create -volname "cmux" -srcfolder "$STAGING_DIR" -ov -format UDZO "$DMG_RELEASE" + rm -rf "$STAGING_DIR" + xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait + xcrun stapler staple "$DMG_RELEASE" + xcrun stapler validate "$DMG_RELEASE" - name: Upload release asset uses: softprops/action-gh-release@v2 with: - files: cmux-macos.zip + files: cmux-macos.dmg generate_release_notes: true - name: Cleanup keychain diff --git a/CLAUDE.md b/CLAUDE.md index 36862af3..b8573df2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,15 @@ # GhosttyTabs agent notes +## Local dev + +After making code changes, always run this flow: + +```bash +xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build +pkill -x cmux || true +open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmux.app +``` + ## Release Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip. @@ -13,5 +23,5 @@ gh run watch --repo manaflow-ai/GhosttyTabs Notes: - Requires GitHub secrets: `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. -- The release asset is `cmux-macos.zip` attached to the tag. -- README download button points to `releases/latest/download/cmux-macos.zip`. +- The release asset is `cmux-macos.dmg` attached to the tag. +- README download button points to `releases/latest/download/cmux-macos.dmg`. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5c268d2c..e620392b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -342,7 +342,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -356,7 +356,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -385,7 +385,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -399,7 +399,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -426,10 +426,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -443,10 +443,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/README.md b/README.md index 55230ba2..5151818d 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Vertical tabs for Ghostty on macOS, built on libghostty. -[![Download macOS](https://img.shields.io/badge/Download-macOS-1b5fdd?style=for-the-badge&logo=apple)](releases/latest/download/cmux-macos.zip) +[![Download macOS](https://img.shields.io/badge/Download-macOS-1b5fdd?style=for-the-badge&logo=apple)](releases/latest/download/cmux-macos.dmg) ## Releases Tag a version like `v0.1.0` and push it to trigger the GitHub Actions release workflow. The workflow builds `GhosttyKit.xcframework`, builds the Release app, signs, notarizes, -staples, and uploads `cmux-macos.zip` to the release. +staples, and uploads `cmux-macos.dmg` to the release. ### Required GitHub secrets diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d6576116..7471cbec 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11,13 +11,17 @@ struct ContentView: View { private let sidebarHandleWidth: CGFloat = 6 @FocusState private var focusedTabId: UUID? @State private var sidebarSelection: SidebarSelection = .tabs + @State private var selectedTabIds: Set = [] + @State private var lastSidebarSelectionIndex: Int? = nil var body: some View { HStack(spacing: 0) { // Vertical Tabs Sidebar VerticalTabsSidebar( sidebarWidth: sidebarWidth, - selection: $sidebarSelection + selection: $sidebarSelection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) .background(GeometryReader { proxy in @@ -100,14 +104,37 @@ struct ContentView: View { .onAppear { focusedTabId = tabManager.selectedTabId tabManager.applyWindowBackgroundForSelectedTab() + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } } .onChange(of: tabManager.selectedTabId) { newValue in focusedTabId = newValue tabManager.applyWindowBackgroundForSelectedTab() + guard let newValue else { return } + if selectedTabIds.count <= 1 { + selectedTabIds = [newValue] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue } + } } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in sidebarSelection = .tabs } + .onReceive(tabManager.$tabs) { tabs in + let existingIds = Set(tabs.map { $0.id }) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let lastIndex = lastSidebarSelectionIndex, lastIndex >= tabs.count { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } + } .onPreferenceChange(SidebarFramePreferenceKey.self) { frame in sidebarMinX = frame.minX } @@ -119,6 +146,8 @@ struct VerticalTabsSidebar: View { @EnvironmentObject var notificationStore: TerminalNotificationStore let sidebarWidth: CGFloat @Binding var selection: SidebarSelection + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? var body: some View { VStack(spacing: 0) { @@ -165,8 +194,14 @@ struct VerticalTabsSidebar: View { // Tab List ScrollView { LazyVStack(spacing: 2) { - ForEach(tabManager.tabs) { tab in - TabItemView(tab: tab, selection: $selection) + ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in + TabItemView( + tab: tab, + index: index, + selection: $selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + ) } } .padding(.vertical, 4) @@ -191,13 +226,20 @@ struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @ObservedObject var tab: Tab + let index: Int @Binding var selection: SidebarSelection + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? @State private var isHovering = false - var isSelected: Bool { + var isActive: Bool { tabManager.selectedTabId == tab.id } + var isMultiSelected: Bool { + selectedTabIds.contains(tab.id) + } + var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -205,7 +247,7 @@ struct TabItemView: View { if unreadCount > 0 { ZStack { Circle() - .fill(isSelected ? Color.white.opacity(0.25) : Color.accentColor) + .fill(isActive ? Color.white.opacity(0.25) : Color.accentColor) Text("\(unreadCount)") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white) @@ -215,7 +257,7 @@ struct TabItemView: View { Text(tab.title) .font(.system(size: 12)) - .foregroundColor(isSelected ? .white : .primary) + .foregroundColor(isActive ? .white : .primary) .lineLimit(1) .truncationMode(.tail) @@ -224,18 +266,18 @@ struct TabItemView: View { Button(action: { tabManager.closeTab(tab) }) { Image(systemName: "xmark") .font(.system(size: 9, weight: .medium)) - .foregroundColor(isSelected ? .white.opacity(0.7) : .secondary) + .foregroundColor(isActive ? .white.opacity(0.7) : .secondary) } .buttonStyle(.plain) .frame(width: 16, height: 16) - .opacity((isHovering || isSelected) && tabManager.tabs.count > 1 ? 1 : 0) - .allowsHitTesting((isHovering || isSelected) && tabManager.tabs.count > 1) + .opacity((isHovering || isActive || isMultiSelected) && tabManager.tabs.count > 1 ? 1 : 0) + .allowsHitTesting((isHovering || isActive || isMultiSelected) && tabManager.tabs.count > 1) } if let subtitle = latestNotificationText { Text(subtitle) .font(.system(size: 10)) - .foregroundColor(isSelected ? .white.opacity(0.8) : .secondary) + .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.leading) @@ -244,7 +286,7 @@ struct TabItemView: View { if let directories = directorySummary { Text(directories) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isSelected ? .white.opacity(0.75) : .secondary) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .lineLimit(1) .truncationMode(.tail) } @@ -253,17 +295,155 @@ struct TabItemView: View { .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 6) - .fill(isSelected ? Color.accentColor : (isHovering ? Color(nsColor: .controlBackgroundColor).opacity(0.5) : Color.clear)) + .fill(backgroundColor) ) .padding(.horizontal, 6) .contentShape(Rectangle()) .onTapGesture { - tabManager.selectTab(tab) - selection = .tabs + updateSelection() } .onHover { hovering in isHovering = hovering } + .contextMenu { + let targetIds = contextTargetIds() + Button("Close Tabs") { + closeTabs(targetIds) + } + .disabled(targetIds.isEmpty) + + Button("Close Others") { + closeOtherTabs(targetIds) + } + .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + + Button("Close Tabs to the Right") { + closeTabsToRight(of: tab.id) + } + .disabled(index >= tabManager.tabs.count - 1) + + Divider() + + Button("Move to Top") { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + + Divider() + + Button("Mark as Read") { + markTabsRead(targetIds) + } + .disabled(!hasUnreadNotifications(in: targetIds)) + + Button("Mark as Unread") { + markTabsUnread(targetIds) + } + .disabled(!hasReadNotifications(in: targetIds)) + } + } + + private var backgroundColor: Color { + if isActive { + return Color.accentColor + } + if isMultiSelected { + return Color.accentColor.opacity(0.25) + } + if isHovering { + return Color(nsColor: .controlBackgroundColor).opacity(0.5) + } + return Color.clear + } + + private func updateSelection() { + let modifiers = NSEvent.modifierFlags + let isCommand = modifiers.contains(.command) + let isShift = modifiers.contains(.shift) + + if isShift, let lastIndex = lastSidebarSelectionIndex { + let lower = min(lastIndex, index) + let upper = max(lastIndex, index) + let rangeIds = tabManager.tabs[lower...upper].map { $0.id } + if isCommand { + selectedTabIds.formUnion(rangeIds) + } else { + selectedTabIds = Set(rangeIds) + } + } else if isCommand { + if selectedTabIds.contains(tab.id) { + selectedTabIds.remove(tab.id) + } else { + selectedTabIds.insert(tab.id) + } + } else { + selectedTabIds = [tab.id] + } + + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + selection = .tabs + } + + private func contextTargetIds() -> [UUID] { + let baseIds: Set = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id] + return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil } + } + + private func closeTabs(_ targetIds: [UUID]) { + for id in targetIds { + if let tab = tabManager.tabs.first(where: { $0.id == id }) { + tabManager.closeTab(tab) + } + } + selectedTabIds.subtract(targetIds) + syncSelectionAfterMutation() + } + + private func closeOtherTabs(_ targetIds: [UUID]) { + let keepIds = Set(targetIds) + let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } + closeTabs(idsToClose) + } + + private func closeTabsToRight(of tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } + closeTabs(idsToClose) + } + + private func markTabsRead(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markRead(forTabId: id) + } + } + + private func markTabsUnread(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markUnread(forTabId: id) + } + } + + private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } + } + + private func hasReadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } + } + + private func syncSelectionAfterMutation() { + let existingIds = Set(tabManager.tabs.map { $0.id }) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } } private var latestNotificationText: String? { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 87b0668d..90832a4f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -698,6 +698,11 @@ class TerminalSurface: Identifiable { ghostty_surface_set_focus(surface, focused) } + func needsConfirmClose() -> Bool { + guard let surface = surface else { return false } + return ghostty_surface_needs_confirm_quit(surface) + } + deinit { if ownsDisplayLink { GhosttyApp.shared.releaseDisplayLink() diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 15886fa3..a7452ab8 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -265,6 +265,14 @@ class TabManager: ObservableObject { tabs.insert(tab, at: 0) } + func moveTabsToTop(_ tabIds: Set) { + guard !tabIds.isEmpty else { return } + let selectedTabs = tabs.filter { tabIds.contains($0.id) } + guard !selectedTabs.isEmpty else { return } + let remainingTabs = tabs.filter { !tabIds.contains($0.id) } + tabs = selectedTabs + remainingTabs + } + func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } let normalized = normalizeDirectory(directory) @@ -308,23 +316,26 @@ class TabManager: ObservableObject { guard let selectedId = selectedTabId, let tab = tabs.first(where: { $0.id == selectedId }), let focusedSurfaceId = tab.focusedSurfaceId else { return } - guard tab.splitTree.isSplit else { return } - guard confirmClose( - title: "Close panel?", - message: "This will close the current split panel in this tab." - ) else { return } + guard tab.splitTree.isSplit else { + closeTabIfRunningProcess(tab) + return + } + + let focusedSurface = tab.surface(for: focusedSurfaceId) + if focusedSurface?.needsConfirmClose() == true { + guard confirmClose( + title: "Close panel?", + message: "This will close the current split panel in this tab." + ) else { return } + } + _ = tab.closeSurface(focusedSurfaceId) } func closeCurrentTabWithConfirmation() { - guard tabs.count > 1 else { return } guard let selectedId = selectedTabId, let tab = tabs.first(where: { $0.id == selectedId }) else { return } - guard confirmClose( - title: "Close tab?", - message: "This will close the current tab and all of its panels." - ) else { return } - closeTab(tab) + closeTabIfRunningProcess(tab) } func selectTab(_ tab: Tab) { @@ -341,6 +352,23 @@ class TabManager: ObservableObject { return alert.runModal() == .alertFirstButtonReturn } + private func closeTabIfRunningProcess(_ tab: Tab) { + guard tabs.count > 1 else { return } + if tabNeedsConfirmClose(tab), + !confirmClose( + title: "Close tab?", + message: "This will close the current tab and all of its panels." + ) { + return + } + closeTab(tab) + } + + private func tabNeedsConfirmClose(_ tab: Tab) -> Bool { + guard let root = tab.splitTree.root else { return false } + return root.leaves().contains { $0.needsConfirmClose() } + } + func titleForTab(_ tabId: UUID) -> String? { tabs.first(where: { $0.id == tabId })?.title } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 8615c139..821e4baa 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -79,6 +79,14 @@ final class TerminalNotificationStore: ObservableObject { } } + func markUnread(forTabId tabId: UUID) { + for index in notifications.indices { + if notifications[index].tabId == tabId { + notifications[index].isRead = false + } + } + } + func remove(id: UUID) { notifications.removeAll { $0.id == id } center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])