Vectrum
Sign In

Genome map for Empryo threads on another device

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>
JjohannSocommitted 1 parent e3107facommit2b41c45

Showing 6 changed files with 295 additions and 69 deletions.

+2−1README.mdView File
@@ -152,7 +152,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
152152 and the files ranked below it with how many files import each (`→12`) and what changed in
153153 the last two days. A file opens to its symbols, exported ones first with their signatures,
154154 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.
156157 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
157158 transcript says so and Claude picks the work back up and reports back on its own. A
158159 long-running command can be sent to the background from its tool card, like Ctrl+B in
+19−1src/genome.rsView File
@@ -1,7 +1,8 @@
11 //! Empryo's Genome: the ranked map of a repository Empryo keeps in
22 //! `.empryo/genome.db`, a SQLite file it updates as code changes. The Genome
33 //! 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.
56
67 use std::{
78 collections::HashMap,
@@ -11,6 +12,7 @@ use std::{
1112
1213 use anyhow::{Context as _, Result};
1314 use rusqlite::{Connection, OpenFlags, params_from_iter};
15+ use serde::{Deserialize, Serialize};
1416
1517 /// How many files the map shows, by rank.
1618 pub const MAP_FILES: usize = 48;
@@ -19,6 +21,7 @@ const FILE_SYMBOLS: usize = 12;
1921 /// A file changed this recently is marked new, as Empryo does in its map.
2022 const RECENT: Duration = Duration::from_secs(48 * 60 * 60);
2123
24+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2225 pub struct Genome {
2326 pub total_files: u64,
2427 pub total_symbols: u64,
@@ -31,6 +34,7 @@ pub struct Genome {
3134 pub layout: Vec<(f32, f32)>,
3235 }
3336
37+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
3438 pub struct GenomeFile {
3539 pub path: String,
3640 pub language: String,
@@ -48,6 +52,7 @@ pub struct GenomeFile {
4852 pub symbols: Vec<GenomeSymbol>,
4953 }
5054
55+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
5156 pub struct GenomeSymbol {
5257 pub name: String,
5358 pub kind: String,
@@ -56,6 +61,7 @@ pub struct GenomeSymbol {
5661 pub signature: Option<String>,
5762 }
5863
64+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
5965 pub struct GenomeEdge {
6066 pub from: usize,
6167 pub to: usize,
@@ -68,6 +74,18 @@ pub fn db_path(cwd: &Path) -> Option<PathBuf> {
6874 cwd.ancestors().map(|dir| dir.join(".empryo").join("genome.db")).find(|path| path.is_file())
6975 }
7076
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+
7189 /// Reads the map of the checkout `cwd` is in.
7290 pub fn load(cwd: &Path) -> Result<Option<Genome>> {
7391 let Some(path) = db_path(cwd) else { return Ok(None) };
+33−2src/remote/client.rsView File
@@ -22,7 +22,7 @@ use std::{
2222 use tungstenite::{Message, WebSocket};
2323
2424 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,
2626 };
2727
2828 /// How long to wait for a route that goes over the internet or a VPN.
@@ -70,6 +70,8 @@ pub struct RemoteClient {
7070 pub events: smol::channel::Receiver<ClientEvent>,
7171 /// Diffs asked for and not answered yet, by request.
7272 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>>>,
7375 next_request: AtomicU64,
7476 }
7577
@@ -144,7 +146,7 @@ impl RemoteClient {
144146 }
145147 });
146148 }
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) }
148150 }
149151
150152 /// Asks the host for the diff between two of a thread's snapshots. The
@@ -178,6 +180,35 @@ impl RemoteClient {
178180 self.diffs.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request);
179181 }
180182
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+
181212 /// Changes the routes without dropping the connection; the next try,
182213 /// and the next look for a faster route, use them.
183214 pub fn set_routes(&self, routes: Vec<Route>) {
+66−1src/remote/mod.rsView File
@@ -22,7 +22,8 @@
2222 //! the host's [`device_proxy`] on the same port. Clients without a catalog
2323 //! of their own, like the Android app, ask for the host's agents and models
2424 //! 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`].
2627 //!
2728 //! A host can be reached more than one way, like T3 Code's routes: over the
2829 //! LAN at home and over Tailscale or another VPN away from it. The host
@@ -247,6 +248,11 @@ pub enum ClientMessage {
247248 /// `base` and `head`), for one file or all of them. Older hosts don't
248249 /// answer.
249250 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> },
250256 /// Updates the host's app to the newest release and restarts it, all in
251257 /// one; it answers with [`ServerMessage::UpdateStatus`] until it goes
252258 /// down. Only hosts that say they're `updatable` take it.
@@ -304,12 +310,28 @@ pub enum ServerMessage {
304310 /// The answer to a [`ClientMessage::Diff`]: the changed files, or what
305311 /// went wrong.
306312 DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> },
313+ /// The answer to a [`ClientMessage::Genome`].
314+ GenomeReply { request: u64, answer: GenomeAnswer },
307315 /// How an update a client asked for goes: `checking`, `downloading`
308316 /// (with `progress`, 0 to 1), `restarting`, or `failed` with `error`.
309317 /// The connection drops when the host restarts.
310318 UpdateStatus { state: String, progress: Option<f32>, error: Option<String> },
311319 }
312320
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+
313335 /// A provider instance on the host and what its last health check found.
314336 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
315337 #[serde(default)]
@@ -1083,6 +1105,49 @@ mod tests {
10831105 assert_eq!((back.lines[1].text.as_str(), back.lines[1].new_line), ("new", Some(1)));
10841106 }
10851107
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+
10861151 #[::core::prelude::v1::test]
10871152 fn client_and_server_talk() {
10881153 use std::time::Duration;
+129−63src/ui/workspace/genome.rsView File
@@ -1,10 +1,11 @@
11 //! 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.
67
7- use std::path::{Path, PathBuf};
8+ use std::path::PathBuf;
89 use std::rc::Rc;
910 use std::sync::Arc;
1011 use std::time::{Duration, SystemTime};
@@ -17,6 +18,7 @@ use super::Workspace;
1718 use super::surfaces::Surface;
1819 use crate::genome::{self, Genome, GenomeFile};
1920 use crate::models::Provider;
21+ use crate::remote::GenomeAnswer;
2022 use crate::ui::icons::Lucide;
2123 use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
2224
@@ -26,12 +28,23 @@ const MAP_WIDTH: f32 = GENOME_PANEL_WIDTH - 12. - 12. - 16.;
2628 const MAP_HEIGHT: f32 = 300.;
2729 /// How often an open map looks for Empryo's updates.
2830 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);
2933 /// How many files the map labels by name.
3034 const LABELED: usize = 6;
3135
3236 /// An import line in the map: from, to, and whether it's lit.
3337 type MapLine = ((f32, f32), (f32, f32), bool);
3438
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+
3548 /// What the Genome map shows for a checkout.
3649 enum GenomeLoad {
3750 Loading,
@@ -42,11 +55,11 @@ enum GenomeLoad {
4255 }
4356
4457 pub(super) struct GenomePanel {
45- /// The checkout it maps.
46- cwd: PathBuf,
58+ source: GenomeSource,
4759 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>,
5063 reading: bool,
5164 /// The file opened in the list, by path, so it stays open across reloads.
5265 selected: Option<String>,
@@ -55,28 +68,37 @@ pub(super) struct GenomePanel {
5568 }
5669
5770 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.
6073 pub(super) fn genome_available(&self, cx: &App) -> bool {
61- let local = self.active_project().is_some_and(|project| project.environment.is_none());
6274 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,
7076 None => self.current_settings(cx).provider,
7177 };
72- local && provider == Provider::Empryo
78+ provider == Provider::Empryo && self.genome_source(cx).is_some()
7379 }
7480
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> {
7783 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+ }
80102 }
81103 }
82104
@@ -86,8 +108,8 @@ impl Workspace {
86108 if !self.surface_open(Surface::Genome) || self.settings_open {
87109 return;
88110 }
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) {
91113 return;
92114 }
93115 let watch = cx.spawn(async move |this, cx| {
@@ -98,8 +120,12 @@ impl Workspace {
98120 if panel.reading || !this.surface_open(Surface::Genome) {
99121 return;
100122 }
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 {
103129 this.read_genome(cx);
104130 }
105131 });
@@ -109,7 +135,7 @@ impl Workspace {
109135 }
110136 });
111137 self.genome = Some(GenomePanel {
112- cwd,
138+ source,
113139 load: GenomeLoad::Loading,
114140 read_at: None,
115141 reading: false,
@@ -120,31 +146,79 @@ impl Workspace {
120146 self.read_genome(cx);
121147 }
122148
123- /// Reads the Genome again, in the background.
149+ /// Reads the Genome again, in the background, or asks the device it's on.
124150 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;
143177 };
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+ };
148222 cx.notify();
149223 }
150224
@@ -237,7 +311,9 @@ impl Workspace {
237311 let selected_ix = selected.as_ref().and_then(|path| genome.files.iter().position(|file| &file.path == path));
238312 let hovered = panel.and_then(|panel| panel.hovered);
239313 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)));
241317
242318 let rows: Vec<AnyElement> = genome
243319 .files
@@ -505,16 +581,6 @@ impl Workspace {
505581 }
506582 }
507583
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-
518584 fn file_name(path: &str) -> String {
519585 path.rsplit('/').next().unwrap_or(path).to_string()
520586 }
+46−1src/ui/workspace/remote.rsView File
@@ -20,7 +20,7 @@ use crate::claude::PermissionMode;
2020 use crate::git::GitInfo;
2121 use crate::models::Catalog;
2222 use crate::remote::{
23- ClientMessage, ProjectInfo, RemoteFileDiff, RemoteInstance, Route, ServerMessage, ThreadAction, ThreadSummary,
23+ ClientMessage, GenomeAnswer, ProjectInfo, RemoteFileDiff, RemoteInstance, Route, ServerMessage, ThreadAction, ThreadSummary,
2424 client::{ClientEvent, ConnectionStatus, RemoteClient},
2525 new_key,
2626 server::{Peer, RemoteServer, ServerEvent},
@@ -639,6 +639,36 @@ impl Workspace {
639639 })
640640 .detach();
641641 }
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+ }
642672 }
643673 }
644674
@@ -986,6 +1016,11 @@ impl Workspace {
9861016 ClientEvent::Message(ServerMessage::Denied { .. }) => {}
9871017 // Only sent in reply to requests this app doesn't make.
9881018 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+ }
9891024 ClientEvent::Message(ServerMessage::DiffReply { request, files, error }) => {
9901025 if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) {
9911026 environment.client.resolve_diff(request, error.map_or(Ok(files), Err));
@@ -1029,6 +1064,16 @@ impl Workspace {
10291064 }
10301065 }
10311066
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+
10321077 fn send_to_environment(&self, env: &str, message: &ClientMessage) {
10331078 if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) {
10341079 environment.client.send(message);