From c9f02e1fe0091d02c932a90eb450ef5556cf9753 Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Mon, 13 Jul 2026 18:38:11 +0800 Subject: [PATCH 01/12] Add kanban board data model, config, and persistence Introduce the kanban feature's foundation: a KanbanColumn config value type (id/title/order/colorHex/isArchive/isCollapsed) seeded with To Do / In Progress / Done / Archive, exposed as the global kanban.columns JSON setting via a new KanbanCatalogSection. Card membership is per-tab state: kanbanColumnId/kanbanOrder on Workspace, persisted as optional fields on SessionWorkspaceSnapshot (no schema-version bump; old manifests decode). Pure column-list transforms (add/rename/recolor/collapse/delete + guard rules) live in KanbanColumnMutations so they're unit-testable without a TabManager. Adds model/back-compat tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Keys/KanbanCatalogSection.swift | 11 ++ .../CmuxSettings/Keys/SettingCatalog.swift | 2 + .../CmuxSettings/Values/KanbanColumn.swift | 80 +++++++++++++ .../Values/KanbanColumnMutations.swift | 98 ++++++++++++++++ .../KanbanColumnMutationsTests.swift | 106 ++++++++++++++++++ Sources/SessionPersistence.swift | 9 ++ Sources/Workspace.swift | 11 ++ cmuxTests/KanbanColumnAssignmentTests.swift | 93 +++++++++++++++ 8 files changed, 410 insertions(+) create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KanbanCatalogSection.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumn.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumnMutations.swift create mode 100644 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/KanbanColumnMutationsTests.swift create mode 100644 cmuxTests/KanbanColumnAssignmentTests.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KanbanCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KanbanCatalogSection.swift new file mode 100644 index 000000000000..befc21a40348 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KanbanCatalogSection.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Settings under the dotted-id prefix `kanban.*`. +public struct KanbanCatalogSection: SettingCatalogSection { + public let columns = JSONKey<[KanbanColumn]>( + id: "kanban.columns", + defaultValue: KanbanColumn.defaults + ) + + public init() {} +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift index c5cf37f6593a..2d560bf62100 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift @@ -30,6 +30,8 @@ public struct SettingCatalog: SettingCatalogSection { /// Settings for cmux pane divider and focused-pane chrome. public let paneChrome = PaneChromeCatalogSection() public let workspaceColors = WorkspaceColorsCatalogSection() + /// Settings for the kanban board columns (the `kanban.*` keys). + public let kanban = KanbanCatalogSection() /// Settings for sidebar workspace groups (the `workspaceGroups.*` keys). public let workspaceGroups = WorkspaceGroupsCatalogSection() public let automation = AutomationCatalogSection() diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumn.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumn.swift new file mode 100644 index 000000000000..534aaf0f6ae3 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumn.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A single column on the kanban board (`kanban.columns` in `cmux.json`). +/// +/// Columns are global config, shared across every window — see +/// `KanbanCatalogSection`. A workspace's membership in a column is separate, +/// per-tab state (`Workspace.kanbanColumnId`), so deleting a column here never +/// touches the workspace itself, only its assignment. +public struct KanbanColumn: Codable, Identifiable, Equatable, Sendable { + /// Stable, non-localized identifier. Persisted in `Workspace.kanbanColumnId` + /// and used as the JSON object key, so renaming ``title`` never breaks + /// existing card assignments. + public let id: String + public var title: String + /// Sort position among columns; lower sorts first. + public var order: Int + /// Optional hex color for the column header, e.g. "#C0392B". + public var colorHex: String? + /// The pinned Archive column. Non-deletable but renamable/recolorable. + public var isArchive: Bool + /// Collapsed columns hide their cards, showing only the header. + public var isCollapsed: Bool + + public init( + id: String, + title: String, + order: Int, + colorHex: String? = nil, + isArchive: Bool = false, + isCollapsed: Bool = false + ) { + self.id = id + self.title = title + self.order = order + self.colorHex = colorHex + self.isArchive = isArchive + self.isCollapsed = isCollapsed + } + + /// Seeded columns for a fresh install: three working buckets plus a + /// collapsed Archive. Titles are placeholder English; localization of the + /// display title happens in a later stage, so the stable `id` is what + /// persisted card assignments key off of. + public static let defaults: [KanbanColumn] = [ + KanbanColumn(id: "todo", title: "To Do", order: 0), + KanbanColumn(id: "in-progress", title: "In Progress", order: 1), + KanbanColumn(id: "done", title: "Done", order: 2), + KanbanColumn(id: "archive", title: "Archive", order: 3, isArchive: true, isCollapsed: true), + ] +} + +// MARK: - SettingCodable + +/// Stored as a nested JSON object array, mirroring `TerminalUploadCommandRule`. +/// Decode is all-or-nothing per column, and `Array`'s conformance makes a +/// malformed list reject as a whole (falling back to ``defaults``), so a +/// corrupt entry never silently drops just one column. +extension KanbanColumn: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> KanbanColumn? { + decodeFromJSON(raw) + } + + public func encodeForUserDefaults() -> Any { + encodeForJSON() + } + + public static func decodeFromJSON(_ raw: Any?) -> KanbanColumn? { + guard let object = raw as? [String: Any] else { return nil } + guard let data = try? JSONSerialization.data(withJSONObject: object) else { return nil } + return try? JSONDecoder().decode(KanbanColumn.self, from: data) + } + + public func encodeForJSON() -> Any { + guard let data = try? JSONEncoder().encode(self), + let object = try? JSONSerialization.jsonObject(with: data) else { + return NSNull() + } + return object + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumnMutations.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumnMutations.swift new file mode 100644 index 000000000000..ef7148a60d8d --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/KanbanColumnMutations.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Pure transforms over a `[KanbanColumn]` list: add/rename/recolor/collapse/ +/// delete, plus the guard rules ("keep >=1 real column", "Archive is +/// non-deletable") and default-column resolution. No `TabManager`/`Workspace`/ +/// settings-store dependency, so this behavior is covered by tests without +/// constructing the app's runtime. +/// +/// `TabManager+Kanban` (the app target's shared mutation path, per +/// cmux-shared-behavior) calls these, then persists the resulting list through +/// `SettingsRuntime.jsonStore` and — for ``deletingColumn(_:id:reassignTo:)`` — +/// reassigns the affected `Workspace`s to ``ColumnDeletion/reassignedToColumnId``. +public enum KanbanColumnMutations { + /// Appends a new column titled `title` after every existing column (max + /// `order` + 1). `id` is caller-supplied (a fresh UUID string in + /// production) so this stays deterministic for tests. + public static func addingColumn(_ columns: [KanbanColumn], id: String, title: String) -> [KanbanColumn] { + let newOrder = (columns.map(\.order).max() ?? -1) + 1 + return columns + [KanbanColumn(id: id, title: title, order: newOrder)] + } + + /// Renames column `id`. No-op (returns `columns` unchanged) if `id` isn't found. + public static func renamingColumn(_ columns: [KanbanColumn], id: String, title: String) -> [KanbanColumn] { + columns.map { column in + guard column.id == id else { return column } + var updated = column + updated.title = title + return updated + } + } + + /// Sets (or clears, with `colorHex: nil`) column `id`'s color. + public static func settingColumnColor(_ columns: [KanbanColumn], id: String, colorHex: String?) -> [KanbanColumn] { + columns.map { column in + guard column.id == id else { return column } + var updated = column + updated.colorHex = colorHex + return updated + } + } + + /// Sets column `id`'s collapsed state. + public static func settingColumnCollapsed(_ columns: [KanbanColumn], id: String, collapsed: Bool) -> [KanbanColumn] { + columns.map { column in + guard column.id == id else { return column } + var updated = column + updated.isCollapsed = collapsed + return updated + } + } + + /// The result of a successful ``deletingColumn(_:id:reassignTo:)`` call: + /// the column list with `id` removed, and the column every one of its + /// cards must be reassigned to. + public struct ColumnDeletion: Equatable { + public let columns: [KanbanColumn] + public let reassignedToColumnId: String + } + + /// Deletes column `id`. Refuses (returns `nil`) when `id` is the Archive + /// column (non-deletable, but renamable/recolorable/collapsible) or the + /// last remaining non-archive column (keep >=1 real column at all times). + /// A refusal never silently no-ops a delete the user asked for — the + /// caller must check for `nil` and surface it. + /// + /// `reassignTo`, when it names a column that will still exist after the + /// delete, is used as the reassignment target; otherwise the first + /// remaining non-archive column (by `order`) is used. The caller must + /// apply the reassignment to every card BEFORE removing the column, so no + /// card is ever orphaned into a non-existent column id. + public static func deletingColumn( + _ columns: [KanbanColumn], + id: String, + reassignTo: String? = nil + ) -> ColumnDeletion? { + guard let target = columns.first(where: { $0.id == id }), !target.isArchive else { return nil } + let remaining = columns.filter { $0.id != id } + let remainingNonArchive = remaining.filter { !$0.isArchive } + guard !remainingNonArchive.isEmpty else { return nil } + let resolvedTarget = reassignTo + .flatMap { candidate in remainingNonArchive.first(where: { $0.id == candidate })?.id } + ?? remainingNonArchive.sorted(by: { $0.order < $1.order })[0].id + return ColumnDeletion(columns: remaining, reassignedToColumnId: resolvedTarget) + } + + /// The first non-archive column by `order` — the default landing column + /// for a card with no assignment (or one pointing at a deleted column). + /// `nil` only if every column is archive (shouldn't happen in practice; + /// guarded against by ``deletingColumn(_:id:reassignTo:)``). + public static func firstNonArchiveColumnId(_ columns: [KanbanColumn]) -> String? { + columns.filter { !$0.isArchive }.sorted { $0.order < $1.order }.first?.id + } + + /// The Archive column's id, or `nil` if none is configured. + public static func archiveColumnId(_ columns: [KanbanColumn]) -> String? { + columns.first(where: \.isArchive)?.id + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/KanbanColumnMutationsTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/KanbanColumnMutationsTests.swift new file mode 100644 index 000000000000..da27911f7601 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/KanbanColumnMutationsTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CmuxSettings + +/// Pure column-list behavior for the kanban board's Stage 3 mutation path +/// (`TabManager+Kanban` in the app target). Covers add/rename/recolor/ +/// collapse/delete and the delete guard rules without constructing a +/// `TabManager` or `Workspace`. +@Suite("KanbanColumnMutations") +struct KanbanColumnMutationsTests { + private func columns( + _ entries: [(id: String, title: String, order: Int, isArchive: Bool)] + ) -> [KanbanColumn] { + entries.map { KanbanColumn(id: $0.id, title: $0.title, order: $0.order, isArchive: $0.isArchive) } + } + + @Test func addingColumnAppendsAfterMaxOrder() { + let result = KanbanColumnMutations.addingColumn(KanbanColumn.defaults, id: "new-1", title: "Review") + #expect(result.last?.id == "new-1") + #expect(result.last?.title == "Review") + #expect(result.last?.order == (KanbanColumn.defaults.map(\.order).max() ?? -1) + 1) + #expect(result.count == KanbanColumn.defaults.count + 1) + } + + @Test func renamingColumnUpdatesOnlyTheMatchingColumn() { + let result = KanbanColumnMutations.renamingColumn(KanbanColumn.defaults, id: "todo", title: "Backlog") + #expect(result.first(where: { $0.id == "todo" })?.title == "Backlog") + #expect(result.first(where: { $0.id == "done" })?.title == "Done") + } + + @Test func renamingUnknownColumnIsANoOp() { + let result = KanbanColumnMutations.renamingColumn(KanbanColumn.defaults, id: "nonexistent", title: "X") + #expect(result == KanbanColumn.defaults) + } + + @Test func settingColumnColorSetsAndClears() { + let colored = KanbanColumnMutations.settingColumnColor(KanbanColumn.defaults, id: "todo", colorHex: "#FF0000") + #expect(colored.first(where: { $0.id == "todo" })?.colorHex == "#FF0000") + + let cleared = KanbanColumnMutations.settingColumnColor(colored, id: "todo", colorHex: nil) + #expect(cleared.first(where: { $0.id == "todo" })?.colorHex == nil) + } + + @Test func settingColumnCollapsedTogglesOnlyThatColumn() { + let result = KanbanColumnMutations.settingColumnCollapsed(KanbanColumn.defaults, id: "todo", collapsed: true) + #expect(result.first(where: { $0.id == "todo" })?.isCollapsed == true) + #expect(result.first(where: { $0.id == "in-progress" })?.isCollapsed == false) + } + + @Test func deletingColumnReassignsToFirstRemainingNonArchiveColumnByDefault() { + let deletion = KanbanColumnMutations.deletingColumn(KanbanColumn.defaults, id: "in-progress") + #expect(deletion != nil) + #expect(deletion?.reassignedToColumnId == "todo") + #expect(deletion?.columns.contains(where: { $0.id == "in-progress" }) == false) + #expect(deletion?.columns.count == KanbanColumn.defaults.count - 1) + } + + @Test func deletingColumnHonorsExplicitReassignTo() { + let deletion = KanbanColumnMutations.deletingColumn(KanbanColumn.defaults, id: "todo", reassignTo: "done") + #expect(deletion?.reassignedToColumnId == "done") + } + + @Test func deletingColumnIgnoresReassignToTargetingTheDeletedColumnItself() { + // Asking to reassign to the column being deleted must fall back to the + // default resolution, not point cards at a column that's about to vanish. + let deletion = KanbanColumnMutations.deletingColumn(KanbanColumn.defaults, id: "todo", reassignTo: "todo") + #expect(deletion?.reassignedToColumnId != "todo") + } + + @Test func deletingArchiveColumnIsRefused() { + let deletion = KanbanColumnMutations.deletingColumn(KanbanColumn.defaults, id: "archive") + #expect(deletion == nil) + } + + @Test func deletingTheLastNonArchiveColumnIsRefused() { + let onlyOneRealColumn = columns([ + ("todo", "To Do", 0, false), + ("archive", "Archive", 1, true), + ]) + let deletion = KanbanColumnMutations.deletingColumn(onlyOneRealColumn, id: "todo") + #expect(deletion == nil) + } + + @Test func deletingWhenTwoRealColumnsRemainSucceeds() { + let twoRealColumns = columns([ + ("todo", "To Do", 0, false), + ("done", "Done", 1, false), + ("archive", "Archive", 2, true), + ]) + let deletion = KanbanColumnMutations.deletingColumn(twoRealColumns, id: "todo") + #expect(deletion?.reassignedToColumnId == "done") + } + + @Test func firstNonArchiveColumnIdIsLowestOrderNonArchiveColumn() { + #expect(KanbanColumnMutations.firstNonArchiveColumnId(KanbanColumn.defaults) == "todo") + } + + @Test func archiveColumnIdFindsTheArchiveFlaggedColumn() { + #expect(KanbanColumnMutations.archiveColumnId(KanbanColumn.defaults) == "archive") + } + + @Test func archiveColumnIdIsNilWhenNoneConfigured() { + let noArchive = columns([("todo", "To Do", 0, false)]) + #expect(KanbanColumnMutations.archiveColumnId(noArchive) == nil) + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 109afcee60bc..a8d97758c456 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -202,6 +202,7 @@ struct SessionRectSnapshot: Codable, Equatable, Sendable { enum SessionSidebarSelection: String, Codable, Sendable, Equatable { case tabs case notifications + case board init(selection: SidebarSelection) { switch selection { @@ -209,6 +210,8 @@ enum SessionSidebarSelection: String, Codable, Sendable, Equatable { self = .tabs case .notifications: self = .notifications + case .board: + self = .board } } @@ -218,6 +221,8 @@ enum SessionSidebarSelection: String, Codable, Sendable, Equatable { return .tabs case .notifications: return .notifications + case .board: + return .board } } } @@ -1772,6 +1777,10 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { /// User-defined per-workspace environment variables (issue #5995). Optional /// with a `nil` default so manifests written before this field decode cleanly. var environment: [String: String]? = nil + /// Kanban board column assignment. Optional with a `nil` default so + /// manifests written before the kanban board decode cleanly. + var kanbanColumnId: String? = nil + var kanbanOrder: Double? = nil } extension SessionWorkspaceSnapshot: WorkspaceSessionRemoteRestoreSnapshot {} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a0f9806d55da..ec781eac8dbf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -129,6 +129,8 @@ extension Workspace { customColor: customColor, isPinned: isPinned, groupId: groupId, + kanbanColumnId: kanbanColumnId, + kanbanOrder: kanbanOrder, isManuallyUnread: isWorkspaceManuallyUnread, hasUnreadIndicator: hasWorkspaceUnreadIndicator, notifications: workspaceNotificationSnapshots.isEmpty ? nil : workspaceNotificationSnapshots, @@ -231,6 +233,8 @@ extension Workspace { setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned groupId = snapshot.groupId + kanbanColumnId = snapshot.kanbanColumnId + kanbanOrder = snapshot.kanbanOrder ?? 0 // Status entries and agent PIDs are ephemeral runtime state tied to running // processes (e.g. claude_code "Running"). Don't restore them across app @@ -1972,6 +1976,13 @@ final class Workspace: Identifiable, ObservableObject { /// The group entity itself lives in `TabManager.workspaceGroups`. @Published var groupId: UUID? @Published var customColor: String? // hex string, e.g. "#C0392B" + /// Kanban board column this workspace's card is assigned to. `nil` renders + /// in the first (TODO) column by default. The column definitions + /// themselves live in `CmuxSettings.SettingCatalog.kanban`, not here. + @Published var kanbanColumnId: String? = nil + /// Fractional index for cheap reordering within a kanban column, without + /// renumbering every other card on a move. + @Published var kanbanOrder: Double = 0 /// User-defined environment variables applied to every shell spawned in this /// workspace: the initial terminal, every later pane/surface/split, and every /// surface recreated on session restore. Managed `CMUX_*` and terminal-identity diff --git a/cmuxTests/KanbanColumnAssignmentTests.swift b/cmuxTests/KanbanColumnAssignmentTests.swift new file mode 100644 index 000000000000..0d314114905b --- /dev/null +++ b/cmuxTests/KanbanColumnAssignmentTests.swift @@ -0,0 +1,93 @@ +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Model/persistence-layer coverage for the kanban board's Stage 1 foundation: +/// the `KanbanColumn` config value type and the `SessionWorkspaceSnapshot` +/// fields that carry a workspace's column assignment across restarts. +@Suite struct KanbanColumnAssignmentTests { + // MARK: - SessionWorkspaceSnapshot round-trip + + @Test + func kanbanFieldsRoundTripThroughSnapshotEncoding() throws { + let snapshot = SessionWorkspaceSnapshot( + processTitle: "Terminal", + isPinned: false, + currentDirectory: "/tmp", + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + kanbanColumnId: "in-progress", + kanbanOrder: 12.5 + ) + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + #expect(decoded.kanbanColumnId == "in-progress") + #expect(decoded.kanbanOrder == 12.5) + } + + /// A manifest written before the kanban board has no `kanbanColumnId` / + /// `kanbanOrder` keys at all; it must decode with both fields nil (and a + /// nil pair must not bloat new manifests) so a card with no assignment + /// lands in the default column. + @Test + func absentKanbanFieldsDecodeAsNilForBackCompat() throws { + let snapshot = SessionWorkspaceSnapshot( + processTitle: "Terminal", + isPinned: false, + currentDirectory: "/tmp", + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [] + ) + let data = try JSONEncoder().encode(snapshot) + let raw = try JSONSerialization.jsonObject(with: data) + let object = try #require(raw as? [String: Any]) + #expect(object["kanbanColumnId"] == nil, "nil kanbanColumnId should be omitted from the manifest") + #expect(object["kanbanOrder"] == nil, "nil kanbanOrder should be omitted from the manifest") + + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + #expect(decoded.kanbanColumnId == nil) + #expect(decoded.kanbanOrder == nil) + } + + // MARK: - KanbanColumn.defaults + + @Test + func defaultsSeedExactlyOneArchiveColumn() { + let archiveColumns = KanbanColumn.defaults.filter(\.isArchive) + #expect(archiveColumns.count == 1) + #expect(archiveColumns.first?.id == "archive") + } + + @Test + func defaultsHaveExpectedIdsTitlesAndOrder() { + let defaults = KanbanColumn.defaults + #expect(defaults.map(\.id) == ["todo", "in-progress", "done", "archive"]) + #expect(defaults.map(\.title) == ["To Do", "In Progress", "Done", "Archive"]) + #expect(defaults.map(\.order) == [0, 1, 2, 3]) + #expect(defaults.last?.isCollapsed == true) + #expect(defaults.dropLast().allSatisfy { !$0.isArchive && !$0.isCollapsed }) + } + + @Test + func columnRoundTripsThroughSettingCodable() { + let column = KanbanColumn( + id: "review", + title: "Review", + order: 5, + colorHex: "#C0392B", + isArchive: false, + isCollapsed: true + ) + #expect(KanbanColumn.decodeFromJSON(column.encodeForJSON()) == column) + } +} From e6ed0b51f125ff67571c16bdfffc8b9234fcaba6 Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Mon, 13 Jul 2026 18:38:26 +0800 Subject: [PATCH 02/12] Add kanban board view mode, card drag/drop, and column CRUD Add a .board view mode to SidebarSelection, cross-faded in ContentView.terminalContent like the existing NotificationsPage layer, and make it the default landing view (persisted/restored via the session snapshot). The board reads kanban.columns through @LiveSetting and buckets tabManager.tabs into columns; clicking a card selects that workspace and switches to the terminal view. All mutations route through a single shared path (TabManager+Kanban): per-tab moves set the Workspace fields (sending objectWillChange so the board re-renders), column CRUD writes kanban.columns through the shared SettingsRuntime JSON store. Cards drag between columns reusing the existing com.cmux.sidebar-tab-reorder payload; card/column context menus cover move/archive/rename/recolor/collapse/new/delete. Board views keep the snapshot-boundary rule (immutable value snapshots + closure bundles below the ForEach). Wires new sources into the cmux target. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AppDelegate.swift | 23 ++ Sources/ContentView.swift | 6 + Sources/Kanban/KanbanBoardView.swift | 130 ++++++++++ Sources/Kanban/KanbanCardView.swift | 94 +++++++ Sources/Kanban/KanbanColumnView.swift | 358 ++++++++++++++++++++++++++ Sources/SidebarSelectionState.swift | 2 +- Sources/TabManager+Kanban.swift | 133 ++++++++++ cmux.xcodeproj/project.pbxproj | 20 ++ 8 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 Sources/Kanban/KanbanBoardView.swift create mode 100644 Sources/Kanban/KanbanCardView.swift create mode 100644 Sources/Kanban/KanbanColumnView.swift create mode 100644 Sources/TabManager+Kanban.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 88294bcefb4b..10e6bf90b492 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3955,6 +3955,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hasher.combine(0) case .notifications: hasher.combine(1) + case .board: + hasher.combine(2) } if let window = context.window ?? windowForMainWindowId(context.windowId) { @@ -6584,6 +6586,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + /// Toggles the main content between the kanban board and the terminal + /// view for the active main window: `.board` -> `.tabs`, anything else -> `.board`. + @discardableResult + func toggleBoardViewInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) else { + return false + } + let window = context.window ?? windowForMainWindowId(context.windowId) + if let window { + setActiveMainWindow(window) + } + context.sidebarSelectionState.selection = context.sidebarSelectionState.selection == .board ? .tabs : .board + return true + } + @discardableResult func toggleRightSidebarInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) else { @@ -12340,6 +12357,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch sidebarSelection { case .tabs: return "tabs" case .notifications: return "notifications" + case .board: return "board" } }() writeMultiWindowNotificationTestData([ @@ -13473,6 +13491,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .toggleBoardView) { + _ = toggleBoardViewInActiveMainWindow(preferredWindow: mainWindowForShortcutEvent(event)) + return true + } + if matchConfiguredShortcut(event: event, action: .openDiffViewer) { // Shares the command palette's diff-open path; targets the event window's // focused workspace and beeps if it can't be opened (matching the palette). diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8065512a8f2d..0421ec88afb1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1733,6 +1733,11 @@ struct ContentView: View { .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) + + KanbanBoardView(selection: $sidebarSelectionState.selection) + .opacity(sidebarSelectionState.selection == .board ? 1 : 0) + .allowsHitTesting(sidebarSelectionState.selection == .board) + .accessibilityHidden(sidebarSelectionState.selection != .board) } .modifier(WorkspacePresentationModeContentTopPaddingModifier( isFullScreen: isFullScreen, @@ -16026,4 +16031,5 @@ private struct ExtensionSidebarBrowserStackEndDropDelegate: DropDelegate { enum SidebarSelection { case tabs case notifications + case board } diff --git a/Sources/Kanban/KanbanBoardView.swift b/Sources/Kanban/KanbanBoardView.swift new file mode 100644 index 000000000000..1c33b37594df --- /dev/null +++ b/Sources/Kanban/KanbanBoardView.swift @@ -0,0 +1,130 @@ +import CmuxSettings +import CmuxSettingsUI +import SwiftUI + +/// Top-level kanban board: the `.board` layer `ContentView.terminalContent(appearance:)` +/// cross-fades against `.tabs`/`.notifications`, exactly like `NotificationsPage`. +/// +/// Mirrors `NotificationsPage`: a plain `@EnvironmentObject var tabManager` for the +/// live workspace list plus a `@Binding` back to the shared selection, rather than +/// holding `SidebarSelectionState` itself. Columns come from the `kanban.columns` +/// JSON setting via `@LiveSetting`, which hot-reloads on external `cmux.json` edits. +/// +/// Everything below the column `ForEach` receives immutable value snapshots plus a +/// closure action bundle only — no `Workspace`/`TabManager` reference passes the +/// boundary. See `IndexSectionActions`/`SectionGapActions` in +/// `Sources/SessionIndexView.swift` for the reference pattern this follows. +/// +/// Every mutation (move, archive, column CRUD) routes through `TabManager+Kanban` +/// — the one shared action path per cmux-shared-behavior — so drag/drop, the +/// card/column context menus, and any future CLI/Command Palette entrypoint stay +/// in lockstep. +struct KanbanBoardView: View { + @EnvironmentObject var tabManager: TabManager + @Binding var selection: SidebarSelection + @LiveSetting(\.kanban.columns) private var columns: [KanbanColumn] + + var body: some View { + let sortedColumns = columns.sorted { $0.order < $1.order } + let columnSnapshots = sortedColumns.map { KanbanColumnSnapshot(column: $0, displayTitle: displayTitle(for: $0)) } + let cardsByColumnId = bucketedCards(sortedColumns: sortedColumns) + let columnActions = self.columnActions(sortedColumns: sortedColumns) + + return ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 16) { + ForEach(sortedColumns) { column in + KanbanColumnView( + column: KanbanColumnSnapshot(column: column, displayTitle: displayTitle(for: column)), + cards: cardsByColumnId[column.id] ?? [], + allColumns: columnSnapshots, + actions: columnActions + ) + .equatable() + } + } + .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + } + + /// Resolves the title to render for `column`. The seeded default column + /// ids (todo/in-progress/done/archive) get a localized display title; any + /// other column — user-created, or a seeded column the user renamed — + /// falls back to its stored `title` verbatim. Config is never rewritten + /// by this: `kanban.columns` in cmux.json always keeps the plain seed + /// text, and only the id is stable, per the plan's "store a stable + /// non-localized id, localize only the display title." + private func displayTitle(for column: KanbanColumn) -> String { + // Only resolve to the localized title when the stored title still + // matches the (always-English) seed default for this id — otherwise + // the user renamed it, and their text wins. + guard let seedDefault = KanbanColumn.defaults.first(where: { $0.id == column.id }), + column.title == seedDefault.title else { + return column.title + } + switch column.id { + case "todo": + return String(localized: "kanban.column.title.todo", defaultValue: "To Do") + case "in-progress": + return String(localized: "kanban.column.title.inProgress", defaultValue: "In Progress") + case "done": + return String(localized: "kanban.column.title.done", defaultValue: "Done") + case "archive": + return String(localized: "kanban.column.title.archive", defaultValue: "Archive") + default: + return column.title + } + } + + /// Buckets every workspace into its assigned column, sorted within the + /// column by `kanbanOrder` (so drag/drop-driven reordering is reflected). + /// A workspace whose `kanbanColumnId` is nil, or points at a column that + /// no longer exists (e.g. deleted in a later stage), falls back to the + /// first non-archive column so it's never silently dropped off the board. + private func bucketedCards(sortedColumns: [KanbanColumn]) -> [String: [KanbanCardSnapshot]] { + let knownColumnIds = Set(sortedColumns.map(\.id)) + let defaultColumnId = KanbanColumnMutations.firstNonArchiveColumnId(sortedColumns) + var result: [String: [KanbanCardSnapshot]] = [:] + for tab in tabManager.tabs { + let assignedColumnId = tab.kanbanColumnId.flatMap { knownColumnIds.contains($0) ? $0 : nil } + guard let columnId = assignedColumnId ?? defaultColumnId else { continue } + result[columnId, default: []].append(KanbanCardSnapshot(workspace: tab)) + } + for key in result.keys { + result[key]?.sort { $0.order < $1.order } + } + return result + } + + /// The action bundle every column/card below the `ForEach` boundary + /// invokes instead of touching `tabManager`/the kanban store directly. + /// Every closure routes to a `TabManager+Kanban` method. + private func columnActions(sortedColumns: [KanbanColumn]) -> KanbanColumnActions { + KanbanColumnActions( + onCardTap: selectWorkspace, + onToggleCollapsed: { columnId in + let isCollapsed = sortedColumns.first(where: { $0.id == columnId })?.isCollapsed ?? false + tabManager.setColumnCollapsed(id: columnId, collapsed: !isCollapsed) + }, + onCardDropped: { tabId, targetColumnId, dropOrder in + tabManager.setKanbanColumn(tabId: tabId, columnId: targetColumnId, order: dropOrder) + }, + onMoveCardToColumn: { tabId, columnId in + tabManager.moveWorkspace(tabId: tabId, toColumn: columnId) + }, + onArchiveCard: { tabId in tabManager.archiveWorkspace(tabId: tabId) }, + onUnarchiveCard: { tabId in tabManager.unarchiveWorkspace(tabId: tabId) }, + onRenameColumn: { id, title in tabManager.renameKanbanColumn(id: id, title: title) }, + onSetColumnColor: { id, colorHex in tabManager.setColumnColor(id: id, colorHex: colorHex) }, + onDeleteColumn: { id in tabManager.deleteKanbanColumn(id: id) }, + onAddColumn: { title in tabManager.addKanbanColumn(title: title) } + ) + } + + private func selectWorkspace(_ workspaceId: UUID) { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + tabManager.selectWorkspace(workspace) + selection = .tabs + } +} diff --git a/Sources/Kanban/KanbanCardView.swift b/Sources/Kanban/KanbanCardView.swift new file mode 100644 index 000000000000..dcc16c778f48 --- /dev/null +++ b/Sources/Kanban/KanbanCardView.swift @@ -0,0 +1,94 @@ +import CmuxFoundation +import SwiftUI + +/// Immutable per-card value snapshot of a `Workspace`, computed once at the +/// board root (`KanbanBoardView`). `KanbanCardView` never holds a `Workspace` +/// reference, so a `kanbanColumnId`/title/color change elsewhere can't cascade +/// a re-render into every other card below the board's `ForEach` boundary. +struct KanbanCardSnapshot: Identifiable, Equatable { + let id: UUID + let title: String + let colorHex: String? + /// Fractional position within its column (`Workspace.kanbanOrder`). Used + /// by `KanbanBoardView` to sort each column's cards and by the column's + /// drop delegate to compute "place at end of column". + let order: Double + + init(workspace: Workspace) { + id = workspace.id + title = workspace.customTitle ?? workspace.title + colorHex = workspace.customColor + order = workspace.kanbanOrder + } +} + +/// One kanban card = one workspace. Tapping it selects the workspace and +/// jumps back to the terminal view (`KanbanBoardView.selectWorkspace`). +/// Dragging it (`.onDrag`) reuses the existing `com.cmux.sidebar-tab-reorder` +/// payload (`SidebarTabDragPayload`) so `KanbanColumnView`'s drop delegate can +/// decode the same wire format the sidebar already uses. +/// +/// Follows the `.equatable()` value pattern of `TabItemView` +/// (`Sources/ContentView.swift:13078`): closures are excluded from `==`, +/// since they're recreated every parent eval but don't affect rendering; +/// `otherColumns`/`isInArchiveColumn` are plain data, so they ARE compared. +struct KanbanCardView: View, Equatable { + let card: KanbanCardSnapshot + let onTap: () -> Void + /// Non-archive columns other than this card's own, for the "Move to + /// Column" submenu. Passed as a value snapshot (not the board's + /// `[KanbanColumn]`) so this view stays below the snapshot boundary. + let otherColumns: [KanbanColumnSnapshot] + /// Whether this card currently sits in the Archive column — swaps the + /// context menu's "Archive" item for "Move Out of Archive". + let isInArchiveColumn: Bool + let onMoveToColumn: (String) -> Void + let onArchive: () -> Void + let onUnarchive: () -> Void + + static func == (lhs: KanbanCardView, rhs: KanbanCardView) -> Bool { + lhs.card == rhs.card && + lhs.otherColumns == rhs.otherColumns && + lhs.isInArchiveColumn == rhs.isInArchiveColumn + } + + var body: some View { + HStack(spacing: 8) { + if let colorHex = card.colorHex, let color = Color(hex: colorHex) { + Circle().fill(color).frame(width: 8, height: 8) + } + Text(card.title) + .cmuxFont(.body) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + .contentShape(Rectangle()) + .onTapGesture(perform: onTap) + .onDrag { + SidebarTabDragPayload(tabId: card.id).provider() + } + .contextMenu { + if isInArchiveColumn { + Button(String(localized: "kanban.card.unarchive", defaultValue: "Move Out of Archive")) { + onUnarchive() + } + } else { + if !otherColumns.isEmpty { + Menu(String(localized: "kanban.card.moveToColumn", defaultValue: "Move to Column")) { + ForEach(otherColumns) { column in + Button(column.title) { onMoveToColumn(column.id) } + } + } + } + Button(String(localized: "kanban.card.archive", defaultValue: "Archive")) { + onArchive() + } + } + } + } +} diff --git a/Sources/Kanban/KanbanColumnView.swift b/Sources/Kanban/KanbanColumnView.swift new file mode 100644 index 000000000000..55e4be71e6ed --- /dev/null +++ b/Sources/Kanban/KanbanColumnView.swift @@ -0,0 +1,358 @@ +import AppKit +import CmuxFoundation +import CmuxSettings +import SwiftUI +import UniformTypeIdentifiers + +/// Immutable value snapshot of a `KanbanColumn`, computed once at the board +/// root. `KanbanColumnView` never touches `KanbanColumn`/`CmuxSettings` +/// directly so it stays a plain value-in, value-out row below the board's +/// `ForEach` boundary. +struct KanbanColumnSnapshot: Identifiable, Equatable { + let id: String + let title: String + let order: Int + let colorHex: String? + let isArchive: Bool + let isCollapsed: Bool + + /// - Parameter displayTitle: The title to render, already resolved by the + /// caller (`KanbanBoardView.displayTitle(for:)`). The seeded default + /// columns (todo/in-progress/done/archive) get a localized title; any + /// other (user-created) column keeps its stored `title` verbatim. + /// Defaults to `column.title` so call sites that don't need the + /// resolver still work. + init(column: KanbanColumn, displayTitle: String? = nil) { + id = column.id + title = displayTitle ?? column.title + order = column.order + colorHex = column.colorHex + isArchive = column.isArchive + isCollapsed = column.isCollapsed + } +} + +/// Closure bundle handed to `KanbanColumnView` in place of a `TabManager` or +/// kanban config store reference, so nothing below the board's column +/// `ForEach` can observe those stores directly. Mirrors `IndexSectionActions` +/// in `Sources/SessionIndexView.swift`. Every closure here routes to a +/// `TabManager+Kanban` method — the one shared mutation path per +/// cmux-shared-behavior — so drag/drop and the context menus below can never +/// drift from each other. +struct KanbanColumnActions { + let onCardTap: (UUID) -> Void + let onToggleCollapsed: (String) -> Void + /// A card was dropped on this column: the tab id, the target column id, + /// and the fractional order to place it at (computed by the column's drop + /// delegate — end-of-column for Stage 3, no inter-card gap detection). + let onCardDropped: (_ tabId: UUID, _ targetColumnId: String, _ dropOrder: Double) -> Void + /// "Move to Column" context-menu path (no drop-neighbor context, so the + /// card lands at the end of the target column). + let onMoveCardToColumn: (_ tabId: UUID, _ columnId: String) -> Void + let onArchiveCard: (UUID) -> Void + let onUnarchiveCard: (UUID) -> Void + let onRenameColumn: (_ id: String, _ title: String) -> Void + let onSetColumnColor: (_ id: String, _ colorHex: String?) -> Void + let onDeleteColumn: (_ id: String) -> Void + let onAddColumn: (_ title: String) -> Void +} + +/// One kanban column: header (title, card count, color accent, collapse +/// chevron, context menu) plus a vertical list of cards and a drop target for +/// card moves. The Archive column is pinned last by the board sorting +/// columns by `order` (its default `order` is highest); when collapsed, only +/// the header renders. +struct KanbanColumnView: View, Equatable { + let column: KanbanColumnSnapshot + let cards: [KanbanCardSnapshot] + /// Every column on the board, for this column's cards' "Move to Column" + /// submenus and for this column's own delete-guard check. A value + /// snapshot array, not the board's live `[KanbanColumn]` — stays below + /// the snapshot boundary. + let allColumns: [KanbanColumnSnapshot] + let actions: KanbanColumnActions + + @State private var isDropTargeted = false + + /// Preset swatches for the "Set Color" menu — self-contained rather than + /// reusing `WorkspaceTabColorSettings` (a different settings domain), per + /// the Stage 3 brief's "a small preset menu" fallback. + private static let presetColors = [ + "#E53935", "#FB8C00", "#FDD835", "#43A047", "#00ACC1", "#1E88E5", "#8E24AA", "#6D4C41", + ] + + /// `actions` holds closures (not comparable) and is expected to be stable + /// across the board's re-renders, so it's excluded here — the same + /// tradeoff `IndexSectionView.==` makes for its `actions` bundle. + static func == (lhs: KanbanColumnView, rhs: KanbanColumnView) -> Bool { + lhs.column == rhs.column && lhs.cards == rhs.cards && lhs.allColumns == rhs.allColumns + } + + /// Keep >=1 real column: refuse deleting the Archive column (checked + /// separately by `TabManager+Kanban`, but mirrored here so the menu item + /// is disabled up front) or the last remaining non-archive column. + private var canDelete: Bool { + !column.isArchive && allColumns.filter { !$0.isArchive }.count > 1 + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + if !column.isCollapsed { + cardList + } + } + .padding(12) + .frame(width: 260, alignment: .leading) + .background(isDropTargeted ? Color.accentColor.opacity(0.15) : Color(nsColor: .underPageBackgroundColor)) + .cornerRadius(10) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: KanbanColumnDropDelegate( + targetColumnId: column.id, + nextOrder: (cards.map(\.order).max() ?? -1) + 1, + isTargeted: $isDropTargeted, + onCardDropped: actions.onCardDropped + )) + } + + private var header: some View { + HStack(spacing: 8) { + if let colorHex = column.colorHex, let color = Color(hex: colorHex) { + Circle().fill(color).frame(width: 8, height: 8) + } + Text(column.title) + .cmuxFont(.headline) + .lineLimit(1) + Text("\(cards.count)") + .cmuxFont(.caption) + .foregroundColor(.secondary) + Spacer(minLength: 0) + Button { + actions.onToggleCollapsed(column.id) + } label: { + Image(systemName: column.isCollapsed ? "chevron.right" : "chevron.down") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel( + column.isCollapsed + ? String(localized: "kanban.column.expand", defaultValue: "Expand column") + : String(localized: "kanban.column.collapse", defaultValue: "Collapse column") + ) + } + .contentShape(Rectangle()) + .contextMenu { + Button(String(localized: "kanban.column.rename", defaultValue: "Rename Column…")) { + promptRenameColumn() + } + + Menu(String(localized: "kanban.column.setColor", defaultValue: "Set Color")) { + if column.colorHex != nil { + Button(String(localized: "kanban.column.clearColor", defaultValue: "Clear Color")) { + actions.onSetColumnColor(column.id, nil) + } + Divider() + } + ForEach(Self.presetColors, id: \.self) { hex in + Button { + actions.onSetColumnColor(column.id, hex) + } label: { + Label { + Text(hex) + } icon: { + Circle().fill(Color(hex: hex) ?? .gray).frame(width: 10, height: 10) + } + } + } + Divider() + Button(String(localized: "kanban.column.customColor", defaultValue: "Custom Color…")) { + promptCustomColumnColor() + } + } + + Divider() + + Button(String(localized: "kanban.column.new", defaultValue: "New Column…")) { + promptNewColumn() + } + + if !column.isArchive { + Button(role: .destructive) { + promptDeleteColumn() + } label: { + Text(String(localized: "kanban.column.delete", defaultValue: "Delete Column")) + } + .disabled(!canDelete) + } + } + } + + private var cardList: some View { + ScrollView { + VStack(spacing: 8) { + ForEach(cards) { card in + KanbanCardView( + card: card, + onTap: { actions.onCardTap(card.id) }, + otherColumns: allColumns.filter { $0.id != column.id && !$0.isArchive }, + isInArchiveColumn: column.isArchive, + onMoveToColumn: { targetId in actions.onMoveCardToColumn(card.id, targetId) }, + onArchive: { actions.onArchiveCard(card.id) }, + onUnarchive: { actions.onUnarchiveCard(card.id) } + ) + .equatable() + } + } + } + } + + // MARK: - AppKit prompts + + /// Mirrors `promptRename()` (`Sources/ContentView.swift:14854`): an + /// `NSAlert` with an `NSTextField` accessory, run modally from a + /// SwiftUI context-menu Button action — the app's existing lightweight + /// text-prompt pattern. + private func promptRenameColumn() { + let alert = NSAlert() + alert.messageText = String(localized: "kanban.column.rename.title", defaultValue: "Rename Column") + alert.informativeText = String(localized: "kanban.column.rename.message", defaultValue: "Enter a new name for this column.") + let input = NSTextField(string: column.title) + input.placeholderString = String(localized: "kanban.column.rename.placeholder", defaultValue: "Column name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "kanban.column.rename.confirm", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "kanban.column.rename.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + guard alert.runModal() == .alertFirstButtonReturn else { return } + let trimmed = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + actions.onRenameColumn(column.id, trimmed) + } + + private func promptNewColumn() { + let alert = NSAlert() + alert.messageText = String(localized: "kanban.column.new.title", defaultValue: "New Column") + alert.informativeText = String(localized: "kanban.column.new.message", defaultValue: "Enter a name for the new column.") + let input = NSTextField(string: "") + input.placeholderString = String(localized: "kanban.column.new.placeholder", defaultValue: "Column name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "kanban.column.new.confirm", defaultValue: "Add")) + alert.addButton(withTitle: String(localized: "kanban.column.new.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + guard alert.runModal() == .alertFirstButtonReturn else { return } + let trimmed = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + actions.onAddColumn(trimmed) + } + + /// Mirrors `promptCustomColor(targetIds:)` (`Sources/ContentView.swift:14811`). + private func promptCustomColumnColor() { + let alert = NSAlert() + alert.messageText = String(localized: "kanban.column.customColor.title", defaultValue: "Custom Column Color") + alert.informativeText = String(localized: "kanban.column.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") + let input = NSTextField(string: column.colorHex ?? "") + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "kanban.column.customColor.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "kanban.column.customColor.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + guard alert.runModal() == .alertFirstButtonReturn else { return } + let trimmed = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard Color(hex: trimmed) != nil else { + showInvalidColumnColorAlert(trimmed) + return + } + actions.onSetColumnColor(column.id, trimmed) + } + + private func showInvalidColumnColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "kanban.column.customColor.invalid.title", defaultValue: "Invalid Color") + if value.isEmpty { + alert.informativeText = String(localized: "kanban.column.customColor.invalid.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") + } else { + alert.informativeText = String( + format: String(localized: "kanban.column.customColor.invalid.message", defaultValue: "\"%@\" is not a valid hex color. Use #RRGGBB."), + value + ) + } + alert.addButton(withTitle: String(localized: "kanban.column.customColor.invalid.ok", defaultValue: "OK")) + _ = alert.runModal() + } + + private func promptDeleteColumn() { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + format: String(localized: "kanban.column.delete.title", defaultValue: "Delete \"%@\"?"), + column.title + ) + alert.informativeText = String(localized: "kanban.column.delete.message", defaultValue: "Cards in this column will move to another column. This can't be undone.") + alert.addButton(withTitle: String(localized: "kanban.column.delete.confirm", defaultValue: "Delete")) + alert.addButton(withTitle: String(localized: "kanban.column.delete.cancel", defaultValue: "Cancel")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + actions.onDeleteColumn(column.id) + } +} + +/// Drop target for card moves onto a kanban column. Decodes the same +/// `com.cmux.sidebar-tab-reorder` payload the sidebar already produces +/// (`SidebarTabDragPayload`) rather than hand-rolling a new wire format. +/// Holds no store reference — just the target column id, the order to place +/// a dropped card at, and the board-root-provided mutation closure. +@MainActor +private struct KanbanColumnDropDelegate: DropDelegate { + let targetColumnId: String + let nextOrder: Double + @Binding var isTargeted: Bool + let onCardDropped: (_ tabId: UUID, _ targetColumnId: String, _ dropOrder: Double) -> Void + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: SidebarTabDragPayload.dropContentTypes) + } + + func dropEntered(info: DropInfo) { + isTargeted = true + } + + func dropExited(info: DropInfo) { + isTargeted = false + } + + func performDrop(info: DropInfo) -> Bool { + isTargeted = false + guard let provider = info.itemProviders(for: SidebarTabDragPayload.dropContentTypes).first else { + return false + } + let columnId = targetColumnId + let order = nextOrder + provider.loadDataRepresentation(forTypeIdentifier: SidebarTabDragPayload.typeIdentifier) { data, _ in + guard let data, + let payload = String(data: data, encoding: .utf8), + payload.hasPrefix(SidebarTabDragPayload.prefix), + let tabId = UUID(uuidString: String(payload.dropFirst(SidebarTabDragPayload.prefix.count))) + else { return } + Task { @MainActor in + onCardDropped(tabId, columnId, order) + } + } + return true + } +} diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 78ea1ab510ff..d6265b7e0bf9 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -4,7 +4,7 @@ import SwiftUI final class SidebarSelectionState: ObservableObject { @Published var selection: SidebarSelection - init(selection: SidebarSelection = .tabs) { + init(selection: SidebarSelection = .board) { self.selection = selection } } diff --git a/Sources/TabManager+Kanban.swift b/Sources/TabManager+Kanban.swift new file mode 100644 index 000000000000..4ed511497b76 --- /dev/null +++ b/Sources/TabManager+Kanban.swift @@ -0,0 +1,133 @@ +import CmuxSettings +import CmuxSettingsUI +import Foundation + +/// The one shared mutation path for kanban board state (per +/// cmux-shared-behavior): every entrypoint that moves a card, archives/ +/// unarchives, or edits the column list — board drag/drop, card + column +/// context menus, and any future CLI/Command Palette entrypoint — must call +/// through these methods rather than mutating `Workspace.kanbanColumnId` or +/// writing `kanban.columns` inline. +/// +/// Per-tab moves mutate the `Workspace` `@Published` fields directly +/// (persisted by the existing 8s autosave/terminate session-snapshot path). +/// Column CRUD writes through the `CmuxSettings` `kanban.columns` JSON +/// setting via the shared `SettingsRuntime` — the exact read/write pair +/// `DevWindowDisplayDefault` uses: `jsonStore.snapshotValue(for:)` for a +/// synchronous read, `try await jsonStore.set(_:for:)` for the write. The +/// pure list transforms (add/rename/recolor/collapse/delete + guard rules) +/// live in `KanbanColumnMutations` (CmuxSettings package) so they're testable +/// without constructing a `TabManager`. +/// +/// `KanbanBoardView` observes `tabManager` (not individual workspaces), so +/// every method here that mutates a `Workspace` field calls +/// `objectWillChange.send()` after mutating — otherwise a lone +/// `Workspace.kanbanColumnId` change only fires that workspace's own +/// `objectWillChange`, and the board never re-renders (caught in the Stage 2 +/// review). Column-list writes go through `writeKanbanColumns`, which also +/// sends `objectWillChange`; the board's `@LiveSetting(\.kanban.columns)` +/// reacts to the JSON store's own change stream regardless, so that send is +/// belt-and-suspenders there, not load-bearing. +extension TabManager { + // MARK: - Per-tab column assignment + + /// Assigns `tabId`'s card to `columnId` at fractional position `order` + /// (cheap reordering within a column without renumbering neighbors). + func setKanbanColumn(tabId: UUID, columnId: String, order: Double) { + guard let workspace = workspacesById[tabId] else { return } + workspace.kanbanColumnId = columnId + workspace.kanbanOrder = order + objectWillChange.send() + } + + /// Moves `tabId`'s card to the end of `columnId` — the "Move to Column" + /// context-menu path, where there's no drop-neighbor context to place it + /// between. + func moveWorkspace(tabId: UUID, toColumn columnId: String) { + setKanbanColumn(tabId: tabId, columnId: columnId, order: nextOrder(inColumn: columnId)) + } + + /// Moves `tabId`'s card to the Archive column. No-op if there is no + /// archive column (shouldn't happen; `KanbanColumn.defaults` always seeds + /// one and it's non-deletable). + func archiveWorkspace(tabId: UUID) { + guard let archiveColumnId = KanbanColumnMutations.archiveColumnId(currentKanbanColumns()) else { return } + moveWorkspace(tabId: tabId, toColumn: archiveColumnId) + } + + /// Moves `tabId`'s card out of Archive, back to the first non-archive column. + func unarchiveWorkspace(tabId: UUID) { + guard let targetColumnId = KanbanColumnMutations.firstNonArchiveColumnId(currentKanbanColumns()) else { return } + moveWorkspace(tabId: tabId, toColumn: targetColumnId) + } + + // MARK: - Column CRUD + + func addKanbanColumn(title: String) { + let updated = KanbanColumnMutations.addingColumn(currentKanbanColumns(), id: UUID().uuidString, title: title) + writeKanbanColumns(updated) + } + + func renameKanbanColumn(id: String, title: String) { + writeKanbanColumns(KanbanColumnMutations.renamingColumn(currentKanbanColumns(), id: id, title: title)) + } + + func setColumnColor(id: String, colorHex: String?) { + writeKanbanColumns(KanbanColumnMutations.settingColumnColor(currentKanbanColumns(), id: id, colorHex: colorHex)) + } + + func setColumnCollapsed(id: String, collapsed: Bool) { + writeKanbanColumns(KanbanColumnMutations.settingColumnCollapsed(currentKanbanColumns(), id: id, collapsed: collapsed)) + } + + /// Deletes column `id`, reassigning every card currently in it BEFORE + /// removing the column, so no card is ever orphaned into a non-existent + /// column. See `KanbanColumnMutations.deletingColumn` for the guard rules + /// (Archive is non-deletable; the last non-archive column can't be + /// deleted). Returns `false` on refusal. + @discardableResult + func deleteKanbanColumn(id: String, reassignTo: String? = nil) -> Bool { + guard let deletion = KanbanColumnMutations.deletingColumn(currentKanbanColumns(), id: id, reassignTo: reassignTo) else { + return false + } + for tab in tabs where tab.kanbanColumnId == id { + tab.kanbanColumnId = deletion.reassignedToColumnId + tab.kanbanOrder = nextOrder(inColumn: deletion.reassignedToColumnId) + } + writeKanbanColumns(deletion.columns) + return true + } + + // MARK: - Shared column-state helpers + + /// Synchronous snapshot of the current `kanban.columns` setting. Safe to + /// call from the main actor before any suspension point (mirrors + /// `DevWindowDisplayDefault.current`'s use of `snapshotValue(for:)`). + /// Falls back to `KanbanColumn.defaults` if the app has no settings + /// runtime yet (shouldn't happen outside of very early startup). + func currentKanbanColumns() -> [KanbanColumn] { + guard let runtime = AppDelegate.shared?.settingsRuntime else { return KanbanColumn.defaults } + return runtime.jsonStore.snapshotValue(for: runtime.catalog.kanban.columns) + } + + /// Fractional order placing a card at the end of `columnId` (max existing + /// `kanbanOrder` among that column's current cards + 1). + private func nextOrder(inColumn columnId: String) -> Double { + let existingOrders = tabs.filter { $0.kanbanColumnId == columnId }.map(\.kanbanOrder) + return (existingOrders.max() ?? -1) + 1 + } + + /// Persists `columns` through the shared `SettingsRuntime`'s JSON store — + /// the same store `@LiveSetting(\.kanban.columns)` reads from, so the + /// board picks up the change via its own hot-reload stream. The write + /// itself is fire-and-forget (matches `DevWindowDisplayDefault.set`); a + /// failure is swallowed here the same way that helper does, rather than + /// surfaced to the caller. + private func writeKanbanColumns(_ columns: [KanbanColumn]) { + guard let runtime = AppDelegate.shared?.settingsRuntime else { return } + Task { + try? await runtime.jsonStore.set(columns, for: runtime.catalog.kanban.columns) + } + objectWillChange.send() + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6f15cfb06ff6..e1b8c34b8063 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -824,6 +824,10 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; }; C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; + BF8D5911E5BA4D1645763E3A /* KanbanBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F582CE26362425687EA896CE /* KanbanBoardView.swift */; }; + 8290D26533B789EAF071E516 /* KanbanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */; }; + 352BD1EC2A2EE809462AD495 /* KanbanColumnAssignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EA9212E0CA29AC414C26D2 /* KanbanColumnAssignmentTests.swift */; }; + 50D23A13B244A0D9E02E3BF4 /* KanbanColumnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A570020000000000000002 /* KeyboardShortcutActionContext.swift */; }; C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34670030000000000000002 /* KeyboardShortcutContext.swift */; }; @@ -1424,6 +1428,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */; }; + 4D10AACD02C06B61177802D0 /* TabManager+Kanban.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F6473980221F131DB38E11 /* TabManager+Kanban.swift */; }; 737100000000000000000001 /* TabManager+NonInteractiveClose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 737100000000000000000002 /* TabManager+NonInteractiveClose.swift */; }; E3B7A4000000000000000009 /* TabManager+NotificationDismissalHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000A /* TabManager+NotificationDismissalHosting.swift */; }; C0DE1A030000000000000001 /* TabManager+SavedLayouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */; }; @@ -2573,6 +2578,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5FB1310 /* JSONCObjectEditor+Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONCObjectEditor+Set.swift"; sourceTree = ""; }; C0DEF0B10000000000000002 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; + F582CE26362425687EA896CE /* KanbanBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kanban/KanbanBoardView.swift; sourceTree = ""; }; + 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kanban/KanbanCardView.swift; sourceTree = ""; }; + 53EA9212E0CA29AC414C26D2 /* KanbanColumnAssignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KanbanColumnAssignmentTests.swift; sourceTree = ""; }; + 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kanban/KanbanColumnView.swift; sourceTree = ""; }; A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; C4A570020000000000000002 /* KeyboardShortcutActionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutActionContext.swift; sourceTree = ""; }; C34670030000000000000002 /* KeyboardShortcutContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutContext.swift; sourceTree = ""; }; @@ -3157,6 +3166,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+FocusHistoryHosting.swift"; sourceTree = ""; }; + F2F6473980221F131DB38E11 /* TabManager+Kanban.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+Kanban.swift"; sourceTree = ""; }; 737100000000000000000002 /* TabManager+NonInteractiveClose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+NonInteractiveClose.swift"; sourceTree = ""; }; E3B7A400000000000000000A /* TabManager+NotificationDismissalHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+NotificationDismissalHosting.swift"; sourceTree = ""; }; C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SavedLayouts.swift"; sourceTree = ""; }; @@ -3993,6 +4003,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */, + F582CE26362425687EA896CE /* KanbanBoardView.swift */, + 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */, + 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, 63F0CD0E /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */, C0DE7B330000000000000002 /* TextBoxAgentDetection.swift */, @@ -4061,6 +4074,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E3B7A4000000000000000008 /* WorkspaceIndicatorStyle+Display.swift */, E3B7A400000000000000000A /* TabManager+NotificationDismissalHosting.swift */, E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */, + F2F6473980221F131DB38E11 /* TabManager+Kanban.swift */, E3B7A400000000000000020B /* Workspace+WorkspaceSurfaceTreeReading.swift */, CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */, A5001511 /* UITestRecorder.swift */, @@ -5184,6 +5198,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C06552030000000000000002 /* TabManagerTitleUpdateStalenessTests.swift */, C06552020000000000000002 /* TabManagerTitleUpdateTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + 53EA9212E0CA29AC414C26D2 /* KanbanColumnAssignmentTests.swift */, 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */, 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */, C0DE1A080000000000000002 /* SavedLayoutDefinitionTests.swift */, @@ -6206,6 +6221,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5FB1307 /* JSONCObjectEditor+Remove.swift in Sources */, A5FB1311 /* JSONCObjectEditor+Set.swift in Sources */, C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */, + BF8D5911E5BA4D1645763E3A /* KanbanBoardView.swift in Sources */, + 8290D26533B789EAF071E516 /* KanbanCardView.swift in Sources */, + 50D23A13B244A0D9E02E3BF4 /* KanbanColumnView.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */, C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */, @@ -6597,6 +6615,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */, + 4D10AACD02C06B61177802D0 /* TabManager+Kanban.swift in Sources */, 737100000000000000000001 /* TabManager+NonInteractiveClose.swift in Sources */, E3B7A4000000000000000009 /* TabManager+NotificationDismissalHosting.swift in Sources */, C0DE1A030000000000000001 /* TabManager+SavedLayouts.swift in Sources */, @@ -7206,6 +7225,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */, B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */, F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, + 352BD1EC2A2EE809462AD495 /* KanbanColumnAssignmentTests.swift in Sources */, C34670050000000000000001 /* KeyboardShortcutContextSwiftTests.swift in Sources */, C34670020000000000000001 /* KeyboardShortcutContextTests.swift in Sources */, 6042A0026042A0026042A002 /* KeyboardShortcutModifierHoldHintsSettingsFileTests.swift in Sources */, From 1d3a4dc033258585ef81232bcd85a7cc839c9561 Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Mon, 13 Jul 2026 18:38:38 +0800 Subject: [PATCH 03/12] Add board toggle shortcut, localization, and docs Add a "Toggle Board View" shortcut (default Cmd-Shift-B) that flips between the board and terminal views, wired through the shortcut action enums, dispatch handler, and cmux.json schema per the shortcut policy. Localize all kanban UI strings (en + ja) in Localizable.xcstrings, and resolve the four seeded column titles to localized display names while leaving user-renamed columns untouched. Document the shortcut and the kanban.columns config key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Values/ShortcutAction+Defaults.swift | 1 + .../CmuxSettings/Values/ShortcutAction.swift | 6 +- Resources/Localizable.xcstrings | 646 ++++++++++++++++++ Sources/KeyboardShortcutSettings.swift | 4 + .../(landing)/docs/configuration/page.tsx | 8 + web/data/cmux-shortcuts.ts | 1 + web/data/cmux.schema.json | 53 ++ 7 files changed, 718 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index d0600f429e90..9ea154237c4c 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -54,6 +54,7 @@ extension ShortcutAction { case .switchRightSidebarToFeed: return ShortcutStroke(key: "4", control: true) case .switchRightSidebarToDock: return ShortcutStroke(key: "5", control: true) case .triggerFlash: return ShortcutStroke(key: "h", command: true, shift: true) + case .toggleBoardView: return ShortcutStroke(key: "b", command: true, shift: true) case .nextSidebarTab: return ShortcutStroke(key: "]", command: true, control: true) case .prevSidebarTab: return ShortcutStroke(key: "[", command: true, control: true) case .focusHistoryBack: return ShortcutStroke(key: "[", command: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 7656abb640ab..903d0b31bb95 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -44,6 +44,8 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case switchRightSidebarToFeed case switchRightSidebarToDock case triggerFlash + /// Toggles the main content between the kanban board and the terminal view. + case toggleBoardView // MARK: Navigation case nextSurface @@ -181,7 +183,7 @@ extension ShortcutAction { .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, - .switchRightSidebarToDock, .triggerFlash: + .switchRightSidebarToDock, .triggerFlash, .toggleBoardView: return .workspace case .nextSurface, .prevSurface, .selectSurfaceByNumber, .nextSidebarTab, .prevSidebarTab, .focusHistoryBack, .focusHistoryForward, @@ -361,6 +363,8 @@ extension ShortcutAction { case .switchRightSidebarToFeed: return "Show Sidebar Feed" case .switchRightSidebarToDock: return "Show Sidebar Dock" case .triggerFlash: return "Flash Focused Panel" + case .toggleBoardView: + return String(localized: "shortcut.toggleBoardView.label", defaultValue: "Toggle Board View") case .nextSurface: return "Next Surface" case .prevSurface: return "Previous Surface" case .selectSurfaceByNumber: return "Select Surface 1…9" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e7d20ccb9865..fc269df00334 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -234025,6 +234025,652 @@ } } } + }, + "kanban.column.expand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列を展開" + } + } + } + }, + "kanban.column.collapse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列を折りたたむ" + } + } + } + }, + "kanban.card.unarchive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Out of Archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブから戻す" + } + } + } + }, + "kanban.card.moveToColumn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列に移動" + } + } + } + }, + "kanban.card.archive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブ" + } + } + } + }, + "kanban.column.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Column…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列名を変更…" + } + } + } + }, + "kanban.column.setColor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "色を設定" + } + } + } + }, + "kanban.column.clearColor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "色をクリア" + } + } + } + }, + "kanban.column.customColor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Color…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムカラー…" + } + } + } + }, + "kanban.column.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Column…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい列…" + } + } + } + }, + "kanban.column.delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列を削除" + } + } + } + }, + "kanban.column.rename.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列名を変更" + } + } + } + }, + "kanban.column.rename.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a new name for this column." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この列の新しい名前を入力してください。" + } + } + } + }, + "kanban.column.rename.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Column name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列名" + } + } + } + }, + "kanban.column.rename.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更" + } + } + } + }, + "kanban.column.rename.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "kanban.column.new.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい列" + } + } + } + }, + "kanban.column.new.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a name for the new column." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい列の名前を入力してください。" + } + } + } + }, + "kanban.column.new.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Column name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "列名" + } + } + } + }, + "kanban.column.new.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + } + } + }, + "kanban.column.new.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "kanban.column.customColor.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Column Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム列カラー" + } + } + } + }, + "kanban.column.customColor.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a hex color in the format #RRGGBB." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB 形式の16進数カラーを入力してください。" + } + } + } + }, + "kanban.column.customColor.apply": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "適用" + } + } + } + }, + "kanban.column.customColor.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "kanban.column.customColor.invalid.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なカラー" + } + } + } + }, + "kanban.column.customColor.invalid.emptyMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a hex color in the format #RRGGBB." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB 形式の16進数カラーを入力してください。" + } + } + } + }, + "kanban.column.customColor.invalid.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" is not a valid hex color. Use #RRGGBB." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」は有効な16進数カラーではありません。#RRGGBB形式を使用してください。" + } + } + } + }, + "kanban.column.customColor.invalid.ok": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "kanban.column.delete.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete \"%@\"?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」を削除しますか?" + } + } + } + }, + "kanban.column.delete.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cards in this column will move to another column. This can't be undone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この列のカードは別の列に移動します。この操作は元に戻せません。" + } + } + } + }, + "kanban.column.delete.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + } + } + }, + "kanban.column.delete.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "shortcut.toggleBoardView.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Board View" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボード表示を切り替え" + } + } + } + }, + "kanban.column.title.todo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To Do" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未着手" + } + } + } + }, + "kanban.column.title.inProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In Progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進行中" + } + } + } + }, + "kanban.column.title.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "kanban.column.title.archive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブ" + } + } + } } }, "version": "1.0" diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f1772a48cedc..d468f7baf7e2 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -95,6 +95,7 @@ enum KeyboardShortcutSettings { case switchRightSidebarToFeed case switchRightSidebarToDock case triggerFlash + case toggleBoardView // Navigation case nextSurface @@ -220,6 +221,7 @@ enum KeyboardShortcutSettings { case .switchRightSidebarToFeed: return String(localized: "shortcut.switchRightSidebarToFeed.label", defaultValue: "Show Sidebar Feed") case .switchRightSidebarToDock: return String(localized: "shortcut.switchRightSidebarToDock.label", defaultValue: "Show Sidebar Dock") case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel") + case .toggleBoardView: return String(localized: "shortcut.toggleBoardView.label", defaultValue: "Toggle Board View") case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface") case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface") case .selectSurfaceByNumber: return String(localized: "shortcut.selectSurfaceByNumber.label", defaultValue: "Select Surface 1…9") @@ -389,6 +391,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "5", command: false, shift: false, option: false, control: true) case .triggerFlash: return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false) + case .toggleBoardView: + return StoredShortcut(key: "b", command: true, shift: true, option: false, control: false) case .nextSidebarTab: return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: diff --git a/web/app/[locale]/(landing)/docs/configuration/page.tsx b/web/app/[locale]/(landing)/docs/configuration/page.tsx index b3c3d777dd2c..e021c979a2ab 100644 --- a/web/app/[locale]/(landing)/docs/configuration/page.tsx +++ b/web/app/[locale]/(landing)/docs/configuration/page.tsx @@ -42,6 +42,7 @@ const sectionOrder = [ "sidebar", "workspaceGroups", "workspaceColors", + "kanban", "sidebarAppearance", "automation", "actions", @@ -132,6 +133,13 @@ function buildSettingsFileExample(t: ConfigurationTranslation) { // "newWorkspacePlacement": "afterCurrent" // }, + // "kanban": { + // "columns": [ + // { "id": "todo", "title": "To Do", "order": 0 }, + // { "id": "review", "title": "Review", "order": 1 } + // ] + // }, + // "agentChat": { // "url": "http://127.0.0.1:7739", // "startCommand": "cmux-chat" diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index ebcc62f7518a..806c52d6b563 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -81,6 +81,7 @@ export const shortcutCategories: ShortcutCategory[] = [ shortcuts: [ { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle left sidebar", ja: "左サイドバーを切り替え" } }, { id: "toggleFileExplorer", combos: [["⌘", "⌥", "B"]], description: { en: "Toggle right sidebar", ja: "右サイドバーを切り替え" } }, + { id: "toggleBoardView", combos: [["⌘", "⇧", "B"]], description: { en: "Toggle kanban board view", ja: "カンバンボード表示を切り替え" } }, { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, { id: "newBrowserWorkspace", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 6af8288a4f29..07626f9037db 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1143,6 +1143,58 @@ } } }, + "kanban": { + "title": "kanban", + "description": "Kanban board column definitions, shared across all windows. Per-tab card assignments are not stored here; they live with the session.", + "type": "object", + "additionalProperties": false, + "properties": { + "columns": { + "type": "array", + "default": [ + { "id": "todo", "title": "To Do", "order": 0, "isArchive": false, "isCollapsed": false }, + { "id": "in-progress", "title": "In Progress", "order": 1, "isArchive": false, "isCollapsed": false }, + { "id": "done", "title": "Done", "order": 2, "isArchive": false, "isCollapsed": false }, + { "id": "archive", "title": "Archive", "order": 3, "isArchive": true, "isCollapsed": true } + ], + "description": "Board columns in declaration order (rendered sorted by order). id is a stable identifier that card assignments key off of, so renaming title never breaks existing cards. The archive column (isArchive: true) is non-deletable but renamable/recolorable/collapsible; at least one non-archive column must remain.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "order"], + "properties": { + "id": { + "type": "string", + "description": "Stable identifier. Never shown in the UI; a workspace's kanbanColumnId points at this, not the title." + }, + "title": { + "type": "string", + "description": "Display text shown in the column header." + }, + "order": { + "type": "integer", + "description": "Sort position among columns; lower sorts first." + }, + "colorHex": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Optional column header accent color." + }, + "isArchive": { + "type": "boolean", + "default": false, + "description": "Marks the pinned Archive column. Non-deletable, but renamable/recolorable." + }, + "isCollapsed": { + "type": "boolean", + "default": false, + "description": "Collapsed columns hide their cards, showing only the header." + } + } + } + } + } + }, "sidebarAppearance": { "title": "sidebarAppearance", "description": "Sidebar tint settings from Settings > Sidebar Appearance.", @@ -1541,6 +1593,7 @@ "switchRightSidebarToFeed", "switchRightSidebarToDock", "triggerFlash", + "toggleBoardView", "nextSurface", "prevSurface", "selectSurfaceByNumber", From 6e0bbe5d5713e9ba71747be017b850330fd301c3 Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Mon, 13 Jul 2026 22:21:26 +0800 Subject: [PATCH 04/12] Fix Swift 6 strict-concurrency errors in kanban views Under Swift 6 strict concurrency, the board views referenced main-actor state from non-isolated contexts. Mark KanbanCardSnapshot's Workspace init @MainActor, isolate KanbanBoardView and KanbanColumnView to the main actor (their helpers read tabManager / run NSAlert), and type the drop delegate's onCardDropped closure @MainActor @Sendable so it can be carried into loadDataRepresentation's @Sendable completion. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Kanban/KanbanBoardView.swift | 1 + Sources/Kanban/KanbanCardView.swift | 1 + Sources/Kanban/KanbanColumnView.swift | 8 ++++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Kanban/KanbanBoardView.swift b/Sources/Kanban/KanbanBoardView.swift index 1c33b37594df..89802a368014 100644 --- a/Sources/Kanban/KanbanBoardView.swift +++ b/Sources/Kanban/KanbanBoardView.swift @@ -19,6 +19,7 @@ import SwiftUI /// — the one shared action path per cmux-shared-behavior — so drag/drop, the /// card/column context menus, and any future CLI/Command Palette entrypoint stay /// in lockstep. +@MainActor struct KanbanBoardView: View { @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection diff --git a/Sources/Kanban/KanbanCardView.swift b/Sources/Kanban/KanbanCardView.swift index dcc16c778f48..f3739453082d 100644 --- a/Sources/Kanban/KanbanCardView.swift +++ b/Sources/Kanban/KanbanCardView.swift @@ -14,6 +14,7 @@ struct KanbanCardSnapshot: Identifiable, Equatable { /// drop delegate to compute "place at end of column". let order: Double + @MainActor init(workspace: Workspace) { id = workspace.id title = workspace.customTitle ?? workspace.title diff --git a/Sources/Kanban/KanbanColumnView.swift b/Sources/Kanban/KanbanColumnView.swift index 55e4be71e6ed..1c9a60b40bac 100644 --- a/Sources/Kanban/KanbanColumnView.swift +++ b/Sources/Kanban/KanbanColumnView.swift @@ -45,7 +45,10 @@ struct KanbanColumnActions { /// A card was dropped on this column: the tab id, the target column id, /// and the fractional order to place it at (computed by the column's drop /// delegate — end-of-column for Stage 3, no inter-card gap detection). - let onCardDropped: (_ tabId: UUID, _ targetColumnId: String, _ dropOrder: Double) -> Void + /// `@MainActor @Sendable` so `KanbanColumnDropDelegate` can carry it into + /// `loadDataRepresentation`'s `@Sendable` completion (the capture stays on + /// the main actor at the creation site, where it closes over `tabManager`). + let onCardDropped: @MainActor @Sendable (_ tabId: UUID, _ targetColumnId: String, _ dropOrder: Double) -> Void /// "Move to Column" context-menu path (no drop-neighbor context, so the /// card lands at the end of the target column). let onMoveCardToColumn: (_ tabId: UUID, _ columnId: String) -> Void @@ -62,6 +65,7 @@ struct KanbanColumnActions { /// card moves. The Archive column is pinned last by the board sorting /// columns by `order` (its default `order` is highest); when collapsed, only /// the header renders. +@MainActor struct KanbanColumnView: View, Equatable { let column: KanbanColumnSnapshot let cards: [KanbanCardSnapshot] @@ -322,7 +326,7 @@ private struct KanbanColumnDropDelegate: DropDelegate { let targetColumnId: String let nextOrder: Double @Binding var isTargeted: Bool - let onCardDropped: (_ tabId: UUID, _ targetColumnId: String, _ dropOrder: Double) -> Void + let onCardDropped: @MainActor @Sendable (_ tabId: UUID, _ targetColumnId: String, _ dropOrder: Double) -> Void func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: SidebarTabDragPayload.dropContentTypes) From a79b3440b4f9007237161d2e0e89c4416830dd5a Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Mon, 13 Jul 2026 22:27:24 +0800 Subject: [PATCH 05/12] Fix SessionWorkspaceSnapshot capture argument order kanbanColumnId/kanbanOrder are declared last on SessionWorkspaceSnapshot, so the memberwise-init call in Workspace.sessionSnapshot() must pass them after environment, not after groupId. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Workspace.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ec781eac8dbf..faea130f3459 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -129,8 +129,6 @@ extension Workspace { customColor: customColor, isPinned: isPinned, groupId: groupId, - kanbanColumnId: kanbanColumnId, - kanbanOrder: kanbanOrder, isManuallyUnread: isWorkspaceManuallyUnread, hasUnreadIndicator: hasWorkspaceUnreadIndicator, notifications: workspaceNotificationSnapshots.isEmpty ? nil : workspaceNotificationSnapshots, @@ -145,7 +143,9 @@ extension Workspace { progress: progressSnapshot, gitBranch: gitBranchSnapshot, remote: remoteConfiguration?.sessionSnapshot(), - environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment + environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment, + kanbanColumnId: kanbanColumnId, + kanbanOrder: kanbanOrder ) } From 57663371cb08286232925616f48ef23f19ac0c04 Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 12:24:50 +0800 Subject: [PATCH 06/12] Hide terminal portal when board/notifications page is shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ghostty terminal is a portal view drawn above SwiftUI, so the terminal layer's `.opacity(0)` didn't hide it on the board page — it bled through. Gate each workspace's visibility, input-active, and portal priority on `selection == .tabs` so the portal is torn down when the board (or notifications) owns the screen. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ContentView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0421ec88afb1..d4fcb42c0698 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1685,6 +1685,11 @@ struct ContentView: View { let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } let selectedWorkspaceId = tabManager.selectedTabId let retiringWorkspaceId = self.retiringWorkspaceId + // The terminal layer only owns the screen when `.tabs` is selected. On the + // board/notifications pages, force every workspace not-visible/not-input so + // the portal-hosted Ghostty surface (which draws above SwiftUI and ignores + // this layer's `.opacity`) is torn down instead of bleeding through. + let terminalLayerActive = sidebarSelectionState.selection == .tabs return ZStack { ZStack { @@ -1699,11 +1704,12 @@ struct ContentView: View { // Allowing both selected+retiring workspaces to be input-active lets the // old workspace steal first responder (notably with WKWebView), which can // delay handoff completion and make browser returns feel laggy. - let isInputActive = isSelectedWorkspace - let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) + let isInputActive = isSelectedWorkspace && terminalLayerActive + let basePortalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) + let portalPriority = terminalLayerActive ? basePortalPriority : 0 WorkspaceContentView( workspace: tab, - isWorkspaceVisible: presentation.isPanelVisible, + isWorkspaceVisible: presentation.isPanelVisible && terminalLayerActive, isWorkspaceInputActive: isInputActive, rightSidebarOwnsInputFocus: fileExplorerState.rightSidebarOwnsInputFocus, isFullScreen: isFullScreen, From a541fc15c634f33fd5bb2b442be670c75133456b Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 13:11:46 +0800 Subject: [PATCH 07/12] Add board-on-launch and keyboard navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land on the board on every cold launch: force SidebarSelection to .board in both fresh-window and session-restore setup, and suppress the initial terminal auto-focus (which otherwise flips to .tabs) via a one-shot isInitialBoardLanding flag cleared on the first explicit selection. Add board keyboard navigation: a per-window KanbanFocusState tracks the focused card (highlighted via a value passed down to the card, preserving the snapshot boundary). Arrow keys move focus (via .onKeyPress, since bare arrows can't be bound as cmux shortcuts). Return opens the focused card, Ctrl-Opt-Left/Right move it between columns (including Archive), and Opt-Delete archives it — all real cmux shortcuts scoped to a new .board shortcut context so they only fire while the board is shown. Shortcuts are registered, localized (en+ja), schema-supported, and documented. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Values/ShortcutAction+Defaults.swift | 4 + .../CmuxSettings/Values/ShortcutAction.swift | 26 +++- .../Values/ShortcutContextKnownKey.swift | 4 +- Resources/Localizable.xcstrings | 68 ++++++++++ Sources/AppDelegate.swift | 112 +++++++++++++++- Sources/ContentView.swift | 18 ++- Sources/Kanban/KanbanBoardView.swift | 121 ++++++++++++++++++ Sources/Kanban/KanbanCardView.swift | 10 ++ Sources/Kanban/KanbanColumnView.swift | 11 +- Sources/Kanban/KanbanFocusState.swift | 18 +++ Sources/KeyboardShortcutActionContext.swift | 16 ++- Sources/KeyboardShortcutContext.swift | 4 + Sources/KeyboardShortcutSettings.swift | 23 +++- Sources/SidebarSelectionState.swift | 6 + cmux.xcodeproj/project.pbxproj | 4 + .../references/shortcut-actions.md | 8 ++ web/data/cmux-shortcuts.ts | 4 + web/data/cmux.schema.json | 7 + 18 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 Sources/Kanban/KanbanFocusState.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 9ea154237c4c..7adfa66b1294 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -55,6 +55,10 @@ extension ShortcutAction { case .switchRightSidebarToDock: return ShortcutStroke(key: "5", control: true) case .triggerFlash: return ShortcutStroke(key: "h", command: true, shift: true) case .toggleBoardView: return ShortcutStroke(key: "b", command: true, shift: true) + case .openFocusedBoardCard: return ShortcutStroke(key: "\r") + case .moveFocusedBoardCardToPrevColumn: return ShortcutStroke(key: "←", option: true, control: true) + case .moveFocusedBoardCardToNextColumn: return ShortcutStroke(key: "→", option: true, control: true) + case .archiveFocusedBoardCard: return ShortcutStroke(key: "⌫", option: true) case .nextSidebarTab: return ShortcutStroke(key: "]", command: true, control: true) case .prevSidebarTab: return ShortcutStroke(key: "[", command: true, control: true) case .focusHistoryBack: return ShortcutStroke(key: "[", command: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 903d0b31bb95..a42273a66b7d 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -46,6 +46,14 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case triggerFlash /// Toggles the main content between the kanban board and the terminal view. case toggleBoardView + /// Opens the keyboard-focused card on the kanban board (same as clicking it). + case openFocusedBoardCard + /// Moves the keyboard-focused board card to the previous column. + case moveFocusedBoardCardToPrevColumn + /// Moves the keyboard-focused board card to the next column. + case moveFocusedBoardCardToNextColumn + /// Archives the keyboard-focused board card. + case archiveFocusedBoardCard // MARK: Navigation case nextSurface @@ -183,7 +191,9 @@ extension ShortcutAction { .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, - .switchRightSidebarToDock, .triggerFlash, .toggleBoardView: + .switchRightSidebarToDock, .triggerFlash, .toggleBoardView, + .openFocusedBoardCard, .moveFocusedBoardCardToPrevColumn, + .moveFocusedBoardCardToNextColumn, .archiveFocusedBoardCard: return .workspace case .nextSurface, .prevSurface, .selectSurfaceByNumber, .nextSidebarTab, .prevSidebarTab, .focusHistoryBack, .focusHistoryForward, @@ -247,7 +257,8 @@ extension ShortcutAction { .diffViewerScrollToTop, .diffViewerOpenFileSearch, .fileExplorerOpenSelection, - .fileExplorerOpenSelectionFinderAlias: + .fileExplorerOpenSelectionFinderAlias, + .openFocusedBoardCard: return true default: return false @@ -275,6 +286,9 @@ extension ShortcutAction { return .atom(.sidebarFocus) case .fileExplorerOpenSelection, .fileExplorerOpenSelectionFinderAlias: return .atom(.sidebarFocus) + case .openFocusedBoardCard, .moveFocusedBoardCardToPrevColumn, + .moveFocusedBoardCardToNextColumn, .archiveFocusedBoardCard: + return .key(ShortcutContextKnownKey.boardVisible.rawValue) case .renameTab, .renameWorkspace: return .and(.not(.atom(.browserFocus)), .not(.atom(.sidebarFocus))) case .sendCtrlFToTerminal, .clearScreenKeepScrollback: @@ -365,6 +379,14 @@ extension ShortcutAction { case .triggerFlash: return "Flash Focused Panel" case .toggleBoardView: return String(localized: "shortcut.toggleBoardView.label", defaultValue: "Toggle Board View") + case .openFocusedBoardCard: + return String(localized: "shortcut.openFocusedBoardCard.label", defaultValue: "Board: Open Focused Card") + case .moveFocusedBoardCardToPrevColumn: + return String(localized: "shortcut.moveFocusedBoardCardToPrevColumn.label", defaultValue: "Board: Move Focused Card to Previous Column") + case .moveFocusedBoardCardToNextColumn: + return String(localized: "shortcut.moveFocusedBoardCardToNextColumn.label", defaultValue: "Board: Move Focused Card to Next Column") + case .archiveFocusedBoardCard: + return String(localized: "shortcut.archiveFocusedBoardCard.label", defaultValue: "Board: Archive Focused Card") case .nextSurface: return "Next Surface" case .prevSurface: return "Previous Surface" case .selectSurfaceByNumber: return "Select Surface 1…9" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutContextKnownKey.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutContextKnownKey.swift index fb10fe1dedb5..69d19514bae9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutContextKnownKey.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutContextKnownKey.swift @@ -33,6 +33,8 @@ public enum ShortcutContextKnownKey: String, CaseIterable, Sendable { case terminalFindVisible /// The focused workspace is using the freeform canvas layout. case workspaceCanvasLayout + /// The kanban board is the main content view (`SidebarSelection.board`). + case boardVisible /// The right sidebar's active mode (`files`, `find`, `sessions`, `feed`, `dock`). case sidebarMode /// The number of panes in the focused workspace. @@ -44,7 +46,7 @@ public enum ShortcutContextKnownKey: String, CaseIterable, Sendable { public var valueType: ShortcutContextValueType { switch self { case .sidebarFocus, .browserFocus, .markdownFocus, .filePreviewTextEditorFocus, .terminalFocus, - .commandPaletteVisible, .terminalFindVisible, .workspaceCanvasLayout: + .commandPaletteVisible, .terminalFindVisible, .workspaceCanvasLayout, .boardVisible: return .bool case .sidebarMode: return .string diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fc269df00334..6da715ac6f4f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -234604,6 +234604,74 @@ } } }, + "shortcut.openFocusedBoardCard.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Board: Open Focused Card" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボード: フォーカス中のカードを開く" + } + } + } + }, + "shortcut.moveFocusedBoardCardToPrevColumn.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Board: Move Focused Card to Previous Column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボード: フォーカス中のカードを前の列へ移動" + } + } + } + }, + "shortcut.moveFocusedBoardCardToNextColumn.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Board: Move Focused Card to Next Column" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボード: フォーカス中のカードを次の列へ移動" + } + } + } + }, + "shortcut.archiveFocusedBoardCard.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Board: Archive Focused Card" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボード: フォーカス中のカードをアーカイブ" + } + } + } + }, "kanban.column.title.todo": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 10e6bf90b492..dc8df72a229b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -545,6 +545,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let tabManager: TabManager let sidebarState: SidebarState let sidebarSelectionState: SidebarSelectionState + /// Per-window keyboard-focus cursor for the kanban board (Stage 5 + /// board navigation shortcuts). Created once alongside + /// `sidebarSelectionState` and never swapped on re-registration, + /// mirroring that property. + let kanbanFocusState: KanbanFocusState var fileExplorerState: FileExplorerState? let keyboardFocusCoordinator: MainWindowFocusController var cmuxConfigStore: CmuxConfigStore? @@ -558,6 +563,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: TabManager, sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState, + kanbanFocusState: KanbanFocusState = KanbanFocusState(), fileExplorerState: FileExplorerState?, cmuxConfigStore: CmuxConfigStore?, window: NSWindow? @@ -566,6 +572,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.tabManager = tabManager self.sidebarState = sidebarState self.sidebarSelectionState = sidebarSelectionState + self.kanbanFocusState = kanbanFocusState self.fileExplorerState = fileExplorerState self.cmuxConfigStore = cmuxConfigStore self.window = window @@ -3467,7 +3474,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent context.sidebarState.persistedWidth = CGFloat( SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) ) - context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + // Every cold launch lands on the board, regardless of the restored + // window's persisted selection (`snapshot.sidebar.selection`), which + // is intentionally ignored here — matches the fresh-window default in + // `createMainWindow`. `isInitialBoardLanding` lets the initial + // workspace auto-focus (`.ghosttyDidFocusTab`) fire without stealing + // the board back to `.tabs`. + context.sidebarSelectionState.selection = .board + context.sidebarSelectionState.isInitialBoardLanding = true if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { window.setFrame(restoredFrame, display: true) @@ -4563,6 +4577,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: TabManager, sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState, + kanbanFocusState: KanbanFocusState = KanbanFocusState(), fileExplorerState: FileExplorerState? = nil, cmuxConfigStore: CmuxConfigStore? = nil ) { @@ -4634,6 +4649,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + kanbanFocusState: kanbanFocusState, fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore, window: window @@ -6601,6 +6617,68 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // MARK: - Board keyboard navigation (Stage 5) + // + // These three dispatch the board's keyboard shortcuts (all scoped to the + // `.board` shortcut context, so they only fire while the board is shown) + // against the active window's `KanbanFocusState.focusedCardId`. Board + // arrow-key navigation itself is handled in SwiftUI via `.onKeyPress` on + // `KanbanBoardView` (bare arrows have no modifier, so they can't be bound + // as cmux shortcuts) — only the modifier-bearing open/move/archive actions + // are real shortcuts here. + + /// Opens the board's keyboard-focused card: the same effect as clicking + /// it (`KanbanBoardView.selectWorkspace`). + @discardableResult + func openFocusedBoardCardInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow), + let focusedCardId = context.kanbanFocusState.focusedCardId, + let workspace = context.tabManager.workspacesById[focusedCardId] else { + return false + } + context.tabManager.selectWorkspace(workspace) + context.sidebarSelectionState.selection = .tabs + context.sidebarSelectionState.isInitialBoardLanding = false + return true + } + + /// Moves the board's keyboard-focused card to the previous (`direction: + /// -1`) or next (`direction: 1`) column in `kanban.columns` order — + /// INCLUDING the Archive column, so ⌥⌃→ from the last real column + /// archives the card and ⌥⌃← from Archive un-archives it. Focus stays on + /// the moved card (its id doesn't change). + @discardableResult + func moveFocusedBoardCardInActiveMainWindow(direction: Int, preferredWindow: NSWindow? = nil) -> Bool { + guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow), + let focusedCardId = context.kanbanFocusState.focusedCardId, + let workspace = context.tabManager.workspacesById[focusedCardId] else { + return false + } + let columns = context.tabManager.currentKanbanColumns().sorted { $0.order < $1.order } + let currentColumnId = workspace.kanbanColumnId ?? KanbanColumnMutations.firstNonArchiveColumnId(columns) + guard let currentIndex = columns.firstIndex(where: { $0.id == currentColumnId }) else { + return false + } + let targetIndex = currentIndex + direction + guard columns.indices.contains(targetIndex) else { return false } + context.tabManager.moveWorkspace(tabId: focusedCardId, toColumn: columns[targetIndex].id) + return true + } + + /// Archives the board's keyboard-focused card. Focus stays on the + /// archived card; the next arrow-key press re-focuses away from it since + /// a collapsed Archive column is excluded from keyboard navigation + /// (`KanbanBoardView`'s navigable-columns filter). + @discardableResult + func archiveFocusedBoardCardInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow), + let focusedCardId = context.kanbanFocusState.focusedCardId else { + return false + } + context.tabManager.archiveWorkspace(tabId: focusedCardId) + return true + } + @discardableResult func toggleRightSidebarInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) else { @@ -8682,9 +8760,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent : (sessionWindowSnapshot?.sidebar.isVisible ?? true), persistedWidth: CGFloat(sidebarWidth) ) - let sidebarSelectionState = SidebarSelectionState( - selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs - ) + // Every cold launch lands on the board, regardless of the persisted + // selection — `SidebarSelectionState`'s default (`.board`, + // `isInitialBoardLanding: true`) already gives that, so the session + // snapshot's `sidebar.selection` is intentionally NOT read here. See + // the matching restore-path fix in `applySessionWindowSnapshot`. + let sidebarSelectionState = SidebarSelectionState() + let kanbanFocusState = KanbanFocusState() // Seed the per-window Bonsplit tab-bar leading inset before ContentView first // renders. The initial workspace is created inside TabManager.init, at which @@ -8719,6 +8801,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .environmentObject(notificationStore.sidebarUnread) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(kanbanFocusState) .environmentObject(fileExplorerState) .environmentObject(cmuxConfigStore) // AppKit hosts this ContentView in its own NSHostingView, which does @@ -8854,6 +8937,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + kanbanFocusState: kanbanFocusState, fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore ) @@ -13496,6 +13580,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .openFocusedBoardCard) { + _ = openFocusedBoardCardInActiveMainWindow(preferredWindow: mainWindowForShortcutEvent(event)) + return true + } + + if matchConfiguredShortcut(event: event, action: .moveFocusedBoardCardToPrevColumn) { + _ = moveFocusedBoardCardInActiveMainWindow(direction: -1, preferredWindow: mainWindowForShortcutEvent(event)) + return true + } + + if matchConfiguredShortcut(event: event, action: .moveFocusedBoardCardToNextColumn) { + _ = moveFocusedBoardCardInActiveMainWindow(direction: 1, preferredWindow: mainWindowForShortcutEvent(event)) + return true + } + + if matchConfiguredShortcut(event: event, action: .archiveFocusedBoardCard) { + _ = archiveFocusedBoardCardInActiveMainWindow(preferredWindow: mainWindowForShortcutEvent(event)) + return true + } + if matchConfiguredShortcut(event: event, action: .openDiffViewer) { // Shares the command palette's diff-open path; targets the event window's // focused workspace and beeps if it can't be opened (matching the palette). diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d4fcb42c0698..0982da14455f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -810,6 +810,7 @@ struct ContentView: View { var notificationStore: TerminalNotificationStore { .shared } @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState + @EnvironmentObject var kanbanFocusState: KanbanFocusState @EnvironmentObject var cmuxConfigStore: CmuxConfigStore @EnvironmentObject var fileExplorerState: FileExplorerState @Environment(\.colorScheme) private var colorScheme @@ -1740,7 +1741,10 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) - KanbanBoardView(selection: $sidebarSelectionState.selection) + KanbanBoardView( + selection: $sidebarSelectionState.selection, + isInitialBoardLanding: $sidebarSelectionState.isInitialBoardLanding + ) .opacity(sidebarSelectionState.selection == .board ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .board) .accessibilityHidden(sidebarSelectionState.selection != .board) @@ -2610,6 +2614,17 @@ struct ContentView: View { }.onReceive(NotificationCenter.default.publisher(for: .systemAppearanceDidChange)) { _ in scheduleTitlebarThemeRefresh(reason: "systemAppearanceChanged") }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in + // The initial workspace auto-focuses at launch, which would otherwise + // immediately steal the cold-launch board landing (#board-landing). + // Consume that first post-launch focus without leaving the board; + // every later focus event behaves as before. + if sidebarSelectionState.isInitialBoardLanding { + sidebarSelectionState.isInitialBoardLanding = false + if sidebarSelectionState.selection == .board { + scheduleTitlebarTextRefresh() + return + } + } sidebarSelectionState.selection = .tabs scheduleTitlebarTextRefresh() }) @@ -3188,6 +3203,7 @@ struct ContentView: View { tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + kanbanFocusState: kanbanFocusState, fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore ) diff --git a/Sources/Kanban/KanbanBoardView.swift b/Sources/Kanban/KanbanBoardView.swift index 89802a368014..775aeee447bb 100644 --- a/Sources/Kanban/KanbanBoardView.swift +++ b/Sources/Kanban/KanbanBoardView.swift @@ -22,14 +22,26 @@ import SwiftUI @MainActor struct KanbanBoardView: View { @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var kanbanFocusState: KanbanFocusState @Binding var selection: SidebarSelection + /// Cleared (`false`) the moment the user makes an explicit choice — + /// clicking a card or using the "Open Focused Card" shortcut — so the + /// cold-launch board landing (`ContentView`'s `.ghosttyDidFocusTab` + /// handler) never re-triggers after that. See `SidebarSelectionState`. + @Binding var isInitialBoardLanding: Bool @LiveSetting(\.kanban.columns) private var columns: [KanbanColumn] + /// Drives keyboard focus onto the board so arrow-key navigation works + /// without an extra click: granted whenever the board becomes the + /// visible selection (see the `.onChange(of: selection)` below), since + /// `.focusable()` alone doesn't auto-focus a view. + @FocusState private var isBoardFocused: Bool var body: some View { let sortedColumns = columns.sorted { $0.order < $1.order } let columnSnapshots = sortedColumns.map { KanbanColumnSnapshot(column: $0, displayTitle: displayTitle(for: $0)) } let cardsByColumnId = bucketedCards(sortedColumns: sortedColumns) let columnActions = self.columnActions(sortedColumns: sortedColumns) + let focusedCardId = kanbanFocusState.focusedCardId return ScrollView(.horizontal) { HStack(alignment: .top, spacing: 16) { @@ -38,6 +50,7 @@ struct KanbanBoardView: View { column: KanbanColumnSnapshot(column: column, displayTitle: displayTitle(for: column)), cards: cardsByColumnId[column.id] ?? [], allColumns: columnSnapshots, + focusedCardId: focusedCardId, actions: columnActions ) .equatable() @@ -47,6 +60,113 @@ struct KanbanBoardView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) + // Bare arrow keys can't be bound as cmux shortcuts (the recorder + // requires a modifier on the first stroke), so board focus + // navigation is handled directly here instead of through + // `KeyboardShortcutSettings` — the modifier-bearing open/move/archive + // actions below ARE real cmux shortcuts (see `TabManager+Kanban` / + // `AppDelegate`'s board dispatch block). + .focusable() + .focused($isBoardFocused) + .onKeyPress(.leftArrow) { + moveFocusToAdjacentColumn(delta: -1, sortedColumns: sortedColumns, cardsByColumnId: cardsByColumnId) + return .handled + } + .onKeyPress(.rightArrow) { + moveFocusToAdjacentColumn(delta: 1, sortedColumns: sortedColumns, cardsByColumnId: cardsByColumnId) + return .handled + } + .onKeyPress(.upArrow) { + moveFocusWithinColumn(delta: -1, sortedColumns: sortedColumns, cardsByColumnId: cardsByColumnId) + return .handled + } + .onKeyPress(.downArrow) { + moveFocusWithinColumn(delta: 1, sortedColumns: sortedColumns, cardsByColumnId: cardsByColumnId) + return .handled + } + .onAppear { + ensureValidFocus(sortedColumns: sortedColumns, cardsByColumnId: cardsByColumnId) + if selection == .board { + isBoardFocused = true + } + } + .onChange(of: selection) { _, newSelection in + if newSelection == .board { + isBoardFocused = true + } + } + } + + // MARK: - Keyboard focus navigation + + /// Moves keyboard focus to the previous (`delta: -1`) or next (`delta: + /// 1`) column, at the same relative card index (clamped to that + /// column's card count). Collapsed columns are skipped — their cards + /// aren't rendered, so they can't receive focus. Falls back to focusing + /// the first card of the first navigable column when nothing is + /// currently focused (or the focused card no longer exists / its column + /// just collapsed — the same "stale focus" case). + private func moveFocusToAdjacentColumn( + delta: Int, + sortedColumns: [KanbanColumn], + cardsByColumnId: [String: [KanbanCardSnapshot]] + ) { + let navigableColumns = sortedColumns.filter { !$0.isCollapsed } + guard let focusedCardId = kanbanFocusState.focusedCardId, + let currentColumnIndex = navigableColumns.firstIndex(where: { column in + cardsByColumnId[column.id]?.contains(where: { $0.id == focusedCardId }) == true + }) else { + focusFirstCard(sortedColumns: navigableColumns, cardsByColumnId: cardsByColumnId) + return + } + let currentCards = cardsByColumnId[navigableColumns[currentColumnIndex].id] ?? [] + let rowIndex = currentCards.firstIndex(where: { $0.id == focusedCardId }) ?? 0 + let targetColumnIndex = min(max(currentColumnIndex + delta, 0), navigableColumns.count - 1) + guard targetColumnIndex != currentColumnIndex else { return } + let targetCards = cardsByColumnId[navigableColumns[targetColumnIndex].id] ?? [] + guard !targetCards.isEmpty else { return } + kanbanFocusState.focusedCardId = targetCards[min(rowIndex, targetCards.count - 1)].id + } + + /// Moves keyboard focus to the previous (`delta: -1`) or next (`delta: + /// 1`) card within the currently focused column (clamped). + private func moveFocusWithinColumn( + delta: Int, + sortedColumns: [KanbanColumn], + cardsByColumnId: [String: [KanbanCardSnapshot]] + ) { + let navigableColumns = sortedColumns.filter { !$0.isCollapsed } + guard let focusedCardId = kanbanFocusState.focusedCardId, + let column = navigableColumns.first(where: { cardsByColumnId[$0.id]?.contains(where: { $0.id == focusedCardId }) == true }), + let cards = cardsByColumnId[column.id], + let rowIndex = cards.firstIndex(where: { $0.id == focusedCardId }) else { + focusFirstCard(sortedColumns: navigableColumns, cardsByColumnId: cardsByColumnId) + return + } + let targetIndex = min(max(rowIndex + delta, 0), cards.count - 1) + kanbanFocusState.focusedCardId = cards[targetIndex].id + } + + private func focusFirstCard(sortedColumns: [KanbanColumn], cardsByColumnId: [String: [KanbanCardSnapshot]]) { + for column in sortedColumns { + if let firstCard = cardsByColumnId[column.id]?.first { + kanbanFocusState.focusedCardId = firstCard.id + return + } + } + kanbanFocusState.focusedCardId = nil + } + + /// Called once from `.onAppear` (the board stays mounted and only + /// cross-fades opacity, so this fires on first launch, not every visit). + /// Seeds focus to the first card of the first column when nothing is + /// focused yet, or when the previously focused card no longer exists. + private func ensureValidFocus(sortedColumns: [KanbanColumn], cardsByColumnId: [String: [KanbanCardSnapshot]]) { + if let focusedCardId = kanbanFocusState.focusedCardId, + cardsByColumnId.values.contains(where: { $0.contains(where: { $0.id == focusedCardId }) }) { + return + } + focusFirstCard(sortedColumns: sortedColumns, cardsByColumnId: cardsByColumnId) } /// Resolves the title to render for `column`. The seeded default column @@ -127,5 +247,6 @@ struct KanbanBoardView: View { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } tabManager.selectWorkspace(workspace) selection = .tabs + isInitialBoardLanding = false } } diff --git a/Sources/Kanban/KanbanCardView.swift b/Sources/Kanban/KanbanCardView.swift index f3739453082d..0db3957305bc 100644 --- a/Sources/Kanban/KanbanCardView.swift +++ b/Sources/Kanban/KanbanCardView.swift @@ -35,6 +35,11 @@ struct KanbanCardSnapshot: Identifiable, Equatable { /// `otherColumns`/`isInArchiveColumn` are plain data, so they ARE compared. struct KanbanCardView: View, Equatable { let card: KanbanCardSnapshot + /// Whether this card is the board's keyboard-focused card (Stage 5 board + /// navigation), passed down as a plain value from `KanbanBoardView` + /// through `KanbanColumnView` — never the `KanbanFocusState` store + /// itself, per the snapshot-boundary rule. + let isFocused: Bool let onTap: () -> Void /// Non-archive columns other than this card's own, for the "Move to /// Column" submenu. Passed as a value snapshot (not the board's @@ -49,6 +54,7 @@ struct KanbanCardView: View, Equatable { static func == (lhs: KanbanCardView, rhs: KanbanCardView) -> Bool { lhs.card == rhs.card && + lhs.isFocused == rhs.isFocused && lhs.otherColumns == rhs.otherColumns && lhs.isInArchiveColumn == rhs.isInArchiveColumn } @@ -68,6 +74,10 @@ struct KanbanCardView: View, Equatable { .frame(maxWidth: .infinity, alignment: .leading) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: isFocused ? 2 : 0) + ) .contentShape(Rectangle()) .onTapGesture(perform: onTap) .onDrag { diff --git a/Sources/Kanban/KanbanColumnView.swift b/Sources/Kanban/KanbanColumnView.swift index 1c9a60b40bac..8ff1cac05884 100644 --- a/Sources/Kanban/KanbanColumnView.swift +++ b/Sources/Kanban/KanbanColumnView.swift @@ -74,6 +74,11 @@ struct KanbanColumnView: View, Equatable { /// snapshot array, not the board's live `[KanbanColumn]` — stays below /// the snapshot boundary. let allColumns: [KanbanColumnSnapshot] + /// The board's keyboard-focused card id (Stage 5 board navigation), or + /// `nil` when nothing is focused. Passed down as a plain value from + /// `KanbanBoardView.body` — never the `KanbanFocusState` store itself — + /// to keep the snapshot boundary below the column `ForEach`. + let focusedCardId: UUID? let actions: KanbanColumnActions @State private var isDropTargeted = false @@ -89,7 +94,10 @@ struct KanbanColumnView: View, Equatable { /// across the board's re-renders, so it's excluded here — the same /// tradeoff `IndexSectionView.==` makes for its `actions` bundle. static func == (lhs: KanbanColumnView, rhs: KanbanColumnView) -> Bool { - lhs.column == rhs.column && lhs.cards == rhs.cards && lhs.allColumns == rhs.allColumns + lhs.column == rhs.column + && lhs.cards == rhs.cards + && lhs.allColumns == rhs.allColumns + && lhs.focusedCardId == rhs.focusedCardId } /// Keep >=1 real column: refuse deleting the Archive column (checked @@ -196,6 +204,7 @@ struct KanbanColumnView: View, Equatable { ForEach(cards) { card in KanbanCardView( card: card, + isFocused: card.id == focusedCardId, onTap: { actions.onCardTap(card.id) }, otherColumns: allColumns.filter { $0.id != column.id && !$0.isArchive }, isInArchiveColumn: column.isArchive, diff --git a/Sources/Kanban/KanbanFocusState.swift b/Sources/Kanban/KanbanFocusState.swift new file mode 100644 index 000000000000..e8d1bf7490e9 --- /dev/null +++ b/Sources/Kanban/KanbanFocusState.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Per-window keyboard-focus tracking for the kanban board (Stage 5 board +/// navigation). Tracks which card is "focused" for arrow-key navigation and +/// the Return/⌥⌃←/⌥⌃→/⌥⌫ mutation shortcuts, independent of the AppKit first +/// responder — the board has no per-card focusable views, so this is a +/// dedicated logical-focus cursor instead. +/// +/// Injected exactly like `SidebarSelectionState`: created once per window in +/// `AppDelegate`, held on `MainWindowContext` for shortcut dispatch, and +/// `.environmentObject`-injected at the `ContentView` root so `KanbanBoardView` +/// can read/write it. +@MainActor +final class KanbanFocusState: ObservableObject { + /// The workspace id of the currently keyboard-focused card, or `nil` when + /// nothing is focused (e.g. the board has no cards yet). + @Published var focusedCardId: UUID? +} diff --git a/Sources/KeyboardShortcutActionContext.swift b/Sources/KeyboardShortcutActionContext.swift index 13ce3a03a1c6..64c93f432035 100644 --- a/Sources/KeyboardShortcutActionContext.swift +++ b/Sources/KeyboardShortcutActionContext.swift @@ -11,6 +11,8 @@ extension KeyboardShortcutSettings.Action { case rightSidebarFocus case canvasLayout case canvasLayoutOutsideFocusedContent + /// The kanban board is the main content view (`SidebarSelection.board`). + case board var isAlwaysAvailable: Bool { self == .application } @@ -28,7 +30,8 @@ extension KeyboardShortcutSettings.Action { focusedMarkdownPanel: Bool, focusedFilePreviewTextEditor: Bool = false, rightSidebarFocused: Bool, - workspaceCanvasLayout: Bool = false + workspaceCanvasLayout: Bool = false, + boardVisible: Bool = false ) -> Bool { switch self { case .application: return true @@ -43,6 +46,7 @@ extension KeyboardShortcutSettings.Action { && !focusedBrowserPanel && !focusedMarkdownPanel && !focusedFilePreviewTextEditor + case .board: return boardVisible } } @@ -52,7 +56,8 @@ extension KeyboardShortcutSettings.Action { focusedMarkdownPanel: context.markdownPanel != nil, focusedFilePreviewTextEditor: context.filePreviewTextEditorFocused, rightSidebarFocused: context.rightSidebarFocused, - workspaceCanvasLayout: context.shortcutContext.bool(ShortcutContextKnownKey.workspaceCanvasLayout.rawValue) + workspaceCanvasLayout: context.shortcutContext.bool(ShortcutContextKnownKey.workspaceCanvasLayout.rawValue), + boardVisible: context.shortcutContext.bool(ShortcutContextKnownKey.boardVisible.rawValue) ) } @@ -62,7 +67,8 @@ extension KeyboardShortcutSettings.Action { focusedMarkdownPanel: context.bool(CommandPaletteContextKeys.panelIsMarkdown), focusedFilePreviewTextEditor: context.bool(CommandPaletteContextKeys.panelIsFilePreviewTextEditor), rightSidebarFocused: false, - workspaceCanvasLayout: context.bool(CommandPaletteContextKeys.workspaceCanvasLayout) + workspaceCanvasLayout: context.bool(CommandPaletteContextKeys.workspaceCanvasLayout), + boardVisible: false ) } @@ -84,6 +90,7 @@ extension KeyboardShortcutSettings.Action { .and(.not(.atom(.markdownFocus)), .not(.atom(.filePreviewTextEditorFocus))) ) ) + case .board: return .key(ShortcutContextKnownKey.boardVisible.rawValue) } } @@ -156,6 +163,9 @@ extension KeyboardShortcutSettings.Action { return .canvasLayout case .saveLayoutTemplate: return .application + case .openFocusedBoardCard, .moveFocusedBoardCardToPrevColumn, + .moveFocusedBoardCardToNextColumn, .archiveFocusedBoardCard: + return .board default: return .application } diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index d47ce85a10f1..d34d082ce9be 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -110,6 +110,10 @@ extension AppDelegate { if let mode = window.flatMap({ keyboardFocusCoordinator(for: $0)?.activeRightSidebarMode }) { context.setString(ShortcutContextKnownKey.sidebarMode.rawValue, mode.rawValue) } + context.setBool( + ShortcutContextKnownKey.boardVisible.rawValue, + shortcutMainWindowContext(in: window)?.sidebarSelectionState.selection == .board + ) return context } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index d468f7baf7e2..ee2a6d1297cf 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -96,6 +96,10 @@ enum KeyboardShortcutSettings { case switchRightSidebarToDock case triggerFlash case toggleBoardView + case openFocusedBoardCard + case moveFocusedBoardCardToPrevColumn + case moveFocusedBoardCardToNextColumn + case archiveFocusedBoardCard // Navigation case nextSurface @@ -222,6 +226,10 @@ enum KeyboardShortcutSettings { case .switchRightSidebarToDock: return String(localized: "shortcut.switchRightSidebarToDock.label", defaultValue: "Show Sidebar Dock") case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel") case .toggleBoardView: return String(localized: "shortcut.toggleBoardView.label", defaultValue: "Toggle Board View") + case .openFocusedBoardCard: return String(localized: "shortcut.openFocusedBoardCard.label", defaultValue: "Board: Open Focused Card") + case .moveFocusedBoardCardToPrevColumn: return String(localized: "shortcut.moveFocusedBoardCardToPrevColumn.label", defaultValue: "Board: Move Focused Card to Previous Column") + case .moveFocusedBoardCardToNextColumn: return String(localized: "shortcut.moveFocusedBoardCardToNextColumn.label", defaultValue: "Board: Move Focused Card to Next Column") + case .archiveFocusedBoardCard: return String(localized: "shortcut.archiveFocusedBoardCard.label", defaultValue: "Board: Archive Focused Card") case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface") case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface") case .selectSurfaceByNumber: return String(localized: "shortcut.selectSurfaceByNumber.label", defaultValue: "Select Surface 1…9") @@ -393,6 +401,14 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false) case .toggleBoardView: return StoredShortcut(key: "b", command: true, shift: true, option: false, control: false) + case .openFocusedBoardCard: + return StoredShortcut(key: "\r", command: false, shift: false, option: false, control: false) + case .moveFocusedBoardCardToPrevColumn: + return StoredShortcut(key: "←", command: false, shift: false, option: true, control: true) + case .moveFocusedBoardCardToNextColumn: + return StoredShortcut(key: "→", command: false, shift: false, option: true, control: true) + case .archiveFocusedBoardCard: + return StoredShortcut(key: "⌫", command: false, shift: false, option: true, control: false) case .nextSidebarTab: return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: @@ -607,7 +623,8 @@ enum KeyboardShortcutSettings { .diffViewerScrollToTop, .diffViewerOpenFileSearch, .fileExplorerOpenSelection, - .fileExplorerOpenSelectionFinderAlias: + .fileExplorerOpenSelectionFinderAlias, + .openFocusedBoardCard: return true default: return false @@ -1798,6 +1815,7 @@ struct ShortcutStroke: Equatable, Hashable { case 48: return "\t" // tab case 49: return "space" // kVK_Space case 36, 76: return "\r" // return, keypad enter + case 51: return "⌫" // delete (backspace) case 33: return "[" // kVK_ANSI_LeftBracket case 30: return "]" // kVK_ANSI_RightBracket case 27: return "-" // kVK_ANSI_Minus @@ -2030,13 +2048,14 @@ struct ShortcutStroke: Equatable, Hashable { case "→": return 124 case "↓": return 125 case "↑": return 126 + case "⌫": return 51 default: return nil } } private static func usesDirectKeyCodeMatching(_ key: String) -> Bool { - key == "\t" || key == "space" || functionKeyDisplayString(for: key) != nil || key.hasPrefix("media.") + key == "\t" || key == "space" || key == "⌫" || functionKeyDisplayString(for: key) != nil || key.hasPrefix("media.") } private static func functionKeyDisplayString(for key: String) -> String? { diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index d6265b7e0bf9..985504e742b6 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -3,6 +3,12 @@ import SwiftUI @MainActor final class SidebarSelectionState: ObservableObject { @Published var selection: SidebarSelection + /// True from window creation until the first `.ghosttyDidFocusTab` after + /// launch is consumed. Lets that first auto-focus keep the cold-launch + /// board landing instead of immediately flipping to `.tabs` — every + /// later focus event, or an explicit card/tab selection, clears it and + /// restores normal focus-follows-selection behavior. + @Published var isInitialBoardLanding: Bool = true init(selection: SidebarSelection = .board) { self.selection = selection diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e1b8c34b8063..0bb95ce904a0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -828,6 +828,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 8290D26533B789EAF071E516 /* KanbanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */; }; 352BD1EC2A2EE809462AD495 /* KanbanColumnAssignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EA9212E0CA29AC414C26D2 /* KanbanColumnAssignmentTests.swift */; }; 50D23A13B244A0D9E02E3BF4 /* KanbanColumnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */; }; + 4C864505B5BE16BE0056D80C /* KanbanFocusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C50E72CB8168BCBD90B1C11 /* KanbanFocusState.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A570020000000000000002 /* KeyboardShortcutActionContext.swift */; }; C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34670030000000000000002 /* KeyboardShortcutContext.swift */; }; @@ -2582,6 +2583,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kanban/KanbanCardView.swift; sourceTree = ""; }; 53EA9212E0CA29AC414C26D2 /* KanbanColumnAssignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KanbanColumnAssignmentTests.swift; sourceTree = ""; }; 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kanban/KanbanColumnView.swift; sourceTree = ""; }; + 4C50E72CB8168BCBD90B1C11 /* KanbanFocusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kanban/KanbanFocusState.swift; sourceTree = ""; }; A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; C4A570020000000000000002 /* KeyboardShortcutActionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutActionContext.swift; sourceTree = ""; }; C34670030000000000000002 /* KeyboardShortcutContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutContext.swift; sourceTree = ""; }; @@ -4006,6 +4008,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F582CE26362425687EA896CE /* KanbanBoardView.swift */, 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */, 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */, + 4C50E72CB8168BCBD90B1C11 /* KanbanFocusState.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, 63F0CD0E /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */, C0DE7B330000000000000002 /* TextBoxAgentDetection.swift */, @@ -6224,6 +6227,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = BF8D5911E5BA4D1645763E3A /* KanbanBoardView.swift in Sources */, 8290D26533B789EAF071E516 /* KanbanCardView.swift in Sources */, 50D23A13B244A0D9E02E3BF4 /* KanbanColumnView.swift in Sources */, + 4C864505B5BE16BE0056D80C /* KanbanFocusState.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */, C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */, diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md index e4c2a2382854..cf3907d6349d 100644 --- a/skills/cmux-settings/references/shortcut-actions.md +++ b/skills/cmux-settings/references/shortcut-actions.md @@ -90,6 +90,14 @@ Values for `shortcuts.bindings.`: - `shortcuts.bindings.markOldestUnreadAndJumpNext` - `shortcuts.bindings.triggerFlash` +## Board + +- `shortcuts.bindings.toggleBoardView` +- `shortcuts.bindings.openFocusedBoardCard` +- `shortcuts.bindings.moveFocusedBoardCardToPrevColumn` +- `shortcuts.bindings.moveFocusedBoardCardToNextColumn` +- `shortcuts.bindings.archiveFocusedBoardCard` + ## Right sidebar - `shortcuts.bindings.toggleSidebar` diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 806c52d6b563..4d245e3e9b21 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -82,6 +82,10 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle left sidebar", ja: "左サイドバーを切り替え" } }, { id: "toggleFileExplorer", combos: [["⌘", "⌥", "B"]], description: { en: "Toggle right sidebar", ja: "右サイドバーを切り替え" } }, { id: "toggleBoardView", combos: [["⌘", "⇧", "B"]], description: { en: "Toggle kanban board view", ja: "カンバンボード表示を切り替え" } }, + { id: "openFocusedBoardCard", combos: [["↩"]], description: { en: "Board: open focused card", ja: "ボード: フォーカス中のカードを開く" } }, + { id: "moveFocusedBoardCardToPrevColumn", combos: [["⌃", "⌥", "←"]], description: { en: "Board: move focused card to previous column", ja: "ボード: フォーカス中のカードを前の列へ移動" } }, + { id: "moveFocusedBoardCardToNextColumn", combos: [["⌃", "⌥", "→"]], description: { en: "Board: move focused card to next column", ja: "ボード: フォーカス中のカードを次の列へ移動" } }, + { id: "archiveFocusedBoardCard", combos: [["⌥", "⌫"]], description: { en: "Board: archive focused card", ja: "ボード: フォーカス中のカードをアーカイブ" } }, { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, { id: "newBrowserWorkspace", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 07626f9037db..4e3a6523876a 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1594,6 +1594,10 @@ "switchRightSidebarToDock", "triggerFlash", "toggleBoardView", + "openFocusedBoardCard", + "moveFocusedBoardCardToPrevColumn", + "moveFocusedBoardCardToNextColumn", + "archiveFocusedBoardCard", "nextSurface", "prevSurface", "selectSurfaceByNumber", @@ -1699,6 +1703,9 @@ }, "diffViewerOpenFileSearch": { "$ref": "#/$defs/bareFirstStrokeShortcutBindingNullable" + }, + "openFocusedBoardCard": { + "$ref": "#/$defs/bareFirstStrokeShortcutBindingNullable" } }, "additionalProperties": { From 0d457616362c5a7f71e689e4f16afea84f50db83 Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 13:20:31 +0800 Subject: [PATCH 08/12] Make KanbanFocusState init nonisolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainWindowContext.init and registerMainWindow default the new kanbanFocusState param to KanbanFocusState(), evaluated in a nonisolated context — but the @MainActor class's implicit init was main-actor isolated. Add an explicit nonisolated init (the nil default needs no isolation) so the default argument compiles. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Kanban/KanbanFocusState.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Kanban/KanbanFocusState.swift b/Sources/Kanban/KanbanFocusState.swift index e8d1bf7490e9..ff03a04a8b0a 100644 --- a/Sources/Kanban/KanbanFocusState.swift +++ b/Sources/Kanban/KanbanFocusState.swift @@ -15,4 +15,10 @@ final class KanbanFocusState: ObservableObject { /// The workspace id of the currently keyboard-focused card, or `nil` when /// nothing is focused (e.g. the board has no cards yet). @Published var focusedCardId: UUID? + + /// `nonisolated` so it can be used as a default argument value + /// (`kanbanFocusState: KanbanFocusState = KanbanFocusState()`) in + /// `MainWindowContext.init` / `registerMainWindow`, which are called from + /// nonisolated contexts. Initializing the `nil` default needs no isolation. + nonisolated init() {} } From 2b02139bd2e6bac72c08d11bf46f5e32dd42df3e Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 13:57:47 +0800 Subject: [PATCH 09/12] Add Rename option to kanban card context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a card now offers "Rename…", which renames the card's underlying workspace via a prompt (reusing the app's existing Rename Workspace strings). Routes through a new TabManager.renameCard that calls setCustomTitle and fires objectWillChange so the board re-renders the card title. New label localized en+ja. Co-Authored-By: Claude Opus 4.8 (1M context) --- Resources/Localizable.xcstrings | 17 ++++++++++++++++ Sources/Kanban/KanbanBoardView.swift | 1 + Sources/Kanban/KanbanCardView.swift | 5 +++++ Sources/Kanban/KanbanColumnView.swift | 29 +++++++++++++++++++++++++++ Sources/TabManager+Kanban.swift | 9 +++++++++ 5 files changed, 61 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 6da715ac6f4f..216206f0419a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -234111,6 +234111,23 @@ } } }, + "kanban.card.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を変更…" + } + } + } + }, "kanban.column.rename": { "extractionState": "manual", "localizations": { diff --git a/Sources/Kanban/KanbanBoardView.swift b/Sources/Kanban/KanbanBoardView.swift index 775aeee447bb..5d4551688d1e 100644 --- a/Sources/Kanban/KanbanBoardView.swift +++ b/Sources/Kanban/KanbanBoardView.swift @@ -236,6 +236,7 @@ struct KanbanBoardView: View { }, onArchiveCard: { tabId in tabManager.archiveWorkspace(tabId: tabId) }, onUnarchiveCard: { tabId in tabManager.unarchiveWorkspace(tabId: tabId) }, + onRenameCard: { tabId, title in tabManager.renameCard(tabId: tabId, title: title) }, onRenameColumn: { id, title in tabManager.renameKanbanColumn(id: id, title: title) }, onSetColumnColor: { id, colorHex in tabManager.setColumnColor(id: id, colorHex: colorHex) }, onDeleteColumn: { id in tabManager.deleteKanbanColumn(id: id) }, diff --git a/Sources/Kanban/KanbanCardView.swift b/Sources/Kanban/KanbanCardView.swift index 0db3957305bc..bdf70a4174fc 100644 --- a/Sources/Kanban/KanbanCardView.swift +++ b/Sources/Kanban/KanbanCardView.swift @@ -41,6 +41,7 @@ struct KanbanCardView: View, Equatable { /// itself, per the snapshot-boundary rule. let isFocused: Bool let onTap: () -> Void + let onRename: () -> Void /// Non-archive columns other than this card's own, for the "Move to /// Column" submenu. Passed as a value snapshot (not the board's /// `[KanbanColumn]`) so this view stays below the snapshot boundary. @@ -84,6 +85,10 @@ struct KanbanCardView: View, Equatable { SidebarTabDragPayload(tabId: card.id).provider() } .contextMenu { + Button(String(localized: "kanban.card.rename", defaultValue: "Rename…")) { + onRename() + } + Divider() if isInArchiveColumn { Button(String(localized: "kanban.card.unarchive", defaultValue: "Move Out of Archive")) { onUnarchive() diff --git a/Sources/Kanban/KanbanColumnView.swift b/Sources/Kanban/KanbanColumnView.swift index 8ff1cac05884..a192a6ebd275 100644 --- a/Sources/Kanban/KanbanColumnView.swift +++ b/Sources/Kanban/KanbanColumnView.swift @@ -54,6 +54,8 @@ struct KanbanColumnActions { let onMoveCardToColumn: (_ tabId: UUID, _ columnId: String) -> Void let onArchiveCard: (UUID) -> Void let onUnarchiveCard: (UUID) -> Void + /// Renames a card (its workspace's custom title). + let onRenameCard: (_ tabId: UUID, _ title: String) -> Void let onRenameColumn: (_ id: String, _ title: String) -> Void let onSetColumnColor: (_ id: String, _ colorHex: String?) -> Void let onDeleteColumn: (_ id: String) -> Void @@ -206,6 +208,7 @@ struct KanbanColumnView: View, Equatable { card: card, isFocused: card.id == focusedCardId, onTap: { actions.onCardTap(card.id) }, + onRename: { promptRenameCard(card) }, otherColumns: allColumns.filter { $0.id != column.id && !$0.isArchive }, isInArchiveColumn: column.isArchive, onMoveToColumn: { targetId in actions.onMoveCardToColumn(card.id, targetId) }, @@ -220,6 +223,32 @@ struct KanbanColumnView: View, Equatable { // MARK: - AppKit prompts + /// Renames the card's underlying workspace, reusing the app's existing + /// "Rename Workspace" prompt strings (a card currently maps 1:1 to a + /// workspace). Routes through `actions.onRenameCard` → + /// `TabManager.renameCard` so the board re-renders. + private func promptRenameCard(_ card: KanbanCardSnapshot) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: card.title) + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + guard alert.runModal() == .alertFirstButtonReturn else { return } + let trimmed = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + actions.onRenameCard(card.id, trimmed) + } + /// Mirrors `promptRename()` (`Sources/ContentView.swift:14854`): an /// `NSAlert` with an `NSTextField` accessory, run modally from a /// SwiftUI context-menu Button action — the app's existing lightweight diff --git a/Sources/TabManager+Kanban.swift b/Sources/TabManager+Kanban.swift index 4ed511497b76..be1a38c4be83 100644 --- a/Sources/TabManager+Kanban.swift +++ b/Sources/TabManager+Kanban.swift @@ -61,6 +61,15 @@ extension TabManager { moveWorkspace(tabId: tabId, toColumn: targetColumnId) } + /// Renames a card's underlying workspace (its custom title). Goes through + /// the shared `setCustomTitle` path, then sends `objectWillChange` so the + /// board (which observes `tabManager`, not each workspace) re-renders the + /// card with the new title. + func renameCard(tabId: UUID, title: String) { + setCustomTitle(tabId: tabId, title: title) + objectWillChange.send() + } + // MARK: - Column CRUD func addKanbanColumn(title: String) { From 18d41927ec9532c928e626d432ac2cb459207eae Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 14:13:36 +0800 Subject: [PATCH 10/12] Add titlebar button to toggle the board view Add a board-toggle button to the titlebar controls, immediately left of the notifications (bell) button. It calls the same shared toggleBoardViewInActiveMainWindow path as the Cmd-Shift-B shortcut (per shared-behavior), with a localized accessibility label + tooltip. Co-Authored-By: Claude Opus 4.8 (1M context) --- Resources/Localizable.xcstrings | 34 ++++++++++++++++++++ Sources/Update/UpdateTitlebarAccessory.swift | 20 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 216206f0419a..6e512314faa8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -216170,6 +216170,40 @@ } } }, + "titlebar.board.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Board" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボードを切り替え" + } + } + } + }, + "titlebar.board.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show or hide the board" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボードの表示/非表示" + } + } + } + }, "titlebar.notifications.accessibilityLabel": { "extractionState": "manual", "localizations": { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index d85f70520205..97226788d9f4 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1027,6 +1027,26 @@ struct TitlebarControlsView: View { } .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) + TitlebarControlButton( + config: config, + foregroundColor: foregroundColor, + accessibilityIdentifier: "titlebarControl.toggleBoard", + accessibilityLabel: String(localized: "titlebar.board.accessibilityLabel", defaultValue: "Toggle Board"), + action: { + #if DEBUG + cmuxDebugLog("titlebar.toggleBoard") + #endif + // Same shared toggle the ⌘⇧B shortcut uses (per shared-behavior). + _ = AppDelegate.shared?.toggleBoardViewInActiveMainWindow() + }) { + iconLabel( + systemName: "rectangle.split.3x1", + config: config, + iconGeometryKeyPrefix: "titlebarControl_toggleBoardIcon" + ) + } + .safeHelp(KeyboardShortcutSettings.Action.toggleBoardView.tooltip(String(localized: "titlebar.board.tooltip", defaultValue: "Show or hide the board"))) + TitlebarControlButton( config: config, foregroundColor: foregroundColor, From 0ed6feada9476dc74a5bf85b35a522f8528b5cff Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 14:34:00 +0800 Subject: [PATCH 11/12] Wire board toggle button into minimal-mode titlebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board toggle button already renders in minimal mode (shared TitlebarControlsView), but minimal mode routes clicks through slot-indexed proxies. Add a .toggleBoard slot to MinimalModeSidebarControlActionSlot (between toggleSidebar and showNotifications), its hit-lane geometry in TitlebarControlsHitRegions, and dispatch in both the SwiftUI proxy and the WindowDecorationsController click target — all calling the shared toggleBoardViewInActiveMainWindow. Add a matching shortcut-hint slot so the button shows its ⌘⇧B badge, and update the exhaustive-switch tests. Refresh the file-length budget for the four files this feature grew (AppDelegate, UpdateTitlebarAccessory, WindowDragHandleView, ShortcutAction). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 7 ++++--- Sources/Update/MinimalModeSidebarControls.swift | 9 ++++++--- Sources/Update/UpdateTitlebarAccessory.swift | 7 +++++++ Sources/WindowDecorationsController.swift | 2 ++ Sources/WindowDragHandleView.swift | 9 ++++++++- cmuxTests/ShortcutAndCommandPaletteTests.swift | 1 + cmuxTests/UpdatePillReleaseVisibilityTests.swift | 2 +- cmuxTests/WindowAndDragTests.swift | 5 +++-- 8 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a8e6f2e55107..f7b5dc4cb7fe 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 35384 CLI/cmux.swift -17793 Sources/AppDelegate.swift +17920 Sources/AppDelegate.swift 16141 Sources/ContentView.swift 15005 Sources/TerminalController.swift 12931 Sources/Workspace.swift @@ -32,7 +32,7 @@ 3673 cmuxTests/TabManagerSessionSnapshotTests.swift 3668 cmuxTests/CLIGenericHookPersistenceTests.swift 3314 Sources/CmuxConfig.swift -2882 Sources/Update/UpdateTitlebarAccessory.swift +2909 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift 2611 Sources/KeyboardShortcutSettings.swift @@ -52,7 +52,7 @@ 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1847 cmuxTests/TerminalControllerSocketSecurityTests.swift 1810 Sources/SessionIndexStore.swift -1760 Sources/WindowDragHandleView.swift +1767 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -255,4 +255,5 @@ 503 Sources/Settings/ConfigSource.swift 503 Sources/TerminalNotificationQueue.swift 502 Sources/CmuxEventPublishing.swift +502 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift 500 Sources/KeyboardShortcutRecorder.swift diff --git a/Sources/Update/MinimalModeSidebarControls.swift b/Sources/Update/MinimalModeSidebarControls.swift index cec8df9bb486..c59402dd5c6e 100644 --- a/Sources/Update/MinimalModeSidebarControls.swift +++ b/Sources/Update/MinimalModeSidebarControls.swift @@ -43,7 +43,8 @@ enum TitlebarControlsHitRegions { ) -> ClosedRange? { let startX = outerLeadingPadding + config.groupPadding.leading let sidebarX = startX - let notificationsX = sidebarX + config.buttonSize + config.spacing + let boardX = sidebarX + config.buttonSize + config.spacing + let notificationsX = boardX + config.buttonSize + config.spacing let newTabX = notificationsX + config.buttonSize + config.spacing let newTabWidth = TitlebarNewWorkspaceCloudSplitButtonMetrics.primaryWidth(config: config) let cloudMenuX = newTabX + newTabWidth @@ -54,6 +55,8 @@ enum TitlebarControlsHitRegions { let minX: CGFloat = switch slot { case .toggleSidebar: sidebarX + case .toggleBoard: + boardX case .showNotifications: notificationsX case .newTab: @@ -70,7 +73,7 @@ enum TitlebarControlsHitRegions { newTabWidth case .cloudVM: cloudMenuWidth - case .toggleSidebar, .showNotifications, .focusHistoryBack, .focusHistoryForward: + case .toggleSidebar, .toggleBoard, .showNotifications, .focusHistoryBack, .focusHistoryForward: config.buttonSize } return minX...(minX + width) @@ -240,7 +243,7 @@ final class MinimalModeSidebarControlActionView: NSView { _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: self, event: event, direction: .back) case .focusHistoryForward: _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: self, event: event, direction: .forward) - case .showNotifications: + case .showNotifications, .toggleBoard: super.rightMouseDown(with: event) } } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 97226788d9f4..8256af26f0d2 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -482,6 +482,7 @@ func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) enum TitlebarShortcutHintActionSlot: Int, CaseIterable { case toggleSidebar + case toggleBoard case showNotifications case newTab case focusHistoryBack @@ -491,6 +492,8 @@ enum TitlebarShortcutHintActionSlot: Int, CaseIterable { switch self { case .toggleSidebar: return .toggleSidebar + case .toggleBoard: + return .toggleBoardView case .showNotifications: return .showNotifications case .newTab: @@ -532,6 +535,8 @@ enum TitlebarControlsLayoutMetrics { let actionSlot: MinimalModeSidebarControlActionSlot = switch slot { case .toggleSidebar: .toggleSidebar + case .toggleBoard: + .toggleBoard case .showNotifications: .showNotifications case .newTab: @@ -1490,6 +1495,8 @@ struct HiddenTitlebarSidebarControlsView: View { switch slot { case .toggleSidebar: onToggleSidebar() + case .toggleBoard: + _ = AppDelegate.shared?.toggleBoardViewInActiveMainWindow() case .showNotifications: onToggleNotifications(anchorView) case .newTab: diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift index b48890ab9f1b..b8ab3b3d7ad4 100644 --- a/Sources/WindowDecorationsController.swift +++ b/Sources/WindowDecorationsController.swift @@ -339,6 +339,8 @@ final class WindowDecorationsController { switch slot { case .toggleSidebar: _ = AppDelegate.shared?.toggleSidebarInActiveMainWindow(preferredWindow: window) + case .toggleBoard: + _ = AppDelegate.shared?.toggleBoardViewInActiveMainWindow(preferredWindow: window) case .showNotifications: let resolvedAnchorView = NotificationsAnchorRegistry.shared.closestAnchor( in: window, diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 28e87a0cef16..9b19ac44b2bd 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -811,6 +811,7 @@ private func minimalModeTrafficLightFrameInContentCoordinates(for window: NSWind enum MinimalModeSidebarControlActionSlot: Int, CaseIterable { case toggleSidebar + case toggleBoard case showNotifications case newTab case cloudVM @@ -821,6 +822,8 @@ enum MinimalModeSidebarControlActionSlot: Int, CaseIterable { switch self { case .toggleSidebar: return "titlebarControl.toggleSidebar" + case .toggleBoard: + return "titlebarControl.toggleBoard" case .showNotifications: return "titlebarControl.showNotifications" case .newTab: @@ -838,6 +841,8 @@ enum MinimalModeSidebarControlActionSlot: Int, CaseIterable { switch self { case .toggleSidebar: return String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar") + case .toggleBoard: + return String(localized: "titlebar.board.accessibilityLabel", defaultValue: "Toggle Board") case .showNotifications: return String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications") case .newTab: @@ -855,6 +860,8 @@ enum MinimalModeSidebarControlActionSlot: Int, CaseIterable { switch self { case .toggleSidebar: return "toggleSidebar" + case .toggleBoard: + return "toggleBoard" case .showNotifications: return "showNotifications" case .newTab: @@ -872,7 +879,7 @@ enum MinimalModeSidebarControlActionSlot: Int, CaseIterable { switch self { case .toggleSidebar, .newTab, .cloudVM, .focusHistoryBack, .focusHistoryForward: return true - case .showNotifications: + case .showNotifications, .toggleBoard: return false } } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 55148e876ee9..65e5d8a1b6ab 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -962,6 +962,7 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { TitlebarShortcutHintActionSlot.allCases.map(\.action), [ .toggleSidebar, + .toggleBoardView, .showNotifications, .newTab, .focusHistoryBack, diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 88d2ae555116..11920cfc68bc 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -460,7 +460,7 @@ struct TitlebarControlsHoverPolicyTests { TitlebarNewWorkspaceCloudSplitButtonMetrics.primaryWidth(config: config) case .some(.cloudVM): TitlebarNewWorkspaceCloudSplitButtonMetrics.dropdownWidth(config: config) - case .some(.toggleSidebar), .some(.showNotifications), .some(.focusHistoryBack), .some(.focusHistoryForward), nil: + case .some(.toggleSidebar), .some(.toggleBoard), .some(.showNotifications), .some(.focusHistoryBack), .some(.focusHistoryForward), nil: config.buttonSize } checkEqual( diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index d4717777586d..759e8c760b94 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -884,6 +884,7 @@ final class WindowDragHandleHitTests: XCTestCase { MinimalModeSidebarControlActionSlot.allCases.map(\.accessibilityIdentifier), [ "titlebarControl.toggleSidebar", + "titlebarControl.toggleBoard", "titlebarControl.showNotifications", "titlebarControl.newTab", "titlebarControl.cloudVM", @@ -951,11 +952,11 @@ final class WindowDragHandleHitTests: XCTestCase { XCTAssertFalse( TitlebarControlsHitRegions.pointFallsInButtonColumn(NSPoint(x: firstGapX, y: 14), config: config), - "The gap between the sidebar and notification icons should remain available for window dragging" + "The gap between the sidebar and board icons should remain available for window dragging" ) XCTAssertFalse( TitlebarControlsHitRegions.pointFallsInButtonColumn(NSPoint(x: secondGapX, y: 14), config: config), - "The gap between the notification and new-workspace icons should remain available for window dragging" + "The gap between the board and notification icons should remain available for window dragging" ) XCTAssertGreaterThanOrEqual( From 333028e0cebba4b906e38f71a9420f9b2af0ee7a Mon Sep 17 00:00:00 2001 From: Ivan Kyu Date: Tue, 14 Jul 2026 14:40:39 +0800 Subject: [PATCH 12/12] Add Rename to preset sidebar (Project Worktrees) row context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled preset sidebar views (Project Worktrees, Attention Queue, Dev Servers, etc.) render rows via CmuxExtensionSidebarWorkspaceRowView, which had no context menu — so workspaces there couldn't be renamed. Add a right-click "Rename Workspace…" item that renames the row's workspace, reusing the existing setCustomTitle path, NSAlert prompt pattern, and localization keys. The rename closure is supplied by VerticalTabsSidebar (which holds tabManager), keeping the row a value+closure view. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ContentView.swift | 27 ++++++++++++++++++- .../ExtensionSidebarWorkspaceRowView.swift | 6 +++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0982da14455f..c521800135ce 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11626,7 +11626,8 @@ struct VerticalTabsSidebar: View { relativeNow: now, isSelected: row.workspaceId == selectedWorkspaceId, onSelect: selectExtensionSidebarWorkspace, - onOpenWindow: CmuxExtensionSidebarInspectorWindowController.show + onOpenWindow: CmuxExtensionSidebarInspectorWindowController.show, + onRename: promptRenameExtensionSidebarWorkspace ) .id(row.id) .accessibilityIdentifier("extensionSidebar.workspace.\(row.workspaceId.uuidString)") @@ -11658,6 +11659,30 @@ struct VerticalTabsSidebar: View { tabManager.selectWorkspace(workspace) } + private func promptRenameExtensionSidebarWorkspace(_ workspaceId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + let trimmed = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + tabManager.setCustomTitle(tabId: tab.id, title: trimmed) + } + private func createExtensionWorktreeWorkspace(for section: CmuxSidebarProviderTreeSection) { guard let projectRootPath = section.projectRootPath, !extensionSidebarWorktreeCreationInFlightSectionIds.contains(section.id) else { diff --git a/Sources/ExtensionSidebarWorkspaceRowView.swift b/Sources/ExtensionSidebarWorkspaceRowView.swift index 6f4475250692..62155b8c6bae 100644 --- a/Sources/ExtensionSidebarWorkspaceRowView.swift +++ b/Sources/ExtensionSidebarWorkspaceRowView.swift @@ -12,6 +12,7 @@ struct CmuxExtensionSidebarWorkspaceRowView: View, Equatable { let isSelected: Bool let onSelect: (UUID) -> Void let onOpenWindow: (CmuxSidebarProviderWorkspace) -> Void + let onRename: (UUID) -> Void @State private var showsInspector = false @State private var inspectorDraft: CmuxExtensionWorkspaceInspectorDraft? @@ -107,6 +108,11 @@ struct CmuxExtensionSidebarWorkspaceRowView: View, Equatable { .onTapGesture { onSelect(row.workspaceId) } + .contextMenu { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + onRename(row.workspaceId) + } + } } private func rendered(_ text: CmuxSidebarProviderText?) -> String? {