Skip to content
Open
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
43 changes: 42 additions & 1 deletion crates/doc/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use serde_json::{Value, json};
use zeron_proto::{Chat, ChatConfig, Device, MAX_SIDEBAR_PINS, Session, SidebarPreferences, Space};

use crate::schema::DocError;
use crate::workspace::{DeletedSpace, WorkspaceState};
use crate::workspace::{DeletedDevice, DeletedSpace, WorkspaceState};

/// Row kinds — synced sidebar entities plus user preferences.
pub const KIND_DEVICES: &str = "devices";
Expand Down Expand Up @@ -745,6 +745,47 @@ impl RegistryDoc {
Ok(true)
}

/// Tombstone a device and all spaces, chats, and session index rows it owns.
/// The session documents themselves remain intact, just as with chat deletion.
pub fn delete_device(&mut self, device_id: &str) -> Result<DeletedDevice, DocError> {
let existed = self.row_exists(KIND_DEVICES, device_id);
let space_ids: Vec<String> = self
.read_spaces()?
.into_iter()
.filter(|space| space.device_id == device_id)
.map(|space| space.id)
.collect();
let space_set: std::collections::HashSet<_> = space_ids.iter().collect();
let chat_ids: Vec<String> = self
.read_chats()?
.into_iter()
.filter(|chat| {
chat.device_id == device_id
|| chat
.space_id
.as_ref()
.is_some_and(|space_id| space_set.contains(space_id))
})
.map(|chat| chat.id)
.collect();

let mut keys = Vec::with_capacity(1 + space_ids.len() + chat_ids.len() * 2);
keys.push((KIND_DEVICES, device_id));
for space_id in &space_ids {
keys.push((KIND_SPACES, space_id));
}
for chat_id in &chat_ids {
keys.push((KIND_CHATS, chat_id));
keys.push((KIND_SESSIONS, chat_id));
}
self.delete_row_ops(&keys);
Ok(DeletedDevice {
existed,
space_ids,
chat_ids,
})
}

