Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f333333
test(cmux-next): drag to group by pointer zones with a dwell
teamleaderleo Oct 6, 2026
5d62e96
fix(cmux-next): drag a workspace onto another to group them
teamleaderleo Oct 6, 2026
af09334
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
bd85d5b
fix(cmux-next): keep SidebarListView under the god-type limit after t…
teamleaderleo Oct 6, 2026
c1ad494
fix(cmux-next): keep the group dwell internal to the sidebar module
teamleaderleo Oct 6, 2026
59e2639
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
878faee
fix(cmux-next): drag to group follows the centre band, no dwell
teamleaderleo Oct 6, 2026
0fc385d
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
b168f09
test(cmux-next): a group made by a drop renames on its settled header
teamleaderleo Oct 6, 2026
919acf5
fix(cmux-next): place the inline rename field on the row's settled frame
teamleaderleo Oct 6, 2026
63d0e5a
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
4acc2a4
fix(cmux-next): rename a dropped group once the drag has landed
teamleaderleo Oct 6, 2026
b913ee8
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
5cdb03e
test(cmux-next): a dropped group's rename follows the group the store…
teamleaderleo Oct 6, 2026
3440c48
fix(cmux-next): a dropped group's rename follows the store's group
teamleaderleo Oct 6, 2026
eb87ca7
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
6f99b0e
fix(cmux-next): keep the centre band's sticky edge to a sliver
teamleaderleo Oct 6, 2026
6785809
Merge remote-tracking branch 'origin/feat-cmux-next' into op-next-sid…
teamleaderleo Oct 6, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import CmuxNextDesign
import CoreGraphics
import Foundation

