Vectrum
Sign In

Remote connections: serve this device's threads to other devices and mirror theirs, with a Connections settings page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 0fa4846commit12d7ea8

Showing 8 changed files with 1183 additions and 51 deletions.

+51−0src/remote/mod.rsView File
@@ -249,4 +249,55 @@ mod tests {
249249 let text = serde_json::to_string(&message).unwrap();
250250 assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
251251 }
252+
253+ #[::core::prelude::v1::test]
254+ fn client_and_server_talk() {
255+ use std::time::Duration;
256+
257+ use super::client::{ClientEvent, ConnectionStatus, RemoteClient};
258+ use super::server::{RemoteServer, ServerEvent};
259+
260+ let port = 47_900 + (std::process::id() % 90) as u16;
261+ let key = new_key();
262+ let server = RemoteServer::start(port, key.clone(), "test-host".into()).unwrap();
263+ fn wait<T>(receiver: &smol::channel::Receiver<T>) -> Option<T> {
264+ smol::block_on(async {
265+ smol::future::or(async { receiver.recv().await.ok() }, async {
266+ smol::Timer::after(Duration::from_secs(10)).await;
267+ None
268+ })
269+ .await
270+ })
271+ }
272+
273+ let client = RemoteClient::connect(&format!("127.0.0.1:{port}"), key, "test-client".into());
274+ assert!(matches!(wait(&client.events), Some(ClientEvent::Status(ConnectionStatus::Connecting))));
275+ match wait(&client.events) {
276+ Some(ClientEvent::Status(ConnectionStatus::Connected(host))) => assert_eq!(host, "test-host"),
277+ _ => panic!("didn't connect"),
278+ }
279+ let conn = match wait(&server.events) {
280+ Some(ServerEvent::Connected { conn, name, .. }) => {
281+ assert_eq!(name, "test-client");
282+ conn
283+ }
284+ _ => panic!("server didn't see the client"),
285+ };
286+ client.send(&ClientMessage::Subscribe { thread: "t1".into() });
287+ match wait(&server.events) {
288+ Some(ServerEvent::Message { message: ClientMessage::Subscribe { thread }, .. }) => assert_eq!(thread, "t1"),
289+ _ => panic!("server didn't get the message"),
290+ }
291+ server.send(conn, &ServerMessage::Created { reference: 7, thread: "t2".into() });
292+ match wait(&client.events) {
293+ Some(ClientEvent::Message(ServerMessage::Created { reference, thread })) => {
294+ assert_eq!((reference, thread.as_str()), (7, "t2"))
295+ }
296+ _ => panic!("client didn't get the reply"),
297+ }
298+
299+ let intruder = RemoteClient::connect(&format!("127.0.0.1:{port}"), "wrong".into(), "intruder".into());
300+ assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Connecting))));
301+ assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Denied(_)))));
302+ }
252303 }
+5−0src/session.rsView File
@@ -2338,6 +2338,11 @@ impl Session {
23382338 session
23392339 }
23402340
2341+ /// Asks the host to delete a mirrored thread.
2342+ pub fn forget_remote(&self) {
2343+ self.forward(ThreadAction::Delete);
2344+ }
2345+
23412346 pub fn is_remote(&self) -> bool {
23422347 self.remote.is_some()
23432348 }
+44−5src/settings.rsView File
@@ -148,6 +148,24 @@ impl ProviderInstance {
148148 }
149149 }
150150
151+ /// Another device's app this one connects to.
152+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
153+ #[serde(default)]
154+ pub struct RemoteHost {
155+ pub id: String,
156+ pub name: String,
157+ /// `host` or `host:port`.
158+ pub address: String,
159+ pub key: String,
160+ pub enabled: bool,
161+ }
162+
163+ impl RemoteHost {
164+ pub fn label(&self) -> String {
165+ if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() }
166+ }
167+ }
168+
151169 /// A usage hub the Usage page also reads, such as a CLIProxyAPI server.
152170 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
153171 #[serde(default)]
@@ -262,6 +280,15 @@ pub struct Settings {
262280 pub providers: Vec<ProviderInstance>,
263281 pub usage_hubs: Vec<UsageHub>,
264282
283+ // ---- remote connections ----
284+ /// Let other devices' apps connect to this one.
285+ pub remote_server: bool,
286+ pub remote_port: u16,
287+ /// The key other devices connect with; made when first needed.
288+ pub remote_key: String,
289+ /// Other devices' apps this one connects to.
290+ pub remotes: Vec<RemoteHost>,
291+
265292 /// Superseded by `providers`; read once to carry an old path override over.
266293 #[serde(skip_serializing)]
267294 claude_bin: Option<String>,
@@ -319,6 +346,10 @@ impl Default for Settings {
319346 editor: None,
320347 providers: Vec::new(),
321348 usage_hubs: Vec::new(),
349+ remote_server: false,
350+ remote_port: crate::remote::DEFAULT_PORT,
351+ remote_key: String::new(),
352+ remotes: Vec::new(),
322353 claude_bin: None,
323354 pi_bin: None,
324355 }
@@ -401,13 +432,21 @@ pub fn update(edit: impl FnOnce(&mut Settings)) {
401432 }
402433 }
403434
404- /// Puts everything but favorites, agents and usage hubs back to its default.
435+ /// Puts everything but favorites, agents, usage hubs and remote connections
436+ /// back to its default.
405437 pub fn restore_defaults() {
406438 update(|settings| {
407- let favorites = std::mem::take(&mut settings.favorites);
408- let providers = std::mem::take(&mut settings.providers);
409- let usage_hubs = std::mem::take(&mut settings.usage_hubs);
410- *settings = Settings { favorites, providers, usage_hubs, ..Settings::default() };
439+ let kept = Settings {
440+ favorites: std::mem::take(&mut settings.favorites),
441+ providers: std::mem::take(&mut settings.providers),
442+ usage_hubs: std::mem::take(&mut settings.usage_hubs),
443+ remote_server: settings.remote_server,
444+ remote_port: settings.remote_port,
445+ remote_key: std::mem::take(&mut settings.remote_key),
446+ remotes: std::mem::take(&mut settings.remotes),
447+ ..Settings::default()
448+ };
449+ *settings = kept;
411450 });
412451 }
413452
+27−15src/ui/chat.rsView File
@@ -44,8 +44,9 @@ pub enum ChatEvent {
4444 /// Start a thread on this model; it belongs to another provider than this
4545 /// thread's.
4646 NewThreadWithModel(ModelEntry),
47- /// Move this (still empty) thread to another project.
48- SwitchProject(PathBuf),
47+ /// Move this (still empty) thread to another project: its folder, and
48+ /// the remote connection it's on (`None` here).
49+ SwitchProject(PathBuf, Option<String>),
4950 AddProject,
5051 StartWithoutProject,
5152 SwitchBranch(String),
@@ -510,30 +511,37 @@ impl ChatView {
510511 fn render_start(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
511512 let p = metal::palette(cx);
512513 let session = self.session.read(cx);
513- let cwd = session.cwd.clone();
514+ let cwd = session.project.clone();
515+ let env = session.remote.as_ref().map(|remote| remote.env.clone());
514516 let directory = cx.try_global::<ProjectDirectory>();
515- let current = directory.and_then(|directory| directory.projects.iter().find(|project| project.path == cwd));
517+ let current = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
516518 let scratch = current.is_some_and(|project| project.scratch);
517519 let project = current.map(|project| project.name.clone()).unwrap_or_else(|| display_path(&cwd).into());
518- let projects: Vec<(SharedString, PathBuf)> = directory
520+ let projects: Vec<(SharedString, PathBuf, Option<String>)> = directory
519521 .map(|directory| {
520522 directory
521523 .projects
522524 .iter()
523525 .filter(|project| !project.scratch)
524- .map(|project| (project.name.clone(), project.path.clone()))
526+ .map(|project| {
527+ let name = match &project.host {
528+ Some(host) => format!("{} · {host}", project.name).into(),
529+ None => project.name.clone(),
530+ };
531+ (name, project.path.clone(), project.environment.clone())
532+ })
525533 .collect()
526534 })
527535 .unwrap_or_default();
528536 let chat = cx.entity().downgrade();
529537 let project_menu = move |mut menu: gpui_kit::component::menu::PopupMenu, _: &mut Window, _: &mut Context<gpui_kit::component::menu::PopupMenu>| {
530538 menu = menu.min_w(px(240.)).label("Project");
531- for (name, path) in projects.clone() {
539+ for (name, path, project_env) in projects.clone() {
532540 let chat = chat.clone();
533- let checked = path == cwd;
541+ let checked = path == cwd && project_env == env;
534542 menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| {
535- let path = path.clone();
536- let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path)));
543+ let (path, project_env) = (path.clone(), project_env.clone());
544+ let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path, project_env)));
537545 }));
538546 }
539547 let chat = chat.clone();
@@ -1085,10 +1093,14 @@ impl ChatView {
10851093 /// Under the start composer: where the thread will run, and on which branch.
10861094 fn render_start_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
10871095 let p = metal::palette(cx);
1088- let cwd = self.session.read(cx).cwd.clone();
1096+ let session = self.session.read(cx);
1097+ let cwd = session.project.clone();
1098+ let env = session.remote.as_ref().map(|remote| remote.env.clone());
10891099 let directory = cx.try_global::<ProjectDirectory>();
1090- let hostname = directory.map(|directory| directory.hostname.clone()).unwrap_or_default();
1091- let project = directory.and_then(|directory| directory.projects.iter().find(|project| project.path == cwd));
1100+ let project = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
1101+ let remote_host = project.and_then(|project| project.host.clone());
1102+ let is_remote = remote_host.is_some();
1103+ let hostname = remote_host.unwrap_or_else(|| directory.map(|directory| directory.hostname.clone()).unwrap_or_default());
10921104 let plan = self.session.read(cx).pending_worktree.clone();
10931105 let branch = match &plan {
10941106 Some(plan) => plan.base.clone().or_else(|| project.and_then(|project| project.branch.clone())),
@@ -1115,10 +1127,10 @@ impl ChatView {
11151127 .text_size(px(12.5))
11161128 .font_weight(FontWeight::MEDIUM)
11171129 .text_color(p.ink3)
1118- .child(Icon::new(Lucide::Laptop).size(px(13.)))
1130+ .child(Icon::new(if is_remote { Lucide::Server } else { Lucide::Laptop }).size(px(13.)))
11191131 .child(Icon::new(IconName::Folder).size(px(13.)))
11201132 .child(hostname)
1121- .child(metal::tag("Local", p)),
1133+ .child(metal::tag(if is_remote { "Remote" } else { "Local" }, p)),
11221134 )
11231135 .child(div().flex_1())
11241136 .when(can_worktree, |this| {
+3−0src/ui/icons.rsView File
@@ -31,6 +31,8 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
3131 Layers,
3232 ListOrdered,
3333 Lock,
34+ MonitorSmartphone,
35+ Network,
3436 LockOpen,
3537 PanelLeftClose,
3638 PanelLeftOpen,
@@ -44,6 +46,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
4446 Plug,
4547 Plus,
4648 RotateCcw,
49+ Server,
4750 ShieldCheck,
4851 SquareSlash,
4952 SlidersHorizontal,
+305−4src/ui/settings.rsView File
@@ -22,9 +22,10 @@ use gpui_kit::*;
2222 use crate::CloseSettings;
2323 use crate::claude::{self, Effort, PermissionMode};
2424 use crate::models::{self, Catalog, InstanceState, ProbeStatus, Provider, split_key};
25+ use crate::remote::{self, DEFAULT_PORT, client::ConnectionStatus};
2526 use crate::session::{Reasoning, Session};
2627 use crate::settings::{
27- self, BackgroundActivity, EnvVar, FollowUp, ProjectOrder, ProviderInstance, QuitShortcut, Settings, Streaming,
28+ self, BackgroundActivity, EnvVar, FollowUp, ProjectOrder, ProviderInstance, QuitShortcut, RemoteHost, Settings, Streaming,
2829 Submodules, TimeFormat, WorkspaceMode,
2930 };
3031 use crate::ui::chat::display_path;
@@ -41,17 +42,19 @@ pub enum Page {
4142 Behavior,
4243 Appearance,
4344 Providers,
45+ Connections,
4446 Keybindings,
4547 Archive,
4648 About,
4749 }
4850
4951 impl Page {
50- const ALL: [Page; 7] = [
52+ const ALL: [Page; 8] = [
5153 Page::General,
5254 Page::Behavior,
5355 Page::Appearance,
5456 Page::Providers,
57+ Page::Connections,
5558 Page::Keybindings,
5659 Page::Archive,
5760 Page::About,
@@ -63,6 +66,7 @@ impl Page {
6366 Page::Behavior => "Behavior",
6467 Page::Appearance => "Appearance",
6568 Page::Providers => "Providers",
69+ Page::Connections => "Connections",
6670 Page::Keybindings => "Keybindings",
6771 Page::Archive => "Archive",
6872 Page::About => "About",
@@ -75,6 +79,7 @@ impl Page {
7579 Page::Behavior => Icon::new(Lucide::Bell),
7680 Page::Appearance => Icon::new(IconName::Palette),
7781 Page::Providers => Icon::new(Lucide::Plug),
82+ Page::Connections => Icon::new(Lucide::Network),
7883 Page::Keybindings => Icon::new(Lucide::Keyboard),
7984 Page::Archive => Icon::new(Lucide::Archive),
8085 Page::About => Icon::new(IconName::Info),
@@ -102,6 +107,12 @@ pub struct SettingsView {
102107 page: Page,
103108 add_project_dir: Entity<InputState>,
104109 provider_form: Option<ProviderForm>,
110+ remote_port: Entity<InputState>,
111+ /// The "Add connection" form: name, address or pairing code, key.
112+ connect_name: Entity<InputState>,
113+ connect_address: Entity<InputState>,
114+ connect_key: Entity<InputState>,
115+ show_key: bool,
105116 /// Instances an update is running for.
106117 updating: Vec<String>,
107118 focus_handle: FocusHandle,
@@ -141,7 +152,22 @@ impl SettingsView {
141152 let add_project_dir = cx.new(|cx| {
142153 InputState::new(window, cx).placeholder("~/").default_value(saved.add_project_dir.clone())
143154 });
144- let subscriptions = vec![cx.subscribe_in(
155+ let remote_port = cx.new(|cx| {
156+ InputState::new(window, cx).placeholder(DEFAULT_PORT.to_string()).default_value(saved.remote_port.to_string())
157+ });
158+ let connect_name = cx.new(|cx| InputState::new(window, cx).placeholder("Studio desktop"));
159+ let connect_address = cx.new(|cx| InputState::new(window, cx).placeholder("192.168.2.10 or a pairing code"));
160+ let connect_key = cx.new(|cx| InputState::new(window, cx).placeholder("Key").masked(true));
161+ let port_subscription = cx.subscribe_in(&remote_port, window, |this, input, event: &InputEvent, window, cx| {
162+ if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
163+ let port = input.read(cx).value().trim().parse::<u16>().ok().filter(|port| *port > 0).unwrap_or(DEFAULT_PORT);
164+ if settings::get(|settings| settings.remote_port != port) {
165+ settings::update(|settings| settings.remote_port = port);
166+ this.changed(window, cx);
167+ }
168+ }
169+ });
170+ let subscriptions = vec![port_subscription, cx.subscribe_in(
145171 &add_project_dir,
146172 window,
147173 |this, input, event: &InputEvent, window, cx| {
@@ -159,6 +185,11 @@ impl SettingsView {
159185 page: Page::General,
160186 add_project_dir,
161187 provider_form: None,
188+ remote_port,
189+ connect_name,
190+ connect_address,
191+ connect_key,
192+ show_key: false,
162193 updating: Vec::new(),
163194 focus_handle: cx.focus_handle(),
164195 _subscriptions: subscriptions,
@@ -174,8 +205,9 @@ impl SettingsView {
174205 }
175206
176207 fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
177- let dir = settings::get(|settings| settings.add_project_dir.clone());
208+ let (dir, port) = settings::get(|settings| (settings.add_project_dir.clone(), settings.remote_port));
178209 self.add_project_dir.update(cx, |input, cx| input.set_value(dir, window, cx));
210+ self.remote_port.update(cx, |input, cx| input.set_value(port.to_string(), window, cx));
179211 let selected = self.provider_form.as_ref().map(|form| form.id.clone());
180212 self.provider_form = None;
181213 if let Some(id) = selected {
@@ -342,6 +374,7 @@ impl SettingsView {
342374 Page::Behavior => self.render_behavior(cx),
343375 Page::Appearance => self.render_appearance(cx),
344376 Page::Providers => self.render_providers(window, cx),
377+ Page::Connections => self.render_connections(window, cx),
345378 Page::Keybindings => self.render_keybindings(cx),
346379 Page::Archive => self.render_archive(cx),
347380 Page::About => self.render_about(cx),
@@ -1411,6 +1444,274 @@ impl SettingsView {
14111444 .into_any_element()
14121445 }
14131446
1447+ // ---- connections -------------------------------------------------------
1448+
1449+ /// Saves the "Add connection" form as a new connection.
1450+ fn add_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1451+ let value = |input: &Entity<InputState>| input.read(cx).value().trim().to_string();
1452+ let (address, code_key) = remote::parse_pairing(&value(&self.connect_address));
1453+ let key = Some(value(&self.connect_key)).filter(|key| !key.is_empty()).or(code_key);
1454+ let (Some(key), false) = (key, address.is_empty()) else {
1455+ window.push_notification(Notification::error("Enter the other device's address and key, or its pairing code."), cx);
1456+ return;
1457+ };
1458+ let name = value(&self.connect_name);
1459+ let ids: Vec<String> = settings::get(|settings| settings.remotes.iter().map(|host| host.id.clone()).collect());
1460+ let id = (1..).map(|n| format!("remote-{n}")).find(|id| !ids.contains(id)).unwrap_or_default();
1461+ settings::update(|settings| {
1462+ settings.remotes.push(RemoteHost { id, name, address, key, enabled: true });
1463+ });
1464+ for input in [&self.connect_name, &self.connect_address, &self.connect_key] {
1465+ input.update(cx, |input, cx| input.set_value("", window, cx));
1466+ }
1467+ self.changed(window, cx);
1468+ }
1469+
1470+ fn edit_remote(&mut self, window: &mut Window, cx: &mut Context<Self>, change: impl FnOnce(&mut Vec<RemoteHost>)) {
1471+ settings::update(|settings| change(&mut settings.remotes));
1472+ self.changed(window, cx);
1473+ }
1474+
1475+ fn render_connections(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
1476+ let p = metal::palette(cx);
1477+ let saved = settings::load();
1478+ let view = cx.weak_entity();
1479+ let overview = self.workspace.upgrade().map(|workspace| workspace.read(cx).remote_overview());
1480+ let (serving, error, peers, statuses) = match overview {
1481+ Some(overview) => (overview.serving, overview.error, overview.peers, overview.environments),
1482+ None => (None, None, Vec::new(), Vec::new()),
1483+ };
1484+ let addresses: Vec<String> =
1485+ remote::local_addresses().into_iter().map(|address| format!("{address}:{}", saved.remote_port)).collect();
1486+
1487+ let mut this_device = vec![row(
1488+ "Allow remote connections",
1489+ "Other devices running Vectrum Code can open this device's projects and threads and work in them, with this device's agents and files.",
1490+ switch("remote-server", saved.remote_server, &view, p, |settings, on| settings.remote_server = on),
1491+ p,
1492+ )];
1493+ if saved.remote_server {
1494+ let (led, status) = match (&error, serving) {
1495+ (Some(error), _) => (Led::Failed, error.clone()),
1496+ (None, Some(port)) => (
1497+ Led::Live,
1498+ match addresses.as_slice() {
1499+ [] => format!("Listening on port {port}."),
1500+ addresses => format!("Listening at {}.", addresses.join(", ")),
1501+ },
1502+ ),
1503+ (None, None) => (Led::Waiting, "Starting…".to_string()),
1504+ };
1505+ this_device.push(row("Status", status, metal::led("remote-server-led", led, serving.is_none() && error.is_none(), p), p));
1506+ this_device.push(row(
1507+ "Port",
1508+ "The port other devices connect to. Your firewall must let it in.",
1509+ text_field(&self.remote_port, 110., window, cx),
1510+ p,
1511+ ));
1512+ let key = saved.remote_key.clone();
1513+ let shown = if self.show_key {
1514+ key.clone()
1515+ } else {
1516+ format!("{}{}", "•".repeat(12), key.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect::<String>())
1517+ };
1518+ let copy_key = key.clone();
1519+ this_device.push(row(
1520+ "Key",
1521+ "Other devices need it to connect. A new key disconnects everyone using the old one.",
1522+ h_flex()
1523+ .gap(px(6.))
1524+ .child(
1525+ div()
1526+ .px(px(10.))
1527+ .py(px(6.))
1528+ .rounded(px(10.))
1529+ .well(p)
1530+ .font_family(metal::MONO)
1531+ .text_size(px(11.5))
1532+ .text_color(p.ink2)
1533+ .max_w(px(230.))
1534+ .truncate()
1535+ .child(shown),
1536+ )
1537+ .child(
1538+ MetalButton::new("show-key")
1539+ .ghost()
1540+ .mini()
1541+ .icon(if self.show_key { IconName::EyeOff } else { IconName::Eye })
1542+ .tooltip(if self.show_key { "Hide" } else { "Show" })
1543+ .on_click(cx.listener(|this, _, _, cx| {
1544+ this.show_key = !this.show_key;
1545+ cx.notify();
1546+ })),
1547+ )
1548+ .child(
1549+ MetalButton::new("copy-key")
1550+ .ghost()
1551+ .mini()
1552+ .icon(IconName::Copy)
1553+ .tooltip("Copy key")
1554+ .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy_key.clone()))),
1555+ )
1556+ .child(
1557+ MetalButton::new("new-key")
1558+ .ghost()
1559+ .mini()
1560+ .icon(Lucide::RotateCcw)
1561+ .tooltip("Make a new key")
1562+ .on_click(cx.listener(|this, _, window, cx| {
1563+ settings::update(|settings| settings.remote_key = remote::new_key());
1564+ this.changed(window, cx);
1565+ })),
1566+ ),
1567+ p,
1568+ ));
1569+ if let Some(address) = addresses.first() {
1570+ let code = remote::pairing_code(address, &key);
1571+ let copy = code.clone();
1572+ this_device.push(row(
1573+ "Pairing code",
1574+ "Paste it into Settings → Connections on the other device: the address and key in one.",
1575+ MetalButton::new("copy-pairing")
1576+ .compact()
1577+ .icon(IconName::Copy)
1578+ .label("Copy pairing code")
1579+ .on_click(move |_, window, cx| {
1580+ cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()));
1581+ window.push_notification(Notification::success("Pairing code copied."), cx);
1582+ }),
1583+ p,
1584+ ));
1585+ }
1586+ let workspace = self.workspace.clone();
1587+ let peer_rows: Vec<AnyElement> = if peers.is_empty() {
1588+ vec![row("Connected devices", "None right now.", div(), p)]
1589+ } else {
1590+ peers
1591+ .into_iter()
1592+ .map(|peer| {
1593+ let workspace = workspace.clone();
1594+ let conn = peer.conn;
1595+ row(
1596+ "Connected device",
1597+ format!("{} · {}", peer.name, peer.address),
1598+ MetalButton::new(("disconnect-peer", conn as usize))
1599+ .ghost()
1600+ .compact()
1601+ .label("Disconnect")
1602+ .on_click(move |_, _, cx| {
1603+ let _ = workspace.update(cx, |workspace, cx| workspace.disconnect_peer(conn, cx));
1604+ }),
1605+ p,
1606+ )
1607+ })
1608+ .collect()
1609+ };
1610+ this_device.extend(peer_rows);
1611+ }
1612+ let this_device = section(
1613+ "This device",
1614+ Some("Connections aren't encrypted. Allow them on networks you trust, or over a VPN such as Tailscale or WireGuard."),
1615+ this_device,
1616+ p,
1617+ );
1618+
1619+ let mut others: Vec<AnyElement> = saved
1620+ .remotes
1621+ .iter()
1622+ .map(|host| {
1623+ let status = statuses.iter().find(|(id, _)| *id == host.id).map(|(_, status)| status.clone());
1624+ let (led, text) = match (&status, host.enabled) {
1625+ (_, false) => (Led::Off, "Off".to_string()),
1626+ (Some(ConnectionStatus::Connected(name)), _) => (Led::Live, format!("Connected to {name}")),
1627+ (Some(ConnectionStatus::Connecting) | None, _) => (Led::Waiting, "Connecting…".to_string()),
1628+ (Some(ConnectionStatus::Failed(error)), _) => (Led::Failed, format!("Retrying · {error}")),
1629+ (Some(ConnectionStatus::Denied(reason)), _) => (Led::Failed, reason.clone()),
1630+ };
1631+ let (toggle_id, remove_id) = (host.id.clone(), host.id.clone());
1632+ let (toggle_view, remove_view) = (view.clone(), view.clone());
1633+ h_flex()
1634+ .px(px(18.))
1635+ .py(px(12.))
1636+ .gap(px(12.))
1637+ .child(metal::led(SharedString::from(format!("remote-led-{}", host.id)), led, status == Some(ConnectionStatus::Connecting), p))
1638+ .child(
1639+ v_flex()
1640+ .flex_1()
1641+ .min_w_0()
1642+ .gap(px(2.))
1643+ .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(host.label()))
1644+ .child(
1645+ div()
1646+ .truncate()
1647+ .text_size(px(12.5))
1648+ .text_color(p.ink3)
1649+ .child(format!("{} · {text}", remote::with_port(&host.address))),
1650+ ),
1651+ )
1652+ .child(metal::toggle(SharedString::from(format!("remote-enabled-{}", host.id)), host.enabled, p, move |on, window, cx| {
1653+ let id = toggle_id.clone();
1654+ let _ = toggle_view.update(cx, |this, cx| {
1655+ this.edit_remote(window, cx, move |hosts| {
1656+ if let Some(host) = hosts.iter_mut().find(|host| host.id == id) {
1657+ host.enabled = on;
1658+ }
1659+ })
1660+ });
1661+ }))
1662+ .child(
1663+ MetalButton::new(SharedString::from(format!("remove-remote-{}", host.id)))
1664+ .ghost()
1665+ .mini()
1666+ .icon(Lucide::Trash)
1667+ .tooltip("Remove")
1668+ .on_click(move |_, window, cx| {
1669+ let id = remove_id.clone();
1670+ let _ = remove_view.update(cx, |this, cx| {
1671+ this.edit_remote(window, cx, move |hosts| hosts.retain(|host| host.id != id))
1672+ });
1673+ }),
1674+ )
1675+ .into_any_element()
1676+ })
1677+ .collect();
1678+ others.push(
1679+ v_flex()
1680+ .px(px(18.))
1681+ .py(px(14.))
1682+ .gap(px(10.))
1683+ .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child("Add connection"))
1684+ .child(
1685+ div()
1686+ .text_size(px(12.5))
1687+ .text_color(p.ink3)
1688+ .child("On the other device, turn on Allow remote connections and copy its pairing code, or its address and key."),
1689+ )
1690+ .child(
1691+ h_flex()
1692+ .gap(px(8.))
1693+ .child(text_field(&self.connect_name, 150., window, cx))
1694+ .child(text_field(&self.connect_address, 220., window, cx))
1695+ .child(text_field(&self.connect_key, 150., window, cx))
1696+ .child(
1697+ MetalButton::new("connect-remote")
1698+ .primary()
1699+ .compact()
1700+ .label("Connect")
1701+ .on_click(cx.listener(|this, _, window, cx| this.add_connection(window, cx))),
1702+ ),
1703+ )
1704+ .into_any_element(),
1705+ );
1706+ let other_devices = section(
1707+ "Other devices",
1708+ Some("Their projects and threads appear in the sidebar, tagged with the device's name."),
1709+ others,
1710+ p,
1711+ );
1712+ vec![this_device, other_devices]
1713+ }
1714+
14141715 fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
14151716 let p = metal::palette(cx);
14161717 let (ctrl_enter, hold) =
+123−27src/ui/workspace.rsView File
@@ -40,6 +40,10 @@ use crate::ui::settings::{SettingsEvent, SettingsView};
4040 use crate::worktree::{self, WorktreePlan};
4141 use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleProjectPanel, ToggleSidebar};
4242
43+ mod remote;
44+
45+ use remote::RemoteState;
46+
4347 pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
4448 const PANEL_WIDTH: f32 = 292.;
4549 /// Below this window width the project panel stays out of the conversation's way.
@@ -69,9 +73,20 @@ pub struct ProjectDirectory {
6973 pub projects: Vec<ProjectSummary>,
7074 }
7175
76+ impl ProjectDirectory {
77+ /// The project at `path` on a remote connection (`None` for this device).
78+ pub fn find(&self, path: &std::path::Path, environment: Option<&str>) -> Option<&ProjectSummary> {
79+ self.projects.iter().find(|project| project.path == path && project.environment.as_deref() == environment)
80+ }
81+ }
82+
7283 pub struct ProjectSummary {
7384 pub name: SharedString,
7485 pub path: PathBuf,
86+ /// The remote connection it's on, by id; `None` on this device.
87+ pub environment: Option<String>,
88+ /// The device's name, for projects on other devices.
89+ pub host: Option<SharedString>,
7590 /// The folder threads "without a project" run in.
7691 pub scratch: bool,
7792 /// Whether it's a git repository, so threads can start in a worktree.
@@ -104,6 +119,8 @@ struct Project {
104119 sessions: Vec<Entity<Session>>,
105120 /// `None` until read, or when the folder isn't a git repository.
106121 git: Option<GitInfo>,
122+ /// The remote connection it's on, by id; `None` on this device.
123+ environment: Option<String>,
107124 }
108125
109126 /// A thread in the sidebar: its project's index and the session.
@@ -167,10 +184,12 @@ pub struct Workspace {
167184 last_health: Instant,
168185 last_pr_check: Option<Instant>,
169186 last_repaint: Instant,
187+ remote: RemoteState,
170188 focus_handle: FocusHandle,
171189 observers: HashMap<EntityId, Vec<Subscription>>,
172190 _subscriptions: Vec<Subscription>,
173191 _ticker: Task<()>,
192+ _remote_flush: Task<()>,
174193 }
175194
176195 impl Workspace {
@@ -218,6 +237,15 @@ impl Workspace {
218237 }
219238 });
220239
240+ let remote_flush = cx.spawn(async move |this, cx| {
241+ loop {
242+ cx.background_executor().timer(remote::FLUSH).await;
243+ if this.update(cx, |this, cx| this.flush_remote(cx)).is_err() {
244+ break;
245+ }
246+ }
247+ });
248+
221249 let editors = detect_editors();
222250 let saved_editor = settings::get(|settings| settings.editor.clone());
223251 let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
@@ -251,10 +279,12 @@ impl Workspace {
251279 last_health: now,
252280 last_pr_check: None,
253281 last_repaint: now,
282+ remote: RemoteState::default(),
254283 focus_handle: cx.focus_handle(),
255284 observers: HashMap::new(),
256285 _subscriptions: subscriptions,
257286 _ticker: ticker,
287+ _remote_flush: remote_flush,
258288 };
259289 let active = workspace.restore(window, cx);
260290 match (initial_project, active) {
@@ -272,6 +302,7 @@ impl Workspace {
272302 workspace.replace_thread(ix, settings, window, cx);
273303 }
274304 }
305+ workspace.sync_remote(window, cx);
275306 workspace
276307 }
277308
@@ -314,7 +345,7 @@ impl Workspace {
314345 projects: self
315346 .projects
316347 .iter()
317- .filter(|project| Some(&project.path) != scratch.as_ref())
348+ .filter(|project| project.environment.is_none() && Some(&project.path) != scratch.as_ref())
318349 .map(|project| SavedProject {
319350 path: project.path.clone(),
320351 name: project.name.to_string(),
@@ -341,8 +372,9 @@ impl Workspace {
341372 .flat_map(|project| &project.sessions)
342373 .filter(|session| dirty.contains(&session.entity_id()))
343374 .map(|session| session.read(cx))
344- // A thread nothing was sent in isn't worth keeping.
345- .filter(|session| !session.items.is_empty())
375+ // A thread nothing was sent in isn't worth keeping, and another
376+ // device keeps its own.
377+ .filter(|session| !session.items.is_empty() && !session.is_remote())
346378 .map(Session::save)
347379 .collect();
348380 if threads.is_empty() && state.is_none() {
@@ -364,7 +396,7 @@ impl Workspace {
364396 fn save_everything(&mut self, cx: &App) {
365397 for session in self.projects.iter().flat_map(|project| &project.sessions) {
366398 let session = session.read(cx);
367- if !session.items.is_empty() {
399+ if !session.items.is_empty() && !session.is_remote() {
368400 store::save_thread(&session.save());
369401 }
370402 }
@@ -419,7 +451,8 @@ impl Workspace {
419451 fn poll_git(&mut self, cx: &mut Context<Self>) {
420452 self.git_busy = true;
421453 self.last_git = Instant::now();
422- let mut paths: Vec<PathBuf> = self.projects.iter().map(|project| project.path.clone()).collect();
454+ let mut paths: Vec<PathBuf> =
455+ self.projects.iter().filter(|project| project.environment.is_none()).map(|project| project.path.clone()).collect();
423456 let worktree = self.active_worktree(cx);
424457 paths.extend(worktree.clone());
425458 let task = cx.background_executor().spawn(async move {
@@ -437,7 +470,8 @@ impl Workspace {
437470 this.worktree_git.insert(path, info);
438471 changed = true;
439472 }
440- } else if let Some(project) = this.projects.iter_mut().find(|project| project.path == path)
473+ } else if let Some(project) =
474+ this.projects.iter_mut().find(|project| project.environment.is_none() && project.path == path)
441475 && project.git != info
442476 {
443477 project.git = info;
@@ -459,7 +493,13 @@ impl Workspace {
459493 let (settle_inactive, inactive_days) =
460494 settings::get(|settings| (settings.auto_settle_inactive, settings.inactive_days.max(1)));
461495 let inactive = Duration::from_secs(u64::from(inactive_days) * 86_400);
462- let sessions: Vec<Entity<Session>> = self.projects.iter().flat_map(|project| project.sessions.clone()).collect();
496+ // Other devices look after their own threads.
497+ let sessions: Vec<Entity<Session>> = self
498+ .projects
499+ .iter()
500+ .filter(|project| project.environment.is_none())
501+ .flat_map(|project| project.sessions.clone())
502+ .collect();
463503 for session in &sessions {
464504 let (wake, resume, settle) = {
465505 let state = session.read(cx);
@@ -500,6 +540,7 @@ impl Workspace {
500540 let threads: Vec<(Entity<Session>, PathBuf, String)> = self
501541 .projects
502542 .iter()
543+ .filter(|project| project.environment.is_none())
503544 .flat_map(|project| &project.sessions)
504545 .filter_map(|session| {
505546 let state = session.read(cx);
@@ -629,7 +670,7 @@ impl Workspace {
629670 fn worktree_plan(&self, project_ix: usize, mode: WorkspaceMode) -> Option<WorktreePlan> {
630671 let project = self.projects.get(project_ix)?;
631672 let git = project.git.as_ref()?;
632- if mode != WorkspaceMode::Worktree || Some(&project.path) == scratch_dir().as_ref() {
673+ if mode != WorkspaceMode::Worktree || project.environment.is_some() || Some(&project.path) == scratch_dir().as_ref() {
633674 return None;
634675 }
635676 let (from_origin, submodules) = settings::get(|settings| (settings.start_from_origin, settings.submodules));
@@ -648,8 +689,36 @@ impl Workspace {
648689 window: &mut Window,
649690 cx: &mut Context<Self>,
650691 ) -> Option<Entity<ChatView>> {
651- let project = self.projects.get(project_ix)?.path.clone();
652- let worktree = self.worktree_plan(project_ix, settings::get(|settings| settings.workspace));
692+ let project = self.projects.get(project_ix)?;
693+ let mode = settings::get(|settings| settings.workspace);
694+ let session = match project.environment.clone() {
695+ Some(env) => {
696+ let worktree = mode == WorkspaceMode::Worktree && project.git.is_some();
697+ let path = project.path.clone();
698+ let session = self.new_remote_session(&env, path, settings, worktree, cx)?;
699+ self.attach(project_ix, session.clone(), window, cx);
700+ session
701+ }
702+ None => {
703+ let plan = self.worktree_plan(project_ix, mode);
704+ self.spawn_thread(project_ix, settings, plan, window, cx)
705+ }
706+ };
707+ let chat = self.chats.get(&session.entity_id()).cloned();
708+ self.select(project_ix, session, window, cx);
709+ chat
710+ }
711+
712+ /// Starts a local thread without opening it.
713+ fn spawn_thread(
714+ &mut self,
715+ project_ix: usize,
716+ settings: ThreadSettings,
717+ worktree: Option<WorktreePlan>,
718+ window: &mut Window,
719+ cx: &mut Context<Self>,
720+ ) -> Entity<Session> {
721+ let project = self.projects[project_ix].path.clone();
653722 let session = cx.new(|cx| {
654723 Session::new(
655724 NewSession {
@@ -665,9 +734,8 @@ impl Workspace {
665734 cx,
666735 )
667736 });
668- let chat = self.attach(project_ix, session.clone(), window, cx);
669- self.select(project_ix, session, window, cx);
670- Some(chat)
737+ self.attach(project_ix, session.clone(), window, cx);
738+ session
671739 }
672740
673741 /// Adds a thread to a project with a conversation view for it.
@@ -700,7 +768,7 @@ impl Workspace {
700768 window: &mut Window,
701769 cx: &mut Context<Self>,
702770 ) {
703- let previous = self.active.clone().filter(|session| session.read(cx).items.is_empty());
771+ let previous = self.active.clone().filter(|session| session.read(cx).is_blank());
704772 let previous_chat = previous.as_ref().and_then(|session| self.chats.get(&session.entity_id()).cloned());
705773 let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) else { return };
706774 if let (Some(previous), Some(previous_chat)) = (previous, previous_chat) {
@@ -717,6 +785,7 @@ impl Workspace {
717785 fn on_session_changed(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
718786 cx.notify();
719787 self.dirty.insert(session.entity_id());
788+ self.remote_thread_changed(&session, cx);
720789 let state = session.read(cx);
721790 let now = Activity { working: state.is_working(), attention: state.needs_attention() };
722791 let before = self.activity.insert(session.entity_id(), now).unwrap_or_default();
@@ -765,8 +834,10 @@ impl Workspace {
765834 let settings = self.settings_for(model, cx);
766835 self.replace_thread(project_ix, settings, window, cx);
767836 }
768- ChatEvent::SwitchProject(path) => {
769- if let Some(target) = self.projects.iter().position(|project| &project.path == path) {
837+ ChatEvent::SwitchProject(path, environment) => {
838+ if let Some(target) =
839+ self.projects.iter().position(|project| &project.path == path && &project.environment == environment)
840+ {
770841 let settings = self.current_settings(cx);
771842 self.replace_thread(target, settings, window, cx);
772843 }
@@ -795,7 +866,17 @@ impl Workspace {
795866 }
796867 }
797868 ChatEvent::SetWorkspace(mode) => {
798- let plan = self.worktree_plan(project_ix, *mode);
869+ let project = &self.projects[project_ix];
870+ let plan = match &project.environment {
871+ // The other device makes the worktree; this only says so.
872+ Some(_) => (*mode == WorkspaceMode::Worktree && project.git.is_some()).then(|| WorktreePlan {
873+ project: project.path.clone(),
874+ base: None,
875+ from_origin: false,
876+ submodules: settings::Submodules::Skip,
877+ }),
878+ None => self.worktree_plan(project_ix, *mode),
879+ };
799880 session.update(cx, |session, cx| session.set_worktree_plan(plan, cx));
800881 }
801882 }
@@ -820,9 +901,9 @@ impl Workspace {
820901 }
821902 }
822903
823- /// The index of the project at `path`, adding it if needed.
904+ /// The index of this device's project at `path`, adding it if needed.
824905 fn ensure_project(&mut self, path: PathBuf, name: Option<&str>) -> usize {
825- if let Some(ix) = self.projects.iter().position(|project| project.path == path) {
906+ if let Some(ix) = self.projects.iter().position(|project| project.environment.is_none() && project.path == path) {
826907 return ix;
827908 }
828909 let name = name.map(str::to_string).unwrap_or_else(|| {
@@ -839,6 +920,7 @@ impl Workspace {
839920 added: SystemTime::now(),
840921 sessions: Vec::new(),
841922 git,
923+ environment: None,
842924 });
843925 self.projects.len() - 1
844926 }
@@ -872,6 +954,7 @@ impl Workspace {
872954 cx: &mut Context<Self>,
873955 ) {
874956 self.active_project = project_ix;
957+ session.update(cx, |session, _| session.subscribe_remote());
875958 if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
876959 chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
877960 }
@@ -1012,12 +1095,15 @@ impl Workspace {
10121095 window: &mut Window,
10131096 cx: &mut Context<Self>,
10141097 ) {
1015- let (id, worktree) = {
1098+ let (id, worktree, remote) = {
10161099 let state = session.read(cx);
1017- (state.id.clone(), state.worktree.clone())
1100+ (state.id.clone(), state.worktree.clone(), state.is_remote())
10181101 };
10191102 self.remove_thread(project_ix, &session, cx);
1020- cx.background_executor()
1103+ if remote {
1104+ session.read(cx).forget_remote();
1105+ } else {
1106+ cx.background_executor()
10211107 .spawn(async move {
10221108 store::delete_thread(&id);
10231109 if let Some(worktree) = worktree {
@@ -1025,6 +1111,7 @@ impl Workspace {
10251111 }
10261112 })
10271113 .detach();
1114+ }
10281115 let entity_id = session.entity_id();
10291116 if self.active.as_ref().is_some_and(|active| active.entity_id() == entity_id) {
10301117 self.active = None;
@@ -1168,6 +1255,7 @@ impl Workspace {
11681255
11691256 /// Lets every open conversation pick up changed settings.
11701257 pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1258+ self.sync_remote(window, cx);
11711259 for chat in self.chats.values() {
11721260 chat.update(cx, |chat, cx| chat.settings_changed(window, cx));
11731261 }
@@ -1426,6 +1514,7 @@ impl Workspace {
14261514 None => project.git.as_ref().and_then(|git| git.branch.clone()),
14271515 };
14281516 let in_worktree = state.worktree.is_some() || state.pending_worktree.is_some();
1517+ let host = project.environment.as_deref().map(|env| self.environment_name(env));
14291518 let settled = state.settled;
14301519 let pinned = state.pinned;
14311520 let snoozed_until = state.snoozed_until.filter(|_| state.is_snoozed());
@@ -1552,6 +1641,7 @@ impl Workspace {
15521641 .text_color(p.ink3)
15531642 .child(project.name.clone()),
15541643 )
1644+ .when_some(host, |this, host| this.child(metal::tag(host, p)))
15551645 .when(snoozed_until.is_some(), |this| {
15561646 this.child(Icon::new(Lucide::AlarmClock).size(px(11.)).text_color(p.ink3))
15571647 })
@@ -1925,6 +2015,8 @@ impl Workspace {
19252015 None => project.git.clone(),
19262016 };
19272017 let path = worktree.as_ref().map(|worktree| worktree.root.clone()).unwrap_or_else(|| project.path.clone());
2018+ let host = project.environment.as_deref().map(|env| self.environment_name(env));
2019+ let remote = host.is_some();
19282020 let has_thread = self.active.is_some();
19292021 let cost = active
19302022 .map(|session| session.total_cost)
@@ -1976,6 +2068,8 @@ impl Workspace {
19762068 let has_changes = git.added + git.removed > 0;
19772069 let branch_row = if worktree.is_some() {
19782070 Self::panel_row(Lucide::FolderGit2, branch_label, p).child(metal::tag("Worktree", p)).into_any_element()
2071+ } else if remote {
2072+ Self::panel_row(Lucide::GitBranch, branch_label, p).into_any_element()
19792073 } else {
19802074 MetalButton::new("branch-menu")
19812075 .ghost()
@@ -2063,10 +2157,10 @@ impl Workspace {
20632157 .gap(px(2.))
20642158 .rounded(px(18.))
20652159 .raised(p)
2066- .child(
2067- Self::panel_row(Lucide::Laptop, self.hostname.clone(), p)
2068- .child(metal::tag("Local", p)),
2069- )
2160+ .child(match host {
2161+ Some(host) => Self::panel_row(Lucide::Server, host, p).child(metal::tag("Remote", p)),
2162+ None => Self::panel_row(Lucide::Laptop, self.hostname.clone(), p).child(metal::tag("Local", p)),
2163+ })
20702164 .child(
20712165 div()
20722166 .px(px(10.))
@@ -2077,7 +2171,7 @@ impl Workspace {
20772171 .text_color(p.ink3)
20782172 .child(display_path(&path)),
20792173 )
2080- .child(editor_row)
2174+ .when(!remote, |this| this.child(editor_row))
20812175 .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
20822176 .child(git_rows)
20832177 .when_some(cost, |this, cost| {
@@ -2196,6 +2290,8 @@ impl Render for Workspace {
21962290 .map(|project| ProjectSummary {
21972291 name: project.name.clone(),
21982292 path: project.path.clone(),
2293+ environment: project.environment.clone(),
2294+ host: project.environment.as_deref().map(|env| self.environment_name(env).into()),
21992295 scratch: Some(&project.path) == scratch.as_ref(),
22002296 git: project.git.is_some(),
22012297 branch: project.git.as_ref().and_then(|git| git.branch.clone()),
+625−0src/ui/workspace/remote.rsaddedView File
@@ -0,0 +1,625 @@
1+ //! The workspace's side of remote connections: serving this device's
2+ //! projects and threads to other devices, and showing other devices'
3+ //! projects and threads in the sidebar.
4+
5+ use std::{
6+ collections::{HashMap, HashSet},
7+ hash::{Hash as _, Hasher as _},
8+ path::PathBuf,
9+ rc::Rc,
10+ time::{Duration, SystemTime},
11+ };
12+
13+ use gpui_kit::component::{WindowExt as _, notification::Notification};
14+ use gpui_kit::*;
15+
16+ use super::{Project, ThreadSettings, Workspace, hostname, initials, tint_for};
17+ use crate::claude::PermissionMode;
18+ use crate::git::GitInfo;
19+ use crate::models::Provider;
20+ use crate::remote::{
21+ ClientMessage, ProjectInfo, ServerMessage, ThreadAction, ThreadSummary,
22+ client::{ClientEvent, ConnectionStatus, RemoteClient},
23+ new_key,
24+ server::{Peer, RemoteServer, ServerEvent},
25+ };
26+ use crate::session::{
27+ NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads,
28+ };
29+ use crate::settings::{self, RemoteHost, WorkspaceMode};
30+
31+ /// How often changed threads are sent to subscribed clients.
32+ pub(super) const FLUSH: Duration = Duration::from_millis(150);
33+
34+ /// A connection to another device's app.
35+ pub(super) struct Environment {
36+ pub(super) host: RemoteHost,
37+ pub(super) status: ConnectionStatus,
38+ pub(super) client: Rc<RemoteClient>,
39+ _events: Task<()>,
40+ }
41+
42+ /// One client's interest in a thread: the hashes of the transcript items it
43+ /// was last sent, or `None` before the first send.
44+ type Subscription = Option<Vec<u64>>;
45+
46+ #[derive(Default)]
47+ pub(super) struct RemoteState {
48+ server: Option<RemoteServer>,
49+ /// The port and key the running server was started with.
50+ server_config: Option<(u16, String)>,
51+ server_error: Option<String>,
52+ peers: Vec<Peer>,
53+ subscriptions: HashMap<u64, HashMap<String, Subscription>>,
54+ dirty_threads: HashSet<String>,
55+ state_dirty: bool,
56+ last_state: String,
57+ pub(super) environments: Vec<Environment>,
58+ _server_events: Option<Task<()>>,
59+ }
60+
61+ /// What the settings show about remote connections.
62+ pub struct RemoteOverview {
63+ /// The port this device listens on, when it does.
64+ pub serving: Option<u16>,
65+ pub error: Option<String>,
66+ pub peers: Vec<Peer>,
67+ /// Each connection to another device, by its id in the settings.
68+ pub environments: Vec<(String, ConnectionStatus)>,
69+ }
70+
71+ impl Workspace {
72+ /// Starts, stops or restarts the server and the connections to match the
73+ /// settings.
74+ pub(super) fn sync_remote(&mut self, window: &mut Window, cx: &mut Context<Self>) {
75+ let (serve, port, mut key, hosts) = settings::get(|settings| {
76+ (settings.remote_server, settings.remote_port, settings.remote_key.clone(), settings.remotes.clone())
77+ });
78+ if serve && key.is_empty() {
79+ key = new_key();
80+ let saved = key.clone();
81+ settings::update(|settings| settings.remote_key = saved);
82+ }
83+ let wanted = serve.then(|| (port, key));
84+ if wanted != self.remote.server_config {
85+ self.remote.server = None;
86+ self.remote._server_events = None;
87+ self.remote.peers.clear();
88+ self.remote.subscriptions.clear();
89+ self.remote.server_error = None;
90+ self.remote.server_config = wanted.clone();
91+ if let Some((port, key)) = wanted {
92+ match RemoteServer::start(port, key, hostname()) {
93+ Ok(server) => {
94+ let events = server.events.clone();
95+ self.remote._server_events = Some(cx.spawn_in(window, async move |this, cx| {
96+ while let Ok(event) = events.recv().await {
97+ if this.update_in(cx, |this, window, cx| this.on_server_event(event, window, cx)).is_err() {
98+ break;
99+ }
100+ }
101+ }));
102+ self.remote.server = Some(server);
103+ self.remote.state_dirty = true;
104+ }
105+ Err(err) => self.remote.server_error = Some(format!("Couldn't listen on port {port}: {err}")),
106+ }
107+ }
108+ }
109+
110+ let wanted: Vec<RemoteHost> = hosts.into_iter().filter(|host| host.enabled && !host.address.trim().is_empty()).collect();
111+ let stale: Vec<String> = self
112+ .remote
113+ .environments
114+ .iter()
115+ .filter(|env| !wanted.iter().any(|host| *host == env.host))
116+ .map(|env| env.host.id.clone())
117+ .collect();
118+ for id in stale {
119+ self.drop_environment(&id, window, cx);
120+ }
121+ for host in wanted {
122+ if !self.remote.environments.iter().any(|env| env.host.id == host.id) {
123+ self.connect_environment(host, window, cx);
124+ }
125+ }
126+ cx.notify();
127+ }
128+
129+ fn connect_environment(&mut self, host: RemoteHost, window: &mut Window, cx: &mut Context<Self>) {
130+ let client = Rc::new(RemoteClient::connect(&host.address, host.key.clone(), hostname()));
131+ let events = client.events.clone();
132+ let id = host.id.clone();
133+ let task = cx.spawn_in(window, async move |this, cx| {
134+ while let Ok(event) = events.recv().await {
135+ let id = id.clone();
136+ if this.update_in(cx, |this, window, cx| this.on_client_event(&id, event, window, cx)).is_err() {
137+ break;
138+ }
139+ }
140+ });
141+ self.remote.environments.push(Environment { host, status: ConnectionStatus::Connecting, client, _events: task });
142+ }
143+
144+ /// Disconnects from a device and takes its projects and threads away.
145+ fn drop_environment(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
146+ self.remote.environments.retain(|env| env.host.id != id);
147+ let removed: Vec<Entity<Session>> = self
148+ .projects
149+ .iter()
150+ .filter(|project| project.environment.as_deref() == Some(id))
151+ .flat_map(|project| project.sessions.clone())
152+ .collect();
153+ for session in &removed {
154+ if let Some(ix) = self.project_of(session) {
155+ self.remove_thread(ix, session, cx);
156+ }
157+ }
158+ let active = self.active.clone();
159+ self.projects.retain(|project| project.environment.as_deref() != Some(id));
160+ self.active = active.filter(|session| self.project_of(session).is_some());
161+ match &self.active {
162+ Some(session) => self.active_project = self.project_of(session).unwrap_or(0),
163+ None => {
164+ self.active_project = 0;
165+ if let Some((ix, next)) = self.threads(cx).0.into_iter().next() {
166+ self.select(ix, next, window, cx);
167+ }
168+ }
169+ }
170+ }
171+
172+ pub fn remote_overview(&self) -> RemoteOverview {
173+ RemoteOverview {
174+ serving: self.remote.server.as_ref().map(|server| server.port),
175+ error: self.remote.server_error.clone(),
176+ peers: self.remote.peers.clone(),
177+ environments: self.remote.environments.iter().map(|env| (env.host.id.clone(), env.status.clone())).collect(),
178+ }
179+ }
180+
181+ pub fn disconnect_peer(&mut self, conn: u64, cx: &mut Context<Self>) {
182+ if let Some(server) = &self.remote.server {
183+ server.disconnect(conn);
184+ }
185+ self.remote.peers.retain(|peer| peer.conn != conn);
186+ self.remote.subscriptions.remove(&conn);
187+ cx.notify();
188+ }
189+
190+ /// Notes a change to a local thread, for the clients watching.
191+ pub(super) fn remote_thread_changed(&mut self, session: &Entity<Session>, cx: &App) {
192+ if self.remote.server.is_none() {
193+ return;
194+ }
195+ let session = session.read(cx);
196+ if !session.is_remote() {
197+ self.remote.dirty_threads.insert(session.id.clone());
198+ self.remote.state_dirty = true;
199+ }
200+ }
201+
202+ /// The local thread with a given id.
203+ fn local_thread(&self, id: &str, cx: &App) -> Option<(usize, Entity<Session>)> {
204+ self.projects.iter().enumerate().filter(|(_, project)| project.environment.is_none()).find_map(|(ix, project)| {
205+ project.sessions.iter().find(|session| session.read(cx).id == id).map(|session| (ix, session.clone()))
206+ })
207+ }
208+
209+ // ---- serving ------------------------------------------------------------
210+
211+ fn host_state(&self, cx: &App) -> ServerMessage {
212+ let local = self.projects.iter().filter(|project| project.environment.is_none());
213+ let projects = local
214+ .clone()
215+ .map(|project| {
216+ let git = project.git.clone();
217+ ProjectInfo {
218+ path: project.path.clone(),
219+ name: project.name.to_string(),
220+ git: git.is_some(),
221+ branch: git.as_ref().and_then(|git| git.branch.clone()),
222+ branches: git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(),
223+ added: git.as_ref().map_or(0, |git| git.added),
224+ removed: git.as_ref().map_or(0, |git| git.removed),
225+ }
226+ })
227+ .collect();
228+ let threads = local
229+ .flat_map(|project| &project.sessions)
230+ .map(|session| session.read(cx))
231+ .filter(|session| !session.is_blank())
232+ .map(Session::summary)
233+ .collect();
234+ ServerMessage::State { projects, threads }
235+ }
236+
237+ fn on_server_event(&mut self, event: ServerEvent, window: &mut Window, cx: &mut Context<Self>) {
238+ let Some(server) = &self.remote.server else { return };
239+ match event {
240+ ServerEvent::Connected { conn, name, address } => {
241+ server.send(conn, &self.host_state(cx));
242+ self.remote.peers.push(Peer { conn, name: name.clone(), address });
243+ self.remote.subscriptions.insert(conn, HashMap::new());
244+ if settings::get(|settings| settings.in_app_notifications) {
245+ window.push_notification(Notification::info(format!("{name} connected.")), cx);
246+ }
247+ cx.notify();
248+ }
249+ ServerEvent::Disconnected { conn } => {
250+ self.remote.peers.retain(|peer| peer.conn != conn);
251+ self.remote.subscriptions.remove(&conn);
252+ cx.notify();
253+ }
254+ ServerEvent::Message { conn, message } => self.on_client_message(conn, message, window, cx),
255+ }
256+ }
257+
258+ fn on_client_message(&mut self, conn: u64, message: ClientMessage, window: &mut Window, cx: &mut Context<Self>) {
259+ match message {
260+ ClientMessage::Hello { .. } => {}
261+ ClientMessage::Subscribe { thread } => {
262+ self.remote.subscriptions.entry(conn).or_default().insert(thread.clone(), None);
263+ self.remote.dirty_threads.insert(thread);
264+ }
265+ ClientMessage::Unsubscribe { thread } => {
266+ if let Some(threads) = self.remote.subscriptions.get_mut(&conn) {
267+ threads.remove(&thread);
268+ }
269+ }
270+ ClientMessage::Action { thread, action } => {
271+ let Some((_, session)) = self.local_thread(&thread, cx) else {
272+ self.reply(conn, ServerMessage::Error { message: "That thread is gone.".into() });
273+ return;
274+ };
275+ self.run_action(session, action, window, cx);
276+ }
277+ ClientMessage::Create {
278+ reference,
279+ project,
280+ instance,
281+ provider,
282+ model,
283+ permission_mode,
284+ reasoning,
285+ fast_mode,
286+ worktree,
287+ base,
288+ text,
289+ attachments,
290+ } => {
291+ let Some(project_ix) =
292+ self.projects.iter().position(|candidate| candidate.environment.is_none() && candidate.path == project)
293+ else {
294+ self.reply(conn, ServerMessage::Error { message: format!("{} isn't a project here.", project.display()) });
295+ return;
296+ };
297+ let thread_settings = ThreadSettings {
298+ instance,
299+ provider,
300+ model,
301+ mode: PermissionMode::from_cli(&permission_mode).unwrap_or_default(),
302+ reasoning: Reasoning::from_key(&reasoning).unwrap_or_default(),
303+ fast_mode,
304+ };
305+ let mode = if worktree { WorkspaceMode::Worktree } else { WorkspaceMode::Local };
306+ let plan = self.worktree_plan(project_ix, mode).map(|mut plan| {
307+ if base.is_some() {
308+ plan.base = base;
309+ }
310+ plan
311+ });
312+ let session = self.spawn_thread(project_ix, thread_settings, plan, window, cx);
313+ let paths = store_uploads(attachments);
314+ session.update(cx, |session, cx| session.send_message(text, paths, cx));
315+ let id = session.read(cx).id.clone();
316+ self.remote.subscriptions.entry(conn).or_default().insert(id.clone(), None);
317+ self.remote.dirty_threads.insert(id.clone());
318+ self.remote.state_dirty = true;
319+ self.reply(conn, ServerMessage::Created { reference, thread: id });
320+ }
321+ }
322+ }
323+
324+ fn reply(&self, conn: u64, message: ServerMessage) {
325+ if let Some(server) = &self.remote.server {
326+ server.send(conn, &message);
327+ }
328+ }
329+
330+ /// Carries out what a client asked of a local thread.
331+ fn run_action(&mut self, session: Entity<Session>, action: ThreadAction, window: &mut Window, cx: &mut Context<Self>) {
332+ match action {
333+ ThreadAction::Send { text, attachments } => {
334+ let paths = store_uploads(attachments);
335+ session.update(cx, |session, cx| session.send_message(text, paths, cx));
336+ }
337+ ThreadAction::Queue { text, attachments } => {
338+ let paths = store_uploads(attachments);
339+ session.update(cx, |session, cx| session.queue_message(text, paths, cx));
340+ }
341+ ThreadAction::SteerQueued { ix } => session.update(cx, |session, cx| session.steer_queued(ix, cx)),
342+ ThreadAction::RemoveQueued { ix } => session.update(cx, |session, cx| session.remove_queued(ix, cx)),
343+ ThreadAction::Interrupt => session.update(cx, |session, cx| session.interrupt(cx)),
344+ ThreadAction::Permission { ix, decision } => {
345+ let decision = match decision.as_str() {
346+ "always" => PermissionDecision::AllowAlways,
347+ "deny" => PermissionDecision::Deny,
348+ _ => PermissionDecision::Allow,
349+ };
350+ session.update(cx, |session, cx| session.respond_permission(ix, decision, cx));
351+ }
352+ ThreadAction::Answer { ix, option } => session.update(cx, |session, cx| session.answer_question(ix, option, cx)),
353+ ThreadAction::SetModel { model } => session.update(cx, |session, cx| session.set_model(model, cx)),
354+ ThreadAction::SetPermissionMode { mode } => {
355+ if let Some(mode) = PermissionMode::from_cli(&mode) {
356+ session.update(cx, |session, cx| session.set_permission_mode(mode, cx));
357+ }
358+ }
359+ ThreadAction::SetReasoning { reasoning } => {
360+ if let Some(reasoning) = Reasoning::from_key(&reasoning) {
361+ session.update(cx, |session, cx| session.set_reasoning(reasoning, cx));
362+ }
363+ }
364+ ThreadAction::SetFastMode { on } => session.update(cx, |session, cx| session.set_fast_mode(on, cx)),
365+ ThreadAction::Settle => self.settle_thread(session, cx),
366+ ThreadAction::Unsettle => session.update(cx, |session, cx| session.unsettle(cx)),
367+ ThreadAction::Pin { pinned } => session.update(cx, |session, cx| session.set_pinned(pinned, cx)),
368+ ThreadAction::Snooze { until } => session.update(cx, |session, cx| session.snooze(until.map(from_millis), cx)),
369+ ThreadAction::Rename { title } => session.update(cx, |session, cx| session.rename(title, cx)),
370+ ThreadAction::CancelResume => session.update(cx, |session, cx| session.cancel_resume(cx)),
371+ ThreadAction::ScheduleResume { at } => {
372+ session.update(cx, |session, cx| session.schedule_resume(from_millis(at), cx))
373+ }
374+ ThreadAction::Delete => self.forget_thread(session, window, cx),
375+ }
376+ }
377+
378+ /// Sends clients the thread list when it changed, and each subscribed
379+ /// thread that changed from the first item that did.
380+ pub(super) fn flush_remote(&mut self, cx: &mut Context<Self>) {
381+ let Some(server) = &self.remote.server else { return };
382+ if std::mem::take(&mut self.remote.state_dirty) {
383+ let state = self.host_state(cx);
384+ let text = serde_json::to_string(&state).unwrap_or_default();
385+ if text != self.remote.last_state {
386+ self.remote.last_state = text;
387+ server.broadcast(&state);
388+ }
389+ }
390+ let dirty = std::mem::take(&mut self.remote.dirty_threads);
391+ if dirty.is_empty() {
392+ return;
393+ }
394+ let mut snapshots = HashMap::new();
395+ for (conn, threads) in &mut self.remote.subscriptions {
396+ for (id, sent) in threads.iter_mut() {
397+ if !dirty.contains(id) && sent.is_some() {
398+ continue;
399+ }
400+ let Some(session) = self
401+ .projects
402+ .iter()
403+ .filter(|project| project.environment.is_none())
404+ .flat_map(|project| &project.sessions)
405+ .find(|session| session.read(cx).id == *id)
406+ else {
407+ continue;
408+ };
409+ let (summary, detail, items, hashes) = snapshots
410+ .entry(id.clone())
411+ .or_insert_with(|| {
412+ let session = session.read(cx);
413+ let items = session.saved_items(0);
414+ let hashes: Vec<u64> = items.iter().map(item_hash).collect();
415+ (session.summary(), session.detail(), items, hashes)
416+ })
417+ .clone();
418+ let previous = sent.take().unwrap_or_default();
419+ let from = previous.iter().zip(&hashes).take_while(|(a, b)| a == b).count();
420+ server.send(
421+ *conn,
422+ &ServerMessage::Thread { summary, detail, from, len: items.len(), items: items[from..].to_vec() },
423+ );
424+ *sent = Some(hashes);
425+ }
426+ }
427+ }
428+
429+ // ---- connecting to other devices -------------------------------------------
430+
431+ fn on_client_event(&mut self, env: &str, event: ClientEvent, window: &mut Window, cx: &mut Context<Self>) {
432+ match event {
433+ ClientEvent::Status(status) => {
434+ let connected = matches!(status, ConnectionStatus::Connected(_));
435+ if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) {
436+ environment.status = status;
437+ }
438+ if connected {
439+ // A new connection knows nothing of what this one watched.
440+ for session in self.environment_sessions(env) {
441+ session.update(cx, |session, _| {
442+ if let Some(remote) = session.remote.as_mut()
443+ && remote.subscribed
444+ {
445+ remote.subscribed = false;
446+ session.subscribe_remote();
447+ }
448+ });
449+ }
450+ }
451+ cx.notify();
452+ }
453+ ClientEvent::Message(ServerMessage::State { projects, threads }) => {
454+ self.reconcile_environment(env, projects, threads, window, cx)
455+ }
456+ ClientEvent::Message(ServerMessage::Thread { summary, detail, from, len, items }) => {
457+ if let Some(session) = self.remote_session(env, &summary.id, cx) {
458+ session.update(cx, |session, cx| session.apply_remote(&summary, detail, from, len, items, cx));
459+ }
460+ }
461+ ClientEvent::Message(ServerMessage::Created { reference, thread }) => {
462+ let session = self.environment_sessions(env).into_iter().find(|session| {
463+ session.read(cx).remote.as_ref().is_some_and(|remote| remote.creating == Some(reference))
464+ });
465+ if let Some(session) = session {
466+ session.update(cx, |session, cx| session.remote_created(thread, cx));
467+ }
468+ }
469+ ClientEvent::Message(ServerMessage::Error { message }) => {
470+ let name = self.environment_name(env);
471+ window.push_notification(Notification::error(message).title(name), cx);
472+ }
473+ ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {}
474+ }
475+ }
476+
477+ fn environment_sessions(&self, env: &str) -> Vec<Entity<Session>> {
478+ self.projects
479+ .iter()
480+ .filter(|project| project.environment.as_deref() == Some(env))
481+ .flat_map(|project| project.sessions.clone())
482+ .collect()
483+ }
484+
485+ fn remote_session(&self, env: &str, id: &str, cx: &App) -> Option<Entity<Session>> {
486+ self.environment_sessions(env).into_iter().find(|session| {
487+ session.read(cx).remote.as_ref().and_then(|remote| remote.thread.as_deref()) == Some(id)
488+ })
489+ }
490+
491+ pub(super) fn environment_name(&self, env: &str) -> String {
492+ self.remote
493+ .environments
494+ .iter()
495+ .find(|candidate| candidate.host.id == env)
496+ .map(|env| env.host.label())
497+ .unwrap_or_else(|| env.to_string())
498+ }
499+
500+ /// The link a new mirrored thread on `env` uses.
501+ pub(super) fn remote_link(&self, env: &str, thread: Option<String>) -> Option<RemoteLink> {
502+ let environment = self.remote.environments.iter().find(|candidate| candidate.host.id == env)?;
503+ Some(RemoteLink {
504+ env: env.to_string(),
505+ thread,
506+ client: environment.client.clone(),
507+ subscribed: false,
508+ loaded: false,
509+ attention: false,
510+ creating: None,
511+ })
512+ }
513+
514+ /// The index of a device's project, adding it if needed.
515+ fn ensure_remote_project(&mut self, env: &str, path: PathBuf, name: Option<&str>) -> usize {
516+ if let Some(ix) =
517+ self.projects.iter().position(|project| project.environment.as_deref() == Some(env) && project.path == path)
518+ {
519+ return ix;
520+ }
521+ let name = name.map(str::to_string).unwrap_or_else(|| {
522+ path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| path.display().to_string())
523+ });
524+ self.projects.push(Project {
525+ path,
526+ initials: initials(&name).into(),
527+ tint: tint_for(&name),
528+ name: name.into(),
529+ added: SystemTime::now(),
530+ sessions: Vec::new(),
531+ git: None,
532+ environment: Some(env.to_string()),
533+ });
534+ self.projects.len() - 1
535+ }
536+
537+ /// Brings a device's projects and threads in line with its latest list.
538+ fn reconcile_environment(
539+ &mut self,
540+ env: &str,
541+ projects: Vec<ProjectInfo>,
542+ threads: Vec<ThreadSummary>,
543+ window: &mut Window,
544+ cx: &mut Context<Self>,
545+ ) {
546+ for info in &projects {
547+ let ix = self.ensure_remote_project(env, info.path.clone(), Some(&info.name));
548+ self.projects[ix].git = info.git.then(|| GitInfo {
549+ branch: info.branch.clone(),
550+ branches: info.branches.clone(),
551+ added: info.added,
552+ removed: info.removed,
553+ });
554+ }
555+ let known: HashSet<&str> = threads.iter().map(|summary| summary.id.as_str()).collect();
556+ for session in self.environment_sessions(env) {
557+ let gone = session
558+ .read(cx)
559+ .remote
560+ .as_ref()
561+ .and_then(|remote| remote.thread.as_deref())
562+ .is_some_and(|id| !known.contains(id));
563+ if gone && let Some(ix) = self.project_of(&session) {
564+ let was_active = self.active.as_ref() == Some(&session);
565+ self.remove_thread(ix, &session, cx);
566+ if was_active {
567+ self.active = None;
568+ if let Some((ix, next)) = self.threads(cx).0.into_iter().next() {
569+ self.select(ix, next, window, cx);
570+ }
571+ }
572+ }
573+ }
574+ for summary in threads {
575+ match self.remote_session(env, &summary.id, cx) {
576+ Some(session) => session.update(cx, |session, cx| {
577+ session.apply_summary(&summary);
578+ cx.notify();
579+ }),
580+ None => {
581+ let Some(link) = self.remote_link(env, Some(summary.id.clone())) else { continue };
582+ let project_ix = self.ensure_remote_project(env, summary.project.clone(), None);
583+ let session = cx.new(|cx| Session::remote(&summary, link, cx));
584+ self.attach(project_ix, session, window, cx);
585+ }
586+ }
587+ }
588+ cx.notify();
589+ }
590+
591+ /// A new, empty thread for one of a device's projects.
592+ pub(super) fn new_remote_session(
593+ &mut self,
594+ env: &str,
595+ project: PathBuf,
596+ settings: ThreadSettings,
597+ worktree: bool,
598+ cx: &mut Context<Self>,
599+ ) -> Option<Entity<Session>> {
600+ let link = self.remote_link(env, None)?;
601+ let plan = worktree.then(|| crate::worktree::WorktreePlan {
602+ project: project.clone(),
603+ base: None,
604+ from_origin: false,
605+ submodules: settings::Submodules::Skip,
606+ });
607+ let new = NewSession {
608+ instance: settings.instance,
609+ provider: if settings.provider == Provider::Pi { Provider::Pi } else { Provider::Claude },
610+ project,
611+ model: settings.model,
612+ permission_mode: settings.mode,
613+ reasoning: settings.reasoning,
614+ fast_mode: settings.fast_mode,
615+ worktree: plan,
616+ };
617+ Some(cx.new(|_| Session::new_remote(new, link)))
618+ }
619+ }
620+
621+ fn item_hash<T: serde::Serialize>(item: &T) -> u64 {
622+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
623+ serde_json::to_string(item).unwrap_or_default().hash(&mut hasher);
624+ hasher.finish()
625+ }