From f12531597fd64976a4e4b009a9d69cf9067ae281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Wed, 14 Oct 2026 21:47:22 -0300 Subject: [PATCH 1/3] Reorder sidebar sections by dragging their header Sections now keep a placed order. Each section row carries its own orderKey, minted between its neighbours' keys as pins are, so the order syncs across devices and concurrent moves converge. Sections no move has keyed yet, including those older apps create, follow by creation. On desktop, dragging a section header lifts the section and slides its siblings aside; releasing outside the sidebar sends it home. Synced workspaces need an engine advertising sidebar-section-order-v1. --- crates/doc/src/registry/sidebar_sections.rs | 98 ++++- crates/doc/src/registry/tests.rs | 65 +++ crates/proto/src/sidebar_pins.rs | 52 +++ crates/proto/src/workspace.rs | 5 + crates/ui/src/shell.rs | 94 ++++- crates/ui/src/shell/sidebar_sections.rs | 444 +++++++++++++++++++- crates/ui/src/shell/spaces.rs | 19 + docs/registry-sync.md | 8 + 8 files changed, 762 insertions(+), 23 deletions(-) diff --git a/crates/doc/src/registry/sidebar_sections.rs b/crates/doc/src/registry/sidebar_sections.rs index 023bc6954c..dc95c85e78 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,8 +166,23 @@ 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; for (id, key) in &mut members { @@ -153,7 +190,7 @@ impl RegistryDoc { 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 +282,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 +412,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..3e1f452d41 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -1314,6 +1314,71 @@ 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")); + 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"]); +} + #[test] fn sidebar_sections_sync_metadata_membership_and_delete_without_deleting_sessions() { use zeron_proto::SidebarSectionChange::*; 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 From 4698cebe1ee84de9c9d7f153a5d159f7c2fd334f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 16 Oct 2026 17:58:38 -0300 Subject: [PATCH 2/3] Drag sessions between sections on iOS On the iOS front page, long-pressing a session and dragging it moves it into Pinned or a user section at the slot it is dropped on, or back to Recent. Placements carry their neighbours as after/before anchors, so the order syncs with desktop. A folded destination opens to show the row. Sections now list their members in their placed order, as desktop does, instead of by recency. The client and FFI gain pin_session_at and place_session; assign_section places without anchors. --- apps/ios/Zeron/App/AppModel.swift | 8 ++ .../ios/Zeron/Core/Generated/zeron_core.swift | 47 ++++++ .../Zeron/Threads/SessionListController.swift | 135 ++++++++++++++++++ crates/client/src/client.rs | 30 +++- crates/client/src/workspace/view.rs | 12 +- crates/client/tests/demo.rs | 43 ++++++ crates/mobile/src/client_ffi/mod.rs | 24 ++++ 7 files changed, 292 insertions(+), 7 deletions(-) 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..f5751426c9 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,13 @@ 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())) + .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/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, From e7ad957088c868c6522159bbd093f1ef372b8950 Mon Sep 17 00:00:00 2001 From: Wing Date: Sat, 17 Oct 2026 10:38:29 -0700 Subject: [PATCH 3/3] Key only unkeyed sections when placing, so concurrent moves survive Placing a section (or member) re-keyed every row once any lacked a key, so a move on one device after a section was created could overwrite a concurrent move from another device with a later clock. Key only the keyless tail, past the last key, and fall back to re-keying everything only when no key fits there. Also de-flake the same-section concurrent move test and dedupe section members in the client view. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/workspace/view.rs | 1 + crates/doc/src/registry/sidebar_sections.rs | 13 ++++++++++++- crates/doc/src/registry/tests.rs | 12 ++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/client/src/workspace/view.rs b/crates/client/src/workspace/view.rs index f5751426c9..88f22989d6 100644 --- a/crates/client/src/workspace/view.rs +++ b/crates/client/src/workspace/view.rs @@ -588,6 +588,7 @@ pub(crate) fn derive( .iter() .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(), }) diff --git a/crates/doc/src/registry/sidebar_sections.rs b/crates/doc/src/registry/sidebar_sections.rs index dc95c85e78..81143835d1 100644 --- a/crates/doc/src/registry/sidebar_sections.rs +++ b/crates/doc/src/registry/sidebar_sections.rs @@ -184,8 +184,19 @@ impl RegistryDoc { ) -> 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()))?; diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 3e1f452d41..4dce8c9db9 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -1368,6 +1368,7 @@ fn sidebar_sections_reorder_and_converge_across_devices() { 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)); @@ -1377,6 +1378,17 @@ fn sidebar_sections_reorder_and_converge_across_devices() { 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]