/// Drag to group on a loose row (spec 1780d02): the card's centre in the
/// row's onto band (`DropResolver.ontoBand`) groups at once. Once entered,
/// the band's edges are sticky out to `holdZone`, so a drop near an edge
/// never flickers between group and reorder.
nonisolated struct SidebarGroupBand: Sendable {
/// The loose row under the card's centre that a drop could group
/// with, and the centre's place in it (0 top, 1 bottom).
struct Hit: Hashable, Sendable {
var anchor: WorkspaceID
var fraction: CGFloat
}

/// The onto band; empty when grouping on drop is off.
static var zone: ClosedRange<CGFloat> { DropResolver.ontoBand.start...max(DropResolver.ontoBand.start, DropResolver.ontoBand.end) }
/// A sliver wide, so the centre band's reorder zones keep their size (spec 1780d02).
static var holdZone: ClosedRange<CGFloat> { (zone.lowerBound - 0.05)...(zone.upperBound + 0.05) }

/// The row the band has grouped with, if any.
private(set) var armed: WorkspaceID?

@discardableResult
mutating func update(_ hit: Hit?) -> WorkspaceID? {
guard let hit, Self.zone.lowerBound < Self.zone.upperBound else {
armed = nil
return nil
}
let range = hit.anchor == armed ? Self.holdZone : Self.zone
armed = range.contains(hit.fraction) ? hit.anchor : nil
return armed
}

/// The loose row of the same machine at display `y` that `dragged`
/// could group with. Grouped rows, group headers, pinned rows and
/// other machines keep the leading-edge rule.
static func hit(y: CGFloat, rows: [SidebarRow], hidden: Set<SidebarRowKey>, dragged: [WorkspaceID],
sections: [SidebarSection]) -> Hit? {
guard let row = rows.first(where: { y >= $0.y && y < $0.maxY && !hidden.contains($0.key) }), row.height > 0,
case let .machine(machine) = row.section, case let .workspace(anchor) = row.key, row.group == nil,
dragged.allSatisfy({ SidebarEdits.workspace($0, in: sections)?.machineID == machine }) else { return nil }
return Hit(anchor: anchor, fraction: (y - row.y) / row.height)
}

/// The first color no group in `sections` uses yet, so a new group
/// stands apart; blue once every color is taken.
static func newGroupColor(in sections: [SidebarSection]) -> GroupColor {
let used = Set(sections.flatMap(\.nodes).compactMap { node -> GroupColor? in
if case let .group(group) = node { return group.color }
return nil
})
return GroupColor.allCases.first { $0 != .grey && !used.contains($0) } ?? .blue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7074,6 +7074,137 @@
}
}
}
},
"sidebar.group.moveTo": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "نقل إلى مجموعة"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Premjesti u grupu"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Flyt til gruppe"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "In Gruppe verschieben"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Move to Group"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Mover al grupo"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Déplacer dans le groupe"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Sposta nel gruppo"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "グループに移動"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "ផ្លាស់ទីទៅក្រុម"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "그룹으로 이동"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Flytt til gruppe"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Przenieś do grupy"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Mover para o grupo"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Переместить в группу"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ย้ายไปยังกลุ่ม"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Gruba Taşı"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Перемістити до групи"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Di chuyển vào nhóm"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "移到组"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "移至群組"
}
}
}
}
},
"version": "1.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum Strings {
static var newWorkspace: String { String(localized: "sidebar.newWorkspace", defaultValue: "New Workspace", bundle: .module) }
static var rename: String { String(localized: "sidebar.rename", defaultValue: "Rename", bundle: .module) }
static var pinned: String { String(localized: "sidebar.section.pinned", defaultValue: "Pinned", bundle: .module) }
static var moveToGroup: String { String(localized: "sidebar.group.moveTo", defaultValue: "Move to Group", bundle: .module) }
static var projects: String { String(localized: "sidebar.section.projects", defaultValue: "Projects", bundle: .module) }
static var pinnedEmpty: String { String(localized: "sidebar.section.pinned.empty", defaultValue: "Drop here to pin", bundle: .module) }
static var sectionEmpty: String { String(localized: "sidebar.section.empty", defaultValue: "No workspaces", bundle: .module) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import AppKit

/// Drag to group in a list (spec 1780d02): a loose row's onto band groups
/// at once, with sticky edges (`SidebarGroupBand`); the drop makes a named,
/// colored group and renames it in place. Cmd-Z puts grouped rows back
/// where the drag took them from.
@MainActor enum SidebarGroupDrop {
/// Keeps a workspace drag's onto-drop while the card's centre stays in
/// the band's sticky edges; otherwise `resolved` (nil keeps the last).
static func gate(_ list: SidebarListView, _ drag: SidebarListDrag, card: CGRect, resolved: DropTarget??) -> DropTarget?? {
guard case let .workspaces(ids) = drag.payload else { return resolved }
let hit = SidebarGroupBand.hit(y: card.midY, rows: list.displayed.rows, hidden: drag.hiddenKeys, dragged: ids,
sections: list.model.sections)
if let anchor = drag.band.update(hit) { return .some(.ontoWorkspace(anchor)) }
return resolved
}

/// Joins `ids` to `group`, with Cmd-Z.
static func join(_ list: SidebarListView, _ ids: [WorkspaceID], _ group: GroupID, origin: DropPosition?) {
list.model.send(.move(ids, toGroup: group))
registerUndo(list, ids, origin: origin, anchor: nil)
}

/// Groups `ids` with `anchor`, at the anchor's row, as a named and colored group, with Cmd-Z.
@discardableResult
static func group(_ list: SidebarListView, _ ids: [WorkspaceID], onto anchor: WorkspaceID, origin: DropPosition?) -> GroupID {
let group = GroupID.make(), sections = list.model.sections
list.model.send(.createGroup(group, name: SidebarGroup.named(""), color: SidebarGroupBand.newGroupColor(in: sections),
workspaces: [anchor] + ids, anchor: anchor))
registerUndo(list, ids, origin: origin, anchor: anchor)
return group
}

/// Registers Cmd-Z for a drop that grouped `ids`.
static func registerUndo(_ list: SidebarListView, _ ids: [WorkspaceID], origin: DropPosition?, anchor: WorkspaceID?) {
guard let origin, let undoManager = list.window?.undoManager else { return }
let record = SidebarGroupUndo(list: list, ids: ids, origin: origin, anchor: anchor)
// The record is the target and the retained object, so it lives as long as the undo entry.
undoManager.registerUndo(withTarget: record, selector: #selector(SidebarGroupUndo.undo(_:)), object: record)
undoManager.setActionName(anchor == nil ? Strings.moveToGroup : SidebarGroup.named(""))
}
}

/// One drag-to-group drop's undo: ungroup the new group around `anchor`
/// (found by member, since the store may give it a new id), then put `ids`
/// back at `origin`.
@MainActor final class SidebarGroupUndo: NSObject {
weak var list: SidebarListView?
let ids: [WorkspaceID]
let origin: DropPosition
let anchor: WorkspaceID?

init(list: SidebarListView, ids: [WorkspaceID], origin: DropPosition, anchor: WorkspaceID?) {
self.list = list
self.ids = ids
self.origin = origin
self.anchor = anchor
}

@objc func undo(_ sender: Any?) {
guard let list else { return }
let model = list.model
if let anchor, let group = model.sections.flatMap(\.nodes).lazy.compactMap({ node -> GroupID? in
guard case let .group(group) = node, group.workspaces.contains(where: { $0.id == anchor }) else { return nil }
return group.id
}).first {
model.send(.ungroup(group))
}
model.send(.reorder(ids, to: origin))
list.reload(animated: true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@ final class SidebarInlineRename: NSObject, NSTextFieldDelegate {
var onEnded: ((_ byKeyboard: Bool) -> Void)?

var isActive: Bool { session != nil }
/// A member of the group being renamed right after a drop made it: the
/// store may replace the new group under another id, and the rename follows.
private var groupMember: WorkspaceID?

/// Renames the group `member` belongs to (drag to group).
func beginGroup(of member: WorkspaceID) {
guard let group = group(containing: member) else { return }
begin(.group(group))
if session != nil { groupMember = member }
}

/// After a reload: moves a rename whose group the store replaced to
/// the group its member is in now, keeping what was typed.
func follow() {
guard let member = groupMember, let current = session, case let .group(id) = current.key, list?.groups[id] == nil else { return }
current.field.delegate = nil
current.field.removeFromSuperview()
session = nil
guard let group = group(containing: member) else { return end(commit: false) }
begin(.group(group))
session?.field.stringValue = current.field.stringValue
session?.original = current.original
groupMember = member
}

private func group(containing member: WorkspaceID) -> GroupID? {
list?.groups.values.first { $0.workspaces.contains { $0.id == member } }?.id
}

func begin(_ key: SidebarRowKey) {
guard let list, list.model.presentation == .shown, list.drag == nil else { return }
Expand All @@ -34,7 +62,9 @@ final class SidebarInlineRename: NSObject, NSTextFieldDelegate {
if let row = list.displayed.row(for: key) { list.scrollToVisible(list.frame(for: row)) }
list.realizeVisibleRows()
guard let view = list.rowViews[key] else { return }
let titleFrame = list.convert(view.titleFrame, from: view)
// The row's settled frame, not the view's: a row that just appeared (a group made by a drop) is still animating in.
let rowFrame = list.displayed.row(for: key).map(list.frame(for:)) ?? view.frame
let titleFrame = view.titleFrame.offsetBy(dx: rowFrame.minX, dy: rowFrame.minY)
// Follows theme changes while open (ThemedTextField).
let field = ThemedTextField(string: original)
// theme-scoped: ThemedTextField resolves its fill inside performWithTheme.
Expand Down Expand Up @@ -65,6 +95,7 @@ final class SidebarInlineRename: NSObject, NSTextFieldDelegate {

/// `byKeyboard`: Return, Escape or Tab ended it (not a click elsewhere).
func end(commit: Bool, byKeyboard: Bool = false) {
groupMember = nil
guard let session else { return }
self.session = nil
session.field.delegate = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ final class SidebarListDrag {
/// (`ontoWorkspace`), the list keeps showing it, so nothing moves.
private(set) var lastPosition: DropPosition?
var lastWindowPoint: NSPoint = .zero
/// A workspace drag's onto band (sticky edges).
var band = SidebarGroupBand()
/// Where the dragged workspaces started (what Cmd-Z puts them back to).
var origin: DropPosition?
/// The row a drop grouped with: its new group renames in place once the drag has landed and the rows have settled.
var renameOnLand: WorkspaceID?
/// The last pointer y in the list and the drag's vertical direction.
var lastY: CGFloat = 0, movingUp = false
/// The last drop probe (debug.sidebar_rows "drop"): the card edge that
Expand All @@ -34,6 +40,7 @@ final class SidebarListDrag {
self.lift = lift
self.target = target
if case let .position(position)? = target { lastPosition = position }
origin = lastPosition
}
/// The drop for the card at `card` (list coordinates, as drawn over
/// `displayed`), or nil to keep the last target; records the probe.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ extension SidebarListView {
drag.lastY = point.y
}
let base = SidebarLayout.make(sections: model.sections, metrics: metrics, options: options(includeGap: false))
guard let target = drag.resolve(card: card, displayed: displayed, base: base, sections: model.sections,
ungroupedFirst: model.ungroupedFirst) else { return }
let resolved = drag.resolve(card: card, displayed: displayed, base: base, sections: model.sections, ungroupedFirst: model.ungroupedFirst)
guard let target = SidebarGroupDrop.gate(self, drag, card: card, resolved: resolved) else { return }
guard target != drag.target else { return }
drag.target = target
drag.lift.setRefused(target == nil)
Expand All @@ -84,13 +84,13 @@ extension SidebarListView {
case let (.workspaces(ids), .position(position)):
model.send(.reorder(ids, to: position))
case let (.workspaces(ids), .intoGroup(group)):
model.send(.move(ids, toGroup: group))
SidebarGroupDrop.join(self, ids, group, origin: drag.origin)
case let (.group(group), .position(position)):
model.send(.reorderGroup(group, index: position.index))
case let (.workspaces(ids), .ontoWorkspace(anchor)):
// The target first, then the dragged rows (the Arc/Dia order).
// The group forms at the target row (`anchor`).
model.send(.createGroup(.make(), name: "", color: .grey, workspaces: [anchor] + ids, anchor: anchor))
SidebarGroupDrop.group(self, ids, onto: anchor, origin: drag.origin)
drag.renameOnLand = anchor
case (.group, .intoGroup), (.group, .ontoWorkspace):
break
}
Expand All @@ -117,6 +117,7 @@ extension SidebarListView {
for key in drag.hiddenKeys { self.rowViews[key]?.alphaValue = 1 }
self.decorations.setPill(self.activePillFrame(in: self.displayed), animated: false)
self.updateHover()
if let anchor = drag.renameOnLand { self.inlineRename.beginGroup(of: anchor) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ final class SidebarListView: NSView {
// rows apply (its anchor is gone); a kept one updates in place.
if let shown = hoverCard.shownID { hoverCards.contentChanged(WorkspaceHoverCardController.targetID(shown)) }
applyKeepingViewport(displayLayout(), animated: animated)
inlineRename.follow()
}
func options(includeGap: Bool) -> SidebarLayoutOptions {
var o = SidebarLayoutOptions()
Expand Down
Loading
Loading