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/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/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index d0600f429e90..7adfa66b1294 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -54,6 +54,11 @@ 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 .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 7656abb640ab..a42273a66b7d 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -44,6 +44,16 @@ 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 + /// 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 @@ -181,7 +191,9 @@ extension ShortcutAction { .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, - .switchRightSidebarToDock, .triggerFlash: + .switchRightSidebarToDock, .triggerFlash, .toggleBoardView, + .openFocusedBoardCard, .moveFocusedBoardCardToPrevColumn, + .moveFocusedBoardCardToNextColumn, .archiveFocusedBoardCard: return .workspace case .nextSurface, .prevSurface, .selectSurfaceByNumber, .nextSidebarTab, .prevSidebarTab, .focusHistoryBack, .focusHistoryForward, @@ -245,7 +257,8 @@ extension ShortcutAction { .diffViewerScrollToTop, .diffViewerOpenFileSearch, .fileExplorerOpenSelection, - .fileExplorerOpenSelectionFinderAlias: + .fileExplorerOpenSelectionFinderAlias, + .openFocusedBoardCard: return true default: return false @@ -273,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: @@ -361,6 +377,16 @@ 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 .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/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/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e7d20ccb9865..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": { @@ -234025,6 +234059,737 @@ } } } + }, + "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.card.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename…" + } + }, + "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": "ボード表示を切り替え" + } + } + } + }, + "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": { + "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/AppDelegate.swift b/Sources/AppDelegate.swift index 88294bcefb4b..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) @@ -3955,6 +3969,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) { @@ -4561,6 +4577,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: TabManager, sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState, + kanbanFocusState: KanbanFocusState = KanbanFocusState(), fileExplorerState: FileExplorerState? = nil, cmuxConfigStore: CmuxConfigStore? = nil ) { @@ -4632,6 +4649,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + kanbanFocusState: kanbanFocusState, fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore, window: window @@ -6584,6 +6602,83 @@ 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 + } + + // 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 { @@ -8665,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 @@ -8702,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 @@ -8837,6 +8937,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + kanbanFocusState: kanbanFocusState, fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore ) @@ -12340,6 +12441,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch sidebarSelection { case .tabs: return "tabs" case .notifications: return "notifications" + case .board: return "board" } }() writeMultiWindowNotificationTestData([ @@ -13473,6 +13575,31 @@ 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: .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 8065512a8f2d..c521800135ce 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 @@ -1685,6 +1686,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 +1705,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, @@ -1733,6 +1740,14 @@ struct ContentView: View { .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) + + KanbanBoardView( + selection: $sidebarSelectionState.selection, + isInitialBoardLanding: $sidebarSelectionState.isInitialBoardLanding + ) + .opacity(sidebarSelectionState.selection == .board ? 1 : 0) + .allowsHitTesting(sidebarSelectionState.selection == .board) + .accessibilityHidden(sidebarSelectionState.selection != .board) } .modifier(WorkspacePresentationModeContentTopPaddingModifier( isFullScreen: isFullScreen, @@ -2599,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() }) @@ -3177,6 +3203,7 @@ struct ContentView: View { tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + kanbanFocusState: kanbanFocusState, fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore ) @@ -11599,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)") @@ -11631,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 { @@ -16026,4 +16078,5 @@ private struct ExtensionSidebarBrowserStackEndDropDelegate: DropDelegate { enum SidebarSelection { case tabs case notifications + case board } 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? { diff --git a/Sources/Kanban/KanbanBoardView.swift b/Sources/Kanban/KanbanBoardView.swift new file mode 100644 index 000000000000..5d4551688d1e --- /dev/null +++ b/Sources/Kanban/KanbanBoardView.swift @@ -0,0 +1,253 @@ +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. +@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) { + ForEach(sortedColumns) { column in + KanbanColumnView( + column: KanbanColumnSnapshot(column: column, displayTitle: displayTitle(for: column)), + cards: cardsByColumnId[column.id] ?? [], + allColumns: columnSnapshots, + focusedCardId: focusedCardId, + actions: columnActions + ) + .equatable() + } + } + .padding(16) + } + .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 + /// 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) }, + 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) }, + 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 + isInitialBoardLanding = false + } +} diff --git a/Sources/Kanban/KanbanCardView.swift b/Sources/Kanban/KanbanCardView.swift new file mode 100644 index 000000000000..bdf70a4174fc --- /dev/null +++ b/Sources/Kanban/KanbanCardView.swift @@ -0,0 +1,110 @@ +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 + + @MainActor + 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 + /// 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 + 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. + 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.isFocused == rhs.isFocused && + 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) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: isFocused ? 2 : 0) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onTap) + .onDrag { + 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() + } + } 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..a192a6ebd275 --- /dev/null +++ b/Sources/Kanban/KanbanColumnView.swift @@ -0,0 +1,400 @@ +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). + /// `@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 + 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 + 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. +@MainActor +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] + /// 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 + + /// 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 + && lhs.focusedCardId == rhs.focusedCardId + } + + /// 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, + 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) }, + onArchive: { actions.onArchiveCard(card.id) }, + onUnarchive: { actions.onUnarchiveCard(card.id) } + ) + .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 + /// 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: @MainActor @Sendable (_ 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/Kanban/KanbanFocusState.swift b/Sources/Kanban/KanbanFocusState.swift new file mode 100644 index 000000000000..ff03a04a8b0a --- /dev/null +++ b/Sources/Kanban/KanbanFocusState.swift @@ -0,0 +1,24 @@ +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? + + /// `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() {} +} 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 f1772a48cedc..ee2a6d1297cf 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -95,6 +95,11 @@ enum KeyboardShortcutSettings { case switchRightSidebarToFeed case switchRightSidebarToDock case triggerFlash + case toggleBoardView + case openFocusedBoardCard + case moveFocusedBoardCardToPrevColumn + case moveFocusedBoardCardToNextColumn + case archiveFocusedBoardCard // Navigation case nextSurface @@ -220,6 +225,11 @@ 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 .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") @@ -389,6 +399,16 @@ 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 .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: @@ -603,7 +623,8 @@ enum KeyboardShortcutSettings { .diffViewerScrollToTop, .diffViewerOpenFileSearch, .fileExplorerOpenSelection, - .fileExplorerOpenSelectionFinderAlias: + .fileExplorerOpenSelectionFinderAlias, + .openFocusedBoardCard: return true default: return false @@ -1794,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 @@ -2026,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/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/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 78ea1ab510ff..985504e742b6 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -3,8 +3,14 @@ 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 = .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..be1a38c4be83 --- /dev/null +++ b/Sources/TabManager+Kanban.swift @@ -0,0 +1,142 @@ +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) + } + + /// 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) { + 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/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 d85f70520205..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: @@ -1027,6 +1032,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, @@ -1470,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/Sources/Workspace.swift b/Sources/Workspace.swift index a0f9806d55da..faea130f3459 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -143,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 ) } @@ -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/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6f15cfb06ff6..0bb95ce904a0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -824,6 +824,11 @@ 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 */; }; + 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 */; }; @@ -1424,6 +1429,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 +2579,11 @@ 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 = ""; }; + 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 = ""; }; @@ -3157,6 +3168,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 +4005,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */, + F582CE26362425687EA896CE /* KanbanBoardView.swift */, + 2B74F3843A539D946ECE122E /* KanbanColumnView.swift */, + 86B0011AACF9FC14B2CD0822 /* KanbanCardView.swift */, + 4C50E72CB8168BCBD90B1C11 /* KanbanFocusState.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, 63F0CD0E /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */, C0DE7B330000000000000002 /* TextBoxAgentDetection.swift */, @@ -4061,6 +4077,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 +5201,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 +6224,10 @@ 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 */, + 4C864505B5BE16BE0056D80C /* KanbanFocusState.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */, C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */, @@ -6597,6 +6619,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 +7229,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 */, 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) + } +} 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( 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/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..4d245e3e9b21 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -81,6 +81,11 @@ 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: "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 6af8288a4f29..4e3a6523876a 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,11 @@ "switchRightSidebarToFeed", "switchRightSidebarToDock", "triggerFlash", + "toggleBoardView", + "openFocusedBoardCard", + "moveFocusedBoardCardToPrevColumn", + "moveFocusedBoardCardToNextColumn", + "archiveFocusedBoardCard", "nextSurface", "prevSurface", "selectSurfaceByNumber", @@ -1646,6 +1703,9 @@ }, "diffViewerOpenFileSearch": { "$ref": "#/$defs/bareFirstStrokeShortcutBindingNullable" + }, + "openFocusedBoardCard": { + "$ref": "#/$defs/bareFirstStrokeShortcutBindingNullable" } }, "additionalProperties": {