Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 9 changed files with 468 additions and 10 deletions.
| @@ -5249,6 +5249,12 @@ dependencies = [ | ||
| 5249 | 5249 | "bytemuck", |
| 5250 | 5250 | ] |
| 5251 | 5251 | |
| 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 | + | |
| 5252 | 5258 | [[package]] |
| 5253 | 5259 | name = "quick-error" |
| 5254 | 5260 | version = "2.0.1" |
| @@ -7482,6 +7488,7 @@ dependencies = [ | ||
| 7482 | 7488 | "libc", |
| 7483 | 7489 | "mermaid-rs-renderer", |
| 7484 | 7490 | "openh264", |
| 7491 | + "qrcode", | |
| 7485 | 7492 | "serde", |
| 7486 | 7493 | "serde_json", |
| 7487 | 7494 | "smol", |
| @@ -19,6 +19,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } | ||
| 19 | 19 | alacritty_terminal = "0.26" |
| 20 | 20 | openh264 = "0.9" |
| 21 | 21 | mermaid-rs-renderer = { version = "0.3.1", default-features = false } |
| 22 | + qrcode = { version = "0.14.1", default-features = false } | |
| 22 | 23 | |
| 23 | 24 | [profile.dev] |
| 24 | 25 | debug = "line-tables-only" |
| @@ -203,7 +203,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 203 | 203 | side-by-side layout. |
| 204 | 204 | - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on |
| 205 | 205 | 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, | |
| 207 | 207 | continue or start threads there and they run on the host. Their terminal drawer (Ctrl+J) |
| 208 | 208 | runs its shells on the host too, in the thread's folder there. The first time a device connects |
| 209 | 209 | with settings that differ from yours, a dialog asks whether to give it your settings or |
| @@ -365,6 +365,7 @@ pulp 0.22.3 MIT https://github.com/sarah-quinones/pulp/ | ||
| 365 | 365 | pulp-wasm-simd-flag 0.1.1 MIT https://github.com/sarah-quinones/pulp/ |
| 366 | 366 | pxfm 0.1.30 BSD-3-Clause OR Apache-2.0 https://github.com/awxkee/pxfm |
| 367 | 367 | 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 | |
| 368 | 369 | quick-error 2.0.1 MIT OR Apache-2.0 http://github.com/tailhook/quick-error |
| 369 | 370 | quick-xml 0.41.0 MIT https://github.com/tafia/quick-xml |
| 370 | 371 | quote 1.0.47 MIT OR Apache-2.0 https://github.com/dtolnay/quote |
| @@ -509,6 +509,11 @@ impl Catalog { | ||
| 509 | 509 | self.favorites.contains(&model.key()) |
| 510 | 510 | } |
| 511 | 511 | |
| 512 | + /// The favorite models' keys, including ones no instance offers right now. | |
| 513 | + pub fn favorite_keys(&self) -> &[String] { | |
| 514 | + &self.favorites | |
| 515 | + } | |
| 516 | + | |
| 512 | 517 | pub fn favorites(&self) -> Vec<&ModelEntry> { |
| 513 | 518 | self.favorites.iter().filter_map(|key| self.all_models().find(|model| &model.key() == key)).collect() |
| 514 | 519 | } |
| @@ -19,7 +19,10 @@ | ||
| 19 | 19 | //! A client's Device panel uses the host's simulators and emulators: it asks |
| 20 | 20 | //! with [`ClientMessage::Device`], gets [`ServerMessage::DeviceReply`], and |
| 21 | 21 | //! 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`]. | |
| 23 | 26 | |
| 24 | 27 | pub mod client; |
| 25 | 28 | pub mod device_proxy; |
| @@ -30,10 +33,10 @@ use std::path::PathBuf; | ||
| 30 | 33 | |
| 31 | 34 | use serde::{Deserialize, Serialize}; |
| 32 | 35 | |
| 33 | - use crate::git::TurnChanges; | |
| 36 | + use crate::git::{FileDiff, LineKind, TurnChanges}; | |
| 34 | 37 | use crate::projects::ProjectSource; |
| 35 | 38 | use crate::ui::folder_browser::Folder; |
| 36 | - use crate::models::Provider; | |
| 39 | + use crate::models::{Catalog, InstanceState, ModelEntry, ProbeStatus, Provider}; | |
| 37 | 40 | use crate::session::{QueuedMessage, SavedItem}; |
| 38 | 41 | use crate::settings::SettingsPatch; |
| 39 | 42 | use crate::worktree::PullRequest; |
| @@ -222,6 +225,13 @@ pub enum ClientMessage { | ||
| 222 | 225 | /// Something for the host's devices; `request` pairs it with its reply. |
| 223 | 226 | /// Older hosts don't answer. |
| 224 | 227 | 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 }, | |
| 225 | 235 | } |
| 226 | 236 | |
| 227 | 237 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| @@ -254,6 +264,156 @@ pub enum ServerMessage { | ||
| 254 | 264 | DeviceOpened { thread: String, device: crate::device::DeviceSummary }, |
| 255 | 265 | /// An agent closed a thread's device, or all of them without one. |
| 256 | 266 | 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 | + } | |
| 257 | 417 | } |
| 258 | 418 | |
| 259 | 419 | /// A new random key: 24 bytes, hex. |
| @@ -387,6 +547,62 @@ mod tests { | ||
| 387 | 547 | ServerMessage::Settings { values: received } => assert_eq!(received, values), |
| 388 | 548 | _ => panic!("not settings"), |
| 389 | 549 | } |
| 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 | + } | |
| 390 | 606 | } |
| 391 | 607 | |
| 392 | 608 | #[::core::prelude::v1::test] |
| @@ -9,6 +9,7 @@ use gpui_kit::component::{ | ||
| 9 | 9 | v_flex, |
| 10 | 10 | }; |
| 11 | 11 | |
| 12 | + use gpui_kit::prelude::FluentBuilder as _; | |
| 12 | 13 | use gpui_kit::*; |
| 13 | 14 | |
| 14 | 15 | use super::SettingsView; |
| @@ -193,9 +194,22 @@ impl SettingsView { | ||
| 193 | 194 | ); |
| 194 | 195 | if let Some(address) = addresses.first() { |
| 195 | 196 | 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 | + } | |
| 196 | 211 | 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 | |
| 199 | 213 | .control( |
| 200 | 214 | MetalButton::new("copy-pairing").compact().icon(IconName::Copy).label("Copy pairing code").on_click( |
| 201 | 215 | move |_, window, cx| { |
| @@ -334,3 +348,37 @@ impl SettingsView { | ||
| 334 | 348 | vec![machine.render(cx), environments.render(cx)] |
| 335 | 349 | } |
| 336 | 350 | } |
| 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 | + } | |
| @@ -728,9 +728,9 @@ pub fn preview(c: &Palette) -> Div { | ||
| 728 | 728 | |
| 729 | 729 | #[cfg(test)] |
| 730 | 730 | 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}; | |
| 734 | 734 | |
| 735 | 735 | #[test] |
| 736 | 736 | fn every_theme_reads() { |
| @@ -778,4 +778,157 @@ mod tests { | ||
| 778 | 778 | let latte = find("catppuccin-latte", false); |
| 779 | 779 | assert!(!latte.dark && !std::ptr::eq(latte, &*BONE)); |
| 780 | 780 | } |
| 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 | + } | |
| 781 | 934 | } |
| @@ -18,8 +18,9 @@ use gpui_kit::*; | ||
| 18 | 18 | use super::{Project, ThreadSettings, Workspace, hostname, initials, tint_for}; |
| 19 | 19 | use crate::claude::PermissionMode; |
| 20 | 20 | use crate::git::GitInfo; |
| 21 | + use crate::models::Catalog; | |
| 21 | 22 | use crate::remote::{ |
| 22 | - ClientMessage, ProjectInfo, ServerMessage, ThreadAction, ThreadSummary, | |
| 23 | + ClientMessage, ProjectInfo, RemoteFileDiff, RemoteInstance, ServerMessage, ThreadAction, ThreadSummary, | |
| 23 | 24 | client::{ClientEvent, ConnectionStatus, RemoteClient}, |
| 24 | 25 | new_key, |
| 25 | 26 | server::{Peer, RemoteServer, ServerEvent}, |
| @@ -562,6 +563,30 @@ impl Workspace { | ||
| 562 | 563 | ClientMessage::TerminalClose { terminal } => { |
| 563 | 564 | self.remote.shells.remove(&(conn, terminal)); |
| 564 | 565 | } |
| 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 | + } | |
| 565 | 590 | } |
| 566 | 591 | } |
| 567 | 592 | |
| @@ -783,6 +808,8 @@ impl Workspace { | ||
| 783 | 808 | self.new_thread(ix, window, cx); |
| 784 | 809 | } |
| 785 | 810 | 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 { .. }) => {} | |
| 786 | 813 | ClientEvent::Message(ServerMessage::DeviceReply { request, ok, error }) => { |
| 787 | 814 | if let Some(link) = self.devices_link(env) { |
| 788 | 815 | link.resolve(request, error.map_or_else(|| Ok(ok.unwrap_or_default()), Err)); |