Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/ios/Zeron/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) } }
Expand Down
47 changes: 47 additions & 0 deletions apps/ios/Zeron/Core/Generated/zeron_core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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
)
}
}

/**
Expand Down Expand Up @@ -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
}
Expand Down
135 changes: 135 additions & 0 deletions apps/ios/Zeron/Threads/SessionListController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]

Expand All @@ -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<SessionCell, String> { [weak self] cell, path, id in
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -342,6 +466,7 @@ class SessionsViewController: SessionListController {

override func viewDidLoad() {
collapsible = true
movesAcrossSections = true
super.viewDidLoad()
let backdrop = UIView()
backdrop.addSubview(wallpaper)
Expand Down Expand Up @@ -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 }
Expand Down
30 changes: 28 additions & 2 deletions crates/client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
before: Option<String>,
) -> 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(),
Expand Down Expand Up @@ -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<String>) -> 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<String>,
after: Option<String>,
before: Option<String>,
) -> Result<()> {
self.section_change(SidebarSectionChange::Assign {
session_id: chat_id.to_owned(),
section_id,
after: None,
before: None,
after,
before,
})
}

Expand Down
13 changes: 8 additions & 5 deletions crates/client/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(&section.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(&section.id.as_str()))
.filter(|id| seen.insert(id.as_str()))
.map(|id| rows[id].clone())
.collect(),
})
.collect();
Expand Down
43 changes: 43 additions & 0 deletions crates/client/tests/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
};
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(&section, true).unwrap();
client.rename_section(&section, "Someday").unwrap();
let ws = client.workspace();
Expand Down
Loading
Loading