Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Format: max_lines<TAB>relative 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +20 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode omitted kanban flags as defaults

The schema/docs make only id/title/order required and show examples without isArchive/isCollapsed, but synthesized Decodable requires these non-optional Bool keys. A user who configures kanban.columns with the documented minimal objects will fail to decode the whole array and silently fall back to defaults, so custom columns disappear; provide a custom decoder/defaults or make these optional on decode.

Useful? React with 👍 / 👎.


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
}
}
Original file line number Diff line number Diff line change
@@ -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)]
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Archive after newly added columns

When adding a column to the default list, max(order) + 1 assigns the new column order 4 while Archive remains order 3, so the new column renders to the right of Archive even though Archive is treated as the pinned final column and keyboard movement uses this order. Users who add any column get Archive in the middle and moving right from Done archives before reaching the new column; insert before Archive or bump Archive's order instead.

Useful? React with 👍 / 👎.

}

/// 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -245,7 +257,8 @@ extension ShortcutAction {
.diffViewerScrollToTop,
.diffViewerOpenFileSearch,
.fileExplorerOpenSelection,
.fileExplorerOpenSelectionFinderAlias:
.fileExplorerOpenSelectionFinderAlias,
.openFocusedBoardCard:
return true
default:
return false
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading