Skip to content
Draft
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: 6 additions & 1 deletion Sources/Nirux/Model/Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ struct PersistedWorkspace: Codable {
/// Mission owning this child workspace, when the experimental handoff
/// flow created it. Optional for backward compatibility.
var missionID: String?
var isGroupFolded: Bool
var purpose: String?
/// Optional manual phase override. Nil keeps the workspace on automatic
/// phase derivation.
Expand All @@ -581,6 +582,7 @@ struct PersistedWorkspace: Codable {
profileID: String? = nil,
isInactive: Bool = false,
missionID: String? = nil,
isGroupFolded: Bool = false,
purpose: String? = nil,
phase: WorkspacePhase? = nil,
unknownPhaseRawValue: String? = nil,
Expand All @@ -599,6 +601,7 @@ struct PersistedWorkspace: Codable {
self.profileID = profileID
self.isInactive = isInactive
self.missionID = missionID
self.isGroupFolded = isGroupFolded
self.purpose = purpose
self.phase = phase
self.unknownPhaseRawValue = phase == nil ? unknownPhaseRawValue : nil
Expand All @@ -611,7 +614,7 @@ struct PersistedWorkspace: Codable {
}

enum CodingKeys: String, CodingKey {
case id, title, cwd, columns, focusedColumnIndex, profileID, isInactive, missionID
case id, title, cwd, columns, focusedColumnIndex, profileID, isInactive, missionID, isGroupFolded
case purpose, phase, lastSummary, lastSummaryIsManual, lastActivityAt
case nextStep, blocker, reviewRuns
}
Expand All @@ -626,6 +629,7 @@ struct PersistedWorkspace: Codable {
profileID = try container.decodeIfPresent(String.self, forKey: .profileID)
isInactive = try container.decodeIfPresent(Bool.self, forKey: .isInactive) ?? false
missionID = try container.decodeIfPresent(String.self, forKey: .missionID)
isGroupFolded = try container.decodeIfPresent(Bool.self, forKey: .isGroupFolded) ?? false
purpose = try container.decodeIfPresent(String.self, forKey: .purpose)
let phaseRawValue = try container.decodeIfPresent(String.self, forKey: .phase)
phase = phaseRawValue.flatMap(WorkspacePhase.init(rawValue:))
Expand All @@ -649,6 +653,7 @@ struct PersistedWorkspace: Codable {
try container.encodeIfPresent(profileID, forKey: .profileID)
try container.encode(isInactive, forKey: .isInactive)
try container.encodeIfPresent(missionID, forKey: .missionID)
try container.encode(isGroupFolded, forKey: .isGroupFolded)
try container.encodeIfPresent(purpose, forKey: .purpose)
try container.encodeIfPresent(
phase?.rawValue ?? unknownPhaseRawValue,
Expand Down
7 changes: 7 additions & 0 deletions Sources/Nirux/Model/WorkspaceState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ final class WorkspaceState {
/// must not be selected or closed again.
var isClosing = false
let missionID: String?
/// The sidebar lists this workspace's children under a summary row
/// (docs/sidebar-groups.md).
var isGroupFolded = false
/// The child of a Mission that reported `completed`.
var isMissionCompleted: Bool {
MissionStore.shared.missions.contains { $0.id == missionID && $0.status == .completed }
}
/// Controls mission variables for terminals created after a Settings
/// change. Already-running shells retain the environment they launched
/// with and pick up changes after a new terminal or app restart.
Expand Down
91 changes: 73 additions & 18 deletions Sources/Nirux/Model/WorkspaceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,15 @@ final class WorkspaceStore {
reconcileSelection(preferActiveProfile: true)
}

/// A Mission child goes after its parent's group, which it unfolds: the
/// store keeps the sidebar's order, so a group dissolves in place.
func appendWorkspace(_ workspace: WorkspaceState, activate: Bool = true) {
workspaces.append(workspace)
if let parent = groupParentIndex(of: workspaces.count - 1) {
workspaces[parent].isGroupFolded = false
let groupEnd = groupBlock(of: parent).filter { $0 != workspaces.count - 1 }.max() ?? parent
workspaces.insert(workspaces.removeLast(), at: groupEnd + 1)
}
if activate { selectWorkspace(id: workspace.id) }
}

Expand Down Expand Up @@ -117,7 +124,8 @@ final class WorkspaceStore {

@discardableResult
func selectAdjacentWorkspace(delta: Int) -> Int? {
let visible = visibleWorkspaceIndices
// A folded group is one stop, as in the sidebar.
let visible = visibleWorkspaceIndices.filter { $0 == activeWorkspaceIndex || !isInFoldedGroup($0) }
guard let current = visible.firstIndex(of: activeWorkspaceIndex) else {
if let first = visible.first { selectWorkspace(at: first); return first }
return nil
Expand Down Expand Up @@ -146,7 +154,9 @@ final class WorkspaceStore {
let list = visibleWorkspaceIndices(in: profileID)
let position = list.firstIndex(of: index) ?? list.count
let neighbours = (Array(list[position...]) + list[..<position].reversed()).filter(isOpen)
if let neighbour = neighbours.first(where: isActive) { return neighbour }
// Not a card folded out of sight, while a listed one is left.
if let neighbour = neighbours.first(where: { isActive($0) && !self.isInFoldedGroup($0) })
?? neighbours.first(where: isActive) { return neighbour }
let otherSpaces = profiles.filter { $0.id != profileID }
.flatMap { visibleWorkspaceIndices(in: $0.id) }
.filter(isOpen)
Expand All @@ -158,37 +168,54 @@ final class WorkspaceStore {

@discardableResult
func moveWorkspace(at index: Int, delta: Int) -> Bool {
guard delta != 0, let (candidates, position) = visibleGroupPosition(of: index) else { return false }
guard delta != 0, let (candidates, position) = visibleSiblingPosition(of: index) else { return false }
let newPosition = position + delta
guard candidates.indices.contains(newPosition) else { return false }

let workspace = workspaces[index]
let targetWorkspace = workspaces[candidates[newPosition]]
workspaces.remove(at: index)
let adjustedTarget = workspaces.firstIndex { $0 === targetWorkspace } ?? workspaces.count
let insertIndex = delta > 0 ? adjustedTarget + 1 : adjustedTarget
workspaces.insert(workspace, at: min(insertIndex, workspaces.count))
// The group moves as one block of the store, past the target's.
let moving = groupBlock(of: index).sorted().map { workspaces[$0] }
let targetBlock = groupBlock(of: candidates[newPosition])
let anchor = workspaces[(delta > 0 ? targetBlock.max() : targetBlock.min()) ?? candidates[newPosition]]
workspaces.removeAll { workspace in moving.contains { $0 === workspace } }
let anchorIndex = workspaces.firstIndex { $0 === anchor } ?? workspaces.count
workspaces.insert(contentsOf: moving, at: min(delta > 0 ? anchorIndex + 1 : anchorIndex, workspaces.count))
reconcileSelection(preferActiveProfile: true)
return true
}

/// Move a workspace to an absolute position within its own
/// active/inactive group (0 = top of group). The position is clamped to
/// the group's bounds; a move never crosses the active/inactive boundary.
/// A workspace and its descendants, in the sidebar's order.
private func groupBlock(of index: Int) -> [Int] {
let list = visibleWorkspaceIndices(in: workspaces[index].profileID)
guard let start = list.firstIndex(of: index) else { return [index] }
return [index] + list[(start + 1)...].prefix { isDescendant($0, of: index) }
}

private func isDescendant(_ index: Int, of ancestor: Int) -> Bool {
guard let parent = groupParentIndex(of: index) else { return false }
return parent == ancestor || isDescendant(parent, of: ancestor)
}

/// Move a workspace to an absolute position among its siblings (0 =
/// first), its children with it. The position is clamped to the
/// siblings' bounds; a move never crosses the active/inactive boundary
/// nor leaves its parent.
@discardableResult
func moveWorkspace(at index: Int, toPosition targetPosition: Int) -> Bool {
guard let (candidates, position) = visibleGroupPosition(of: index) else { return false }
guard let (candidates, position) = visibleSiblingPosition(of: index) else { return false }
let clamped = max(0, min(targetPosition, candidates.count - 1))
guard clamped != position else { return false }
return moveWorkspace(at: index, delta: clamped - position)
}

/// A workspace's same-group neighbours within the visible (active
/// profile) list, plus its position among them.
private func visibleGroupPosition(of index: Int) -> (candidates: [Int], position: Int)? {
/// A workspace's siblings within the visible (active profile) list:
/// same section, same parent. Plus its position among them.
private func visibleSiblingPosition(of index: Int) -> (candidates: [Int], position: Int)? {
guard workspaces.indices.contains(index) else { return nil }
let isInactive = workspaces[index].isInactive
let candidates = visibleWorkspaceIndices.filter { workspaces[$0].isInactive == isInactive }
let parent = groupParentIndex(of: index)
let candidates = visibleWorkspaceIndices.filter {
workspaces[$0].isInactive == isInactive && groupParentIndex(of: $0) == parent
}
guard let position = candidates.firstIndex(of: index) else { return nil }
return (candidates, position)
}
Expand Down Expand Up @@ -283,9 +310,37 @@ final class WorkspaceStore {
return true
}

/// ACTIVE, then INACTIVE; each workspace followed by its children,
/// depth first (docs/sidebar-groups.md).
func visibleWorkspaceIndices(in profileID: String) -> [Int] {
let matching = workspaces.indices.filter { workspaces[$0].profileID == profileID }
return matching.filter { !workspaces[$0].isInactive } + matching.filter { workspaces[$0].isInactive }
let sections = matching.filter { !workspaces[$0].isInactive } + matching.filter { workspaces[$0].isInactive }
var children: [Int?: [Int]] = [:]
for index in sections { children[groupParentIndex(of: index), default: []].append(index) }
func withDescendants(_ index: Int) -> [Int] { [index] + (children[index] ?? []).flatMap(withDescendants) }
return (children[nil] ?? []).flatMap(withDescendants)
}

/// The workspace a Mission was opened from, by Mission id. Read on each
/// call: missions load after the workspaces are restored.
var missionParentID: (String) -> String? = { missionID in
MissionStore.shared.missions.first { $0.id == missionID }?.parentWorkspaceID
}

/// The workspace this one is listed under: its Mission's parent, when
/// that one is open in the same project and section.
func groupParentIndex(of index: Int) -> Int? {
let workspace = workspaces[index]
guard let parentID = workspace.missionID.flatMap(missionParentID) else { return nil }
return workspaces.firstIndex {
$0.id == parentID && $0.profileID == workspace.profileID && $0.isInactive == workspace.isInactive
}
}

/// Under a folded workspace, at any depth.
func isInFoldedGroup(_ index: Int) -> Bool {
guard let parent = groupParentIndex(of: index) else { return false }
return workspaces[parent].isGroupFolded || isInFoldedGroup(parent)
}

private func reconcileSelection(preferActiveProfile: Bool) {
Expand Down
3 changes: 2 additions & 1 deletion Sources/Nirux/NiruxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ final class NiruxApp: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, NSMen
window.makeKeyAndOrderFront(nil)
mainWindow = window

// Before the restore: missions group the workspaces it lays out.
MissionStore.shared.load()
// Restore previous session if available
shellView.restoreState()

Expand All @@ -152,7 +154,6 @@ final class NiruxApp: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, NSMen
// run AFTER restoreState so queued events resolve to live columns.
installAgentHooks(reportingTo: shellView)
ActivityStore.shared.load()
MissionStore.shared.load()
ActivityStore.shared.onChange = { [weak shellView] in
shellView?.refreshActivitySidebar()
}
Expand Down
10 changes: 7 additions & 3 deletions Sources/Nirux/Views/NiruxShellView+PermissionApprovals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,19 @@ extension NiruxShellView {
/// drawn with the buttons, for a column the user is not looking at
/// (its terminal dialog answers). Not in a collapsed sidebar, another
/// space (`listed` holds the listed workspaces), a workspace in the
/// folded inactive section (listed there when it waits, without its
/// buttons), or under VoiceOver, which the buttons don't serve.
/// folded inactive section or a folded group (listed there when it
/// waits, without its buttons), or under VoiceOver, which the buttons
/// don't serve.
func approvalHold(workspaceIndex: Int, columnIndex: Int, listed: Set<Int>) -> PermissionApprovalHold {
guard workspaces.indices.contains(workspaceIndex) else { return .never }
let workspace = workspaces[workspaceIndex]
let isOnScreen = columnIndex == workspace.focusedIndex && workspaceIndex == activeWSIndex
let cardShown = isSidebarExpanded && !isOnScreen
&& listed.contains(workspaceIndex)
&& sidebar.listsWorkspace(isInactive: workspace.isInactive, isActive: workspaceIndex == activeWSIndex)
&& sidebar.listsWorkspace(
isInactive: workspace.isInactive, isInFoldedGroup: workspaceStore.isInFoldedGroup(workspaceIndex),
isActive: workspaceIndex == activeWSIndex
)
&& !NSWorkspace.shared.isVoiceOverEnabled
let userSeesSidebar = NSApp.isActive && window?.isVisible == true && window?.isMiniaturized == false
&& window?.occlusionState.contains(.visible) == true
Expand Down
2 changes: 2 additions & 0 deletions Sources/Nirux/Views/NiruxShellView+Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ extension NiruxShellView {
missionHandoffsEnabled: state.settings?.missionHandoffsEnabled == true
)
workspace.isInactive = persistedWS.isInactive
workspace.isGroupFolded = persistedWS.isGroupFolded
workspace.purpose = persistedWS.purpose
workspace.phase = persistedWS.phase
workspace.unknownPhaseRawValue = persistedWS.unknownPhaseRawValue
Expand Down Expand Up @@ -269,6 +270,7 @@ extension NiruxShellView {
profileID: workspace.profileID,
isInactive: workspace.isInactive,
missionID: workspace.missionID,
isGroupFolded: workspace.isGroupFolded,
purpose: workspace.purpose,
phase: workspace.phase,
unknownPhaseRawValue: workspace.unknownPhaseRawValue,
Expand Down
4 changes: 3 additions & 1 deletion Sources/Nirux/Views/NiruxShellView+SidebarGit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ extension NiruxShellView {
blocker: workspace.blocker, phase: workspace.effectivePhase,
lastSummary: workspace.lastSummary, lastActivityAt: workspace.lastActivityAt,
reviewBadges: workspace.reviewBadges,
mergedCleanup: mergedCleanupOffer(workspaceIndex: index))
mergedCleanup: mergedCleanupOffer(workspaceIndex: index),
groupParentID: workspaceStore.groupParentIndex(of: index).map { workspaces[$0].id }, isGroupFolded: workspace.isGroupFolded,
isInFoldedGroup: workspaceStore.isInFoldedGroup(index), isMissionCompleted: workspace.isMissionCompleted)
}
tickHiddenSpaceAgents(visibleIndices: visibleIndices, foregroundProcesses: foregroundProcesses)
updateColumnHeaders(infos: infos, foregroundProcesses: foregroundProcesses, now: now)
Expand Down
5 changes: 4 additions & 1 deletion Sources/Nirux/Views/NiruxShellView+Workspaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,9 @@ extension NiruxShellView {
if didChange { workspaceStore.selectWorkspace(at: workspaceIndex) }
case .markInactive:
didChange = workspaceStore.setWorkspaceInactive(at: workspaceIndex, true)
case .toggleGroup:
workspaces[workspaceIndex].isGroupFolded.toggle()
didChange = true
case .close:
requestCloseWorkspace(at: workspaceIndex)
return
Expand Down Expand Up @@ -342,7 +345,7 @@ extension NiruxShellView {
}

/// Drop handler for sidebar drag-reorder: move the workspace to an
/// absolute position within its active/inactive group, then run the
/// absolute position among its siblings, then run the
/// same refresh dance as the context-menu moves.
func handleWorkspaceReorder(workspaceIndex: Int, targetPosition: Int) {
guard workspaces.indices.contains(workspaceIndex),
Expand Down
7 changes: 5 additions & 2 deletions Sources/Nirux/Views/SidebarCardLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ struct SidebarCardLayout {
actions = isCompact ? [] : workspace.cardActions

let rowWidth = sidebarWidth - (Metrics.workspaceInsetX + Metrics.cardPaddingX) * 2 - Metrics.cardIndent
- (workspace.groupParentID == nil ? 0 : Metrics.groupIndent)
let linksWidth = links.reduce(CGFloat(0)) { $0 + $1.width } + Self.linkGaps(links)
let firstRowWidth = rowWidth - (links.isEmpty ? 0 : linksWidth + Theme.Space.sm)
var rows: [[ChipPlacement]] = []
Expand Down Expand Up @@ -202,8 +203,10 @@ struct SidebarCardLayout {
}
}

var cardX: CGFloat { Metrics.workspaceInsetX }
var cardWidth: CGFloat { sidebarWidth - Metrics.workspaceInsetX * 2 }
/// A child card sits right of its parent's.
private var groupIndent: CGFloat { workspace.groupParentID == nil ? 0 : Metrics.groupIndent }
var cardX: CGFloat { Metrics.workspaceInsetX + groupIndent }
var cardWidth: CGFloat { sidebarWidth - Metrics.workspaceInsetX * 2 - groupIndent }
var contentX: CGFloat { cardX + Metrics.cardPaddingX }
var contentMaxX: CGFloat { cardX + cardWidth - Metrics.cardPaddingX }
var indentX: CGFloat { contentX + Metrics.cardIndent }
Expand Down
9 changes: 7 additions & 2 deletions Sources/Nirux/Views/SidebarExpandedMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ enum SidebarExpandedMetrics {
// An INACTIVE workspace that asks nothing: one line.
static let compactRowHeight: CGFloat = 26

// A workspace's children (docs/sidebar-groups.md): indented, under its
// summary row.
static let groupIndent = Theme.Space.md
static let groupToggleHeight: CGFloat = 18

// The action block: rows separated by `actionRowGap`; a line above it
// unless it only says something (the next step, the review badges).
static let actionBlockGap: CGFloat = 8
Expand All @@ -55,9 +60,9 @@ enum SidebarExpandedMetrics {
// font, hard-wrapped at a fixed column so the lines are exactly the
// text, cut nowhere.
static var approvalFont: NSFont { Theme.Font.code }
static let approvalCharactersPerLine = 28
static let approvalCharactersPerLine = 26
static let approvalLineHeight: CGFloat = 16
/// 28 characters of `approvalFont` take 208 of the box's 212.
/// 26 characters of `approvalFont` take 193 of a child card's box, 200.
static let approvalBoxPaddingX: CGFloat = 6
static let approvalBoxPaddingY: CGFloat = 6

Expand Down
11 changes: 11 additions & 0 deletions Sources/Nirux/Views/SidebarModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ struct WorkspaceInfo: Hashable {
var reviewBadges: ReviewBadges? = nil
/// What the card shows after "#N merged"; nil shows nothing.
var mergedCleanup: MergedCleanupOffer?
/// The workspace this card is listed under, indented
/// (docs/sidebar-groups.md).
var groupParentID: String?
/// Its children are folded under its summary row.
var isGroupFolded = false
/// Under a folded workspace, at any depth.
var isInFoldedGroup = false
/// The child of a Mission that reported `completed`.
var isMissionCompleted = false

var sidebarAction: (text: String, isBlocker: Bool)? {
if let blocker = normalizedContextText(blocker) {
Expand Down Expand Up @@ -325,6 +334,8 @@ enum WorkspaceSidebarAction {
case closeColumn(columnIndex: Int)
case cleanUpWorktree
case askWhyCIFailed, rerunFailedCI
/// Folds or unfolds the workspace's children.
case toggleGroup
}

/// Hover highlight target in the sidebar. Links have their own dedicated
Expand Down
Loading
Loading