Vectrum
Sign In

Share the model catalog and diffs with remote clients, show a pairing QR code, and export themes for Android

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

Showing 9 changed files with 468 additions and 10 deletions.

+7−0Cargo.lockView File
@@ -5249,6 +5249,12 @@ dependencies = [
52495249 "bytemuck",
52505250 ]
52515251
5252+ [[package]]
5253+ name = "qrcode"
5254+ version = "0.14.1"
5255+ source = "registry+https://github.com/rust-lang/crates.io-index"
5256+ checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
5257+
52525258 [[package]]
52535259 name = "quick-error"
52545260 version = "2.0.1"
@@ -7482,6 +7488,7 @@ dependencies = [
74827488 "libc",
74837489 "mermaid-rs-renderer",
74847490 "openh264",
7491+ "qrcode",
74857492 "serde",
74867493 "serde_json",
74877494 "smol",
+1−0Cargo.tomlView File
@@ -19,6 +19,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
1919 alacritty_terminal = "0.26"
2020 openh264 = "0.9"
2121 mermaid-rs-renderer = { version = "0.3.1", default-features = false }
22+ qrcode = { version = "0.14.1", default-features = false }
2223
2324 [profile.dev]
2425 debug = "line-tables-only"
+1−1README.mdView File
@@ -203,7 +203,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
203203 side-by-side layout.
204204 - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on
205205 one device, and paste its pairing code (address and key) under Settings → Connections on
206- another. The host's projects and threads appear in the sidebar tagged with its name; open,
206+ another, or scan the QR code shown next to it with a phone. The host's projects and threads appear in the sidebar tagged with its name; open,
207207 continue or start threads there and they run on the host. Their terminal drawer (Ctrl+J)
208208 runs its shells on the host too, in the thread's folder there. The first time a device connects
209209 with settings that differ from yours, a dialog asks whether to give it your settings or
+1−0assets/licenses.tsvView File
@@ -365,6 +365,7 @@ pulp 0.22.3 MIT https://github.com/sarah-quinones/pulp/
365365 pulp-wasm-simd-flag 0.1.1 MIT https://github.com/sarah-quinones/pulp/
366366 pxfm 0.1.30 BSD-3-Clause OR Apache-2.0 https://github.com/awxkee/pxfm
367367 qoi 0.4.1 MIT OR Apache-2.0 https://github.com/aldanor/qoi-rust
368+ qrcode 0.14.1 MIT OR Apache-2.0 https://github.com/kennytm/qrcode-rust
368369 quick-error 2.0.1 MIT OR Apache-2.0 http://github.com/tailhook/quick-error
369370 quick-xml 0.41.0 MIT https://github.com/tafia/quick-xml
370371 quote 1.0.47 MIT OR Apache-2.0 https://github.com/dtolnay/quote
+5−0src/models.rsView File
@@ -509,6 +509,11 @@ impl Catalog {
509509 self.favorites.contains(&model.key())
510510 }
511511
512+ /// The favorite models' keys, including ones no instance offers right now.
513+ pub fn favorite_keys(&self) -> &[String] {
514+ &self.favorites
515+ }
516+
512517 pub fn favorites(&self) -> Vec<&ModelEntry> {
513518 self.favorites.iter().filter_map(|key| self.all_models().find(|model| &model.key() == key)).collect()
514519 }
+219−3src/remote/mod.rsView File
@@ -19,7 +19,10 @@
1919 //! A client's Device panel uses the host's simulators and emulators: it asks
2020 //! with [`ClientMessage::Device`], gets [`ServerMessage::DeviceReply`], and
2121 //! follows [`ServerMessage::DeviceState`]; the video and touches go through
22- //! the host's [`device_proxy`] on the same port.
22+ //! the host's [`device_proxy`] on the same port. Clients without a catalog
23+ //! of their own, like the Android app, ask for the host's agents and models
24+ //! with [`ClientMessage::Catalog`] and for a turn's diff with
25+ //! [`ClientMessage::Diff`].
2326
2427 pub mod client;
2528 pub mod device_proxy;
@@ -30,10 +33,10 @@ use std::path::PathBuf;
3033
3134 use serde::{Deserialize, Serialize};
3235
33- use crate::git::TurnChanges;
36+ use crate::git::{FileDiff, LineKind, TurnChanges};
3437 use crate::projects::ProjectSource;
3538 use crate::ui::folder_browser::Folder;
36- use crate::models::Provider;
39+ use crate::models::{Catalog, InstanceState, ModelEntry, ProbeStatus, Provider};
3740 use crate::session::{QueuedMessage, SavedItem};
3841 use crate::settings::SettingsPatch;
3942 use crate::worktree::PullRequest;
@@ -222,6 +225,13 @@ pub enum ClientMessage {
222225 /// Something for the host's devices; `request` pairs it with its reply.
223226 /// Older hosts don't answer.
224227 Device { request: u64, call: DeviceCall },
228+ /// Asks for the host's agents, models and favorites, answered with
229+ /// [`ServerMessage::Catalog`]. Older hosts don't answer.
230+ Catalog,
231+ /// Asks for the diff between two of a thread's snapshots (a turn's
232+ /// `base` and `head`), for one file or all of them. Older hosts don't
233+ /// answer.
234+ Diff { request: u64, thread: String, base: String, head: String, path: Option<String>, ignore_whitespace: bool },
225235 }
226236
227237 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -254,6 +264,156 @@ pub enum ServerMessage {
254264 DeviceOpened { thread: String, device: crate::device::DeviceSummary },
255265 /// An agent closed a thread's device, or all of them without one.
256266 DeviceClosed { thread: String, device: Option<String> },
267+ /// The host's agents and the models they offer, as its pickers show
268+ /// them, and its favorite models by key (`instance:id`).
269+ Catalog { instances: Vec<RemoteInstance>, favorites: Vec<String> },
270+ /// The answer to a [`ClientMessage::Diff`]: the changed files, or what
271+ /// went wrong.
272+ DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> },
273+ }
274+
275+ /// A provider instance on the host and what its last health check found.
276+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
277+ #[serde(default)]
278+ pub struct RemoteInstance {
279+ pub id: String,
280+ pub provider: Provider,
281+ pub name: String,
282+ /// A hex color (`#3e9bfa`) marking the instance, if it has one.
283+ pub accent: Option<String>,
284+ /// `ready`, `checking`, `missing`, `disabled` or `failed`.
285+ pub status: String,
286+ pub account: Option<String>,
287+ pub plan: Option<String>,
288+ /// The id of Claude's `default` entry, when it reports one.
289+ pub default_model: Option<String>,
290+ pub models: Vec<RemoteModel>,
291+ pub commands: Vec<RemoteCommand>,
292+ }
293+
294+ /// A model as the host's picker knows it; see [`ModelEntry`].
295+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
296+ #[serde(default)]
297+ pub struct RemoteModel {
298+ pub id: String,
299+ pub resolved: String,
300+ pub name: String,
301+ pub detail: String,
302+ pub legacy: bool,
303+ /// Reasoning levels it accepts, as the CLI spells them (`high`).
304+ pub efforts: Vec<String>,
305+ pub default_effort: Option<String>,
306+ pub ultracode: bool,
307+ pub fast_mode: bool,
308+ pub auto_mode: bool,
309+ pub context_window: Option<u64>,
310+ /// Hidden from the model picker in the host's settings.
311+ pub hidden: bool,
312+ }
313+
314+ /// A `/` command or skill an instance offers.
315+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
316+ #[serde(default)]
317+ pub struct RemoteCommand {
318+ pub name: String,
319+ pub description: String,
320+ pub argument_hint: String,
321+ pub skill: bool,
322+ }
323+
324+ /// One file of a diff.
325+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
326+ #[serde(default)]
327+ pub struct RemoteFileDiff {
328+ pub path: String,
329+ pub binary: bool,
330+ pub lines: Vec<RemoteDiffLine>,
331+ }
332+
333+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
334+ #[serde(default)]
335+ pub struct RemoteDiffLine {
336+ /// `hunk`, `context`, `added` or `removed`.
337+ pub kind: String,
338+ pub text: String,
339+ pub old_line: Option<u32>,
340+ pub new_line: Option<u32>,
341+ }
342+
343+ impl RemoteInstance {
344+ pub fn from_state(state: &InstanceState, catalog: &Catalog) -> Self {
345+ let status = match state.status {
346+ ProbeStatus::Ready => "ready",
347+ ProbeStatus::Checking => "checking",
348+ ProbeStatus::Missing => "missing",
349+ ProbeStatus::Disabled => "disabled",
350+ ProbeStatus::Failed(_) => "failed",
351+ };
352+ let accent = state.config.accent.trim();
353+ Self {
354+ id: state.config.id.clone(),
355+ provider: state.config.kind,
356+ name: state.label(),
357+ accent: (!accent.is_empty()).then(|| accent.to_string()),
358+ status: status.into(),
359+ account: state.account.clone(),
360+ plan: state.plan.clone(),
361+ default_model: state.default.as_ref().map(|model| model.id.clone()),
362+ models: state.models.iter().map(|model| RemoteModel::from_entry(model, catalog)).collect(),
363+ commands: state
364+ .commands
365+ .iter()
366+ .map(|command| RemoteCommand {
367+ name: command.name.clone(),
368+ description: command.description.clone(),
369+ argument_hint: command.argument_hint.clone(),
370+ skill: command.skill,
371+ })
372+ .collect(),
373+ }
374+ }
375+ }
376+
377+ impl RemoteModel {
378+ pub fn from_entry(model: &ModelEntry, catalog: &Catalog) -> Self {
379+ let profile = model.claude_profile();
380+ Self {
381+ id: model.id.clone(),
382+ resolved: model.resolved.clone(),
383+ name: model.name.clone(),
384+ detail: model.detail.clone(),
385+ legacy: model.legacy,
386+ efforts: model.efforts.iter().map(|effort| effort.as_cli().to_string()).collect(),
387+ default_effort: profile.default_effort.map(|effort| effort.as_cli().to_string()),
388+ ultracode: profile.ultracode,
389+ fast_mode: model.fast_mode,
390+ auto_mode: model.auto_mode,
391+ context_window: model.context_window,
392+ hidden: catalog.is_hidden(model),
393+ }
394+ }
395+ }
396+
397+ impl From<&FileDiff> for RemoteFileDiff {
398+ fn from(file: &FileDiff) -> Self {
399+ let lines = file
400+ .lines
401+ .iter()
402+ .map(|line| RemoteDiffLine {
403+ kind: match line.kind {
404+ LineKind::Hunk => "hunk",
405+ LineKind::Context => "context",
406+ LineKind::Added => "added",
407+ LineKind::Removed => "removed",
408+ }
409+ .into(),
410+ text: line.text.clone(),
411+ old_line: line.old_line,
412+ new_line: line.new_line,
413+ })
414+ .collect();
415+ Self { path: file.path.clone(), binary: file.binary, lines }
416+ }
257417 }
258418
259419 /// A new random key: 24 bytes, hex.
@@ -387,6 +547,62 @@ mod tests {
387547 ServerMessage::Settings { values: received } => assert_eq!(received, values),
388548 _ => panic!("not settings"),
389549 }
550+
551+ let text = serde_json::to_string(&ClientMessage::Catalog).unwrap();
552+ assert_eq!(text, r#"{"type":"catalog"}"#);
553+ assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), ClientMessage::Catalog);
554+ let instance = RemoteInstance {
555+ id: "claude".into(),
556+ name: "Claude".into(),
557+ status: "ready".into(),
558+ default_model: Some("default".into()),
559+ models: vec![RemoteModel {
560+ id: "opus".into(),
561+ efforts: vec!["low".into(), "high".into()],
562+ default_effort: Some("medium".into()),
563+ ..Default::default()
564+ }],
565+ commands: vec![RemoteCommand { name: "compact".into(), ..Default::default() }],
566+ ..Default::default()
567+ };
568+ let message = ServerMessage::Catalog { instances: vec![instance.clone()], favorites: vec!["claude:opus".into()] };
569+ let text = serde_json::to_string(&message).unwrap();
570+ match serde_json::from_str::<ServerMessage>(&text).unwrap() {
571+ ServerMessage::Catalog { instances, favorites } => {
572+ assert_eq!(instances, [instance]);
573+ assert_eq!(favorites, ["claude:opus"]);
574+ }
575+ _ => panic!("not a catalog"),
576+ }
577+ // Clients may leave out what they don't know.
578+ let model: RemoteModel = serde_json::from_str(r#"{"id": "sonnet"}"#).unwrap();
579+ assert_eq!((model.id.as_str(), model.hidden), ("sonnet", false));
580+
581+ let message = ClientMessage::Diff {
582+ request: 6,
583+ thread: "t1".into(),
584+ base: "abc".into(),
585+ head: "def".into(),
586+ path: Some("src/a.rs".into()),
587+ ignore_whitespace: true,
588+ };
589+ let text = serde_json::to_string(&message).unwrap();
590+ assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
591+ let file = crate::git::FileDiff {
592+ path: "src/a.rs".into(),
593+ binary: false,
594+ lines: vec![
595+ crate::git::DiffLine { kind: LineKind::Hunk, text: "@@ -1 +1 @@".into(), old_line: None, new_line: None },
596+ crate::git::DiffLine { kind: LineKind::Added, text: "new".into(), old_line: None, new_line: Some(1) },
597+ ],
598+ };
599+ let files = vec![RemoteFileDiff::from(&file)];
600+ assert_eq!(files[0].lines[1].kind, "added");
601+ let text = serde_json::to_string(&ServerMessage::DiffReply { request: 6, files: files.clone(), error: None }).unwrap();
602+ match serde_json::from_str::<ServerMessage>(&text).unwrap() {
603+ ServerMessage::DiffReply { request: 6, files: received, error: None } => assert_eq!(received, files),
604+ _ => panic!("not a diff reply"),
605+ }
390606 }
391607
392608 #[::core::prelude::v1::test]
+50−2src/ui/settings/connections.rsView File
@@ -9,6 +9,7 @@ use gpui_kit::component::{
99 v_flex,
1010 };
1111
12+ use gpui_kit::prelude::FluentBuilder as _;
1213 use gpui_kit::*;
1314
1415 use super::SettingsView;
@@ -193,9 +194,22 @@ impl SettingsView {
193194 );
194195 if let Some(address) = addresses.first() {
195196 let code = remote::pairing_code(address, &key);
197+ let mut pairing = row("Pairing code")
198+ .description("Paste it into Settings → Connections on the other device: the address and key in one.");
199+ if let Some(qr) = pairing_qr(&format!("vectrum://{code}"), p) {
200+ pairing = pairing.below(
201+ h_flex().gap(px(14.)).child(qr).child(
202+ div()
203+ .max_w(px(200.))
204+ .text_size(px(12.))
205+ .line_height(px(18.))
206+ .text_color(p.ink3)
207+ .child("Scan with Vectrum Code for Android to connect your phone."),
208+ ),
209+ );
210+ }
196211 machine = machine.row(
197- row("Pairing code")
198- .description("Paste it into Settings → Connections on the other device: the address and key in one.")
212+ pairing
199213 .control(
200214 MetalButton::new("copy-pairing").compact().icon(IconName::Copy).label("Copy pairing code").on_click(
201215 move |_, window, cx| {
@@ -334,3 +348,37 @@ impl SettingsView {
334348 vec![machine.render(cx), environments.render(cx)]
335349 }
336350 }
351+
352+ /// A pairing code as a QR code for a phone to scan, on a small raised card.
353+ /// The modules are black on white whatever the theme, since that's what
354+ /// scanners read best, with the four-module quiet zone the standard asks for.
355+ fn pairing_qr(code: &str, p: &metal::Palette) -> Option<Div> {
356+ const QUIET: usize = 4;
357+ let qr = qrcode::QrCode::with_error_correction_level(code, qrcode::EcLevel::L).ok()?;
358+ let width = qr.width();
359+ let dark: Vec<bool> = qr.to_colors().into_iter().map(|color| color == qrcode::Color::Dark).collect();
360+ // Whole pixels per module keep the edges crisp; about 160px in all.
361+ let module = (160 / (width + 2 * QUIET)).max(2) as f32;
362+ let rows = dark.chunks(width).map(|row| {
363+ // Each run of dark modules is one div.
364+ let mut runs: Vec<(bool, usize)> = Vec::new();
365+ for &on in row {
366+ match runs.last_mut() {
367+ Some((last, count)) if *last == on => *count += 1,
368+ _ => runs.push((on, 1)),
369+ }
370+ }
371+ h_flex().h(px(module)).children(
372+ runs.into_iter()
373+ .map(|(on, count)| div().flex_none().h_full().w(px(module * count as f32)).when(on, |run| run.bg(black()))),
374+ )
375+ });
376+ Some(
377+ div()
378+ .flex_none()
379+ .p(px(8.))
380+ .rounded(px(14.))
381+ .raised(p)
382+ .child(div().p(px(module * QUIET as f32)).rounded(px(6.)).bg(white()).children(rows)),
383+ )
384+ }
+156−3src/ui/themes.rsView File
@@ -728,9 +728,9 @@ pub fn preview(c: &Palette) -> Div {
728728
729729 #[cfg(test)]
730730 mod tests {
731- use super::{SCHEMES, all, contrast, find, luminance};
732- use crate::ui::metal::{BONE, GRAPHITE};
733- use gpui_kit::white;
731+ use super::{FAMILIES, SCHEMES, all, contrast, css, find, luminance};
732+ use crate::ui::metal::{BONE, GRAPHITE, Palette};
733+ use gpui_kit::{BoxShadow, Hsla, white};
734734
735735 #[test]
736736 fn every_theme_reads() {
@@ -778,4 +778,157 @@ mod tests {
778778 let latte = find("catppuccin-latte", false);
779779 assert!(!latte.dark && !std::ptr::eq(latte, &*BONE));
780780 }
781+
782+ /// Writes every theme's palette for the Android app, which draws the same
783+ /// colorways: `cargo test export_android_themes -- --ignored`. Colors are
784+ /// `#rrggbbaa`, fields are named as in [`Palette`].
785+ #[test]
786+ #[ignore]
787+ fn export_android_themes() {
788+ use serde_json::{Map, Value, json};
789+
790+ let color = |color: Hsla| Value::from(css(color));
791+ let pair = |(top, bottom): (Hsla, Hsla)| json!([css(top), css(bottom)]);
792+ let layers = |shadows: &[BoxShadow]| -> Value {
793+ shadows
794+ .iter()
795+ .map(|shadow| {
796+ json!({
797+ "x": f32::from(shadow.offset.x),
798+ "y": f32::from(shadow.offset.y),
799+ "blur": f32::from(shadow.blur_radius),
800+ "spread": f32::from(shadow.spread_radius),
801+ "inset": shadow.inset,
802+ "color": css(shadow.color),
803+ })
804+ })
805+ .collect()
806+ };
807+ let themes: Vec<Value> = all()
808+ .iter()
809+ .map(|theme| {
810+ // Taken apart field by field, so a new one can't be left out.
811+ let Palette {
812+ dark,
813+ page,
814+ s_hi,
815+ s,
816+ s_lo,
817+ well_top,
818+ well_bot,
819+ ink,
820+ ink2,
821+ ink3,
822+ icon,
823+ engrave,
824+ tag_ring,
825+ rule,
826+ rule_lip,
827+ row_hover,
828+ menu_bg,
829+ menu_row_hover,
830+ field_ink,
831+ cap,
832+ pressed,
833+ row_on,
834+ kbd,
835+ primary,
836+ primary_pressed,
837+ primary_ink,
838+ danger,
839+ danger_pressed,
840+ region,
841+ green_deep,
842+ green_ink,
843+ red,
844+ amber,
845+ amber_ink,
846+ blue_ink,
847+ led_off,
848+ tints,
849+ raise,
850+ raise_lite,
851+ raise_sm,
852+ btn,
853+ pressed_sh,
854+ well,
855+ region_sh,
856+ primary_sh,
857+ primary_pressed_sh,
858+ danger_sh,
859+ danger_pressed_sh,
860+ led_off_sh,
861+ } = theme.palette;
862+ let mut palette = Map::new();
863+ palette.insert("dark".into(), Value::from(*dark));
864+ for (name, value) in [
865+ ("page", page),
866+ ("s_hi", s_hi),
867+ ("s", s),
868+ ("s_lo", s_lo),
869+ ("well_top", well_top),
870+ ("well_bot", well_bot),
871+ ("ink", ink),
872+ ("ink2", ink2),
873+ ("ink3", ink3),
874+ ("icon", icon),
875+ ("engrave", engrave),
876+ ("tag_ring", tag_ring),
877+ ("rule", rule),
878+ ("rule_lip", rule_lip),
879+ ("row_hover", row_hover),
880+ ("menu_bg", menu_bg),
881+ ("menu_row_hover", menu_row_hover),
882+ ("field_ink", field_ink),
883+ ("primary_ink", primary_ink),
884+ ("green_deep", green_deep),
885+ ("green_ink", green_ink),
886+ ("red", red),
887+ ("amber", amber),
888+ ("amber_ink", amber_ink),
889+ ("blue_ink", blue_ink),
890+ ] {
891+ palette.insert(name.into(), color(*value));
892+ }
893+ for (name, value) in [
894+ ("cap", cap),
895+ ("pressed", pressed),
896+ ("row_on", row_on),
897+ ("kbd", kbd),
898+ ("primary", primary),
899+ ("primary_pressed", primary_pressed),
900+ ("danger", danger),
901+ ("danger_pressed", danger_pressed),
902+ ("region", region),
903+ ("led_off", led_off),
904+ ] {
905+ palette.insert(name.into(), pair(*value));
906+ }
907+ palette.insert("tints".into(), tints.iter().map(|tint| color(*tint)).collect());
908+ for (name, value) in [
909+ ("raise", raise),
910+ ("raise_lite", raise_lite),
911+ ("raise_sm", raise_sm),
912+ ("btn", btn),
913+ ("pressed_sh", pressed_sh),
914+ ("well", well),
915+ ("region_sh", region_sh),
916+ ("primary_sh", primary_sh),
917+ ("primary_pressed_sh", primary_pressed_sh),
918+ ("danger_sh", danger_sh),
919+ ("danger_pressed_sh", danger_pressed_sh),
920+ ("led_off_sh", led_off_sh),
921+ ] {
922+ palette.insert(name.into(), layers(value));
923+ }
924+ json!({"id": theme.id, "name": theme.name, "palette": palette})
925+ })
926+ .collect();
927+ let families: Vec<Value> =
928+ FAMILIES.iter().map(|family| json!({"name": family.name, "themes": family.themes})).collect();
929+ let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("android/app/src/main/assets/themes.json");
930+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
931+ let text = serde_json::to_string(&json!({"themes": themes, "families": families})).unwrap();
932+ std::fs::write(&path, text + "\n").unwrap();
933+ }
781934 }
+28−1src/ui/workspace/remote.rsView File
@@ -18,8 +18,9 @@ use gpui_kit::*;
1818 use super::{Project, ThreadSettings, Workspace, hostname, initials, tint_for};
1919 use crate::claude::PermissionMode;
2020 use crate::git::GitInfo;
21+ use crate::models::Catalog;
2122 use crate::remote::{
22- ClientMessage, ProjectInfo, ServerMessage, ThreadAction, ThreadSummary,
23+ ClientMessage, ProjectInfo, RemoteFileDiff, RemoteInstance, ServerMessage, ThreadAction, ThreadSummary,
2324 client::{ClientEvent, ConnectionStatus, RemoteClient},
2425 new_key,
2526 server::{Peer, RemoteServer, ServerEvent},
@@ -562,6 +563,30 @@ impl Workspace {
562563 ClientMessage::TerminalClose { terminal } => {
563564 self.remote.shells.remove(&(conn, terminal));
564565 }
566+ ClientMessage::Catalog => {
567+ let catalog = Catalog::global(cx);
568+ let instances = catalog.instances.iter().map(|state| RemoteInstance::from_state(state, catalog)).collect();
569+ self.reply(conn, ServerMessage::Catalog { instances, favorites: catalog.favorite_keys().to_vec() });
570+ }
571+ ClientMessage::Diff { request, thread, base, head, path, ignore_whitespace } => {
572+ let Some((_, session)) = self.local_thread(&thread, cx) else {
573+ let error = Some("That thread is gone.".to_string());
574+ self.reply(conn, ServerMessage::DiffReply { request, files: Vec::new(), error });
575+ return;
576+ };
577+ let cwd = session.read(cx).cwd.clone();
578+ let diff = cx.background_executor().spawn(async move {
579+ crate::git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace)
580+ });
581+ cx.spawn(async move |this, cx| {
582+ let (files, error) = match diff.await {
583+ Ok(files) => (files.iter().map(RemoteFileDiff::from).collect(), None),
584+ Err(error) => (Vec::new(), Some(error)),
585+ };
586+ let _ = this.update(cx, |this, _| this.reply(conn, ServerMessage::DiffReply { request, files, error }));
587+ })
588+ .detach();
589+ }
565590 }
566591 }
567592
@@ -783,6 +808,8 @@ impl Workspace {
783808 self.new_thread(ix, window, cx);
784809 }
785810 ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {}
811+ // Only sent in reply to requests this app doesn't make.
812+ ClientEvent::Message(ServerMessage::Catalog { .. } | ServerMessage::DiffReply { .. }) => {}
786813 ClientEvent::Message(ServerMessage::DeviceReply { request, ok, error }) => {
787814 if let Some(link) = self.devices_link(env) {
788815 link.resolve(request, error.map_or_else(|| Ok(ok.unwrap_or_default()), Err));