A client asks the thread's host with ClientMessage::Genome; the host reads the Genome of the thread's checkout (or the project's, before the thread exists) and answers with ServerMessage::GenomeReply. While the map is open the client asks again every few seconds with the time of the Genome it has, and the host only sends the map again when Empryo rewrote it. A host too old to answer gets a message saying so after 20 seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 6 changed files with 295 additions and 69 deletions.
| @@ -152,7 +152,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 152 | 152 | and the files ranked below it with how many files import each (`→12`) and what changed in |
| 153 | 153 | the last two days. A file opens to its symbols, exported ones first with their signatures, |
| 154 | 154 | and lights up with its imports in the graph. It follows the thread to its worktree and |
| 155 | - reloads as Empryo updates the map. | |
| 155 | + reloads as Empryo updates the map. For a thread on another device, that device reads its | |
| 156 | + Genome and sends it over, and again only when Empryo changed it. | |
| 156 | 157 | - Background tasks: when a command, monitor or subagent Claude left running finishes, the |
| 157 | 158 | transcript says so and Claude picks the work back up and reports back on its own. A |
| 158 | 159 | long-running command can be sent to the background from its tool card, like Ctrl+B in |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | //! Empryo's Genome: the ranked map of a repository Empryo keeps in |
| 2 | 2 | //! `.empryo/genome.db`, a SQLite file it updates as code changes. The Genome |
| 3 | 3 | //! map panel reads its most important files, what they export and which of |
| 4 | - //! them import which, and lays them out as a graph. | |
| 4 | + //! them import which, and lays them out as a graph. A host reads it the same | |
| 5 | + //! way for a connected device's Empryo threads and sends it over. | |
| 5 | 6 | |
| 6 | 7 | use std::{ |
| 7 | 8 | collections::HashMap, |
| @@ -11,6 +12,7 @@ use std::{ | ||
| 11 | 12 | |
| 12 | 13 | use anyhow::{Context as _, Result}; |
| 13 | 14 | use rusqlite::{Connection, OpenFlags, params_from_iter}; |
| 15 | + use serde::{Deserialize, Serialize}; | |
| 14 | 16 | |
| 15 | 17 | /// How many files the map shows, by rank. |
| 16 | 18 | pub const MAP_FILES: usize = 48; |
| @@ -19,6 +21,7 @@ const FILE_SYMBOLS: usize = 12; | ||
| 19 | 21 | /// A file changed this recently is marked new, as Empryo does in its map. |
| 20 | 22 | const RECENT: Duration = Duration::from_secs(48 * 60 * 60); |
| 21 | 23 | |
| 24 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 22 | 25 | pub struct Genome { |
| 23 | 26 | pub total_files: u64, |
| 24 | 27 | pub total_symbols: u64, |
| @@ -31,6 +34,7 @@ pub struct Genome { | ||
| 31 | 34 | pub layout: Vec<(f32, f32)>, |
| 32 | 35 | } |
| 33 | 36 | |
| 37 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 34 | 38 | pub struct GenomeFile { |
| 35 | 39 | pub path: String, |
| 36 | 40 | pub language: String, |
| @@ -48,6 +52,7 @@ pub struct GenomeFile { | ||
| 48 | 52 | pub symbols: Vec<GenomeSymbol>, |
| 49 | 53 | } |
| 50 | 54 | |
| 55 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 51 | 56 | pub struct GenomeSymbol { |
| 52 | 57 | pub name: String, |
| 53 | 58 | pub kind: String, |
| @@ -56,6 +61,7 @@ pub struct GenomeSymbol { | ||
| 56 | 61 | pub signature: Option<String>, |
| 57 | 62 | } |
| 58 | 63 | |
| 64 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 59 | 65 | pub struct GenomeEdge { |
| 60 | 66 | pub from: usize, |
| 61 | 67 | pub to: usize, |
| @@ -68,6 +74,18 @@ pub fn db_path(cwd: &Path) -> Option<PathBuf> { | ||
| 68 | 74 | cwd.ancestors().map(|dir| dir.join(".empryo").join("genome.db")).find(|path| path.is_file()) |
| 69 | 75 | } |
| 70 | 76 | |
| 77 | + /// When Empryo last wrote the Genome of the checkout `cwd` is in, in | |
| 78 | + /// milliseconds since the epoch: its write-ahead log or the database itself. | |
| 79 | + pub fn written_at(cwd: &Path) -> Option<u64> { | |
| 80 | + let path = db_path(cwd)?; | |
| 81 | + [path.with_extension("db-wal"), path] | |
| 82 | + .iter() | |
| 83 | + .filter_map(|path| std::fs::metadata(path).and_then(|meta| meta.modified()).ok()) | |
| 84 | + .max() | |
| 85 | + .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok()) | |
| 86 | + .map(|since| since.as_millis() as u64) | |
| 87 | + } | |
| 88 | + | |
| 71 | 89 | /// Reads the map of the checkout `cwd` is in. |
| 72 | 90 | pub fn load(cwd: &Path) -> Result<Option<Genome>> { |
| 73 | 91 | let Some(path) = db_path(cwd) else { return Ok(None) }; |
| @@ -22,7 +22,7 @@ use std::{ | ||
| 22 | 22 | use tungstenite::{Message, WebSocket}; |
| 23 | 23 | |
| 24 | 24 | use super::{ |
| 25 | - ClientMessage, DEFAULT_PORT, PROTOCOL, RemoteFileDiff, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes, | |
| 25 | + ClientMessage, DEFAULT_PORT, GenomeAnswer, PROTOCOL, RemoteFileDiff, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes, | |
| 26 | 26 | }; |
| 27 | 27 | |
| 28 | 28 | /// How long to wait for a route that goes over the internet or a VPN. |
| @@ -70,6 +70,8 @@ pub struct RemoteClient { | ||
| 70 | 70 | pub events: smol::channel::Receiver<ClientEvent>, |
| 71 | 71 | /// Diffs asked for and not answered yet, by request. |
| 72 | 72 | diffs: Mutex<HashMap<u64, smol::channel::Sender<Result<Vec<RemoteFileDiff>, String>>>>, |
| 73 | + /// Genomes asked for and not answered yet, by request. | |
| 74 | + genomes: Mutex<HashMap<u64, smol::channel::Sender<GenomeAnswer>>>, | |
| 73 | 75 | next_request: AtomicU64, |
| 74 | 76 | } |
| 75 | 77 | |
| @@ -144,7 +146,7 @@ impl RemoteClient { | ||
| 144 | 146 | } |
| 145 | 147 | }); |
| 146 | 148 | } |
| 147 | - Self { outbox, stop, routes, events, diffs: Mutex::default(), next_request: AtomicU64::new(1) } | |
| 149 | + Self { outbox, stop, routes, events, diffs: Mutex::default(), genomes: Mutex::default(), next_request: AtomicU64::new(1) } | |
| 148 | 150 | } |
| 149 | 151 | |
| 150 | 152 | /// Asks the host for the diff between two of a thread's snapshots. The |
| @@ -178,6 +180,35 @@ impl RemoteClient { | ||
| 178 | 180 | self.diffs.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request); |
| 179 | 181 | } |
| 180 | 182 | |
| 183 | + /// Asks the host for Empryo's Genome of a thread's checkout. The answer | |
| 184 | + /// arrives on the returned channel once [`Self::resolve_genome`] gets it; | |
| 185 | + /// older hosts never answer. | |
| 186 | + pub fn request_genome( | |
| 187 | + &self, | |
| 188 | + thread: Option<String>, | |
| 189 | + project: std::path::PathBuf, | |
| 190 | + since: Option<u64>, | |
| 191 | + ) -> (u64, smol::channel::Receiver<GenomeAnswer>) { | |
| 192 | + let request = self.next_request.fetch_add(1, Ordering::Relaxed); | |
| 193 | + let (tx, rx) = smol::channel::bounded(1); | |
| 194 | + self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(request, tx); | |
| 195 | + self.send(&ClientMessage::Genome { request, thread, project, since }); | |
| 196 | + (request, rx) | |
| 197 | + } | |
| 198 | + | |
| 199 | + /// Hands a Genome reply to the request waiting for it. | |
| 200 | + pub fn resolve_genome(&self, request: u64, answer: GenomeAnswer) { | |
| 201 | + let waiting = self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request); | |
| 202 | + if let Some(waiting) = waiting { | |
| 203 | + let _ = waiting.try_send(answer); | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + /// Forgets a Genome request that stopped waiting. | |
| 208 | + pub fn forget_genome(&self, request: u64) { | |
| 209 | + self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request); | |
| 210 | + } | |
| 211 | + | |
| 181 | 212 | /// Changes the routes without dropping the connection; the next try, |
| 182 | 213 | /// and the next look for a faster route, use them. |
| 183 | 214 | pub fn set_routes(&self, routes: Vec<Route>) { |
| @@ -22,7 +22,8 @@ | ||
| 22 | 22 | //! the host's [`device_proxy`] on the same port. Clients without a catalog |
| 23 | 23 | //! of their own, like the Android app, ask for the host's agents and models |
| 24 | 24 | //! with [`ClientMessage::Catalog`] and for a turn's diff with |
| 25 | - //! [`ClientMessage::Diff`]. | |
| 25 | + //! [`ClientMessage::Diff`]. A client's Genome map shows the host's Empryo | |
| 26 | + //! Genome, read there on [`ClientMessage::Genome`]. | |
| 26 | 27 | //! |
| 27 | 28 | //! A host can be reached more than one way, like T3 Code's routes: over the |
| 28 | 29 | //! LAN at home and over Tailscale or another VPN away from it. The host |
| @@ -247,6 +248,11 @@ pub enum ClientMessage { | ||
| 247 | 248 | /// `base` and `head`), for one file or all of them. Older hosts don't |
| 248 | 249 | /// answer. |
| 249 | 250 | Diff { request: u64, thread: String, base: String, head: String, path: Option<String>, ignore_whitespace: bool }, |
| 251 | + /// Asks for Empryo's Genome of a thread's checkout (the project's, for a | |
| 252 | + /// thread not created yet), answered with [`ServerMessage::GenomeReply`]. | |
| 253 | + /// `since` is when the Genome the client has was written, so an | |
| 254 | + /// unchanged one isn't sent again. Older hosts don't answer. | |
| 255 | + Genome { request: u64, thread: Option<String>, project: PathBuf, since: Option<u64> }, | |
| 250 | 256 | /// Updates the host's app to the newest release and restarts it, all in |
| 251 | 257 | /// one; it answers with [`ServerMessage::UpdateStatus`] until it goes |
| 252 | 258 | /// down. Only hosts that say they're `updatable` take it. |
| @@ -304,12 +310,28 @@ pub enum ServerMessage { | ||
| 304 | 310 | /// The answer to a [`ClientMessage::Diff`]: the changed files, or what |
| 305 | 311 | /// went wrong. |
| 306 | 312 | DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> }, |
| 313 | + /// The answer to a [`ClientMessage::Genome`]. | |
| 314 | + GenomeReply { request: u64, answer: GenomeAnswer }, | |
| 307 | 315 | /// How an update a client asked for goes: `checking`, `downloading` |
| 308 | 316 | /// (with `progress`, 0 to 1), `restarting`, or `failed` with `error`. |
| 309 | 317 | /// The connection drops when the host restarts. |
| 310 | 318 | UpdateStatus { state: String, progress: Option<f32>, error: Option<String> }, |
| 311 | 319 | } |
| 312 | 320 | |
| 321 | + /// What a host found of a checkout's Genome. | |
| 322 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 323 | + #[serde(default)] | |
| 324 | + pub struct GenomeAnswer { | |
| 325 | + /// When Empryo last wrote it, in milliseconds since the epoch; `None` | |
| 326 | + /// when there's no Genome yet. | |
| 327 | + pub written: Option<u64>, | |
| 328 | + /// The map, unless it's unchanged since the client's or missing. | |
| 329 | + pub genome: Option<crate::genome::Genome>, | |
| 330 | + /// It's the one the client has. | |
| 331 | + pub unchanged: bool, | |
| 332 | + pub error: Option<String>, | |
| 333 | + } | |
| 334 | + | |
| 313 | 335 | /// A provider instance on the host and what its last health check found. |
| 314 | 336 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 315 | 337 | #[serde(default)] |
| @@ -1083,6 +1105,49 @@ mod tests { | ||
| 1083 | 1105 | assert_eq!((back.lines[1].text.as_str(), back.lines[1].new_line), ("new", Some(1))); |
| 1084 | 1106 | } |
| 1085 | 1107 | |
| 1108 | + #[::core::prelude::v1::test] | |
| 1109 | + fn genome_messages_round_trip() { | |
| 1110 | + let message = ClientMessage::Genome { request: 3, thread: Some("t1".into()), project: "/repo".into(), since: Some(42) }; | |
| 1111 | + let text = serde_json::to_string(&message).unwrap(); | |
| 1112 | + assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); | |
| 1113 | + let genome = crate::genome::Genome { | |
| 1114 | + total_files: 1, | |
| 1115 | + total_symbols: 1, | |
| 1116 | + total_edges: 0, | |
| 1117 | + files: vec![crate::genome::GenomeFile { | |
| 1118 | + path: "src/a.rs".into(), | |
| 1119 | + language: "rust".into(), | |
| 1120 | + lines: 3, | |
| 1121 | + symbol_count: 1, | |
| 1122 | + rank: 1., | |
| 1123 | + dependents: 0, | |
| 1124 | + dependencies: 0, | |
| 1125 | + recent: true, | |
| 1126 | + symbols: vec![crate::genome::GenomeSymbol { | |
| 1127 | + name: "a".into(), | |
| 1128 | + kind: "function".into(), | |
| 1129 | + line: 1, | |
| 1130 | + exported: true, | |
| 1131 | + signature: Some("pub fn a()".into()), | |
| 1132 | + }], | |
| 1133 | + }], | |
| 1134 | + edges: Vec::new(), | |
| 1135 | + layout: vec![(0.5, 0.5)], | |
| 1136 | + }; | |
| 1137 | + let answer = GenomeAnswer { written: Some(42), genome: Some(genome), ..GenomeAnswer::default() }; | |
| 1138 | + let text = serde_json::to_string(&ServerMessage::GenomeReply { request: 3, answer: answer.clone() }).unwrap(); | |
| 1139 | + match serde_json::from_str::<ServerMessage>(&text).unwrap() { | |
| 1140 | + ServerMessage::GenomeReply { request: 3, answer: received } => assert_eq!(received, answer), | |
| 1141 | + _ => panic!("not a Genome reply"), | |
| 1142 | + } | |
| 1143 | + // An unchanged Genome comes back as just that. | |
| 1144 | + let text = r#"{"type": "genome_reply", "request": 4, "answer": {"written": 42, "unchanged": true}}"#; | |
| 1145 | + match serde_json::from_str::<ServerMessage>(text).unwrap() { | |
| 1146 | + ServerMessage::GenomeReply { answer, .. } => assert!(answer.unchanged && answer.genome.is_none()), | |
| 1147 | + _ => panic!("not a Genome reply"), | |
| 1148 | + } | |
| 1149 | + } | |
| 1150 | + | |
| 1086 | 1151 | #[::core::prelude::v1::test] |
| 1087 | 1152 | fn client_and_server_talk() { |
| 1088 | 1153 | use std::time::Duration; |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | //! The Genome map: Empryo's ranked map of the open thread's checkout, read |
| 2 | - //! from the `.empryo/genome.db` Empryo keeps there. A surface of the right | |
| 3 | - //! panel that the sidebar offers while the thread runs on Empryo. The graph | |
| 4 | - //! shows the highest ranked files and which import which; the list below | |
| 5 | - //! ranks them, and a file opens to what it exports. | |
| 2 | + //! from the `.empryo/genome.db` Empryo keeps there, or for a thread on | |
| 3 | + //! another device, read there and sent over. A surface of the right panel | |
| 4 | + //! that the sidebar offers while the thread runs on Empryo. The graph shows | |
| 5 | + //! the highest ranked files and which import which; the list below ranks | |
| 6 | + //! them, and a file opens to what it exports. | |
| 6 | 7 | |
| 7 | - use std::path::{Path, PathBuf}; | |
| 8 | + use std::path::PathBuf; | |
| 8 | 9 | use std::rc::Rc; |
| 9 | 10 | use std::sync::Arc; |
| 10 | 11 | use std::time::{Duration, SystemTime}; |
| @@ -17,6 +18,7 @@ use super::Workspace; | ||
| 17 | 18 | use super::surfaces::Surface; |
| 18 | 19 | use crate::genome::{self, Genome, GenomeFile}; |
| 19 | 20 | use crate::models::Provider; |
| 21 | + use crate::remote::GenomeAnswer; | |
| 20 | 22 | use crate::ui::icons::Lucide; |
| 21 | 23 | use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; |
| 22 | 24 | |
| @@ -26,12 +28,23 @@ const MAP_WIDTH: f32 = GENOME_PANEL_WIDTH - 12. - 12. - 16.; | ||
| 26 | 28 | const MAP_HEIGHT: f32 = 300.; |
| 27 | 29 | /// How often an open map looks for Empryo's updates. |
| 28 | 30 | const WATCH_EVERY: Duration = Duration::from_secs(3); |
| 31 | + /// How long another device gets to send its Genome. | |
| 32 | + const REMOTE_TIMEOUT: Duration = Duration::from_secs(20); | |
| 29 | 33 | /// How many files the map labels by name. |
| 30 | 34 | const LABELED: usize = 6; |
| 31 | 35 | |
| 32 | 36 | /// An import line in the map: from, to, and whether it's lit. |
| 33 | 37 | type MapLine = ((f32, f32), (f32, f32), bool); |
| 34 | 38 | |
| 39 | + /// Where the checkout a map shows is. | |
| 40 | + #[derive(Clone, Debug, PartialEq)] | |
| 41 | + enum GenomeSource { | |
| 42 | + Local(PathBuf), | |
| 43 | + /// On another device, by its connection's id: a thread's checkout there, | |
| 44 | + /// or the project's for a thread not created yet. | |
| 45 | + Remote { env: String, thread: Option<String>, project: PathBuf }, | |
| 46 | + } | |
| 47 | + | |
| 35 | 48 | /// What the Genome map shows for a checkout. |
| 36 | 49 | enum GenomeLoad { |
| 37 | 50 | Loading, |
| @@ -42,11 +55,11 @@ enum GenomeLoad { | ||
| 42 | 55 | } |
| 43 | 56 | |
| 44 | 57 | pub(super) struct GenomePanel { |
| 45 | - /// The checkout it maps. | |
| 46 | - cwd: PathBuf, | |
| 58 | + source: GenomeSource, | |
| 47 | 59 | load: GenomeLoad, |
| 48 | - /// When the database was last written, as of the last read. | |
| 49 | - read_at: Option<SystemTime>, | |
| 60 | + /// When the database was last written, in milliseconds since the epoch, | |
| 61 | + /// as of the last read. | |
| 62 | + read_at: Option<u64>, | |
| 50 | 63 | reading: bool, |
| 51 | 64 | /// The file opened in the list, by path, so it stays open across reloads. |
| 52 | 65 | selected: Option<String>, |
| @@ -55,28 +68,37 @@ pub(super) struct GenomePanel { | ||
| 55 | 68 | } |
| 56 | 69 | |
| 57 | 70 | impl Workspace { |
| 58 | - /// Whether the open thread (or the new thread's draft) runs on Empryo in | |
| 59 | - /// a checkout on this device, where its Genome can be read. | |
| 71 | + /// Whether the open thread (or the new thread's draft) runs on Empryo, | |
| 72 | + /// here or on another device. | |
| 60 | 73 | pub(super) fn genome_available(&self, cx: &App) -> bool { |
| 61 | - let local = self.active_project().is_some_and(|project| project.environment.is_none()); | |
| 62 | 74 | let provider = match &self.active { |
| 63 | - Some(session) => { | |
| 64 | - let session = session.read(cx); | |
| 65 | - if session.remote.is_some() { | |
| 66 | - return false; | |
| 67 | - } | |
| 68 | - session.provider | |
| 69 | - } | |
| 75 | + Some(session) => session.read(cx).provider, | |
| 70 | 76 | None => self.current_settings(cx).provider, |
| 71 | 77 | }; |
| 72 | - local && provider == Provider::Empryo | |
| 78 | + provider == Provider::Empryo && self.genome_source(cx).is_some() | |
| 73 | 79 | } |
| 74 | 80 | |
| 75 | - /// The checkout the open thread works in. | |
| 76 | - fn genome_cwd(&self, cx: &App) -> Option<PathBuf> { | |
| 81 | + /// The checkout the open thread works in, and where it is. | |
| 82 | + fn genome_source(&self, cx: &App) -> Option<GenomeSource> { | |
| 77 | 83 | match &self.active { |
| 78 | - Some(session) => Some(session.read(cx).cwd.clone()), | |
| 79 | - None => self.active_project().map(|project| project.path.clone()), | |
| 84 | + Some(session) => { | |
| 85 | + let session = session.read(cx); | |
| 86 | + Some(match &session.remote { | |
| 87 | + Some(link) => GenomeSource::Remote { | |
| 88 | + env: link.env.clone(), | |
| 89 | + thread: link.thread.clone(), | |
| 90 | + project: session.project.clone(), | |
| 91 | + }, | |
| 92 | + None => GenomeSource::Local(session.cwd.clone()), | |
| 93 | + }) | |
| 94 | + } | |
| 95 | + None => { | |
| 96 | + let project = self.active_project()?; | |
| 97 | + Some(match &project.environment { | |
| 98 | + Some(env) => GenomeSource::Remote { env: env.clone(), thread: None, project: project.path.clone() }, | |
| 99 | + None => GenomeSource::Local(project.path.clone()), | |
| 100 | + }) | |
| 101 | + } | |
| 80 | 102 | } |
| 81 | 103 | } |
| 82 | 104 | |
| @@ -86,8 +108,8 @@ impl Workspace { | ||
| 86 | 108 | if !self.surface_open(Surface::Genome) || self.settings_open { |
| 87 | 109 | return; |
| 88 | 110 | } |
| 89 | - let Some(cwd) = self.genome_cwd(cx) else { return }; | |
| 90 | - if self.genome.as_ref().is_some_and(|panel| panel.cwd == cwd) { | |
| 111 | + let Some(source) = self.genome_source(cx) else { return }; | |
| 112 | + if self.genome.as_ref().is_some_and(|panel| panel.source == source) { | |
| 91 | 113 | return; |
| 92 | 114 | } |
| 93 | 115 | let watch = cx.spawn(async move |this, cx| { |
| @@ -98,8 +120,12 @@ impl Workspace { | ||
| 98 | 120 | if panel.reading || !this.surface_open(Surface::Genome) { |
| 99 | 121 | return; |
| 100 | 122 | } |
| 101 | - let written = written_at(&panel.cwd); | |
| 102 | - if written != panel.read_at { | |
| 123 | + // Another device answers with what changed, if anything. | |
| 124 | + let changed = match &panel.source { | |
| 125 | + GenomeSource::Local(cwd) => genome::written_at(cwd) != panel.read_at, | |
| 126 | + GenomeSource::Remote { .. } => true, | |
| 127 | + }; | |
| 128 | + if changed { | |
| 103 | 129 | this.read_genome(cx); |
| 104 | 130 | } |
| 105 | 131 | }); |
| @@ -109,7 +135,7 @@ impl Workspace { | ||
| 109 | 135 | } |
| 110 | 136 | }); |
| 111 | 137 | self.genome = Some(GenomePanel { |
| 112 | - cwd, | |
| 138 | + source, | |
| 113 | 139 | load: GenomeLoad::Loading, |
| 114 | 140 | read_at: None, |
| 115 | 141 | reading: false, |
| @@ -120,31 +146,79 @@ impl Workspace { | ||
| 120 | 146 | self.read_genome(cx); |
| 121 | 147 | } |
| 122 | 148 | |
| 123 | - /// Reads the Genome again, in the background. | |
| 149 | + /// Reads the Genome again, in the background, or asks the device it's on. | |
| 124 | 150 | fn read_genome(&mut self, cx: &mut Context<Self>) { |
| 125 | - let Some(panel) = self.genome.as_mut() else { return }; | |
| 126 | - panel.reading = true; | |
| 127 | - let cwd = panel.cwd.clone(); | |
| 128 | - let read = cx.background_executor().spawn({ | |
| 129 | - let cwd = cwd.clone(); | |
| 130 | - async move { (written_at(&cwd), genome::load(&cwd)) } | |
| 131 | - }); | |
| 132 | - cx.spawn(async move |this, cx| { | |
| 133 | - let (written, result) = read.await; | |
| 134 | - let _ = this.update(cx, |this, cx| { | |
| 135 | - let Some(panel) = this.genome.as_mut().filter(|panel| panel.cwd == cwd) else { return }; | |
| 136 | - panel.reading = false; | |
| 137 | - panel.read_at = written; | |
| 138 | - panel.hovered = None; | |
| 139 | - panel.load = match result { | |
| 140 | - Ok(Some(genome)) => GenomeLoad::Ready(Arc::new(genome)), | |
| 141 | - Ok(None) => GenomeLoad::Missing, | |
| 142 | - Err(err) => GenomeLoad::Failed(format!("{err:#}")), | |
| 151 | + let Some(panel) = self.genome.as_ref() else { return }; | |
| 152 | + let source = panel.source.clone(); | |
| 153 | + let since = panel.read_at; | |
| 154 | + match source.clone() { | |
| 155 | + GenomeSource::Local(cwd) => { | |
| 156 | + let read = cx.background_executor().spawn(async move { | |
| 157 | + let written = genome::written_at(&cwd); | |
| 158 | + match genome::load(&cwd) { | |
| 159 | + Ok(genome) => GenomeAnswer { written, genome, ..GenomeAnswer::default() }, | |
| 160 | + Err(err) => GenomeAnswer { written, error: Some(format!("{err:#}")), ..GenomeAnswer::default() }, | |
| 161 | + } | |
| 162 | + }); | |
| 163 | + cx.spawn(async move |this, cx| { | |
| 164 | + let answer = read.await; | |
| 165 | + let _ = this.update(cx, |this, cx| this.show_genome(&source, answer, cx)); | |
| 166 | + }) | |
| 167 | + .detach(); | |
| 168 | + } | |
| 169 | + GenomeSource::Remote { env, thread, project } => { | |
| 170 | + let Some(client) = self.connected_client(&env) else { | |
| 171 | + // It reads again once the device is back. | |
| 172 | + if let Some(panel) = self.genome.as_mut().filter(|panel| !matches!(panel.load, GenomeLoad::Ready(_))) { | |
| 173 | + panel.load = GenomeLoad::Failed("The device this thread runs on isn't connected.".into()); | |
| 174 | + cx.notify(); | |
| 175 | + } | |
| 176 | + return; | |
| 143 | 177 | }; |
| 144 | - cx.notify(); | |
| 145 | - }); | |
| 146 | - }) | |
| 147 | - .detach(); | |
| 178 | + let (request, reply) = client.request_genome(thread, project, since); | |
| 179 | + let timeout = cx.background_executor().timer(REMOTE_TIMEOUT); | |
| 180 | + cx.spawn(async move |this, cx| { | |
| 181 | + let answer = smol::future::or(async { reply.recv().await.ok() }, async { | |
| 182 | + timeout.await; | |
| 183 | + None | |
| 184 | + }) | |
| 185 | + .await; | |
| 186 | + let answer = answer.unwrap_or_else(|| { | |
| 187 | + client.forget_genome(request); | |
| 188 | + GenomeAnswer { | |
| 189 | + written: since, | |
| 190 | + error: Some( | |
| 191 | + "The device didn't send its Genome. Its Vectrum Code may be too old to; update it from the sidebar's corner." | |
| 192 | + .into(), | |
| 193 | + ), | |
| 194 | + ..GenomeAnswer::default() | |
| 195 | + } | |
| 196 | + }); | |
| 197 | + let _ = this.update(cx, |this, cx| this.show_genome(&source, answer, cx)); | |
| 198 | + }) | |
| 199 | + .detach(); | |
| 200 | + } | |
| 201 | + } | |
| 202 | + if let Some(panel) = self.genome.as_mut() { | |
| 203 | + panel.reading = true; | |
| 204 | + } | |
| 205 | + cx.notify(); | |
| 206 | + } | |
| 207 | + | |
| 208 | + /// Shows what a read of `source`'s Genome found, if the map still shows it. | |
| 209 | + fn show_genome(&mut self, source: &GenomeSource, answer: GenomeAnswer, cx: &mut Context<Self>) { | |
| 210 | + let Some(panel) = self.genome.as_mut().filter(|panel| &panel.source == source) else { return }; | |
| 211 | + panel.reading = false; | |
| 212 | + panel.read_at = answer.written; | |
| 213 | + if answer.unchanged { | |
| 214 | + return; | |
| 215 | + } | |
| 216 | + panel.hovered = None; | |
| 217 | + panel.load = match (answer.error, answer.genome) { | |
| 218 | + (Some(error), _) => GenomeLoad::Failed(error), | |
| 219 | + (None, Some(genome)) => GenomeLoad::Ready(Arc::new(genome)), | |
| 220 | + (None, None) => GenomeLoad::Missing, | |
| 221 | + }; | |
| 148 | 222 | cx.notify(); |
| 149 | 223 | } |
| 150 | 224 | |
| @@ -237,7 +311,9 @@ impl Workspace { | ||
| 237 | 311 | let selected_ix = selected.as_ref().and_then(|path| genome.files.iter().position(|file| &file.path == path)); |
| 238 | 312 | let hovered = panel.and_then(|panel| panel.hovered); |
| 239 | 313 | let focus = hovered.or(selected_ix); |
| 240 | - let updated = panel.and_then(|panel| panel.read_at).map(crate::clock::when); | |
| 314 | + let updated = panel | |
| 315 | + .and_then(|panel| panel.read_at) | |
| 316 | + .map(|millis| crate::clock::when(SystemTime::UNIX_EPOCH + Duration::from_millis(millis))); | |
| 241 | 317 | |
| 242 | 318 | let rows: Vec<AnyElement> = genome |
| 243 | 319 | .files |
| @@ -505,16 +581,6 @@ impl Workspace { | ||
| 505 | 581 | } |
| 506 | 582 | } |
| 507 | 583 | |
| 508 | - /// When Empryo last wrote the Genome of the checkout `cwd` is in: its | |
| 509 | - /// write-ahead log or the database itself. | |
| 510 | - fn written_at(cwd: &Path) -> Option<SystemTime> { | |
| 511 | - let path = genome::db_path(cwd)?; | |
| 512 | - [path.with_extension("db-wal"), path] | |
| 513 | - .iter() | |
| 514 | - .filter_map(|path| std::fs::metadata(path).and_then(|meta| meta.modified()).ok()) | |
| 515 | - .max() | |
| 516 | - } | |
| 517 | - | |
| 518 | 584 | fn file_name(path: &str) -> String { |
| 519 | 585 | path.rsplit('/').next().unwrap_or(path).to_string() |
| 520 | 586 | } |
| @@ -20,7 +20,7 @@ use crate::claude::PermissionMode; | ||
| 20 | 20 | use crate::git::GitInfo; |
| 21 | 21 | use crate::models::Catalog; |
| 22 | 22 | use crate::remote::{ |
| 23 | - ClientMessage, ProjectInfo, RemoteFileDiff, RemoteInstance, Route, ServerMessage, ThreadAction, ThreadSummary, | |
| 23 | + ClientMessage, GenomeAnswer, ProjectInfo, RemoteFileDiff, RemoteInstance, Route, ServerMessage, ThreadAction, ThreadSummary, | |
| 24 | 24 | client::{ClientEvent, ConnectionStatus, RemoteClient}, |
| 25 | 25 | new_key, |
| 26 | 26 | server::{Peer, RemoteServer, ServerEvent}, |
| @@ -639,6 +639,36 @@ impl Workspace { | ||
| 639 | 639 | }) |
| 640 | 640 | .detach(); |
| 641 | 641 | } |
| 642 | + ClientMessage::Genome { request, thread, project, since } => { | |
| 643 | + let is_project = self.projects.iter().any(|candidate| candidate.environment.is_none() && candidate.path == project); | |
| 644 | + let cwd = match thread.as_deref().map(|id| self.local_thread(id, cx)) { | |
| 645 | + Some(Some((_, session))) => session.read(cx).cwd.clone(), | |
| 646 | + Some(None) => { | |
| 647 | + let answer = GenomeAnswer { error: Some("That thread is gone.".into()), ..GenomeAnswer::default() }; | |
| 648 | + return self.reply(conn, ServerMessage::GenomeReply { request, answer }); | |
| 649 | + } | |
| 650 | + None if is_project => project, | |
| 651 | + None => { | |
| 652 | + let error = Some(format!("{} isn't a project here.", project.display())); | |
| 653 | + return self.reply(conn, ServerMessage::GenomeReply { request, answer: GenomeAnswer { error, ..GenomeAnswer::default() } }); | |
| 654 | + } | |
| 655 | + }; | |
| 656 | + let read = cx.background_executor().spawn(async move { | |
| 657 | + let written = crate::genome::written_at(&cwd); | |
| 658 | + if written.is_some() && written == since { | |
| 659 | + return GenomeAnswer { written, unchanged: true, ..GenomeAnswer::default() }; | |
| 660 | + } | |
| 661 | + match crate::genome::load(&cwd) { | |
| 662 | + Ok(genome) => GenomeAnswer { written, genome, ..GenomeAnswer::default() }, | |
| 663 | + Err(err) => GenomeAnswer { written, error: Some(format!("{err:#}")), ..GenomeAnswer::default() }, | |
| 664 | + } | |
| 665 | + }); | |
| 666 | + cx.spawn(async move |this, cx| { | |
| 667 | + let answer = read.await; | |
| 668 | + let _ = this.update(cx, |this, _| this.reply(conn, ServerMessage::GenomeReply { request, answer })); | |
| 669 | + }) | |
| 670 | + .detach(); | |
| 671 | + } | |
| 642 | 672 | } |
| 643 | 673 | } |
| 644 | 674 | |
| @@ -986,6 +1016,11 @@ impl Workspace { | ||
| 986 | 1016 | ClientEvent::Message(ServerMessage::Denied { .. }) => {} |
| 987 | 1017 | // Only sent in reply to requests this app doesn't make. |
| 988 | 1018 | ClientEvent::Message(ServerMessage::Catalog { .. }) => {} |
| 1019 | + ClientEvent::Message(ServerMessage::GenomeReply { request, answer }) => { | |
| 1020 | + if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) { | |
| 1021 | + environment.client.resolve_genome(request, answer); | |
| 1022 | + } | |
| 1023 | + } | |
| 989 | 1024 | ClientEvent::Message(ServerMessage::DiffReply { request, files, error }) => { |
| 990 | 1025 | if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) { |
| 991 | 1026 | environment.client.resolve_diff(request, error.map_or(Ok(files), Err)); |
| @@ -1029,6 +1064,16 @@ impl Workspace { | ||
| 1029 | 1064 | } |
| 1030 | 1065 | } |
| 1031 | 1066 | |
| 1067 | + /// The connection to another device, while it's connected. | |
| 1068 | + pub(super) fn connected_client(&self, env: &str) -> Option<Rc<RemoteClient>> { | |
| 1069 | + self.remote | |
| 1070 | + .environments | |
| 1071 | + .iter() | |
| 1072 | + .find(|candidate| candidate.host.id == env) | |
| 1073 | + .filter(|environment| matches!(environment.status, ConnectionStatus::Connected(_))) | |
| 1074 | + .map(|environment| environment.client.clone()) | |
| 1075 | + } | |
| 1076 | + | |
| 1032 | 1077 | fn send_to_environment(&self, env: &str, message: &ClientMessage) { |
| 1033 | 1078 | if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) { |
| 1034 | 1079 | environment.client.send(message); |