diff --git a/crates/doc/src/registry.rs b/crates/doc/src/registry.rs index 19221d584c..3d4e72ae43 100644 --- a/crates/doc/src/registry.rs +++ b/crates/doc/src/registry.rs @@ -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"; @@ -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 { + let existed = self.row_exists(KIND_DEVICES, device_id); + let space_ids: Vec = 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 = 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( diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index c6d6aa015b..dca43298fe 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -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::::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"); diff --git a/crates/doc/src/workspace.rs b/crates/doc/src/workspace.rs index 9060e88207..45c33a3c49 100644 --- a/crates/doc/src/workspace.rs +++ b/crates/doc/src/workspace.rs @@ -60,6 +60,14 @@ pub struct DeletedSpace { pub chat_ids: Vec, } +/// Result of deleting a device and its registry-owned descendants. +#[derive(Debug, Clone, PartialEq)] +pub struct DeletedDevice { + pub existed: bool, + pub space_ids: Vec, + pub chat_ids: Vec, +} + /// A workspace doc handle: typed access over a LoroDoc with the schema above. pub struct WorkspaceDoc { doc: LoroDoc, diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 1401408593..0be042f6be 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -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) @@ -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 @@ -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::::from_timestamp_millis) diff --git a/crates/engine/src/workspace_host.rs b/crates/engine/src/workspace_host.rs index df4329a3dd..cee0ae6ae4 100644 --- a/crates/engine/src/workspace_host.rs +++ b/crates/engine/src/workspace_host.rs @@ -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}; @@ -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 { + 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. diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index b775411976..fa26c8ec33 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -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). diff --git a/crates/ui/src/settings/devices.rs b/crates/ui/src/settings/devices.rs index 7777ca7061..73c7d5f975 100644 --- a/crates/ui/src/settings/devices.rs +++ b/crates/ui/src/settings/devices.rs @@ -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::{ @@ -65,6 +65,7 @@ pub struct DevicesPage { state: Entity, scroll: widgets::PageScroll, rename: Option, + delete: Option<(String, String)>, /// Device id whose id-chip shows "Copied" right now. copied: Option, error: Option, @@ -80,6 +81,7 @@ impl DevicesPage { state, scroll: widgets::PageScroll::default(), rename: None, + delete: None, copied: None, error: None, task: None, @@ -149,6 +151,30 @@ impl DevicesPage { cx.notify(); } + fn submit_delete(&mut self, cx: &mut Context) { + 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) { cx.write_to_clipboard(ClipboardItem::new_string(device_id.clone())); self.copied = Some(device_id); @@ -221,6 +247,52 @@ impl DevicesPage { Some(popover::modal("rename-device-dialog", viewport, card)) } + fn render_delete_dialog( + &mut self, + viewport: gpui::Size, + cx: &mut Context, + ) -> Option { + 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) { if self.scroll.set_list_hovered(*hovered) { cx.notify(); @@ -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 @@ -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 = vec![ div() .child(SharedString::from(platform_label(&device.platform).to_string())) @@ -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(|| { @@ -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)) } } diff --git a/docs/registry-sync.md b/docs/registry-sync.md index a603603a02..7973f3f61c 100644 --- a/docs/registry-sync.md +++ b/docs/registry-sync.md @@ -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