Repository navigation
Add kanban board main view (macOS): workspaces as cards #7981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c9f02e1
e6ed0b5
1d3a4dc
6e0bbe5
a79b344
5766337
a541fc1
0d45761
2b02139
18d4192
0ed6fea
333028e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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 | ||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When adding a column to the default list, 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The schema/docs make only
id/title/orderrequired and show examples withoutisArchive/isCollapsed, but synthesizedDecodablerequires these non-optionalBoolkeys. A user who configureskanban.columnswith 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 👍 / 👎.