diff --git a/apps/ios/Zeron/App/AppModel.swift b/apps/ios/Zeron/App/AppModel.swift index 0f4c97864b..6b14f87afa 100644 --- a/apps/ios/Zeron/App/AppModel.swift +++ b/apps/ios/Zeron/App/AppModel.swift @@ -512,6 +512,14 @@ final class AppModel { func movePin(_ id: String, after: String?, before: String?) { attempt("move pin") { try client?.movePin(chatId: id, after: after, before: before) } } + /// Pin between neighbours (`nil` at either end), as a drop into Pinned does. + func pin(_ id: String, after: String?, before: String?) { + attempt("pin") { try client?.pinSessionAt(chatId: id, after: after, before: before) } + } + /// Move into a section between its members (`nil` at either end). + func place(_ id: String, inSection section: String, after: String?, before: String?) { + attempt("assign") { try client?.placeSession(chatId: id, sectionId: section, after: after, before: before) } + } func renameSection(_ id: String, _ name: String) { attempt("rename section") { try client?.renameSection(sectionId: id, name: name) } } func deleteSection(_ id: String) { attempt("delete section") { try client?.deleteSection(sectionId: id) } } func createSection(_ name: String) { attempt("section") { _ = try client?.createSection(name: name) } } diff --git a/apps/ios/Zeron/Core/Generated/zeron_core.swift b/apps/ios/Zeron/Core/Generated/zeron_core.swift index 55e89c2ef4..7c2b63d617 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -1127,6 +1127,17 @@ public protocol CoreClientProtocol: AnyObject, Sendable { func pinSession(chatId: String) throws + /** + * Pin between neighbours (`None` at either end). + */ + func pinSessionAt(chatId: String, after: String?, before: String?) throws + + /** + * Move a session into a section between its members (`None` at either + * end), as a drop into the section does. + */ + func placeSession(chatId: String, sectionId: String?, after: String?, before: String?) throws + /** * Warm the most relevant sessions (capped). */ @@ -1671,6 +1682,36 @@ open func pinSession(chatId: String)throws {try rustCallWithError(FfiConverter FfiConverterString.lower(chatId),uniffiCallStatus ) } +} + + /** + * Pin between neighbours (`None` at either end). + */ +open func pinSessionAt(chatId: String, after: String?, before: String?)throws {try rustCallWithError(FfiConverterTypeCoreError_lift) { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_coreclient_pin_session_at( + self.uniffiCloneHandle(), + FfiConverterString.lower(chatId), + FfiConverterOptionString.lower(after), + FfiConverterOptionString.lower(before),uniffiCallStatus + ) +} +} + + /** + * Move a session into a section between its members (`None` at either + * end), as a drop into the section does. + */ +open func placeSession(chatId: String, sectionId: String?, after: String?, before: String?)throws {try rustCallWithError(FfiConverterTypeCoreError_lift) { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_coreclient_place_session( + self.uniffiCloneHandle(), + FfiConverterString.lower(chatId), + FfiConverterOptionString.lower(sectionId), + FfiConverterOptionString.lower(after), + FfiConverterOptionString.lower(before),uniffiCallStatus + ) +} } /** @@ -15361,6 +15402,12 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_coreclient_pin_session() != 14361) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_coreclient_pin_session_at() != 16727) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_coreclient_place_session() != 55306) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_coreclient_preload_sessions() != 4026) { return InitializationResult.apiChecksumMismatch } diff --git a/apps/ios/Zeron/Threads/SessionListController.swift b/apps/ios/Zeron/Threads/SessionListController.swift index d950c39943..6d3e106698 100644 --- a/apps/ios/Zeron/Threads/SessionListController.swift +++ b/apps/ios/Zeron/Threads/SessionListController.swift @@ -33,6 +33,9 @@ class SessionListController: UIViewController, UICollectionViewDelegate { } /// Headers are disclosure rows that fold their section (front page). var collapsible = false + /// Long-press drags a session between the list's sections (front page); + /// `dropTarget` names what each section means as a destination. + var movesAcrossSections = false private(set) var collapsed = Set(UserDefaults.standard.stringArray(forKey: "collapsedSections") ?? []) private var headerStates: [String: SectionHeaderCell.State] = [:] @@ -56,6 +59,12 @@ class SessionListController: UIViewController, UICollectionViewDelegate { collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.backgroundColor = .clear collectionView.delegate = self + if movesAcrossSections { + collectionView.dragDelegate = self + collectionView.dropDelegate = self + // Off by default on iPhone. + collectionView.dragInteractionEnabled = true + } view.addSubview(collectionView) let sessionReg = UICollectionView.CellRegistration { [weak self] cell, path, id in @@ -200,6 +209,10 @@ class SessionListController: UIViewController, UICollectionViewDelegate { /// Long-press actions for a collapsible section header. func headerMenu(_ id: String) -> UIMenu? { nil } + /// What dropping a dragged session into list section `id` means; nil + /// where sessions can't be dropped. + func dropTarget(_ id: String) -> SessionDropTarget? { nil } + func openSession(_ id: String) { // iPad: sessions open beside the sidebar. if let split = splitViewController as? SplitRootController, !split.isCollapsed { @@ -329,6 +342,117 @@ class SessionListController: UIViewController, UICollectionViewDelegate { } } +/// Where a dragged session lands. Pinned and the user's sections keep a placed +/// order (shared with desktop); Recent is by activity, so only membership moves. +enum SessionDropTarget: Equatable { + case pinned + case section(String) + case recent +} + +// MARK: Drag between sections + +extension SessionListController: UICollectionViewDragDelegate, UICollectionViewDropDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let id = sessionId(indexPath), dropTarget(listSection(of: indexPath)) != nil else { return [] } + let item = UIDragItem(itemProvider: NSItemProvider()) + item.localObject = id + return [item] + } + + func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool { + session.localDragSession?.items.first?.localObject is String + } + + func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { + guard let id = session.localDragSession?.items.first?.localObject as? String, + let path = destinationIndexPath, + let slot = dropSlot(for: id, at: path) + else { return UICollectionViewDropProposal(operation: .cancel) } + // Recent sorts by activity: a drag within it has nothing to place. + if slot.target == .recent, currentTarget(of: id) == .recent { + return UICollectionViewDropProposal(operation: .cancel) + } + return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { + guard let item = coordinator.items.first, + let id = item.dragItem.localObject as? String, + let path = coordinator.destinationIndexPath, + let slot = dropSlot(for: id, at: path) + else { return } + let source = currentTarget(of: id) + if slot.target == .recent, source == .recent { return } + // Show the row in its slot at once; the synced state follows. A + // folded destination opens, as desktop's does, to show where it went. + var snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[path.section] + if collapsed.remove(section) != nil { + UserDefaults.standard.set(Array(collapsed), forKey: "collapsedSections") + } + snapshot.deleteItems([.session(id)]) + if slot.index > 0 { + snapshot.insertItems([.session(id)], afterItem: slot.items[slot.index - 1]) + } else if let first = slot.items.first { + snapshot.insertItems([.session(id)], beforeItem: first) + } else { + snapshot.appendItems([.session(id)], toSection: section) + } + dataSource.apply(snapshot, animatingDifferences: false) + coordinator.drop(item.dragItem, toItemAt: IndexPath(item: slot.index, section: path.section)) + UISelectionFeedbackGenerator().selectionChanged() + + switch slot.target { + case .pinned: + if source == .pinned { + app.movePin(id, after: slot.after, before: slot.before) + } else { + app.pin(id, after: slot.after, before: slot.before) + } + case let .section(sectionId): + app.place(id, inSection: sectionId, after: slot.after, before: slot.before) + case .recent: + if source == .pinned { + app.setPinned(id, false) + } else { + app.move(id, toSection: nil) + } + } + } + + private func listSection(of path: IndexPath) -> String { + dataSource.snapshot().sectionIdentifiers[path.section] + } + + /// The destination the session sits in now. + private func currentTarget(of id: String) -> SessionDropTarget? { + dataSource.snapshot().sectionIdentifier(containingItem: .session(id)).flatMap(dropTarget) + } + + /// Where a drop at `path` places `id`: the destination, the section's items + /// without the dragged row, the row's index among them, and its session + /// neighbours. A drop above a section's header has no slot. + private func dropSlot(for id: String, at path: IndexPath) -> (target: SessionDropTarget, items: [Item], index: Int, after: String?, before: String?)? { + let snapshot = dataSource.snapshot() + guard path.section < snapshot.numberOfSections else { return nil } + let section = snapshot.sectionIdentifiers[path.section] + guard let target = dropTarget(section) else { return nil } + let items = snapshot.itemIdentifiers(inSection: section).filter { $0 != .session(id) } + let headed: Int + if case .header = items.first { headed = 1 } else { headed = 0 } + if path.item < headed { return nil } + let index = min(path.item, items.count) + func session(_ item: Item?) -> String? { + if case let .session(id) = item { return id } + return nil + } + let after = index > 0 ? session(items[index - 1]) : nil + let before = index < items.count ? session(items[index]) : nil + return (target, items, index, after, before) + } +} + /// Front page, laid out like the desktop sidebar: Pinned and the user's /// sections inline as foldable groups, then everything else under Recent. /// Fold state persists; long-press a header for its actions. @@ -342,6 +466,7 @@ class SessionsViewController: SessionListController { override func viewDidLoad() { collapsible = true + movesAcrossSections = true super.viewDidLoad() let backdrop = UIView() backdrop.addSubview(wallpaper) @@ -380,6 +505,16 @@ class SessionsViewController: SessionListController { wallpaper.scrollFade = 1 - max(0, travel) / max(1, wallpaper.bounds.height * 0.6) } + override func dropTarget(_ id: String) -> SessionDropTarget? { + // Search results and project groups aren't places a session is put. + guard query.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + switch id { + case "pinned": return .pinned + case "recent": return .recent + default: return app.frontPage.folders.contains { $0.id == id } ? .section(id) : nil + } + } + override func headerMenu(_ id: String) -> UIMenu? { if id == "recent" { return nil } guard let folder = app.frontPage.folders.first(where: { $0.id == id }) else { return nil } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 7da26d0368..2a64ff48c3 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -874,6 +874,20 @@ impl Client { }) } + /// Pin between neighbours (either may be `None` at the ends). + pub fn pin_session_at( + &self, + chat_id: &str, + after: Option, + before: Option, + ) -> Result<()> { + self.pin_change(SidebarPinChange::Pin { + session_id: chat_id.to_owned(), + after, + before, + }) + } + pub fn unpin_session(&self, chat_id: &str) -> Result<()> { self.pin_change(SidebarPinChange::Unpin { session_id: chat_id.to_owned(), @@ -930,11 +944,23 @@ impl Client { /// Move a session into a section (`None` = back to the recent list). pub fn assign_section(&self, chat_id: &str, section_id: Option) -> Result<()> { + self.place_session(chat_id, section_id, None, None) + } + + /// Move a session into a section between its members (either neighbour + /// may be `None` at the ends; both `None` appends). + pub fn place_session( + &self, + chat_id: &str, + section_id: Option, + after: Option, + before: Option, + ) -> Result<()> { self.section_change(SidebarSectionChange::Assign { session_id: chat_id.to_owned(), section_id, - after: None, - before: None, + after, + before, }) } diff --git a/crates/client/src/workspace/view.rs b/crates/client/src/workspace/view.rs index ccc490b302..88f22989d6 100644 --- a/crates/client/src/workspace/view.rs +++ b/crates/client/src/workspace/view.rs @@ -4,7 +4,7 @@ //! //! Ordering mirrors the desktop sidebar (`Shell::sidebar_visible_order`, //! "In one list" + "Last updated"): pinned sessions in their shared manual -//! order, then the user's sections (each in recency order), then everything +//! order, then the user's sections (each in its placed order), then everything //! else by recency ([`zeron_proto::view::sort_active`] keys). Archived and //! child (side/subagent) chats never appear in the active lists, and a chat //! pointing at a deleted project is hidden (projectless chats are first-class). @@ -582,11 +582,14 @@ pub(crate) fn derive( id: section.id.clone(), name: section.name.clone(), collapsed: section.collapsed, - sessions: active + // Members keep their placed order, as on desktop. + sessions: section + .session_ids .iter() - .filter(|c| !rc.pinned.contains(c.id.as_str())) - .filter(|c| rc.section_of.get(c.id.as_str()) == Some(§ion.id.as_str())) - .map(|c| row(c)) + .filter(|id| active_ids.contains(id.as_str()) && !rc.pinned.contains(id.as_str())) + .filter(|id| rc.section_of.get(id.as_str()) == Some(§ion.id.as_str())) + .filter(|id| seen.insert(id.as_str())) + .map(|id| rows[id].clone()) .collect(), }) .collect(); diff --git a/crates/client/tests/demo.rs b/crates/client/tests/demo.rs index 100765a737..2f6f50365c 100644 --- a/crates/client/tests/demo.rs +++ b/crates/client/tests/demo.rs @@ -137,6 +137,49 @@ fn sidebar_writes_reorder_the_front_page() { let later = ws.front.sections.iter().find(|s| s.id == section).unwrap(); assert_eq!(ids(&later.sessions), ["chat-deploy"]); assert!(!ids(&ws.front.recent).contains(&"chat-deploy")); + // A section lists its members in their placed order, as desktop does, + // and a placement out of Pinned unpins. + let members = |client: &Client| { + let ws = client.workspace(); + ids(&ws + .front + .sections + .iter() + .find(|s| s.id == section) + .unwrap() + .sessions) + .into_iter() + .map(str::to_owned) + .collect::>() + }; + client + .place_session( + "chat-home", + Some(section.clone()), + None, + Some("chat-deploy".into()), + ) + .unwrap(); + assert_eq!(members(&client), ["chat-home", "chat-deploy"]); + assert!(!ids(&client.workspace().front.pinned).contains(&"chat-home")); + client + .place_session( + "chat-home", + Some(section.clone()), + Some("chat-deploy".into()), + None, + ) + .unwrap(); + assert_eq!(members(&client), ["chat-deploy", "chat-home"]); + // A drop into Pinned lands between its neighbours. + client + .pin_session_at("chat-home", None, Some("chat-picker".into())) + .unwrap(); + assert_eq!( + ids(&client.workspace().front.pinned), + ["chat-home", "chat-picker"] + ); + assert_eq!(members(&client), ["chat-deploy"]); client.set_section_collapsed(§ion, true).unwrap(); client.rename_section(§ion, "Someday").unwrap(); let ws = client.workspace(); diff --git a/crates/doc/src/registry/sidebar_sections.rs b/crates/doc/src/registry/sidebar_sections.rs index 023bc6954c..81143835d1 100644 --- a/crates/doc/src/registry/sidebar_sections.rs +++ b/crates/doc/src/registry/sidebar_sections.rs @@ -70,18 +70,41 @@ impl RegistryDoc { ); } - pub(super) fn sidebar_sections(&self) -> Vec { - let mut rows = self.overlay_rows(SECTIONS); - rows.retain(|row| row.fields.get("deleted").and_then(Value::as_bool) != Some(true)); - rows.sort_by(|a, b| { - a.fields - .get("createdAt") - .and_then(Value::as_str) - .cmp(&b.fields.get("createdAt").and_then(Value::as_str)) - .then(a.id.cmp(&b.id)) + /// Live sections in their placed order, each with its order key. Sections + /// no reorder has keyed yet (every section before the first move, and any + /// an engine that predates ordering creates) follow the keyed ones by + /// creation, where an append would have put them. + fn ordered_sidebar_sections(&self) -> Vec<(RegistryRow, Option)> { + let mut rows: Vec<_> = self + .overlay_rows(SECTIONS) + .into_iter() + .filter(|row| row.fields.get("deleted").and_then(Value::as_bool) != Some(true)) + .map(|row| { + let key = row + .fields + .get("orderKey") + .and_then(Value::as_str) + .filter(|key| valid_pin_order_key(key)) + .map(str::to_owned); + (row, key) + }) + .collect(); + fn created(row: &RegistryRow) -> Option<&str> { + row.fields.get("createdAt").and_then(Value::as_str) + } + rows.sort_by(|(a, x), (b, y)| match (x, y) { + (Some(x), Some(y)) => x.cmp(y).then(a.id.cmp(&b.id)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => created(a).cmp(&created(b)).then(a.id.cmp(&b.id)), }); - rows.into_iter() - .filter_map(|row| { + rows + } + + pub(super) fn sidebar_sections(&self) -> Vec { + self.ordered_sidebar_sections() + .into_iter() + .filter_map(|(row, _)| { let session_ids = self .sidebar_section_members(&row.id) .into_iter() @@ -135,8 +158,7 @@ impl RegistryDoc { } /// Place `session` into `section` between its anchors: the order key and - /// the clock to write it with. Keyless members are keyed first, in their - /// current order, so every placement has real neighbours to sit between. + /// the clock to write it with. fn place_in_sidebar_section( &mut self, section: &str, @@ -144,16 +166,42 @@ impl RegistryDoc { after: &Option, before: &Option, ) -> Result<(String, String), DocError> { - let mut members = self.sidebar_section_members(section); - members.retain(|(id, _)| id != session); + let members = self.sidebar_section_members(section); + self.place_among(LOCATIONS, members, session, after, before) + } + + /// Place `moving` among `kind` rows already in their placed order, between + /// its anchors: the order key and the clock to write it with. Keyless rows + /// are keyed first, in their current order, so every placement has real + /// neighbours to sit between. + fn place_among( + &mut self, + kind: &str, + mut members: Vec<(String, Option)>, + moving: &str, + after: &Option, + before: &Option, + ) -> Result<(String, String), DocError> { + members.retain(|(id, _)| id != moving); if members.iter().any(|(_, key)| key.is_none()) { - let mut lower: Option = None; + // Keyless rows already sort after the keyed ones, so key just them + // past the last key: rewriting keyed rows would clobber a move + // another device made meanwhile. Only when no key fits past the + // last one (a key at its length cap) does everything restart. + let last = members.iter().rev().find_map(|(_, key)| key.clone()); + // The nonce is fixed-width, so any one tells whether a key fits. + let fits = pin_order_key_between(last.as_deref(), None, "probe").is_ok(); + let mut lower = if fits { last } else { None }; for (id, key) in &mut members { + if fits && key.is_some() { + continue; + } + self.observe_sidebar_row(kind, id); let hlc = self.next_hlc(); let next = pin_order_key_between(lower.as_deref(), None, &hlc) .map_err(|e| DocError::Schema(e.into()))?; self.enqueue_ops(vec![RowOp { - kind: LOCATIONS.into(), + kind: kind.into(), id: id.clone(), op: OpKind::Upsert, set: Some(fields([("orderKey", json!(next))])), @@ -245,6 +293,15 @@ impl RegistryDoc { { return Err(DocError::Schema("Invalid section membership".into())); } + Move { id, after, before } + if !valid_section_id(id) + || [after, before] + .into_iter() + .flatten() + .any(|anchor| !valid_section_id(anchor)) => + { + return Err(DocError::Schema("Invalid section move".into())); + } Import { sections } if sections.iter().any(|s| { !valid_section_id(&s.id) @@ -366,6 +423,26 @@ impl RegistryDoc { })?; } } + Move { id, after, before } => { + // A section deleted meanwhile has nowhere left to go. + if self.live_sidebar_section(id) { + self.observe_sidebar_row(SECTIONS, id); + let sections = self + .ordered_sidebar_sections() + .into_iter() + .map(|(row, key)| (row.id, key)) + .collect(); + let (key, hlc) = self.place_among(SECTIONS, sections, id, after, before)?; + self.enqueue_ops(vec![RowOp { + kind: SECTIONS.into(), + id: id.clone(), + op: OpKind::Upsert, + set: Some(fields([("orderKey", json!(key))])), + hlc, + clocks: None, + }]); + } + } } Ok(()) } diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 2bdfeabc68..4dce8c9db9 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -1314,6 +1314,83 @@ fn sidebar_sections_keep_placed_order_across_devices() { assert_eq!(order(&a), ["legacy", "two", "three"]); } +#[test] +fn sidebar_sections_reorder_and_converge_across_devices() { + use zeron_proto::SidebarSectionChange::*; + let mut a = RegistryDoc::new("a"); + let mut b = RegistryDoc::new("b"); + let mut server = HashMap::new(); + let mut seq = 0; + let create = |doc: &mut RegistryDoc, id: &str| { + section_change( + doc, + Create { + id: id.into(), + name: id.into(), + }, + ) + }; + let moved = |doc: &mut RegistryDoc, id: &str, after: Option<&str>, before: Option<&str>| { + section_change( + doc, + Move { + id: id.into(), + after: after.map(Into::into), + before: before.map(Into::into), + }, + ) + }; + let order = |doc: &RegistryDoc| { + doc.sidebar_preferences() + .unwrap() + .sections + .into_iter() + .map(|s| s.id) + .collect::>() + }; + for id in ["one", "two", "three"] { + create(&mut a, id); + } + // Unmoved sections keep creation order. + assert_eq!(order(&a), ["one", "two", "three"]); + moved(&mut a, "three", None, Some("one")); + assert_eq!(order(&a), ["three", "one", "two"]); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + assert_eq!(order(&b), ["three", "one", "two"]); + // A section created later, keyless as an older engine writes it, appends. + create(&mut b, "four"); + assert_eq!(order(&b), ["three", "one", "two", "four"]); + // Concurrent moves of different sections both survive. + moved(&mut a, "one", Some("two"), None); + moved(&mut b, "four", None, Some("three")); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + assert_eq!(order(&a), order(&b)); + assert_eq!(order(&a), ["four", "three", "two", "one"]); + // Concurrent moves of the same section: the later one wins everywhere. + moved(&mut a, "two", None, Some("four")); + std::thread::sleep(std::time::Duration::from_millis(5)); + moved(&mut b, "two", Some("one"), None); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + assert_eq!(order(&a), order(&b)); + assert_eq!(order(&a), ["four", "three", "one", "two"]); + // Moving a deleted section is a no-op, and its anchors may be gone. + section_change(&mut a, Delete { id: "three".into() }); + moved(&mut a, "three", None, Some("four")); + moved(&mut a, "one", Some("three"), None); + assert_eq!(order(&a), ["four", "two", "one"]); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + // Keying a new section leaves the keyed ones alone, so a concurrent + // move on another device survives a later move that keys it. + moved(&mut a, "one", None, Some("four")); + std::thread::sleep(std::time::Duration::from_millis(5)); + create(&mut b, "five"); + moved(&mut b, "two", Some("five"), None); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + assert_eq!(order(&a), order(&b)); + assert_eq!(order(&a), ["one", "four", "five", "two"]); +} + #[test] fn sidebar_sections_sync_metadata_membership_and_delete_without_deleting_sessions() { use zeron_proto::SidebarSectionChange::*; diff --git a/crates/mobile/src/client_ffi/mod.rs b/crates/mobile/src/client_ffi/mod.rs index 80d8213f11..db69d8ed78 100644 --- a/crates/mobile/src/client_ffi/mod.rs +++ b/crates/mobile/src/client_ffi/mod.rs @@ -248,6 +248,16 @@ impl CoreClient { Ok(self.client.pin_session(&chat_id)?) } + /// Pin between neighbours (`None` at either end). + pub fn pin_session_at( + &self, + chat_id: String, + after: Option, + before: Option, + ) -> CoreResult<()> { + Ok(self.client.pin_session_at(&chat_id, after, before)?) + } + pub fn unpin_session(&self, chat_id: String) -> CoreResult<()> { Ok(self.client.unpin_session(&chat_id)?) } @@ -284,6 +294,20 @@ impl CoreClient { Ok(self.client.assign_section(&chat_id, section_id)?) } + /// Move a session into a section between its members (`None` at either + /// end), as a drop into the section does. + pub fn place_session( + &self, + chat_id: String, + section_id: Option, + after: Option, + before: Option, + ) -> CoreResult<()> { + Ok(self + .client + .place_session(&chat_id, section_id, after, before)?) + } + /// Create (or find) the project for `path` on `device_id`. Returns its id. pub async fn create_project( &self, diff --git a/crates/proto/src/sidebar_pins.rs b/crates/proto/src/sidebar_pins.rs index f6cb47d1ab..741fd0f642 100644 --- a/crates/proto/src/sidebar_pins.rs +++ b/crates/proto/src/sidebar_pins.rs @@ -164,6 +164,16 @@ pub enum SidebarSectionChange { Import { sections: Vec, }, + /// Reorder a section among the others, anchored like a placed member. + /// Engines without [`crate::capabilities::SIDEBAR_SECTION_ORDER_V1`] + /// reject it. + Move { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + after: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + before: Option, + }, } impl SidebarPinChange { @@ -221,6 +231,14 @@ impl SidebarSectionChange { } // Import is resolved by the registry, which also knows tombstones. Self::Import { .. } => {} + Self::Move { id, after, before } => { + let Some(from) = sections.iter().position(|s| &s.id == id) else { + return; + }; + let section = sections.remove(from); + let ids: Vec = sections.iter().map(|s| s.id.clone()).collect(); + sections.insert(section_insert_index(&ids, after, before), section); + } } } } @@ -312,6 +330,40 @@ mod tests { ); } #[test] + fn section_move_reorders_sections_by_anchor() { + let section = |id: &str| crate::SidebarSection { + id: id.into(), + name: id.into(), + session_ids: vec![], + collapsed: false, + }; + let mut sections = vec![section("a"), section("b"), section("c")]; + let order = |sections: &[crate::SidebarSection]| { + sections.iter().map(|s| s.id.clone()).collect::>() + }; + let moved = + |id: &str, after: Option<&str>, before: Option<&str>| SidebarSectionChange::Move { + id: id.into(), + after: after.map(Into::into), + before: before.map(Into::into), + }; + moved("c", None, Some("a")).project(&mut sections); + assert_eq!(order(§ions), ["c", "a", "b"]); + moved("c", Some("b"), None).project(&mut sections); + assert_eq!(order(§ions), ["a", "b", "c"]); + // A vanished right anchor falls back to the left one. + moved("a", Some("b"), Some("gone")).project(&mut sections); + assert_eq!(order(§ions), ["b", "a", "c"]); + // Moving a section that no longer exists changes nothing. + moved("gone", None, Some("b")).project(&mut sections); + assert_eq!(order(§ions), ["b", "a", "c"]); + let json = serde_json::to_value(moved("a", None, Some("c"))).unwrap(); + assert_eq!( + json, + serde_json::json!({"action": "move", "id": "a", "before": "c"}) + ); + } + #[test] fn pending_move_does_not_revive_unpinned_item() { let mut ids = vec!["remote".into()]; SidebarPinChange::Move { diff --git a/crates/proto/src/workspace.rs b/crates/proto/src/workspace.rs index 1a34010055..17eb2164d8 100644 --- a/crates/proto/src/workspace.rs +++ b/crates/proto/src/workspace.rs @@ -18,6 +18,9 @@ pub mod capabilities { /// The host creates a projectless chat's scratch folder /// (`crate::projectless`) when it runs; older hosts only know `~`. pub const PROJECTLESS_SCRATCH_V1: &str = "projectless-scratch-v1"; + /// The host accepts `SidebarSectionChange::Move` and orders sections by + /// their placed key; older hosts reject the action outright. + pub const SIDEBAR_SECTION_ORDER_V1: &str = "sidebar-section-order-v1"; pub const CURRENT: &[&str] = &[ COMPOSER_REFERENCES_V1, @@ -28,6 +31,7 @@ pub mod capabilities { MESSAGE_QUEUE_EDIT_LEASE_V1, HARNESS_UPDATES_V1, PROJECTLESS_SCRATCH_V1, + SIDEBAR_SECTION_ORDER_V1, crate::voice::remote::CAPABILITY, ]; @@ -110,6 +114,7 @@ mod tests { "message-queue-edit-lease-v1", "harness-updates-v1", "projectless-scratch-v1", + "sidebar-section-order-v1", "voice-client-media-v1" ], }) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 07ca494117..82065087b5 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1244,6 +1244,35 @@ struct PinnedSessionDragState { autoscroll_active: bool, } +/// Drag payload for reordering custom sections by their header. +#[derive(Clone)] +struct SidebarSectionDrag { + section_id: String, + profile_key: String, + /// The section order the drag started from. + ids: std::sync::Arc>, +} + +/// Live reorder of the custom sections. The lifted section rides under the +/// pointer while its siblings slide between slots, as pinned rows do. +struct SidebarSectionDragState { + payload: SidebarSectionDrag, + from: usize, + over: usize, + /// Natural slot heights at lift, shared by hit testing and displacement. + heights: Vec, + /// The first slot's top in the list's content coordinates. + top: f32, + /// The slots' left edge within the list, and their width. + left: f32, + width: f32, + /// The pointer's offset below the lifted section's top. + grab_y: f32, + pointer: Point, + siblings: std::collections::HashMap, + generation: u64, +} + type SidebarKeyedRow = (String, f32, AnyElement); struct SidebarSessionRows { @@ -2139,6 +2168,9 @@ pub struct Shell { pinned_session_drag_generation: u64, sidebar_session_transfer: Option, sidebar_session_return: Option, + sidebar_section_drag: Option, + /// Each custom section's painted bounds, measured every frame. + sidebar_section_bounds: std::rc::Rc>>>, /// Pending pin intents are scoped to the active profile and engine attachment. sidebar_pin_write: Option, sidebar_pin_write_generation: u64, @@ -2628,6 +2660,8 @@ impl Shell { pinned_session_drag_generation: 0, sidebar_session_transfer: None, sidebar_session_return: None, + sidebar_section_drag: None, + sidebar_section_bounds: Default::default(), sidebar_pin_write: None, sidebar_pin_write_generation: 0, sidebar_pin_write_notice: None, @@ -8945,6 +8979,11 @@ impl Shell { { self.cancel_pinned_session_drag(cx); } + if self.sidebar_section_drag.is_some() + && (!cx.has_active_drag() || !self.sidebar_section_drag_is_valid(cx)) + { + self.cancel_sidebar_section_drag(cx); + } // Keyed rows: (stable key, estimated height, element) — the key + height // list drives the §1.6 resort FLIP diff below (attention-bucket @@ -8956,7 +8995,7 @@ impl Shell { let pinned_count = session_rows.pinned_count; let custom_count = session_rows.custom_count; let regular_start = pinned_count + custom_count; - let keyed = session_rows.rows; + let mut keyed = session_rows.rows; let regular_count = session_rows.regular_count; let ungrouped = self.settings.sidebar_organization == SidebarOrganization::InOneList; let regular_body_height = spaces::SIDEBAR_DISCLOSURE_BODY_INSET @@ -9030,6 +9069,7 @@ impl Shell { } if self.pinned_session_drag.is_none() && self.sidebar_session_transfer.is_none() + && self.sidebar_section_drag.is_none() && self.sidebar_prev_order != order { let key_order_changed = sidebar_key_order_changed(&self.sidebar_prev_order, &order); @@ -9071,10 +9111,32 @@ impl Shell { .is_none_or(|drag| drag.preview.as_ref().is_none_or(|gap| gap.pinned)) }) .map(|drag| (drag.from, drag.over, drag.prev_over, drag.epoch)); + // A lifted custom section leaves an empty slot behind and paints in + // the list's movement layer; its siblings slide around the gap. + let section_offsets = self + .sidebar_section_drag_offsets() + .filter(|(_, offsets)| offsets.len() == custom_count); + let lifted_section = section_offsets.as_ref().and_then(|(from, _)| { + let height = self.sidebar_section_drag.as_ref()?.heights[*from]; + let (_, _, slot) = keyed.get_mut(pinned_count + from)?; + let placeholder = div().flex_none().h(px(height)).into_any_element(); + let section = std::mem::replace(slot, placeholder); + self.render_lifted_sidebar_section(section, theme) + }); let list_items: Vec = keyed .into_iter() .enumerate() .map(|(ix, (key, _, element))| { + if let Some((_, offsets)) = §ion_offsets + && let Some(offset) = + ix.checked_sub(pinned_count).and_then(|ix| offsets.get(ix)) + { + return div() + .relative() + .top(px(*offset)) + .child(element) + .into_any_element(); + } if ix < pinned_count && let Some((from, over, prev_over, drag_epoch)) = pinned_drag { @@ -9148,6 +9210,7 @@ impl Shell { let regular_empty = regular_items.is_empty(); let pinned_group = show_pinned_section .then(|| self.render_pinned_section(pinned_items, pinned_body_height, theme, cx)); + let section_bounds = self.sidebar_section_bounds.clone(); div() .id("sidebar-active-sessions") .flex() @@ -9155,7 +9218,20 @@ impl Shell { .gap(px(SIDEBAR_LIST_GAP)) .pb(px(Theme::SPACE_SM)) .when_some(pinned_group, |el, group| el.child(group)) - .children(custom_items) + // Each section's painted slot measures the reorder's hit + // testing and displacement. + .when(!custom_items.is_empty(), |el| { + el.child( + div() + .flex() + .flex_col() + .gap(px(SIDEBAR_LIST_GAP)) + .on_children_prepainted(move |bounds, _, _| { + *section_bounds.borrow_mut() = bounds; + }) + .children(custom_items), + ) + }) .when( !regular_items.is_empty() || self.sidebar_session_transfer.is_some(), |el| { @@ -9292,6 +9368,17 @@ impl Shell { this.cancel_sidebar_session_transfer(cx); }, )) + // A section reorder lands wherever it is released in the list. + .on_drag_move::(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.track_sidebar_section_drag(event.event.position, cx); + }, + )) + .on_drop::(cx.listener( + |this, payload: &SidebarSectionDrag, _, cx| { + this.finish_sidebar_section_drag(payload, cx); + }, + )) .px(px(Theme::SPACE_SM)) .flex() .flex_col() @@ -9300,7 +9387,8 @@ impl Shell { .pt(px(SIDEBAR_LIST_PAD_TOP)) .child(active_list) .children(archived_section) - .children(moving_row), + .children(moving_row) + .children(lifted_section), )), ) .fade_overflow_y(&self.sidebar_scroll); diff --git a/crates/ui/src/shell/sidebar_sections.rs b/crates/ui/src/shell/sidebar_sections.rs index fca330c605..63e55a90cd 100644 --- a/crates/ui/src/shell/sidebar_sections.rs +++ b/crates/ui/src/shell/sidebar_sections.rs @@ -3,6 +3,27 @@ use super::*; use crate::settings::SidebarSection; use zeron_proto::{SidebarPinChange, SidebarSectionChange}; +/// The air above each custom section's header. +const SIDEBAR_SECTION_TOP_PAD: f32 = 12.0; + +/// Where a lifted section lands among its siblings: past each one whose middle +/// its leading edge has crossed. `top` is the lifted section's top measured +/// from the first slot; natural slots keep the thresholds still while the +/// siblings slide. +fn section_drop_index(top: f32, heights: &[f32], from: usize) -> usize { + let bottom = top + heights[from]; + let mut slot_top = 0.0; + let mut over = 0; + for (ix, height) in heights.iter().enumerate() { + let middle = slot_top + height / 2.0; + if (ix < from && top > middle) || (ix > from && bottom > middle) { + over += 1; + } + slot_top += height + SIDEBAR_LIST_GAP; + } + over +} + pub(super) struct SectionDialog { profile: String, id: Option, @@ -252,6 +273,7 @@ impl Shell { section: SidebarSection, rows: Vec, drag_group: String, + drag: Option, theme: &Theme, cx: &mut Context, ) -> SidebarKeyedRow { @@ -312,6 +334,10 @@ impl Shell { chevron, ) .id(SharedString::from(format!("section-header-{id}"))) + .debug_selector({ + let id = id.clone(); + move || format!("section-header-{id}") + }) .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { this.section_header_hover = hovered.then(|| hover_id.clone()); cx.notify(); @@ -330,7 +356,18 @@ impl Shell { cx, ); cx.notify(); - })); + })) + // The header lifts the whole section to reorder it among the others. + .when_some(drag, |el, payload| { + let shell = cx.entity(); + el.on_drag(payload, move |payload, grab, window, cx| { + shell.update(cx, |shell, cx| { + shell.begin_sidebar_section_drag(payload, grab, window, cx); + }); + cx.stop_propagation(); + cx.new(|_| DragGhost) + }) + }); let content = div() .w_full() .flex() @@ -363,7 +400,7 @@ impl Shell { .w_full() .flex() .flex_col() - .pt(px(12.0)) + .pt(px(SIDEBAR_SECTION_TOP_PAD)) .on_drag_move::(cx.listener( move |this, event: &gpui::DragMoveEvent, _, cx| { if (empty || !open) && event.bounds.contains(&event.event.position) { @@ -371,7 +408,7 @@ impl Shell { event.bounds.top() - this.sidebar_scroll.bounds().top() - this.sidebar_scroll.offset().y, - ) + 12.0 + ) + SIDEBAR_SECTION_TOP_PAD + spaces::SIDEBAR_DISCLOSURE_HEADER_HEIGHT; if let Some(drag) = this.sidebar_session_transfer.as_mut() { drag.preview = Some(SidebarSessionGap { @@ -398,11 +435,318 @@ impl Shell { .into_any_element(); ( format!("custom:{id}"), - 12.0 + spaces::SIDEBAR_DISCLOSURE_HEADER_HEIGHT + if open { height } else { 0.0 }, + SIDEBAR_SECTION_TOP_PAD + + spaces::SIDEBAR_DISCLOSURE_HEADER_HEIGHT + + if open { height } else { 0.0 }, element, ) } + /// Sections reorder in this device's settings, or through an engine that + /// orders them: an older engine rejects the move outright. + pub(super) fn sidebar_sections_reorderable(&self, cx: &App) -> bool { + let state = self.state.read(cx); + state.workspace_scope == Some(WorkspaceScope::Local) + || (state.sidebar_preferences.can_edit() + && state.engine().is_some_and(|engine| { + engine + .engine_info() + .supports(zeron_proto::capabilities::SIDEBAR_SECTION_ORDER_V1) + })) + } + + /// `grab` is where the header was pressed, from its origin: the drag only + /// starts once the pointer has already moved on. + pub(super) fn begin_sidebar_section_drag( + &mut self, + payload: &SidebarSectionDrag, + grab: Point, + window: &Window, + cx: &mut Context, + ) { + let bounds = self.sidebar_section_bounds.borrow().clone(); + let Some(from) = payload.ids.iter().position(|id| id == &payload.section_id) else { + return; + }; + if bounds.len() != payload.ids.len() { + return; + } + self.cancel_sidebar_session_transfer(cx); + self.section_menu = None; + let viewport = self.sidebar_scroll.bounds(); + let scroll_top = -f32::from(self.sidebar_scroll.offset().y); + let pointer = window.mouse_position(); + self.pinned_session_drag_generation = self.pinned_session_drag_generation.wrapping_add(1); + let generation = self.pinned_session_drag_generation; + self.sidebar_section_drag = Some(SidebarSectionDragState { + payload: payload.clone(), + from, + over: from, + heights: bounds.iter().map(|b| f32::from(b.size.height)).collect(), + top: f32::from(bounds[0].top() - viewport.top()) + scroll_top, + left: f32::from(bounds[0].left() - viewport.left()), + width: f32::from(bounds[0].size.width), + grab_y: f32::from(grab.y) + SIDEBAR_SECTION_TOP_PAD, + pointer, + siblings: Default::default(), + generation, + }); + // Siblings ease and the list edge-scrolls while the pointer rests. + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_millis( + SIDEBAR_DRAG_SCROLL_FRAME_MS, + )) + .await; + let keep_running = this + .update(cx, |shell, cx| { + shell.sidebar_section_drag_tick(generation, cx) + }) + .unwrap_or(false); + if !keep_running { + break; + } + } + }) + .detach(); + cx.notify(); + } + + fn sidebar_section_drag_tick(&mut self, generation: u64, cx: &mut Context) -> bool { + let Some(drag) = self.sidebar_section_drag.as_ref() else { + return false; + }; + if drag.generation != generation { + return false; + } + cx.notify(); + if !cx.has_active_drag() { + // A release outside the sidebar: render settles it home. + return false; + } + let viewport = self.sidebar_scroll.bounds(); + let pointer = drag.pointer; + if pointer.x >= viewport.left() && pointer.x <= viewport.right() { + let delta = spaces::pinned_drag_scroll_delta( + f32::from(pointer.y), + f32::from(viewport.top()), + f32::from(viewport.bottom()), + ); + let offset = self.sidebar_scroll.offset(); + let scroll_top = -f32::from(offset.y); + let max_scroll = f32::from(self.sidebar_scroll.max_offset().y); + let next = (scroll_top + delta).clamp(0.0, max_scroll); + if next != scroll_top { + self.sidebar_scroll + .set_offset(gpui::point(offset.x, px(-next))); + self.update_sidebar_section_drop(); + } + } + true + } + + pub(super) fn track_sidebar_section_drag( + &mut self, + pointer: Point, + cx: &mut Context, + ) { + if let Some(drag) = self.sidebar_section_drag.as_mut() { + drag.pointer = pointer; + self.update_sidebar_section_drop(); + cx.notify(); + } + } + + /// The lifted section's top in the list's content coordinates, unclamped. + fn lifted_sidebar_section_y(&self, drag: &SidebarSectionDragState) -> f32 { + let viewport_top = f32::from(self.sidebar_scroll.bounds().top()); + let scroll_top = -f32::from(self.sidebar_scroll.offset().y); + f32::from(drag.pointer.y) - viewport_top + scroll_top - drag.grab_y + } + + /// Where the lifted section paints: under the pointer, held within the + /// span of the sections it reorders among. + fn lifted_sidebar_section_top(&self, drag: &SidebarSectionDragState) -> f32 { + let total = drag.heights.iter().sum::() + + SIDEBAR_LIST_GAP * drag.heights.len().saturating_sub(1) as f32; + self.lifted_sidebar_section_y(drag).clamp( + drag.top, + drag.top + (total - drag.heights[drag.from]).max(0.0), + ) + } + + fn update_sidebar_section_drop(&mut self) { + let Some(drag) = self.sidebar_section_drag.as_ref() else { + return; + }; + let top = self.lifted_sidebar_section_y(drag) - drag.top; + let over = section_drop_index(top, &drag.heights, drag.from); + if let Some(drag) = self.sidebar_section_drag.as_mut() { + drag.over = over; + } + } + + pub(super) fn sidebar_section_drag_is_valid(&self, cx: &App) -> bool { + let Some(drag) = self.sidebar_section_drag.as_ref() else { + return true; + }; + self.active_sidebar_pin_profile_key(cx).as_deref() == Some(&drag.payload.profile_key) + && self + .active_sidebar_sections(cx) + .iter() + .map(|section| §ion.id) + .eq(drag.payload.ids.iter()) + } + + pub(super) fn finish_sidebar_section_drag( + &mut self, + payload: &SidebarSectionDrag, + cx: &mut Context, + ) { + let Some(drag) = self.sidebar_section_drag.as_ref() else { + return; + }; + if drag.payload.section_id != payload.section_id + || drag.payload.ids != payload.ids + || !self.sidebar_section_drag_is_valid(cx) + { + self.end_sidebar_section_drag(false, cx); + return; + } + let (from, over) = (drag.from, drag.over); + if from == over { + self.end_sidebar_section_drag(false, cx); + return; + } + let mut ids = (*drag.payload.ids).clone(); + let id = ids.remove(from); + let change = SidebarSectionChange::Move { + id, + after: over.checked_sub(1).and_then(|i| ids.get(i)).cloned(), + before: ids.get(over).cloned(), + }; + let placed = self.change_sidebar_section(change, cx); + self.end_sidebar_section_drag(placed, cx); + } + + pub(super) fn cancel_sidebar_section_drag(&mut self, cx: &mut Context) { + self.end_sidebar_section_drag(false, cx); + } + + /// Land the lifted section in its new slot, or home when `placed` is + /// false, gliding every section from where it last painted. + fn end_sidebar_section_drag(&mut self, placed: bool, cx: &mut Context) { + let Some(drag) = self.sidebar_section_drag.as_ref() else { + return; + }; + let mut settle = std::collections::HashMap::new(); + if !self.reduced_motion { + let over = if placed { drag.over } else { drag.from }; + let landed = drag.top + + (0..drag.heights.len()) + .filter(|ix| *ix != drag.from) + .take(over) + .map(|ix| drag.heights[ix] + SIDEBAR_LIST_GAP) + .sum::(); + settle.insert( + format!("custom:{}", drag.payload.section_id), + self.lifted_sidebar_section_top(drag) - landed, + ); + let step = drag.heights[drag.from] + SIDEBAR_LIST_GAP; + for (ix, id) in drag.payload.ids.iter().enumerate() { + let current = drag.siblings.get(id).map_or(0.0, |slide| slide.current()); + let target = crate::terminal::panel::slide_offset(ix, drag.from, over) * step; + if ix != drag.from && (current - target).abs() > 0.5 { + settle.insert(format!("custom:{id}"), current - target); + } + } + } + self.sidebar_section_drag = None; + // The settle glide rides the resort animation; a fresh baseline keeps + // the reorder itself from replaying as a second glide. + self.sidebar_prev_order.clear(); + self.sidebar_new_keys.clear(); + self.sidebar_resort = settle; + self.resort_epoch += 1; + cx.notify(); + } + + /// Each custom section's displacement this frame while one is lifted. + pub(super) fn sidebar_section_drag_offsets(&mut self) -> Option<(usize, Vec)> { + let reduced_motion = self.reduced_motion; + let drag = self.sidebar_section_drag.as_mut()?; + let (from, over) = (drag.from, drag.over); + let step = drag.heights[from] + SIDEBAR_LIST_GAP; + let offsets = drag + .payload + .ids + .iter() + .enumerate() + .map(|(ix, id)| { + if ix == from { + return 0.0; + } + let slide = + drag.siblings + .entry(id.clone()) + .or_insert_with(|| SidebarSessionSlide { + from: 0.0, + to: 0.0, + epoch: 0, + started: std::time::Instant::now(), + }); + slide.retarget(crate::terminal::panel::slide_offset(ix, from, over) * step); + if reduced_motion { + slide.to + } else { + slide.current() + } + }) + .collect(); + Some((from, offsets)) + } + + /// The lifted section, frosted a level above the list as a lifted session + /// is. Its slot's top padding stays outside the card, which frames the + /// header. + pub(super) fn render_lifted_sidebar_section( + &self, + section: AnyElement, + theme: &Theme, + ) -> Option { + const LIFT_PAD: f32 = 4.0; + let drag = self.sidebar_section_drag.as_ref()?; + let trim = (SIDEBAR_SECTION_TOP_PAD - LIFT_PAD).max(0.0); + let height = drag.heights[drag.from]; + let card = div() + .size_full() + .rounded(px(8.0)) + .bg(crate::popover::surface_bg(theme)) + .shadow_md() + .child( + div() + .size_full() + .rounded(px(8.0)) + .bg(crate::theme::wash(0.06)) + .child(div().relative().top(px(-trim)).w_full().child(section)), + ); + Some( + gpui::deferred( + div() + .debug_selector(|| "sidebar-lifted-section".into()) + .absolute() + .left(px(drag.left)) + .top(px(self.lifted_sidebar_section_top(drag) + trim)) + .w(px(drag.width)) + .h(px(height - trim + LIFT_PAD)) + .child(crate::frost::frosted(8.0, crate::frost::MENU_BLUR, card)), + ) + .with_priority(1) + .into_any_element(), + ) + } + fn activate_section_menu(&mut self, action: usize, id: &str, cx: &mut Context) { match action { 0 => self.open_section_dialog(Some(id.to_string()), cx), @@ -862,6 +1206,98 @@ mod tests { .unwrap(); } + #[gpui::test] + fn sections_reorder_by_dragging_their_header(cx: &mut gpui::TestAppContext) { + use gpui::MouseButton; + let dir = tempfile::tempdir().unwrap(); + let window = test_shell(cx, dir.path()); + window + .update(cx, |shell, _, cx| { + prepare(shell, cx); + shell + .settings + .sidebar_sections_by_profile + .get_mut("local") + .unwrap() + .push(SidebarSection { + id: "c".into(), + name: "Someday".into(), + session_ids: vec![], + collapsed: false, + }); + shell.debug_gate = Some(super::GatePhase::Ready); + crate::motion::set_reduced_motion(cx, true); + }) + .unwrap(); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.simulate_resize(gpui::size(px(1200.0), px(900.0))); + cx.run_until_parked(); + let order = |cx: &mut gpui::VisualTestContext| { + window + .update(cx, |shell, _, cx| { + shell + .active_sidebar_sections(cx) + .into_iter() + .map(|s| s.id) + .collect::>() + }) + .unwrap() + }; + let none = gpui::Modifiers::default(); + let from = cx.debug_bounds("section-header-a").unwrap().center(); + let last = cx.debug_bounds("section-header-c").unwrap(); + let to = gpui::point(from.x, last.bottom() + px(30.0)); + let nudge = gpui::point(px(0.0), px(6.0)); + cx.simulate_mouse_down(from, MouseButton::Left, none); + // The first move lifts the section where it sits; it then follows. + cx.simulate_mouse_move(from + nudge, Some(MouseButton::Left), none); + cx.update(|_, cx| assert!(cx.has_active_drag(), "section drag never started")); + cx.simulate_mouse_move(to, Some(MouseButton::Left), none); + cx.run_until_parked(); + assert!( + cx.debug_bounds("sidebar-lifted-section").is_some(), + "the section lifts while it drags" + ); + cx.simulate_mouse_up(to, MouseButton::Left, none); + cx.run_until_parked(); + assert_eq!(order(&mut cx), ["b", "c", "a"]); + assert!(cx.debug_bounds("sidebar-lifted-section").is_none()); + + // Released outside the sidebar, the section goes home. + let from = cx.debug_bounds("section-header-b").unwrap().center(); + let outside = gpui::point(px(900.0), from.y + px(200.0)); + cx.simulate_mouse_down(from, MouseButton::Left, none); + cx.simulate_mouse_move(from + nudge, Some(MouseButton::Left), none); + cx.simulate_mouse_move(outside, Some(MouseButton::Left), none); + cx.simulate_mouse_up(outside, MouseButton::Left, none); + cx.run_until_parked(); + assert_eq!(order(&mut cx), ["b", "c", "a"]); + window + .update(&mut cx, |shell, _, _| { + assert!(shell.sidebar_section_drag.is_none()); + }) + .unwrap(); + } + + #[test] + fn section_drop_index_follows_the_leading_edge() { + let heights = [50.0, 300.0, 50.0]; + let gap = SIDEBAR_LIST_GAP; + // At rest every section keeps its slot. + assert_eq!(section_drop_index(0.0, &heights, 0), 0); + assert_eq!(section_drop_index(50.0 + gap, &heights, 1), 1); + // Dragged down, the bottom edge must pass the next middle. + assert_eq!(section_drop_index(99.0, &heights, 0), 0); + assert_eq!(section_drop_index(140.0 + gap, &heights, 0), 0); + assert_eq!(section_drop_index(160.0 + gap, &heights, 0), 1); + assert_eq!(section_drop_index(400.0, &heights, 0), 2); + // Dragged up, the top edge must pass the previous middle. + let last = 350.0 + 2.0 * gap; + assert_eq!(section_drop_index(last - 100.0, &heights, 2), 2); + assert_eq!(section_drop_index(150.0, &heights, 2), 1); + assert_eq!(section_drop_index(-10.0, &heights, 2), 0); + } + #[gpui::test] fn sections_dialog_persistence_deletion_and_profile_isolation(cx: &mut gpui::TestAppContext) { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index cb3154ac90..44fd2e68ba 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -5099,6 +5099,15 @@ impl Shell { let custom_sections = self.active_sidebar_sections(cx); let custom_count = custom_sections.len(); + let section_order = + (custom_count > 1 && self.sidebar_sections_reorderable(cx)).then(|| { + std::sync::Arc::new( + custom_sections + .iter() + .map(|section| section.id.clone()) + .collect::>(), + ) + }); let mut custom_groups: Vec<_> = custom_sections .iter() .map(|section| { @@ -5453,10 +5462,20 @@ impl Shell { }; if let Some(id) = key.strip_prefix("section:") { if let Some(section) = custom_sections.iter().find(|section| section.id == id) { + let drag = + section_order + .clone() + .zip(profile_key.clone()) + .map(|(ids, profile_key)| SidebarSectionDrag { + section_id: section.id.clone(), + profile_key, + ids, + }); rendered.push(self.render_custom_sidebar_section( section.clone(), rendered_rows, drag_group, + drag, theme, cx, )); diff --git a/docs/registry-sync.md b/docs/registry-sync.md index a603603a02..f3b587990b 100644 --- a/docs/registry-sync.md +++ b/docs/registry-sync.md @@ -125,6 +125,14 @@ Concurrent placements converge by field clock; renaming and collapsing a section update separate fields. Deletion marks the section deleted without deleting chats. Archived chats retain membership. Section IDs are never reused or revived. +Sections and their members keep a placed order through `orderKey` fields, minted +between the neighbours' keys as pins are. A section's key lives on its own +`sidebarSections` row, so concurrent moves of one section converge by clock and +moves of different sections both survive. Sections no move has keyed yet (all of +them before the first reorder, and any an older app creates) follow the keyed +ones by creation. Moving sections is desktop-only and needs an engine advertising +`sidebar-section-order-v1`; apps that predate it keep showing creation order. + The existing sidebar preferences watch includes sections. Pin and section intents share the UI's ordered optimistic queue; a rejected write reveals the latest confirmed state. Later pin toggles from older apps still take effect through their