Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 8 changed files with 1183 additions and 51 deletions.
| @@ -249,4 +249,55 @@ mod tests { | ||
| 249 | 249 | let text = serde_json::to_string(&message).unwrap(); |
| 250 | 250 | assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); |
| 251 | 251 | } |
| 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 | + } | |
| 252 | 303 | } |
| @@ -2338,6 +2338,11 @@ impl Session { | ||
| 2338 | 2338 | session |
| 2339 | 2339 | } |
| 2340 | 2340 | |
| 2341 | + /// Asks the host to delete a mirrored thread. | |
| 2342 | + pub fn forget_remote(&self) { | |
| 2343 | + self.forward(ThreadAction::Delete); | |
| 2344 | + } | |
| 2345 | + | |
| 2341 | 2346 | pub fn is_remote(&self) -> bool { |
| 2342 | 2347 | self.remote.is_some() |
| 2343 | 2348 | } |
| @@ -148,6 +148,24 @@ impl ProviderInstance { | ||
| 148 | 148 | } |
| 149 | 149 | } |
| 150 | 150 | |
| 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 | + | |
| 151 | 169 | /// A usage hub the Usage page also reads, such as a CLIProxyAPI server. |
| 152 | 170 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 153 | 171 | #[serde(default)] |
| @@ -262,6 +280,15 @@ pub struct Settings { | ||
| 262 | 280 | pub providers: Vec<ProviderInstance>, |
| 263 | 281 | pub usage_hubs: Vec<UsageHub>, |
| 264 | 282 | |
| 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 | + | |
| 265 | 292 | /// Superseded by `providers`; read once to carry an old path override over. |
| 266 | 293 | #[serde(skip_serializing)] |
| 267 | 294 | claude_bin: Option<String>, |
| @@ -319,6 +346,10 @@ impl Default for Settings { | ||
| 319 | 346 | editor: None, |
| 320 | 347 | providers: Vec::new(), |
| 321 | 348 | usage_hubs: Vec::new(), |
| 349 | + remote_server: false, | |
| 350 | + remote_port: crate::remote::DEFAULT_PORT, | |
| 351 | + remote_key: String::new(), | |
| 352 | + remotes: Vec::new(), | |
| 322 | 353 | claude_bin: None, |
| 323 | 354 | pi_bin: None, |
| 324 | 355 | } |
| @@ -401,13 +432,21 @@ pub fn update(edit: impl FnOnce(&mut Settings)) { | ||
| 401 | 432 | } |
| 402 | 433 | } |
| 403 | 434 | |
| 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. | |
| 405 | 437 | pub fn restore_defaults() { |
| 406 | 438 | 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; | |
| 411 | 450 | }); |
| 412 | 451 | } |
| 413 | 452 | |
| @@ -44,8 +44,9 @@ pub enum ChatEvent { | ||
| 44 | 44 | /// Start a thread on this model; it belongs to another provider than this |
| 45 | 45 | /// thread's. |
| 46 | 46 | 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>), | |
| 49 | 50 | AddProject, |
| 50 | 51 | StartWithoutProject, |
| 51 | 52 | SwitchBranch(String), |
| @@ -510,30 +511,37 @@ impl ChatView { | ||
| 510 | 511 | fn render_start(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 511 | 512 | let p = metal::palette(cx); |
| 512 | 513 | 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()); | |
| 514 | 516 | 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())); | |
| 516 | 518 | let scratch = current.is_some_and(|project| project.scratch); |
| 517 | 519 | 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 | |
| 519 | 521 | .map(|directory| { |
| 520 | 522 | directory |
| 521 | 523 | .projects |
| 522 | 524 | .iter() |
| 523 | 525 | .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 | + }) | |
| 525 | 533 | .collect() |
| 526 | 534 | }) |
| 527 | 535 | .unwrap_or_default(); |
| 528 | 536 | let chat = cx.entity().downgrade(); |
| 529 | 537 | let project_menu = move |mut menu: gpui_kit::component::menu::PopupMenu, _: &mut Window, _: &mut Context<gpui_kit::component::menu::PopupMenu>| { |
| 530 | 538 | menu = menu.min_w(px(240.)).label("Project"); |
| 531 | - for (name, path) in projects.clone() { | |
| 539 | + for (name, path, project_env) in projects.clone() { | |
| 532 | 540 | let chat = chat.clone(); |
| 533 | - let checked = path == cwd; | |
| 541 | + let checked = path == cwd && project_env == env; | |
| 534 | 542 | 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))); | |
| 537 | 545 | })); |
| 538 | 546 | } |
| 539 | 547 | let chat = chat.clone(); |
| @@ -1085,10 +1093,14 @@ impl ChatView { | ||
| 1085 | 1093 | /// Under the start composer: where the thread will run, and on which branch. |
| 1086 | 1094 | fn render_start_footer(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 1087 | 1095 | 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()); | |
| 1089 | 1099 | 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()); | |
| 1092 | 1104 | let plan = self.session.read(cx).pending_worktree.clone(); |
| 1093 | 1105 | let branch = match &plan { |
| 1094 | 1106 | Some(plan) => plan.base.clone().or_else(|| project.and_then(|project| project.branch.clone())), |
| @@ -1115,10 +1127,10 @@ impl ChatView { | ||
| 1115 | 1127 | .text_size(px(12.5)) |
| 1116 | 1128 | .font_weight(FontWeight::MEDIUM) |
| 1117 | 1129 | .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.))) | |
| 1119 | 1131 | .child(Icon::new(IconName::Folder).size(px(13.))) |
| 1120 | 1132 | .child(hostname) |
| 1121 | - .child(metal::tag("Local", p)), | |
| 1133 | + .child(metal::tag(if is_remote { "Remote" } else { "Local" }, p)), | |
| 1122 | 1134 | ) |
| 1123 | 1135 | .child(div().flex_1()) |
| 1124 | 1136 | .when(can_worktree, |this| { |
| @@ -31,6 +31,8 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 31 | 31 | Layers, |
| 32 | 32 | ListOrdered, |
| 33 | 33 | Lock, |
| 34 | + MonitorSmartphone, | |
| 35 | + Network, | |
| 34 | 36 | LockOpen, |
| 35 | 37 | PanelLeftClose, |
| 36 | 38 | PanelLeftOpen, |
| @@ -44,6 +46,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 44 | 46 | Plug, |
| 45 | 47 | Plus, |
| 46 | 48 | RotateCcw, |
| 49 | + Server, | |
| 47 | 50 | ShieldCheck, |
| 48 | 51 | SquareSlash, |
| 49 | 52 | SlidersHorizontal, |
| @@ -22,9 +22,10 @@ use gpui_kit::*; | ||
| 22 | 22 | use crate::CloseSettings; |
| 23 | 23 | use crate::claude::{self, Effort, PermissionMode}; |
| 24 | 24 | use crate::models::{self, Catalog, InstanceState, ProbeStatus, Provider, split_key}; |
| 25 | + use crate::remote::{self, DEFAULT_PORT, client::ConnectionStatus}; | |
| 25 | 26 | use crate::session::{Reasoning, Session}; |
| 26 | 27 | use crate::settings::{ |
| 27 | - self, BackgroundActivity, EnvVar, FollowUp, ProjectOrder, ProviderInstance, QuitShortcut, Settings, Streaming, | |
| 28 | + self, BackgroundActivity, EnvVar, FollowUp, ProjectOrder, ProviderInstance, QuitShortcut, RemoteHost, Settings, Streaming, | |
| 28 | 29 | Submodules, TimeFormat, WorkspaceMode, |
| 29 | 30 | }; |
| 30 | 31 | use crate::ui::chat::display_path; |
| @@ -41,17 +42,19 @@ pub enum Page { | ||
| 41 | 42 | Behavior, |
| 42 | 43 | Appearance, |
| 43 | 44 | Providers, |
| 45 | + Connections, | |
| 44 | 46 | Keybindings, |
| 45 | 47 | Archive, |
| 46 | 48 | About, |
| 47 | 49 | } |
| 48 | 50 | |
| 49 | 51 | impl Page { |
| 50 | - const ALL: [Page; 7] = [ | |
| 52 | + const ALL: [Page; 8] = [ | |
| 51 | 53 | Page::General, |
| 52 | 54 | Page::Behavior, |
| 53 | 55 | Page::Appearance, |
| 54 | 56 | Page::Providers, |
| 57 | + Page::Connections, | |
| 55 | 58 | Page::Keybindings, |
| 56 | 59 | Page::Archive, |
| 57 | 60 | Page::About, |
| @@ -63,6 +66,7 @@ impl Page { | ||
| 63 | 66 | Page::Behavior => "Behavior", |
| 64 | 67 | Page::Appearance => "Appearance", |
| 65 | 68 | Page::Providers => "Providers", |
| 69 | + Page::Connections => "Connections", | |
| 66 | 70 | Page::Keybindings => "Keybindings", |
| 67 | 71 | Page::Archive => "Archive", |
| 68 | 72 | Page::About => "About", |
| @@ -75,6 +79,7 @@ impl Page { | ||
| 75 | 79 | Page::Behavior => Icon::new(Lucide::Bell), |
| 76 | 80 | Page::Appearance => Icon::new(IconName::Palette), |
| 77 | 81 | Page::Providers => Icon::new(Lucide::Plug), |
| 82 | + Page::Connections => Icon::new(Lucide::Network), | |
| 78 | 83 | Page::Keybindings => Icon::new(Lucide::Keyboard), |
| 79 | 84 | Page::Archive => Icon::new(Lucide::Archive), |
| 80 | 85 | Page::About => Icon::new(IconName::Info), |
| @@ -102,6 +107,12 @@ pub struct SettingsView { | ||
| 102 | 107 | page: Page, |
| 103 | 108 | add_project_dir: Entity<InputState>, |
| 104 | 109 | 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, | |
| 105 | 116 | /// Instances an update is running for. |
| 106 | 117 | updating: Vec<String>, |
| 107 | 118 | focus_handle: FocusHandle, |
| @@ -141,7 +152,22 @@ impl SettingsView { | ||
| 141 | 152 | let add_project_dir = cx.new(|cx| { |
| 142 | 153 | InputState::new(window, cx).placeholder("~/").default_value(saved.add_project_dir.clone()) |
| 143 | 154 | }); |
| 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( | |
| 145 | 171 | &add_project_dir, |
| 146 | 172 | window, |
| 147 | 173 | |this, input, event: &InputEvent, window, cx| { |
| @@ -159,6 +185,11 @@ impl SettingsView { | ||
| 159 | 185 | page: Page::General, |
| 160 | 186 | add_project_dir, |
| 161 | 187 | provider_form: None, |
| 188 | + remote_port, | |
| 189 | + connect_name, | |
| 190 | + connect_address, | |
| 191 | + connect_key, | |
| 192 | + show_key: false, | |
| 162 | 193 | updating: Vec::new(), |
| 163 | 194 | focus_handle: cx.focus_handle(), |
| 164 | 195 | _subscriptions: subscriptions, |
| @@ -174,8 +205,9 @@ impl SettingsView { | ||
| 174 | 205 | } |
| 175 | 206 | |
| 176 | 207 | 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)); | |
| 178 | 209 | 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)); | |
| 179 | 211 | let selected = self.provider_form.as_ref().map(|form| form.id.clone()); |
| 180 | 212 | self.provider_form = None; |
| 181 | 213 | if let Some(id) = selected { |
| @@ -342,6 +374,7 @@ impl SettingsView { | ||
| 342 | 374 | Page::Behavior => self.render_behavior(cx), |
| 343 | 375 | Page::Appearance => self.render_appearance(cx), |
| 344 | 376 | Page::Providers => self.render_providers(window, cx), |
| 377 | + Page::Connections => self.render_connections(window, cx), | |
| 345 | 378 | Page::Keybindings => self.render_keybindings(cx), |
| 346 | 379 | Page::Archive => self.render_archive(cx), |
| 347 | 380 | Page::About => self.render_about(cx), |
| @@ -1411,6 +1444,274 @@ impl SettingsView { | ||
| 1411 | 1444 | .into_any_element() |
| 1412 | 1445 | } |
| 1413 | 1446 | |
| 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 | + | |
| 1414 | 1715 | fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> { |
| 1415 | 1716 | let p = metal::palette(cx); |
| 1416 | 1717 | let (ctrl_enter, hold) = |
| @@ -40,6 +40,10 @@ use crate::ui::settings::{SettingsEvent, SettingsView}; | ||
| 40 | 40 | use crate::worktree::{self, WorktreePlan}; |
| 41 | 41 | use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleProjectPanel, ToggleSidebar}; |
| 42 | 42 | |
| 43 | + mod remote; | |
| 44 | + | |
| 45 | + use remote::RemoteState; | |
| 46 | + | |
| 43 | 47 | pub(crate) const SIDEBAR_WIDTH: f32 = 280.; |
| 44 | 48 | const PANEL_WIDTH: f32 = 292.; |
| 45 | 49 | /// Below this window width the project panel stays out of the conversation's way. |
| @@ -69,9 +73,20 @@ pub struct ProjectDirectory { | ||
| 69 | 73 | pub projects: Vec<ProjectSummary>, |
| 70 | 74 | } |
| 71 | 75 | |
| 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 | + | |
| 72 | 83 | pub struct ProjectSummary { |
| 73 | 84 | pub name: SharedString, |
| 74 | 85 | 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>, | |
| 75 | 90 | /// The folder threads "without a project" run in. |
| 76 | 91 | pub scratch: bool, |
| 77 | 92 | /// Whether it's a git repository, so threads can start in a worktree. |
| @@ -104,6 +119,8 @@ struct Project { | ||
| 104 | 119 | sessions: Vec<Entity<Session>>, |
| 105 | 120 | /// `None` until read, or when the folder isn't a git repository. |
| 106 | 121 | git: Option<GitInfo>, |
| 122 | + /// The remote connection it's on, by id; `None` on this device. | |
| 123 | + environment: Option<String>, | |
| 107 | 124 | } |
| 108 | 125 | |
| 109 | 126 | /// A thread in the sidebar: its project's index and the session. |
| @@ -167,10 +184,12 @@ pub struct Workspace { | ||
| 167 | 184 | last_health: Instant, |
| 168 | 185 | last_pr_check: Option<Instant>, |
| 169 | 186 | last_repaint: Instant, |
| 187 | + remote: RemoteState, | |
| 170 | 188 | focus_handle: FocusHandle, |
| 171 | 189 | observers: HashMap<EntityId, Vec<Subscription>>, |
| 172 | 190 | _subscriptions: Vec<Subscription>, |
| 173 | 191 | _ticker: Task<()>, |
| 192 | + _remote_flush: Task<()>, | |
| 174 | 193 | } |
| 175 | 194 | |
| 176 | 195 | impl Workspace { |
| @@ -218,6 +237,15 @@ impl Workspace { | ||
| 218 | 237 | } |
| 219 | 238 | }); |
| 220 | 239 | |
| 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 | + | |
| 221 | 249 | let editors = detect_editors(); |
| 222 | 250 | let saved_editor = settings::get(|settings| settings.editor.clone()); |
| 223 | 251 | let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0); |
| @@ -251,10 +279,12 @@ impl Workspace { | ||
| 251 | 279 | last_health: now, |
| 252 | 280 | last_pr_check: None, |
| 253 | 281 | last_repaint: now, |
| 282 | + remote: RemoteState::default(), | |
| 254 | 283 | focus_handle: cx.focus_handle(), |
| 255 | 284 | observers: HashMap::new(), |
| 256 | 285 | _subscriptions: subscriptions, |
| 257 | 286 | _ticker: ticker, |
| 287 | + _remote_flush: remote_flush, | |
| 258 | 288 | }; |
| 259 | 289 | let active = workspace.restore(window, cx); |
| 260 | 290 | match (initial_project, active) { |
| @@ -272,6 +302,7 @@ impl Workspace { | ||
| 272 | 302 | workspace.replace_thread(ix, settings, window, cx); |
| 273 | 303 | } |
| 274 | 304 | } |
| 305 | + workspace.sync_remote(window, cx); | |
| 275 | 306 | workspace |
| 276 | 307 | } |
| 277 | 308 | |
| @@ -314,7 +345,7 @@ impl Workspace { | ||
| 314 | 345 | projects: self |
| 315 | 346 | .projects |
| 316 | 347 | .iter() |
| 317 | - .filter(|project| Some(&project.path) != scratch.as_ref()) | |
| 348 | + .filter(|project| project.environment.is_none() && Some(&project.path) != scratch.as_ref()) | |
| 318 | 349 | .map(|project| SavedProject { |
| 319 | 350 | path: project.path.clone(), |
| 320 | 351 | name: project.name.to_string(), |
| @@ -341,8 +372,9 @@ impl Workspace { | ||
| 341 | 372 | .flat_map(|project| &project.sessions) |
| 342 | 373 | .filter(|session| dirty.contains(&session.entity_id())) |
| 343 | 374 | .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()) | |
| 346 | 378 | .map(Session::save) |
| 347 | 379 | .collect(); |
| 348 | 380 | if threads.is_empty() && state.is_none() { |
| @@ -364,7 +396,7 @@ impl Workspace { | ||
| 364 | 396 | fn save_everything(&mut self, cx: &App) { |
| 365 | 397 | for session in self.projects.iter().flat_map(|project| &project.sessions) { |
| 366 | 398 | let session = session.read(cx); |
| 367 | - if !session.items.is_empty() { | |
| 399 | + if !session.items.is_empty() && !session.is_remote() { | |
| 368 | 400 | store::save_thread(&session.save()); |
| 369 | 401 | } |
| 370 | 402 | } |
| @@ -419,7 +451,8 @@ impl Workspace { | ||
| 419 | 451 | fn poll_git(&mut self, cx: &mut Context<Self>) { |
| 420 | 452 | self.git_busy = true; |
| 421 | 453 | 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(); | |
| 423 | 456 | let worktree = self.active_worktree(cx); |
| 424 | 457 | paths.extend(worktree.clone()); |
| 425 | 458 | let task = cx.background_executor().spawn(async move { |
| @@ -437,7 +470,8 @@ impl Workspace { | ||
| 437 | 470 | this.worktree_git.insert(path, info); |
| 438 | 471 | changed = true; |
| 439 | 472 | } |
| 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) | |
| 441 | 475 | && project.git != info |
| 442 | 476 | { |
| 443 | 477 | project.git = info; |
| @@ -459,7 +493,13 @@ impl Workspace { | ||
| 459 | 493 | let (settle_inactive, inactive_days) = |
| 460 | 494 | settings::get(|settings| (settings.auto_settle_inactive, settings.inactive_days.max(1))); |
| 461 | 495 | 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(); | |
| 463 | 503 | for session in &sessions { |
| 464 | 504 | let (wake, resume, settle) = { |
| 465 | 505 | let state = session.read(cx); |
| @@ -500,6 +540,7 @@ impl Workspace { | ||
| 500 | 540 | let threads: Vec<(Entity<Session>, PathBuf, String)> = self |
| 501 | 541 | .projects |
| 502 | 542 | .iter() |
| 543 | + .filter(|project| project.environment.is_none()) | |
| 503 | 544 | .flat_map(|project| &project.sessions) |
| 504 | 545 | .filter_map(|session| { |
| 505 | 546 | let state = session.read(cx); |
| @@ -629,7 +670,7 @@ impl Workspace { | ||
| 629 | 670 | fn worktree_plan(&self, project_ix: usize, mode: WorkspaceMode) -> Option<WorktreePlan> { |
| 630 | 671 | let project = self.projects.get(project_ix)?; |
| 631 | 672 | 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() { | |
| 633 | 674 | return None; |
| 634 | 675 | } |
| 635 | 676 | let (from_origin, submodules) = settings::get(|settings| (settings.start_from_origin, settings.submodules)); |
| @@ -648,8 +689,36 @@ impl Workspace { | ||
| 648 | 689 | window: &mut Window, |
| 649 | 690 | cx: &mut Context<Self>, |
| 650 | 691 | ) -> 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(); | |
| 653 | 722 | let session = cx.new(|cx| { |
| 654 | 723 | Session::new( |
| 655 | 724 | NewSession { |
| @@ -665,9 +734,8 @@ impl Workspace { | ||
| 665 | 734 | cx, |
| 666 | 735 | ) |
| 667 | 736 | }); |
| 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 | |
| 671 | 739 | } |
| 672 | 740 | |
| 673 | 741 | /// Adds a thread to a project with a conversation view for it. |
| @@ -700,7 +768,7 @@ impl Workspace { | ||
| 700 | 768 | window: &mut Window, |
| 701 | 769 | cx: &mut Context<Self>, |
| 702 | 770 | ) { |
| 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()); | |
| 704 | 772 | let previous_chat = previous.as_ref().and_then(|session| self.chats.get(&session.entity_id()).cloned()); |
| 705 | 773 | let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) else { return }; |
| 706 | 774 | if let (Some(previous), Some(previous_chat)) = (previous, previous_chat) { |
| @@ -717,6 +785,7 @@ impl Workspace { | ||
| 717 | 785 | fn on_session_changed(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) { |
| 718 | 786 | cx.notify(); |
| 719 | 787 | self.dirty.insert(session.entity_id()); |
| 788 | + self.remote_thread_changed(&session, cx); | |
| 720 | 789 | let state = session.read(cx); |
| 721 | 790 | let now = Activity { working: state.is_working(), attention: state.needs_attention() }; |
| 722 | 791 | let before = self.activity.insert(session.entity_id(), now).unwrap_or_default(); |
| @@ -765,8 +834,10 @@ impl Workspace { | ||
| 765 | 834 | let settings = self.settings_for(model, cx); |
| 766 | 835 | self.replace_thread(project_ix, settings, window, cx); |
| 767 | 836 | } |
| 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 | + { | |
| 770 | 841 | let settings = self.current_settings(cx); |
| 771 | 842 | self.replace_thread(target, settings, window, cx); |
| 772 | 843 | } |
| @@ -795,7 +866,17 @@ impl Workspace { | ||
| 795 | 866 | } |
| 796 | 867 | } |
| 797 | 868 | 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 | + }; | |
| 799 | 880 | session.update(cx, |session, cx| session.set_worktree_plan(plan, cx)); |
| 800 | 881 | } |
| 801 | 882 | } |
| @@ -820,9 +901,9 @@ impl Workspace { | ||
| 820 | 901 | } |
| 821 | 902 | } |
| 822 | 903 | |
| 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. | |
| 824 | 905 | 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) { | |
| 826 | 907 | return ix; |
| 827 | 908 | } |
| 828 | 909 | let name = name.map(str::to_string).unwrap_or_else(|| { |
| @@ -839,6 +920,7 @@ impl Workspace { | ||
| 839 | 920 | added: SystemTime::now(), |
| 840 | 921 | sessions: Vec::new(), |
| 841 | 922 | git, |
| 923 | + environment: None, | |
| 842 | 924 | }); |
| 843 | 925 | self.projects.len() - 1 |
| 844 | 926 | } |
| @@ -872,6 +954,7 @@ impl Workspace { | ||
| 872 | 954 | cx: &mut Context<Self>, |
| 873 | 955 | ) { |
| 874 | 956 | self.active_project = project_ix; |
| 957 | + session.update(cx, |session, _| session.subscribe_remote()); | |
| 875 | 958 | if let Some(chat) = self.chats.get(&session.entity_id()).cloned() { |
| 876 | 959 | chat.update(cx, |chat, cx| chat.focus_composer(window, cx)); |
| 877 | 960 | } |
| @@ -1012,12 +1095,15 @@ impl Workspace { | ||
| 1012 | 1095 | window: &mut Window, |
| 1013 | 1096 | cx: &mut Context<Self>, |
| 1014 | 1097 | ) { |
| 1015 | - let (id, worktree) = { | |
| 1098 | + let (id, worktree, remote) = { | |
| 1016 | 1099 | let state = session.read(cx); |
| 1017 | - (state.id.clone(), state.worktree.clone()) | |
| 1100 | + (state.id.clone(), state.worktree.clone(), state.is_remote()) | |
| 1018 | 1101 | }; |
| 1019 | 1102 | 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() | |
| 1021 | 1107 | .spawn(async move { |
| 1022 | 1108 | store::delete_thread(&id); |
| 1023 | 1109 | if let Some(worktree) = worktree { |
| @@ -1025,6 +1111,7 @@ impl Workspace { | ||
| 1025 | 1111 | } |
| 1026 | 1112 | }) |
| 1027 | 1113 | .detach(); |
| 1114 | + } | |
| 1028 | 1115 | let entity_id = session.entity_id(); |
| 1029 | 1116 | if self.active.as_ref().is_some_and(|active| active.entity_id() == entity_id) { |
| 1030 | 1117 | self.active = None; |
| @@ -1168,6 +1255,7 @@ impl Workspace { | ||
| 1168 | 1255 | |
| 1169 | 1256 | /// Lets every open conversation pick up changed settings. |
| 1170 | 1257 | pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 1258 | + self.sync_remote(window, cx); | |
| 1171 | 1259 | for chat in self.chats.values() { |
| 1172 | 1260 | chat.update(cx, |chat, cx| chat.settings_changed(window, cx)); |
| 1173 | 1261 | } |
| @@ -1426,6 +1514,7 @@ impl Workspace { | ||
| 1426 | 1514 | None => project.git.as_ref().and_then(|git| git.branch.clone()), |
| 1427 | 1515 | }; |
| 1428 | 1516 | 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)); | |
| 1429 | 1518 | let settled = state.settled; |
| 1430 | 1519 | let pinned = state.pinned; |
| 1431 | 1520 | let snoozed_until = state.snoozed_until.filter(|_| state.is_snoozed()); |
| @@ -1552,6 +1641,7 @@ impl Workspace { | ||
| 1552 | 1641 | .text_color(p.ink3) |
| 1553 | 1642 | .child(project.name.clone()), |
| 1554 | 1643 | ) |
| 1644 | + .when_some(host, |this, host| this.child(metal::tag(host, p))) | |
| 1555 | 1645 | .when(snoozed_until.is_some(), |this| { |
| 1556 | 1646 | this.child(Icon::new(Lucide::AlarmClock).size(px(11.)).text_color(p.ink3)) |
| 1557 | 1647 | }) |
| @@ -1925,6 +2015,8 @@ impl Workspace { | ||
| 1925 | 2015 | None => project.git.clone(), |
| 1926 | 2016 | }; |
| 1927 | 2017 | 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(); | |
| 1928 | 2020 | let has_thread = self.active.is_some(); |
| 1929 | 2021 | let cost = active |
| 1930 | 2022 | .map(|session| session.total_cost) |
| @@ -1976,6 +2068,8 @@ impl Workspace { | ||
| 1976 | 2068 | let has_changes = git.added + git.removed > 0; |
| 1977 | 2069 | let branch_row = if worktree.is_some() { |
| 1978 | 2070 | 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() | |
| 1979 | 2073 | } else { |
| 1980 | 2074 | MetalButton::new("branch-menu") |
| 1981 | 2075 | .ghost() |
| @@ -2063,10 +2157,10 @@ impl Workspace { | ||
| 2063 | 2157 | .gap(px(2.)) |
| 2064 | 2158 | .rounded(px(18.)) |
| 2065 | 2159 | .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 | + }) | |
| 2070 | 2164 | .child( |
| 2071 | 2165 | div() |
| 2072 | 2166 | .px(px(10.)) |
| @@ -2077,7 +2171,7 @@ impl Workspace { | ||
| 2077 | 2171 | .text_color(p.ink3) |
| 2078 | 2172 | .child(display_path(&path)), |
| 2079 | 2173 | ) |
| 2080 | - .child(editor_row) | |
| 2174 | + .when(!remote, |this| this.child(editor_row)) | |
| 2081 | 2175 | .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))) |
| 2082 | 2176 | .child(git_rows) |
| 2083 | 2177 | .when_some(cost, |this, cost| { |
| @@ -2196,6 +2290,8 @@ impl Render for Workspace { | ||
| 2196 | 2290 | .map(|project| ProjectSummary { |
| 2197 | 2291 | name: project.name.clone(), |
| 2198 | 2292 | path: project.path.clone(), |
| 2293 | + environment: project.environment.clone(), | |
| 2294 | + host: project.environment.as_deref().map(|env| self.environment_name(env).into()), | |
| 2199 | 2295 | scratch: Some(&project.path) == scratch.as_ref(), |
| 2200 | 2296 | git: project.git.is_some(), |
| 2201 | 2297 | branch: project.git.as_ref().and_then(|git| git.branch.clone()), |
| @@ -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 | + } | |