Ship DMG releases and enhance tab actions

This commit is contained in:
Lawrence Chen 2026-01-26 14:48:08 -08:00
parent 4102567054
commit b0c61f7d6c
8 changed files with 279 additions and 41 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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)";

View file

@ -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

View file

@ -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<UUID> = []
@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<UUID>
@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<UUID>
@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<UUID> = 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? {

View file

@ -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()

View file

@ -265,6 +265,14 @@ class TabManager: ObservableObject {
tabs.insert(tab, at: 0)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
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
}

View file

@ -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])