/// Stamp `lastSeenAt` on an existing device row (boot/shutdown only —
/// periodic liveness rides presence frames, never rows).
pub fn set_device_last_seen(
Expand Down
48 changes: 48 additions & 0 deletions crates/doc/src/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,54 @@ fn field_mutators_round_trip() {
assert_eq!(dev.last_seen_at, Some(ts(6_000)));
}

#[test]
fn delete_device_cascades_owned_registry_rows() {
let mut ws = RegistryDoc::new("dev-a");
ws.upsert_device(&device("dev-a", "laptop")).unwrap();
ws.upsert_device(&device("dev-b", "old vm")).unwrap();
ws.upsert_space(&space("space-b", "dev-b", "/tmp/old-vm"))
.unwrap();
let mut chat_space = chat("chat-space", "dev-b");
chat_space.space_id = Some("space-b".into());
ws.upsert_chat(&chat_space).unwrap();
ws.upsert_chat(&chat("chat-projectless", "dev-b")).unwrap();
ws.upsert_session(&session("chat-space", "dev-b", SessionStatus::Idle))
.unwrap();
ws.upsert_session(&session("chat-projectless", "dev-b", SessionStatus::Idle))
.unwrap();
ws.upsert_chat(&chat("chat-other", "dev-a")).unwrap();

let deleted = ws.delete_device("dev-b").unwrap();
assert!(deleted.existed);
assert_eq!(deleted.space_ids, vec!["space-b"]);
assert_eq!(deleted.chat_ids, vec!["chat-projectless", "chat-space"]);
assert!(ws.read_devices().unwrap().iter().all(|d| d.id != "dev-b"));
assert!(ws.read_spaces().unwrap().is_empty());
assert!(
ws.read_chats()
.unwrap()
.iter()
.all(|c| c.device_id == "dev-a")
);
assert!(ws.read_sessions().unwrap().is_empty());
}

#[test]
fn delete_device_is_idempotent_and_leaves_unrelated_rows() {
let mut ws = RegistryDoc::new("dev-a");
ws.upsert_device(&device("dev-a", "laptop")).unwrap();
ws.upsert_device(&device("dev-b", "old vm")).unwrap();
ws.upsert_chat(&chat("chat-b", "dev-b")).unwrap();

let first = ws.delete_device("dev-b").unwrap();
let second = ws.delete_device("dev-b").unwrap();
assert!(first.existed);
assert!(!second.existed);
assert_eq!(second.chat_ids, Vec::<String>::new());
assert_eq!(ws.read_devices().unwrap().len(), 1);
assert_eq!(ws.read_chats().unwrap().len(), 0);
}

#[test]
fn delete_chat_tombstones_row_and_session() {
let mut ws = RegistryDoc::new("dev-a");
Expand Down
8 changes: 8 additions & 0 deletions crates/doc/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ pub struct DeletedSpace {
pub chat_ids: Vec<String>,
}

/// Result of deleting a device and its registry-owned descendants.
#[derive(Debug, Clone, PartialEq)]
pub struct DeletedDevice {
pub existed: bool,
pub space_ids: Vec<String>,
pub chat_ids: Vec<String>,
}

/// A workspace doc handle: typed access over a LoroDoc with the schema above.
pub struct WorkspaceDoc {
doc: LoroDoc,
Expand Down
19 changes: 18 additions & 1 deletion crates/engine/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! - `WatchSessions` → stream of `Session[]`: this engine's live statuses merged with
//! remote devices' workspace session rows
//! - `Mutate {op, …}` → `{ok}` — workspace entity mutations (createChat, renameChat,
//! setChatArchived, deleteChat, renameDevice, markChatSeen)
//! setChatArchived, deleteChat, renameDevice, deleteDevice, markChatSeen)
//! - `EngineInfo` → `{deviceId, workspaceScope}` — this runtime's fixed identity
//! and data boundary (never forwarded)
//! - `LocalDevice` → `{deviceId}` — legacy engine identity (never forwarded)
Expand Down Expand Up @@ -533,6 +533,9 @@ enum MutateParams {
/// Live runs hosted here are interrupted best-effort.
#[serde(rename_all = "camelCase")]
DeleteSpace { space_id: String },
/// Remove a device and all of its registry-owned spaces and chats.
#[serde(rename_all = "camelCase")]
DeleteDevice { device_id: String },
#[serde(rename_all = "camelCase")]
RenameChat { chat_id: String, title: String },
/// Set the chat's checkout branch label — the sidebar's
Expand Down Expand Up @@ -1160,6 +1163,20 @@ impl EngineRpc {
.rename_device(&device_id, &name)
.map_err(failed)
.map(drop),
MutateParams::DeleteDevice { device_id } => {
let deleted = self.workspace.delete_device(&device_id).map_err(failed)?;
let sessions = self.sessions.clone();
let doc_host = self.doc_host.clone();
tokio::spawn(async move {
for chat_id in deleted.chat_ids {
if let Err(err) = sessions.interrupt(&chat_id).await {
tracing::debug!(chat = %chat_id, error = %err, "deleteDevice interrupt skipped");
}
doc_host.purge_chat(&chat_id);
}
});
Ok(())
}
MutateParams::MarkChatSeen { chat_id, at } => {
let at = at
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
Expand Down
11 changes: 10 additions & 1 deletion crates/engine/src/workspace_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
use chrono::Utc;
use tokio::sync::watch;

use zeron_doc::{DeletedSpace, REGISTRY_DOC_ID, RegistryDoc, WorkspaceDoc};
use zeron_doc::{DeletedDevice, DeletedSpace, REGISTRY_DOC_ID, RegistryDoc, WorkspaceDoc};
use zeron_proto::{Chat, ChatConfig, Device, Session, SidebarPreferencesState, Space};
use zeron_sync::{DocsStore, RegistryClient, RegistryTuning};

Expand Down Expand Up @@ -1120,6 +1120,15 @@ impl WorkspaceHost {
Ok(self.mutate(|doc| doc.rename_device(device_id, name))?)
}

pub fn delete_device(&self, device_id: &str) -> Result<DeletedDevice, EngineError> {
if device_id == self.inner.config.device_id {
return Err(EngineError::Other(
"cannot delete the current device".into(),
));
}
Ok(self.mutate(|doc| doc.delete_device(device_id))?)
}

// ── git metadata (diff-sync host writes) ────────────────────────────────

/// HEAD-watcher reconciliation: the branch checked out at the chat's cwd.
Expand Down
2 changes: 1 addition & 1 deletion crates/rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ pub mod methods {
pub const WATCH_SPACES: &str = "WatchSpaces";
/// Entity mutations against the workspace doc (feature-inventory §2 DataRpc).
/// Params are tagged `{op: createChat|createSpace|renameSpace|deleteSpace|
/// renameChat|setChatArchived|deleteChat|renameDevice|markChatSeen, …}`.
/// renameChat|setChatArchived|deleteChat|renameDevice|deleteDevice|markChatSeen, …}`.
pub const MUTATE: &str = "Mutate";
/// This engine's identity → `{deviceId}` (IPC-only; never relay-forwarded —
/// the answer is about whichever engine you are directly connected to).
Expand Down
92 changes: 91 additions & 1 deletion crates/ui/src/settings/devices.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Settings → Devices (feature-inventory §1.5): the device registry — name,
//! platform, last-seen, an Online/Offline badge, a "This device" badge, click-to-copy id,
//! and a Rename dialog (Mutate renameDevice).
//! and Rename/Remove dialogs (Mutate renameDevice/deleteDevice).

use chrono::{DateTime, Utc};
use gpui::{
Expand Down Expand Up @@ -65,6 +65,7 @@ pub struct DevicesPage {
state: Entity<AppState>,
scroll: widgets::PageScroll,
rename: Option<RenameDialog>,
delete: Option<(String, String)>,
/// Device id whose id-chip shows "Copied" right now.
copied: Option<String>,
error: Option<SharedString>,
Expand All @@ -80,6 +81,7 @@ impl DevicesPage {
state,
scroll: widgets::PageScroll::default(),
rename: None,
delete: None,
copied: None,
error: None,
task: None,
Expand Down Expand Up @@ -149,6 +151,30 @@ impl DevicesPage {
cx.notify();
}

fn submit_delete(&mut self, cx: &mut Context<Self>) {
let Some((device_id, _)) = self.delete.take() else {
return;
};
let Some(engine) = self.state.read(cx).engine().cloned() else {
return;
};
let params = serde_json::json!({
"op": "deleteDevice",
"deviceId": device_id,
});
self.task = Some(cx.spawn(async move |this, cx| {
let result = engine.client().call(methods::MUTATE, params).await;
this.update(cx, |page, cx| {
if let Err(err) = result {
page.error = Some(format!("Remove failed: {err}").into());
}
cx.notify();
})
.ok();
}));
cx.notify();
}

fn copy_id(&mut self, device_id: String, cx: &mut Context<Self>) {
cx.write_to_clipboard(ClipboardItem::new_string(device_id.clone()));
self.copied = Some(device_id);
Expand Down Expand Up @@ -221,6 +247,52 @@ impl DevicesPage {
Some(popover::modal("rename-device-dialog", viewport, card))
}

fn render_delete_dialog(
&mut self,
viewport: gpui::Size<gpui::Pixels>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let theme = Theme::of(cx).for_popup();
let (_device_id, device_name) = self.delete.clone()?;
let card = popover::dialog_card(&theme)
.id("delete-device-card")
.role(gpui::Role::Dialog)
.aria_label("Remove device")
.child(popover::dialog_title(&theme, "Remove device"))
.child(
div()
.mt(px(12.0))
.child(format!(
"Remove \"{device_name}\" and its spaces and chats from this workspace? Active runs on the device will be stopped when reachable."
)),
)
.child(
div()
.mt(px(16.0))
.flex()
.flex_row()
.justify_end()
.gap(px(8.0))
.child(
popover::btn_ghost(&theme, "Cancel", "delete-device-cancel")
.id("delete-device-cancel")
.on_click(cx.listener(|this, _, _, cx| {
this.delete = None;
cx.notify();
})),
)
.child(
popover::btn_danger(&theme, "Remove")
.id("delete-device-confirm")
.on_click(cx.listener(move |this, _, _, cx| {
this.submit_delete(cx);
})),
),
)
.into_any_element();
Some(popover::modal("delete-device-dialog", viewport, card))
}

fn on_scroll_hovered(&mut self, hovered: &bool, _: &mut Window, cx: &mut Context<Self>) {
if self.scroll.set_list_hovered(*hovered) {
cx.notify();
Expand Down Expand Up @@ -274,6 +346,7 @@ impl Render for DevicesPage {
};
let copied = self.copied.clone();
let dialog = self.render_rename_dialog(window.viewport_size(), cx);
let delete_dialog = self.render_delete_dialog(window.viewport_size(), cx);
// Split into this device and the rest; each renders as rows in one
// block, like every other settings page.
let (local, others): (Vec<_>, Vec<_>) = devices
Expand All @@ -287,6 +360,8 @@ impl Render for DevicesPage {
let copy_id = device.id.clone();
let rename_id = device.id.clone();
let rename_name = device.name.clone();
let delete_id = device.id.clone();
let delete_name = device.name.clone();
let mut meta: Vec<AnyElement> = vec![
div()
.child(SharedString::from(platform_label(&device.platform).to_string()))
Expand Down Expand Up @@ -356,6 +431,20 @@ impl Render for DevicesPage {
})),
),
)
.when(!is_local, |row| {
row.child(
widgets::text_action(&theme, widgets::ActionTone::Quiet, "Remove")
.id(("device-remove", ix))
.tab_index(0)
.role(gpui::Role::Button)
.aria_label(format!("Remove {}", delete_name))
.focus_visible(|s| s.border_2().border_color(theme.accent))
.on_click(cx.listener(move |this, _, _, cx| {
this.delete = Some((delete_id.clone(), delete_name.clone()));
cx.notify();
})),
)
})
.into_any_element()
};
let local_block = (!local.is_empty()).then(|| {
Expand Down Expand Up @@ -442,6 +531,7 @@ impl Render for DevicesPage {
)
.children(scrollbar)
.when_some(dialog, |el, dialog| el.child(dialog))
.when_some(delete_dialog, |el, dialog| el.child(dialog))
}
}

Expand Down
8 changes: 8 additions & 0 deletions docs/registry-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ confirmed state. Later pin toggles from older apps still take effect through the
existing `pinned` field clocks. Section syncing itself requires an updated app.

Legacy per-profile UI sections import after an authoritative registry snapshot.
Device deletion is a registry mutation exposed through `Mutate` as
`{"op":"deleteDevice","deviceId":"..."}`. It tombstones the device row and, in
the same batch, its spaces, chats, and session index rows. Projectless chats
hosted directly by the device are included. The per-chat transcript documents
are not deleted. Deletion is idempotent, works while the target device is
offline, and cannot target the engine's current device. Tombstones prevent stale
registry updates from making removed rows visible again.

Import skips existing section IDs (including deleted ones) and existing explicit
placements, so replay does not overwrite newer edits. The engine persists the
registry snapshot and outbox before acknowledging migration; only then does the
Expand Down
Loading