From 576490f6bf58ed584710e672773fad51bb55ed35 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Sun, 13 Sep 2026 08:55:46 -0300 Subject: [PATCH 1/7] Add custom project icons and in-app image picker --- Cargo.lock | 1 + crates/core/src/project.rs | 8 + crates/protocol/src/command.rs | 5 + crates/protocol/src/query.rs | 14 + crates/runtime/src/app/command_validation.rs | 1 + crates/runtime/src/app/sessions.rs | 29 ++ crates/runtime/src/pipe.rs | 138 +++++ crates/services/Cargo.toml | 1 + crates/services/src/import/tests.rs | 1 + crates/services/src/lib.rs | 1 + crates/services/src/project_icons.rs | 233 +++++++++ crates/services/src/store.rs | 32 +- crates/ui/src/chat/mod.rs | 6 +- crates/ui/src/chat/model.rs | 1 + crates/ui/src/lib.rs | 1 + crates/ui/src/palette.rs | 38 +- crates/ui/src/project_icon.rs | 512 +++++++++++++++++++ crates/ui/src/shell.rs | 1 + crates/ui/src/sidebar.rs | 58 ++- crates/ui/src/store/images.rs | 165 +++++- crates/ui/src/store/mod.rs | 60 ++- docs/DESIGN.md | 40 +- locales/en.yml | 17 + locales/zh-CN.yml | 17 + 24 files changed, 1349 insertions(+), 31 deletions(-) create mode 100644 crates/services/src/project_icons.rs create mode 100644 crates/ui/src/project_icon.rs diff --git a/Cargo.lock b/Cargo.lock index 7f5b6ee2..0d6c450e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8949,6 +8949,7 @@ dependencies = [ "chrono", "dirs 7.0.0", "flate2", + "image", "log", "serde", "serde_json", diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 8448d709..fc3328c1 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -16,6 +16,9 @@ pub struct Project { pub id: String, pub name: String, pub root: PathBuf, + /// Host-owned image selected by the user; absent uses the project config's iconPath. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_path: Option, pub created_at: u64, } @@ -29,6 +32,7 @@ impl Project { id: uuid::Uuid::new_v4().to_string(), name, root, + icon_path: None, created_at: now_secs(), } } @@ -527,18 +531,21 @@ mod tests { id: "p-old".into(), name: "Old".into(), root: PathBuf::from("/old"), + icon_path: None, created_at: 1, }, Project { id: "p-new".into(), name: "New".into(), root: PathBuf::from("/new"), + icon_path: None, created_at: 2, }, Project { id: "p-empty".into(), name: "Empty".into(), root: PathBuf::from("/empty"), + icon_path: None, created_at: 15, }, ]; @@ -572,6 +579,7 @@ mod tests { id: "p".into(), name: "Project".into(), root: PathBuf::from("/p"), + icon_path: None, created_at: 1, }]; let make = |id: &str, updated_at: u64, parent: Option<&str>| { diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 1658fe98..e00cf259 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -198,6 +198,11 @@ pub enum Command { index: usize, }, CycleProjectSort, + /// A normalized PNG override, or None to use the project config again. + SetProjectIcon { + project_id: String, + png: Option>, + }, /// Register a project rooted at `root`. The host validates the path against /// its own filesystem — a client never decides whether a host path is /// absolute or exists — and answers `invalid_project_root` when it is not an diff --git a/crates/protocol/src/query.rs b/crates/protocol/src/query.rs index 474b484a..13aee97c 100644 --- a/crates/protocol/src/query.rs +++ b/crates/protocol/src/query.rs @@ -32,6 +32,15 @@ pub enum Query { base: Option, ignore_whitespace: bool, }, + BrowseIconImages { + directory: PathBuf, + }, + ReadIconImage { + path: PathBuf, + }, + ReadProjectIcon { + project_id: String, + }, ReadFileBytes { path: PathBuf, }, @@ -94,6 +103,11 @@ pub enum QueryResponse { truncated: bool, }, ActiveWorkspace(Vec), + IconImages { + directory: PathBuf, + parent: Option, + entries: Vec, + }, ExternalHistory(Vec), CommitMessage(String), GitDiff(GitDiffResult), diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 9a02525a..c4131ce0 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -31,6 +31,7 @@ impl AppState { )); } Command::ToggleProjectCollapsed { project_id } + | Command::SetProjectIcon { project_id, .. } | Command::DeleteProject { project_id } if !self .projects diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 0f094709..555d6203 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -114,6 +114,35 @@ impl AppState { self.update_settings(settings, cx); } + /// Save a normalized user override, or return to the project config. + pub fn set_project_icon( + &mut self, + project_id: &str, + png: Option>, + cx: &mut HostCx, + ) -> std::io::Result<()> { + let project = self + .projects + .iter_mut() + .find(|p| p.id == project_id) + .ok_or_else(|| std::io::Error::other("unknown project"))?; + let path = if let Some(png) = png { + let path = self + .store + .root() + .join("project-icons") + .join(format!("{}.png", uuid::Uuid::new_v4())); + tcode_services::project_icons::save_override(&path, &png)?; + Some(path) + } else { + None + }; + project.icon_path = path; + let project = project.clone(); + self.enqueue_store_write(StoreWrite::UpsertProject(project), cx); + Ok(()) + } + /// Create a project rooted at `root`, or return the existing id when one /// already covers it. /// diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 08265775..c0266484 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -424,6 +424,11 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co app.remove_review_comment(&session_id, index, cx) } Command::CycleProjectSort => app.cycle_project_sort(cx), + Command::SetProjectIcon { project_id, png } => { + if let Err(error) = app.set_project_icon(&project_id, png, cx) { + return CommandOutcome::Immediate(Err(io_protocol_error(error))); + } + } Command::CreateProject { root } => match app.create_project(root, cx) { Ok(project_id) => response = CommandResponse::ProjectId(Some(project_id)), Err(error) => return CommandOutcome::Immediate(Err(error)), @@ -634,6 +639,30 @@ fn dispatch_query( }); cx.spawn_background(async move { Ok(QueryResponse::GitDiff(task.await)) }) } + Query::BrowseIconImages { directory } => { + let task = cx.unblock(move || tcode_services::project_icons::browse(&directory)); + cx.spawn_background(async move { task.await.map_err(io_protocol_error) }) + } + Query::ReadIconImage { path } => { + let task = cx.unblock(move || tcode_services::project_icons::thumbnail(&path)); + cx.spawn_background(async move { + task.await + .map(QueryResponse::FileBytes) + .map_err(io_protocol_error) + }) + } + Query::ReadProjectIcon { project_id } => { + let project = app.projects.iter().find(|p| p.id == project_id).cloned(); + let task = cx.unblock(move || { + let project = project.ok_or_else(|| std::io::Error::other("unknown project"))?; + tcode_services::project_icons::read_project_icon(&project) + }); + cx.spawn_background(async move { + task.await + .map(QueryResponse::FileBytes) + .map_err(io_protocol_error) + }) + } Query::ReadFileBytes { path } => { let task = cx.unblock(move || std::fs::read(&path)); cx.spawn_background(async move { @@ -1144,6 +1173,115 @@ mod tests { out } + #[test] + fn project_icons_replicate_persist_and_reset_to_t3_config() { + let root = + std::env::temp_dir().join(format!("tcode-project-icons-{}", uuid::Uuid::new_v4())); + let project_root = root.join("project"); + std::fs::create_dir_all(&project_root).unwrap(); + let logo = project_root.join("logo.png"); + std::fs::write(&logo, include_bytes!("../../../assets/icons/app/tcode.png")).unwrap(); + std::fs::write( + project_root.join("t3.json"), + r#"{"iconPath":"logo.png","scripts":[]}"#, + ) + .unwrap(); + let store = SessionStore::open_at(root.clone()).unwrap(); + // An older project record must still load with automatic artwork. + std::fs::write( + root.join("sessions.json"), + serde_json::json!({ + "projects": [{"id":"p", "name":"Project", "root": project_root, "created_at":1}], + "sessions": [] + }) + .to_string(), + ) + .unwrap(); + let host = spawn_host(store.clone(), HostServices::default()).unwrap(); + let link = host.link(); + link.subscribe(Subscription { + after: None, + topic: Topic::Index, + }) + .unwrap(); + let events = link.events(); + next_event(&events, |event| { + matches!(event.event, ServerEvent::IndexSnapshot(_)) + }); + let QueryResponse::FileBytes(png) = smol::block_on(link.query(Query::ReadProjectIcon { + project_id: "p".into(), + })) + .unwrap() else { + panic!() + }; + assert!( + link.command_blocking(Command::SetProjectIcon { + project_id: "p".into(), + png: Some(b"broken".to_vec()) + }) + .is_err() + ); + link.command_blocking(Command::SetProjectIcon { + project_id: "p".into(), + png: Some(png.clone()), + }) + .unwrap(); + let event = next_event( + &events, + |event| matches!(&event.event, ServerEvent::IndexSnapshot(snapshot) if snapshot.projects[0].icon_path.is_some()), + ); + let ServerEvent::IndexSnapshot(snapshot) = event.event else { + panic!() + }; + let first = snapshot.projects[0].icon_path.clone().unwrap(); + assert!(first.starts_with(root.join("project-icons"))); + host.shutdown_blocking().unwrap(); + assert_eq!( + store.read_file().projects[0].icon_path.as_ref(), + Some(&first) + ); + // Removing the source must not break the saved custom copy. + std::fs::remove_file(&logo).unwrap(); + let host = spawn_host(store.clone(), HostServices::default()).unwrap(); + let link = host.link(); + assert!(matches!( + smol::block_on(link.query(Query::ReadProjectIcon { + project_id: "p".into() + })) + .unwrap(), + QueryResponse::FileBytes(_) + )); + link.command_blocking(Command::SetProjectIcon { + project_id: "p".into(), + png: Some(png), + }) + .unwrap(); + host.shutdown_blocking().unwrap(); + let second = store.read_file().projects[0].icon_path.clone().unwrap(); + assert_ne!(first, second); + assert!(!first.exists()); + std::fs::write(&logo, include_bytes!("../../../assets/icons/app/tcode.png")).unwrap(); + let host = spawn_host(store.clone(), HostServices::default()).unwrap(); + let link = host.link(); + link.command_blocking(Command::SetProjectIcon { + project_id: "p".into(), + png: None, + }) + .unwrap(); + assert!(matches!( + smol::block_on(link.query(Query::ReadProjectIcon { + project_id: "p".into() + })) + .unwrap(), + QueryResponse::FileBytes(_) + )); + host.shutdown_blocking().unwrap(); + assert!(store.read_file().projects[0].icon_path.is_none()); + assert!(!second.exists()); + assert!(logo.exists()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn dedicated_host_round_trips_commands_queries_events_and_quit_barrier() { let data_root = diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index a8eecbbb..d0da34b6 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -12,6 +12,7 @@ chrono = "0.4" dirs = "7" flate2 = "1" log = "0.4" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } serde = { version = "1", features = ["derive"] } serde_json = "1" smol = "2" diff --git a/crates/services/src/import/tests.rs b/crates/services/src/import/tests.rs index f453f40f..7164a9b5 100644 --- a/crates/services/src/import/tests.rs +++ b/crates/services/src/import/tests.rs @@ -254,6 +254,7 @@ fn import_is_idempotent_and_replays_into_timeline() { id: "project-1".into(), name: "Project".into(), root: cwd, + icon_path: None, created_at: 1, }; store.upsert_project(&project).unwrap(); diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 95128f35..17885c8a 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -6,6 +6,7 @@ pub mod export; pub mod git; pub mod import; pub mod process; +pub mod project_icons; pub mod provider_auth; pub mod provider_probe; pub mod provider_usage; diff --git a/crates/services/src/project_icons.rs b/crates/services/src/project_icons.rs new file mode 100644 index 00000000..d5c485fb --- /dev/null +++ b/crates/services/src/project_icons.rs @@ -0,0 +1,233 @@ +//! Project defaults and bounded image decoding for the host-owned icon picker. +use image::{ImageFormat, ImageReader, Limits}; +use serde::Deserialize; +use std::{ + fs, + io::{self, Cursor, Read}, + path::Path, +}; +use tcode_core::project::Project; +use tcode_protocol::{PathEntry, QueryResponse}; + +const MAX_BYTES: u64 = 8 * 1024 * 1024; +const ICON_SIZE: u32 = 128; + +fn supported(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ico" + ) + }) +} + +/// List one directory, including folders for navigation and supported images only. +pub fn browse(directory: &Path) -> io::Result { + let directory = directory.canonicalize()?; + let mut entries = Vec::new(); + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let Ok(metadata) = fs::metadata(entry.path()) else { + continue; + }; + let is_dir = metadata.is_dir(); + if !is_dir && (!metadata.is_file() || !supported(&entry.path())) { + continue; + } + entries.push(PathEntry::from_rel( + entry.file_name().to_string_lossy().into_owned(), + is_dir, + )); + } + entries.sort_by_cached_key(|entry| (!entry.is_dir, entry.basename.to_lowercase())); + Ok(QueryResponse::IconImages { + parent: directory.parent().map(Path::to_path_buf), + directory, + entries, + }) +} + +fn read_bounded(path: &Path, limit: u64) -> io::Result> { + let mut bytes = Vec::new(); + fs::File::open(path)? + .take(limit + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(io::Error::other("image exceeds the 8 MiB size limit")); + } + Ok(bytes) +} + +fn decode(bytes: &[u8], max_dimension: u32) -> io::Result { + let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?; + let mut limits = Limits::default(); + limits.max_image_width = Some(max_dimension); + limits.max_image_height = Some(max_dimension); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + reader.decode().map_err(io::Error::other) +} + +/// Decode away from the host event loop and send only a small, static PNG to clients. +pub fn thumbnail(path: &Path) -> io::Result> { + let mut rgba = decode(&read_bounded(path, MAX_BYTES)?, 8192)?.into_rgba32f(); + // Average premultiplied colors so transparent pixels cannot darken the preview. + for pixel in rgba.pixels_mut() { + let alpha = pixel[3]; + for channel in &mut pixel.0[..3] { + *channel *= alpha; + } + } + let mut resized = image::DynamicImage::ImageRgba32F(rgba) + .resize(ICON_SIZE, ICON_SIZE, image::imageops::FilterType::Lanczos3) + .into_rgba32f(); + for pixel in resized.pixels_mut() { + let alpha = pixel[3]; + for channel in &mut pixel.0[..3] { + *channel = if alpha > 0. { *channel / alpha } else { 0. }; + } + } + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba32F(resized) + .to_rgba8() + .write_to(&mut png, ImageFormat::Png) + .map_err(io::Error::other)?; + Ok(png.into_inner()) +} + +pub fn read_project_icon(project: &Project) -> io::Result> { + if let Some(path) = &project.icon_path { + return thumbnail(path); + } + #[derive(Deserialize)] + struct Config { + #[serde(rename = "iconPath")] + icon_path: Option, + } + let bytes = match read_bounded(&project.root.join("tcode.json"), 1024 * 1024) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + read_bounded(&project.root.join("t3.json"), 1024 * 1024)? + } + result => result?, + }; + let config: Config = serde_json::from_slice(&bytes).map_err(io::Error::other)?; + let path = config + .icon_path + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| io::Error::other("project has no iconPath"))?; + thumbnail(&project.root.join(path)) +} + +/// Accept only the small PNG produced by the picker, including on remote writes. +pub fn save_override(path: &Path, png: &[u8]) -> io::Result<()> { + if png.len() > 128 * 1024 || image::guess_format(png).ok() != Some(ImageFormat::Png) { + return Err(io::Error::other( + "project icon must be a PNG of at most 128 KiB", + )); + } + decode(png, ICON_SIZE)?; + fs::create_dir_all(path.parent().expect("managed icon has a parent"))?; + fs::write(path, png) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thumbnail_preserves_color_at_transparent_edges() { + let root = std::env::temp_dir().join(format!("tcode-icons-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + let path = root.join("edge.png"); + // The edge crosses a thumbnail pixel; transparent black must not darken red. + image::RgbaImage::from_fn(512, 256, |x, _| { + if x < 242 { + image::Rgba([255, 0, 0, 255]) + } else { + image::Rgba([0, 0, 0, 0]) + } + }) + .save(&path) + .unwrap(); + let png = thumbnail(&path).unwrap(); + fs::remove_dir_all(root).unwrap(); + let resized = image::load_from_memory(&png).unwrap().into_rgba8(); + assert_eq!(resized.dimensions(), (128, 64)); + let edge = resized.get_pixel(60, 32); + assert!(edge[3] > 0 && edge[3] < 255); + assert_eq!(&edge.0[..3], &[255, 0, 0]); + } + + #[test] + fn tcode_config_takes_precedence_over_t3_config() { + let root = std::env::temp_dir().join(format!("tcode-icons-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + for (name, color) in [("red", [255, 0, 0, 255]), ("blue", [0, 0, 255, 255])] { + image::RgbaImage::from_pixel(16, 16, image::Rgba(color)) + .save(root.join(format!("{name}.png"))) + .unwrap(); + } + let project = Project::from_root(root.clone()); + fs::write(root.join("tcode.json"), r#"{"iconPath":"red.png"}"#).unwrap(); + let expected = thumbnail(&root.join("red.png")).unwrap(); + assert_eq!(read_project_icon(&project).unwrap(), expected); + + fs::write(root.join("t3.json"), r#"{"iconPath":"blue.png"}"#).unwrap(); + assert_eq!(read_project_icon(&project).unwrap(), expected); + fs::write(root.join("tcode.json"), "broken json").unwrap(); + assert!(read_project_icon(&project).is_err()); + + fs::remove_file(root.join("tcode.json")).unwrap(); + assert_eq!( + read_project_icon(&project).unwrap(), + thumbnail(&root.join("blue.png")).unwrap() + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn picker_filters_files_and_project_defaults_yield_to_custom_images() { + let root = std::env::temp_dir().join(format!("tcode-icons-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("assets")).unwrap(); + let sample = include_bytes!("../../../assets/icons/app/tcode.png"); + fs::write(root.join("assets/logo.PNG"), sample).unwrap(); + fs::write(root.join("notes.txt"), "notes").unwrap(); + fs::write( + root.join("t3.json"), + r#"{"iconPath":"assets/logo.PNG","scripts":[{"command":"ignored"}]}"#, + ) + .unwrap(); + let QueryResponse::IconImages { entries, .. } = browse(&root).unwrap() else { + panic!() + }; + assert_eq!( + entries + .iter() + .map(|entry| entry.basename.as_str()) + .collect::>(), + ["assets"] + ); + let QueryResponse::IconImages { entries, .. } = browse(&root.join("assets")).unwrap() + else { + panic!() + }; + assert_eq!(entries[0].basename, "logo.PNG"); + let mut project = Project::from_root(root.clone()); + let png = read_project_icon(&project).unwrap(); + let decoded = image::load_from_memory(&png).unwrap(); + assert!(decoded.width() <= 128 && decoded.height() <= 128); + let custom = root.join("custom.png"); + save_override(&custom, &png).unwrap(); + project.icon_path = Some(custom); + fs::write(root.join("t3.json"), "broken json").unwrap(); + assert!(read_project_icon(&project).is_ok()); + project.icon_path = None; + assert!(read_project_icon(&project).is_err()); + fs::write(root.join("t3.json"), r#"{"iconPath":"missing.png"}"#).unwrap(); + assert!(read_project_icon(&project).is_err()); + assert!(save_override(&root.join("bad.png"), b"not an image").is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index 4dae5124..78d11d71 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -226,23 +226,45 @@ impl SessionStore { self.persist_index(&file) } - /// Insert or replace a project (by id), then persist. + /// Insert or replace a project, then remove its previous managed icon. pub fn upsert_project(&self, project: &Project) -> std::io::Result<()> { let mut file = self.read_file(); + let mut previous = None; if let Some(existing) = file.projects.iter_mut().find(|p| p.id == project.id) { + if existing.icon_path != project.icon_path { + previous = existing.icon_path.clone(); + } *existing = project.clone(); } else { file.projects.push(project.clone()); } - self.persist_index(&file) + self.persist_index(&file)?; + self.remove_project_icon(previous); + Ok(()) } - /// Remove a project from the index. Its sessions are removed separately so - /// their event logs receive the same cleanup as an ordinary thread delete. + /// Remove a project and its managed icon. Sessions are removed separately. pub fn remove_project(&self, id: &str) -> std::io::Result<()> { let mut file = self.read_file(); + let icon = file + .projects + .iter() + .find(|p| p.id == id) + .and_then(|p| p.icon_path.clone()); file.projects.retain(|project| project.id != id); - self.persist_index(&file) + self.persist_index(&file)?; + self.remove_project_icon(icon); + Ok(()) + } + + fn remove_project_icon(&self, path: Option) { + if let Some(path) = path + && path.parent() == Some(self.root.join("project-icons").as_path()) + && let Err(error) = fs::remove_file(&path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!("could not remove project icon {}: {error}", path.display()); + } } /// Persist a whole index file atomically (also flushes migration on startup). diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index 824e3de8..b2bf8dc0 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -2419,11 +2419,7 @@ impl ChatView { store.start_draft(project_id.clone(), cwd.clone(), cx); }); })) - .child( - Icon::new(IconName::Folder) - .size_4() - .text_color(cx.theme().muted_foreground), - ) + .child(crate::project_icon::artwork(&project, 16.)) .child( div() .flex_1() diff --git a/crates/ui/src/chat/model.rs b/crates/ui/src/chat/model.rs index fb0b7711..392586bc 100644 --- a/crates/ui/src/chat/model.rs +++ b/crates/ui/src/chat/model.rs @@ -1272,6 +1272,7 @@ mod tests { id: id.into(), name: name.into(), root: PathBuf::from(format!("/{id}")), + icon_path: None, created_at: 0, }; let projects = vec![ diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 03daff32..4329626e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -33,6 +33,7 @@ pub mod palette; mod pasteboard; mod plan_panel; mod preview_panel; +mod project_icon; pub(crate) mod provider_card; mod provider_dialog; mod provider_model_picker; diff --git a/crates/ui/src/palette.rs b/crates/ui/src/palette.rs index 147400e6..a25b91aa 100644 --- a/crates/ui/src/palette.rs +++ b/crates/ui/src/palette.rs @@ -64,6 +64,7 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { /// A concrete action a palette row triggers. #[derive(Clone)] enum Action { + ChangeProjectIcon(tcode_core::project::Project), NewThread { cwd: std::path::PathBuf, project_id: String, @@ -255,13 +256,19 @@ impl CommandPalette { }; for group in store.grouped_sessions() { push_action( - crate::tr!("palette.new_thread", project = group.project.name).into_owned(), + crate::tr!("palette.new_thread", project = group.project.name.clone()).into_owned(), IconName::Plus, Action::NewThread { cwd: group.project.root.clone(), project_id: group.project.id.clone(), }, ); + push_action( + crate::tr!("project_icon.action", project = group.project.name.clone()) + .into_owned(), + IconName::Folder, + Action::ChangeProjectIcon(group.project), + ); } push_action( crate::tr!("palette.open_settings").into_owned(), @@ -413,6 +420,10 @@ impl CommandPalette { fn activate(&mut self, action: Action, window: &mut Window, cx: &mut Context) { match action { + Action::ChangeProjectIcon(project) => { + self.close(cx); + crate::project_icon::open(self.store.clone(), project, window, cx); + } Action::NewThread { cwd, project_id } => { self.close(cx); self.store.update(cx, |store, cx| { @@ -592,7 +603,30 @@ impl Render for CommandPalette { .when(!is_sel, |s| { s.hover(|style| style.bg(cx.theme().list_hover)) }) - .child(Icon::new(item.icon.clone()).small().text_color(muted)) + .child(match &item.action { + Action::ChangeProjectIcon(project) => { + crate::project_icon::artwork(project, 16.).into_any_element() + } + Action::NewThread { project_id, .. } => self + .store + .read(cx) + .projects() + .into_iter() + .find(|project| &project.id == project_id) + .map(|project| { + crate::project_icon::artwork(&project, 16.).into_any_element() + }) + .unwrap_or_else(|| { + Icon::new(item.icon.clone()) + .small() + .text_color(muted) + .into_any_element() + }), + _ => Icon::new(item.icon.clone()) + .small() + .text_color(muted) + .into_any_element(), + }) .child( v_flex() .flex_1() diff --git a/crates/ui/src/project_icon.rs b/crates/ui/src/project_icon.rs new file mode 100644 index 00000000..2b8939bc --- /dev/null +++ b/crates/ui/src/project_icon.rs @@ -0,0 +1,512 @@ +//! Shared project artwork and an in-app picker over the attached host's files. +use crate::{ + icon::{Icon, IconName}, + overlay::OverlayExt as _, + scroll::ScrollableElement as _, + sizing::fit_viewport, + store::{WorkspaceStore, images}, + theme::ActiveTheme as _, + widgets::{ + button::{Button, ButtonVariants as _}, + input::{Input, InputEvent, InputState}, + }, +}; +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Role, SharedString, StatefulInteractiveElement as _, Styled as _, + StyledImage as _, Subscription, Window, div, img, prelude::FluentBuilder as _, px, +}; +use gpui_base::{h_flex, v_flex}; +use std::path::PathBuf; +use tcode_core::project::Project; +use tcode_protocol::{PathEntry, QueryResponse}; + +pub(crate) fn artwork(project: &Project, size: f32) -> impl IntoElement + use<> { + img(images::project_icon(project, size)) + .size(px(size)) + .flex_none() + .with_fallback(move || { + Icon::new(IconName::Folder) + .size(px(size)) + .into_any_element() + }) +} + +pub(crate) fn open( + store: Entity, + project: Project, + window: &mut Window, + cx: &mut App, +) { + let picker = cx.new(|cx| Picker::new(store, project, window, cx)); + picker.update(cx, |picker, cx| { + picker.browse(picker.project.root.clone(), window, cx) + }); + window.open_dialog(cx, move |dialog, window, _| { + let content = picker.clone(); + dialog + // Enter in a path or filter field must not confirm the dialog. + .on_ok(|_, _, _| false) + .title(crate::tr!("project_icon.title")) + .w(fit_viewport(680., window.viewport_size().width)) + .content(move |el, _, _| el.child(content.clone())) + }); +} + +struct Picker { + store: Entity, + project: Project, + path: Entity, + search: Entity, + directory: PathBuf, + parent: Option, + entries: Vec, + selected: Option, + loading: bool, + saving: bool, + error: Option, + generation: u64, + _subscriptions: Vec, +} + +impl Picker { + fn new( + store: Entity, + project: Project, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let path = cx.new(|cx| InputState::new(window, cx)); + let search = + cx.new(|cx| InputState::new(window, cx).placeholder(crate::tr!("project_icon.search"))); + let subscriptions = vec![ + cx.subscribe_in(&path, window, |this, _, event, window, cx| { + if matches!(event, InputEvent::PressEnter { .. }) && !this.saving { + this.browse( + PathBuf::from(this.path.read(cx).value().as_str()), + window, + cx, + ); + } + }), + cx.subscribe(&search, |_, _, _: &InputEvent, cx| cx.notify()), + ]; + Self { + directory: project.root.clone(), + store, + project, + path, + search, + parent: None, + entries: Vec::new(), + selected: None, + loading: false, + saving: false, + error: None, + generation: 0, + _subscriptions: subscriptions, + } + } + + fn browse(&mut self, directory: PathBuf, window: &mut Window, cx: &mut Context) { + self.generation += 1; + let generation = self.generation; + self.loading = true; + self.error = None; + self.selected = None; + self.entries.clear(); + self.path.update(cx, |input, cx| { + input.set_value(directory.to_string_lossy().to_string(), window, cx) + }); + self.search + .update(cx, |input, cx| input.set_value("", window, cx)); + let task = self + .store + .update(cx, |store, cx| store.browse_icon_images(directory, cx)); + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let _ = this.update_in(cx, |this, window, cx| { + if this.generation != generation { + return; + } + this.loading = false; + match result { + Ok(QueryResponse::IconImages { + directory, + parent, + entries, + }) => { + this.path.update(cx, |input, cx| { + input.set_value(directory.to_string_lossy().to_string(), window, cx) + }); + this.directory = directory; + this.parent = parent; + this.entries = entries; + } + Err(error) => this.error = Some(error.message), + _ => this.error = Some(crate::tr!("project_icon.load_failed").into_owned()), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn save(&mut self, path: Option, window: &mut Window, cx: &mut Context) { + if self.saving { + return; + } + self.saving = true; + self.error = None; + let reset = path.is_none(); + let task = self.store.update(cx, |store, cx| { + store.set_project_icon(self.project.id.clone(), path, cx) + }); + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let _ = this.update_in(cx, |this, window, cx| { + this.saving = false; + match result { + Ok(_) => { + if reset { + let mut default_project = this.project.clone(); + default_project.icon_path = None; + images::invalidate_project_icon(&default_project, cx); + } + window.close_dialog(cx); + } + Err(error) => this.error = Some(error.message), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } +} + +impl Render for Picker { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let search = self.search.read(cx).value().to_lowercase(); + let selected = self.selected.clone(); + let busy = self.saving; + let parent = self.parent.clone(); + let root = self.project.root.clone(); + let host = self + .store + .read(cx) + .remote_host_name() + .map(str::to_owned) + .unwrap_or_else(|| crate::tr!("project_icon.this_machine").into_owned()); + let mut grid = h_flex().flex_wrap().gap_2().items_start(); + let mut count = 0; + for entry in self + .entries + .iter() + .filter(|entry| entry.basename.to_lowercase().contains(&search)) + { + count += 1; + let path = self.directory.join(&entry.basename); + let is_dir = entry.is_dir; + let is_selected = selected.as_ref() == Some(&path); + let content = if is_dir { + Icon::new(IconName::Folder) + .size(px(32.)) + .text_color(cx.theme().muted_foreground) + .into_any_element() + } else { + img(images::icon_thumbnail(path.clone())) + .size(px(64.)) + .with_fallback(|| { + Icon::empty() + .path("icons/image.svg") + .size(px(28.)) + .into_any_element() + }) + .into_any_element() + }; + grid = grid.child( + crate::material::accessible_clickable( + v_flex(), + SharedString::from(format!("icon-file-{}", entry.basename)), + Role::Button, + entry.basename.clone(), + cx, + ) + .debug_selector({ + let name = entry.basename.clone(); + move || format!("icon-file-{name}") + }) + .w(px(96.)) + .h(px(108.)) + .p_2() + .gap_1() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .border_1() + .border_color(if is_selected { + cx.theme().primary + } else { + cx.theme().border + }) + .bg(if is_selected { + cx.theme().accent + } else { + cx.theme().background + }) + .hover(|el| el.bg(cx.theme().accent)) + .cursor_pointer() + .child( + div() + .h(px(68.)) + .flex() + .items_center() + .justify_center() + .child(content), + ) + .child( + div() + .w_full() + .truncate() + .text_size(px(11.)) + .child(entry.basename.clone()), + ) + .on_click(cx.listener(move |this, _, window, cx| { + if this.saving { + return; + } + if is_dir { + this.browse(path.clone(), window, cx); + } else { + this.selected = Some(path.clone()); + this.error = None; + cx.notify(); + } + })), + ); + } + let viewport_height = + (f32::from(window.viewport_size().height) * 0.9 - 360.).clamp(48., 320.); + let content = if self.loading { + div() + .p_4() + .child(crate::tr!("project_icon.loading")) + .into_any_element() + } else if count == 0 { + div() + .p_4() + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("project_icon.empty")) + .into_any_element() + } else { + grid.into_any_element() + }; + v_flex() + .id("project-icon-picker") + .debug_selector(|| "project-icon-picker".into()) + .gap_3() + .child( + h_flex() + .gap_2() + .items_center() + .child(artwork(&self.project, 32.)) + .child( + v_flex().child(self.project.name.clone()).child( + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(host), + ), + ), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("icon-project-root") + .ghost() + .icon(IconName::Folder) + .tooltip(crate::tr!("project_icon.project_folder")) + .disabled(busy) + .on_click(cx.listener(move |this, _, window, cx| { + this.browse(root.clone(), window, cx) + })), + ) + .child( + Button::new("icon-parent") + .ghost() + .icon(IconName::ArrowUp) + .tooltip(crate::tr!("project_icon.parent")) + .disabled(parent.is_none() || busy) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(parent) = &parent { + this.browse(parent.clone(), window, cx); + } + })), + ) + .child( + div() + .id("icon-path") + .debug_selector(|| "icon-path".into()) + .flex_1() + .min_w_0() + .child( + Input::new(&self.path) + .aria_label(crate::tr!("project_icon.folder")) + .disabled(busy), + ), + ) + .child( + Button::new("icon-go") + .label(crate::tr!("project_icon.go")) + .disabled(busy) + .on_click(cx.listener(|this, _, window, cx| { + this.browse( + PathBuf::from(this.path.read(cx).value().as_str()), + window, + cx, + ) + })), + ), + ) + .child(Input::new(&self.search).aria_label(crate::tr!("project_icon.search"))) + .child( + div() + .id("icon-file-grid") + .h(px(viewport_height)) + .overflow_y_scrollbar() + .child(content), + ) + .when_some(self.error.clone(), |el, error| { + el.child( + div() + .text_size(px(12.)) + .text_color(cx.theme().danger) + .child(error), + ) + }) + .child( + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child( + selected + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| crate::tr!("project_icon.hint").into_owned()), + ), + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .justify_between() + .child( + Button::new("icon-reset") + .ghost() + .label(crate::tr!("project_icon.default")) + .disabled(busy) + .on_click( + cx.listener(|this, _, window, cx| this.save(None, window, cx)), + ), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("icon-cancel") + .label(crate::tr!("sidebar.cancel")) + .disabled(busy) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("icon-save") + .primary() + .label(if busy { + crate::tr!("project_icon.saving") + } else { + crate::tr!("project_icon.choose") + }) + .disabled(selected.is_none() || busy) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(path) = &selected { + this.save(Some(path.clone()), window, cx); + } + })), + ), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, VisualTestContext, size}; + use tcode_runtime::pipe::{HostServices, spawn_host}; + use tcode_services::store::SessionStore; + + struct EmptyView; + impl Render for EmptyView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + fn draw(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + } + + #[gpui::test] + fn entering_a_folder_keeps_the_picker_open_and_lists_only_images(cx: &mut TestAppContext) { + cx.update(crate::theme::init); + let root = std::env::temp_dir().join(format!( + "tcode-icon-picker-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let folder = root.join("pictures"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write( + folder.join("logo.png"), + include_bytes!("../../../assets/icons/app/tcode.png"), + ) + .unwrap(); + std::fs::write(folder.join("notes.txt"), "not an image").unwrap(); + let project = Project::from_root(root.clone()); + let disk = SessionStore::open_at(root.join("data")).unwrap(); + disk.upsert_project(&project).unwrap(); + let host = spawn_host(disk, HostServices::default()).unwrap(); + let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let (_, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|_| EmptyView); + crate::overlay::OverlayHost::new(view, window, cx) + }); + cx.simulate_resize(size(px(390.), px(700.))); + cx.update(|window, cx| open(store.clone(), project, window, cx)); + draw(cx); + let path = cx.debug_bounds("icon-path").unwrap(); + cx.simulate_click(path.center(), gpui::Modifiers::default()); + cx.simulate_keystrokes("ctrl-a"); + cx.simulate_input(folder.to_str().unwrap()); + cx.simulate_keystrokes("enter"); + draw(cx); + assert!( + cx.debug_bounds("project-icon-picker").is_some(), + "Enter dismissed the picker" + ); + assert!(cx.debug_bounds("icon-file-logo.png").is_some()); + assert!(cx.debug_bounds("icon-file-notes.txt").is_none()); + cx.update(|window, cx| window.close_dialog(cx)); + host.shutdown_blocking().unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index b4354e27..6bd09ef8 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3220,6 +3220,7 @@ mod tests { id: format!("project-{index}"), name: format!("Project {index}"), root: root.join(format!("project-{index}")), + icon_path: None, created_at: index as u64, }) .collect(); diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index da598cb7..11bf72e8 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -526,6 +526,10 @@ struct ProjectArchiveAll(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_project, no_json)] struct ProjectDelete(String); + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Action)] +#[action(namespace = tcode_project, no_json)] +struct ChangeProjectIcon(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_project, no_json)] struct ProjectReveal(String); @@ -1181,6 +1185,23 @@ impl SessionsSidebar { }); } + fn on_change_project_icon( + &mut self, + action: &ChangeProjectIcon, + window: &mut Window, + cx: &mut Context, + ) { + let project = self + .store + .read(cx) + .projects() + .into_iter() + .find(|p| p.id == action.0); + if let Some(project) = project { + crate::project_icon::open(self.store.clone(), project, window, cx); + } + } + fn on_project_delete( &mut self, action: &ProjectDelete, @@ -1724,11 +1745,7 @@ impl SessionsSidebar { .size_4() .text_color(cx.theme().muted_foreground), ) - .child( - Icon::new(IconName::Folder) - .size_4() - .text_color(cx.theme().muted_foreground), - ) + .child(crate::project_icon::artwork(&group.project, 16.)) .child( truncated_sidebar_label() .text_sm() @@ -1791,7 +1808,11 @@ impl SessionsSidebar { .context_menu(move |menu, _window, _cx| { let id = menu_project_id.clone(); let delete_label = crate::tr!("sidebar.remove_project").into_owned(); - menu.menu_with_enable( + menu.menu( + crate::tr!("project_icon.title"), + Box::new(ChangeProjectIcon(id.clone())), + ) + .menu_with_enable( crate::tr!("sidebar.archive_all").into_owned(), Box::new(ProjectArchiveAll(id.clone())), can_archive, @@ -2476,10 +2497,17 @@ impl SessionsSidebar { }) .when_some(project_name, |line, project_name| { line.child( - Icon::new(IconName::Folder) - .flex_none() - .size_3() - .text_color(cx.theme().muted_foreground), + self.store + .read(cx) + .projects() + .into_iter() + .find(|project| Some(&project.id) == meta.project_id.as_ref()) + .map(|project| { + crate::project_icon::artwork(&project, 12.).into_any_element() + }) + .unwrap_or_else(|| { + Icon::new(IconName::Folder).size_3().into_any_element() + }), ) .child(truncated_sidebar_label().child(project_name)) }) @@ -3065,7 +3093,14 @@ impl SessionsSidebar { })) .text_size(px(13.)) .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::Folder).size(px(14.))) + .when_some( + self.store + .read(cx) + .projects() + .into_iter() + .find(|project| project.id == row.project_id), + |el, project| el.child(crate::project_icon::artwork(&project, 14.)), + ) .child( div() .flex_1() @@ -3569,6 +3604,7 @@ impl Render for SessionsSidebar { .on_action(cx.listener(Self::on_delete)) .on_action(cx.listener(Self::on_project_archive_all)) .on_action(cx.listener(Self::on_project_delete)) + .on_action(cx.listener(Self::on_change_project_icon)) .on_action(cx.listener(Self::on_project_reveal)) .on_action(cx.listener(Self::on_filter_project)) .on_action(cx.listener(Self::on_start_draft_for_project)) diff --git a/crates/ui/src/store/images.rs b/crates/ui/src/store/images.rs index 9643ce00..1226912b 100644 --- a/crates/ui/src/store/images.rs +++ b/crates/ui/src/store/images.rs @@ -7,15 +7,29 @@ use tcode_protocol::{Query, QueryResponse}; pub(super) struct HostImages { pub link: Option, pub namespace: u64, + /// Real host fixtures pump on an OS thread; do not give that thread a + /// deterministic GPUI scheduler waker. Scripted image fixtures stay async. + #[cfg(test)] + pub blocking_queries: bool, } impl Global for HostImages {} +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ImageRequest { + File(PathBuf), + Thumbnail(PathBuf), + Project { + id: String, + override_path: Option, + }, +} + struct HostImage; impl Asset for HostImage { - type Source = (u64, PathBuf); + type Source = (u64, ImageRequest); type Output = Result, ImageCacheError>; fn load( - (namespace, path): Self::Source, + (namespace, request): Self::Source, cx: &mut App, ) -> impl std::future::Future + Send + 'static { let images = cx.global::(); @@ -24,9 +38,24 @@ impl Asset for HostImage { .clone() .filter(|_| images.namespace == namespace) .ok_or_else(|| std::io::Error::other("image belongs to a detached host")); + #[cfg(test)] + let blocking_queries = images.blocking_queries; async move { + let query = match request { + ImageRequest::File(path) => Query::ReadFileBytes { path }, + ImageRequest::Thumbnail(path) => Query::ReadIconImage { path }, + ImageRequest::Project { id, .. } => Query::ReadProjectIcon { project_id: id }, + }; let host = host?; - let bytes = match host.query(Query::ReadFileBytes { path }).await { + #[cfg(test)] + let result = if blocking_queries { + futures_lite::future::block_on(host.query(query)) + } else { + host.query(query).await + }; + #[cfg(not(test))] + let result = host.query(query).await; + let bytes = match result { Ok(QueryResponse::FileBytes(bytes)) => bytes, result => { return Err(std::io::Error::other(format!( @@ -43,16 +72,109 @@ impl Asset for HostImage { } } -pub(crate) fn host_image(path: PathBuf) -> ImageSource { +/// A separate cache entry for each physical display size keeps the GPU from +/// minifying the 128px preview with a four-sample bilinear lookup. +struct IconRaster; +impl Asset for IconRaster { + type Source = (Arc, u32); + type Output = Result, ImageCacheError>; + + #[expect( + clippy::manual_async_fn, + reason = "async fn would capture the borrowed, non-Send App; Asset requires a Send + 'static future" + )] + fn load( + (image, pixels): Self::Source, + _: &mut App, + ) -> impl std::future::Future + Send + 'static { + async move { rasterize_icon(&image, pixels) } + } +} + +fn rasterize_icon(image: &Image, pixels: u32) -> Result, ImageCacheError> { + let mut rgba = image::load_from_memory(&image.bytes)?.into_rgba32f(); + // Filter premultiplied colors so transparent pixels cannot leave dark fringes. + for pixel in rgba.pixels_mut() { + let alpha = pixel[3]; + for channel in &mut pixel.0[..3] { + *channel *= alpha; + } + } + let mut resized = image::DynamicImage::ImageRgba32F(rgba) + .resize(pixels, pixels, image::imageops::FilterType::Lanczos3) + .into_rgba32f(); + for pixel in resized.pixels_mut() { + let alpha = pixel[3]; + for channel in &mut pixel.0[..3] { + *channel = if alpha > 0. { *channel / alpha } else { 0. }; + } + } + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba32F(resized) + .to_rgba8() + .write_to(&mut png, image::ImageFormat::Png)?; + Ok(Arc::new(Image::from_bytes( + gpui::ImageFormat::Png, + png.into_inner(), + ))) +} + +fn source(request: ImageRequest, logical_size: Option) -> ImageSource { ImageSource::from(move |window: &mut gpui::Window, cx: &mut App| { let namespace = cx.try_global::()?.namespace; - match window.use_asset::(&(namespace, path.clone()), cx)? { - Ok(image) => image.use_render_image(window, cx).map(Ok), + match window.use_asset::(&(namespace, request.clone()), cx)? { + Ok(image) => { + let image = if let Some(size) = logical_size { + let pixels = (size * window.scale_factor()).round().max(1.) as u32; + match window.use_asset::(&(image, pixels), cx)? { + Ok(image) => image, + Err(error) => return Some(Err(error)), + } + } else { + image + }; + image.use_render_image(window, cx).map(Ok) + } Err(error) => Some(Err(error)), } }) } +pub(crate) fn host_image(path: PathBuf) -> ImageSource { + source(ImageRequest::File(path), None) +} + +pub(crate) fn icon_thumbnail(path: PathBuf) -> ImageSource { + source(ImageRequest::Thumbnail(path), None) +} + +pub(crate) fn project_icon( + project: &tcode_core::project::Project, + logical_size: f32, +) -> ImageSource { + source( + ImageRequest::Project { + id: project.id.clone(), + override_path: project.icon_path.clone(), + }, + Some(logical_size), + ) +} + +/// A reset can return to a previously cached default after the project config changes. +pub(crate) fn invalidate_project_icon(project: &tcode_core::project::Project, cx: &mut App) { + if let Some(images) = cx.try_global::() { + let key = ( + images.namespace, + ImageRequest::Project { + id: project.id.clone(), + override_path: project.icon_path.clone(), + }, + ); + cx.remove_asset::(&key); + } +} + #[cfg(test)] mod tests { use super::*; @@ -64,6 +186,36 @@ mod tests { }; use tcode_protocol::{ClientPayload, HostMessage, decode_client_line, encode_line}; + #[test] + fn small_icon_rasters_preserve_aspect_and_transparent_edge_color() { + // Opaque red beside transparent black exposes dark fringes if the + // downsampling averages straight-alpha colors. + let pixels = image::RgbaImage::from_fn(128, 64, |x, _| { + if x < 61 { + image::Rgba([255, 0, 0, 255]) + } else { + image::Rgba([0, 0, 0, 0]) + } + }); + let mut png = std::io::Cursor::new(Vec::new()); + pixels.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let source = Image::from_bytes(gpui::ImageFormat::Png, png.into_inner()); + for size in [14, 16, 32] { + let raster = rasterize_icon(&source, size).unwrap(); + let resized = image::load_from_memory(&raster.bytes).unwrap().into_rgba8(); + assert_eq!(resized.dimensions(), (size, size / 2)); + let edge: Vec<_> = resized + .pixels() + .filter(|p| p[3] > 0 && p[3] < 255) + .collect(); + assert!(!edge.is_empty(), "the edge should be antialiased"); + assert!( + edge.iter().all(|p| p[0] >= 254 && p[1] == 0 && p[2] == 0), + "transparent black must not darken the red edge" + ); + } + } + struct ImageMessage { markdown: Entity, cwd: PathBuf, @@ -90,6 +242,7 @@ mod tests { cx.set_global(HostImages { link: Some(link.clone()), namespace: 1, + blocking_queries: false, }); }); let executor = cx.background_executor.clone(); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index cbd62109..69d7aca7 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -37,7 +37,7 @@ use crate::conversation_ui::{ConversationUiState, DiffFocus}; mod history; pub(crate) use history::HISTORY_WINDOW_SCREENS; -mod images; +pub(crate) mod images; mod intents; pub(crate) use images::host_image; mod snapshots; @@ -281,6 +281,8 @@ impl WorkspaceStore { cx.set_global(images::HostImages { link: Some(host.clone()), namespace: image_namespace, + #[cfg(test)] + blocking_queries: seed_blocking, }); let client_preferences = client_host .as_ref() @@ -776,6 +778,8 @@ impl WorkspaceStore { cx.set_global(images::HostImages { link: None, namespace: self.image_namespace, + #[cfg(test)] + blocking_queries: false, }); } } @@ -857,6 +861,7 @@ impl WorkspaceStore { .sort_by_key(|meta| std::cmp::Reverse(meta.updated_at)); } (Topic::Index, ServerEvent::IndexUpsertProject(project)) => { + images::invalidate_project_icon(project, cx); match self .index_replica .1 @@ -896,6 +901,16 @@ impl WorkspaceStore { (Topic::Index, ServerEvent::IndexSnapshot(snapshot)) => { self.index_hydrated = true; self.baseline_topics.insert(Topic::Index); + for project in &snapshot.projects { + if self + .index_replica + .1 + .iter() + .any(|old| old.id == project.id && old.icon_path != project.icon_path) + { + images::invalidate_project_icon(project, cx); + } + } self.index_replica = (snapshot.sessions.clone(), snapshot.projects.clone()); // Client state for a conversation the index no longer lists has // nothing left to return to: a deleted project takes its draft's @@ -2485,6 +2500,48 @@ impl WorkspaceStore { Some(read(&workspace)) } + pub(crate) fn browse_icon_images( + &self, + directory: PathBuf, + cx: &mut App, + ) -> Task> { + let host = self.host.clone(); + #[cfg(test)] + { + let result = + futures_lite::future::block_on(host.query(Query::BrowseIconImages { directory })); + cx.spawn(async move |_| result) + } + #[cfg(not(test))] + cx.spawn(async move |_| host.query(Query::BrowseIconImages { directory }).await) + } + + pub(crate) fn set_project_icon( + &self, + project_id: String, + path: Option, + cx: &mut App, + ) -> Task> { + let host = self.host.clone(); + cx.spawn(async move |_| { + let png = if let Some(path) = path { + match host.query(Query::ReadIconImage { path }).await? { + QueryResponse::FileBytes(bytes) => Some(bytes), + _ => { + return Err(ProtocolError { + code: "invalid_image_response".into(), + message: "Unexpected image response".into(), + }); + } + } + } else { + None + }; + host.command(tcode_protocol::Command::SetProjectIcon { project_id, png }) + .await + }) + } + pub fn list_active_workspace(&self, cx: &mut App) -> Task> { let session_id = self.active_session_id().unwrap_or_default(); let host = self.host.clone(); @@ -3438,6 +3495,7 @@ mod tests { id: id.into(), name: id.into(), root: root.to_path_buf(), + icon_path: None, created_at: 0, } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7eeef9bb..be4bfb6e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -577,7 +577,7 @@ in both states. persistent features are rows here, not new controls elsewhere. 4. Project/thread header: sort, grouped/flat layout and add-project controls. Sorting and layout choices are persisted. -5. Project groups: rotating chevron + folder icon + 13px medium name; hover +5. Project groups: rotating chevron + project icon + 13px medium name; hover shows "+" (new thread in project); collapse state persisted. Thread rows: single-line truncated AI-generated title (first-message fallback while naming) + relative time (muted 11px); hover = accent bg. Inline rename @@ -599,6 +599,44 @@ or choosing its project switches that column back to Chat. Wide routes do not put Back in the Machines header or accumulate a page history. Compact keeps the single navigation stack and its Back semantics described above. +### Project icons + +Projects use their custom image wherever a project folder glyph appears, including +wide and compact group headers, wide flat-row metadata, the start hub, and +project actions in the command palette. Missing or invalid artwork falls back +to the folder glyph without changing row geometry. + +**Change project icon** in a wide project context menu, or **Change icon for +‹project›** in the command palette at either width, opens the same in-app picker. +It browses folders on the attached host, identifies that machine, and shows only +folders and PNG, JPEG, WebP, GIF, BMP, TIFF and ICO files. The path field and Up / +Project folder controls navigate; a filter narrows the current directory. Image +tiles show thumbnails and a selection border. Use image saves; Cancel discards +selection. Loading, empty directories and failures are shown inside the dialog. +The file grid scrolls, and the dialog fits narrow and short viewports. + +Selected artwork is copied into host-owned storage as a static PNG of at most +128 × 128 pixels, preserving aspect ratio and transparency. Originals are never +modified. Inputs are limited to 8 MiB and 8192 pixels per dimension, with a +bounded decoder allocation. Replacement and project removal clean up the previous +managed image after persisting the new project state. + +The 128px image remains the preview/source. Project glyphs use separately cached +Lanczos3 rasters at their displayed physical pixel size: a 16px glyph uses 16px +at 1×, 32px at 2×; compact 14px and metadata 12px glyphs follow the same rule. +Both the initial 128px thumbnail and the small rasters use Lanczos3 with +premultiplied alpha to avoid dark fringes around transparent edges, preserving +aspect ratio. Moving between display scales selects the +matching raster without changing the project's saved image. + +Without a custom override, the host reads only `iconPath` from the project's +`tcode.json`, falling back to `t3.json` only when `tcode.json` is absent, and +resolves relative paths against the project root. Both files use the same schema. Other fields, +including scripts, are ignored. **Use project default** clears the override. +Defaults are loaded when first displayed and refreshed on reset, after reconnecting, +or after restarting the client; live watching of config/image edits is not part of this +contract. The picker never edits either config file. + ### Chat header 52px. The first control is the **sidebar toggle**, immediately left of the diff --git a/locales/en.yml b/locales/en.yml index 43576bba..c1cb55b9 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1058,3 +1058,20 @@ web_auth: failed: "Wrong password or temporarily locked. Try again later." conflict: "A password is already set. Log in with that password." network: "Could not connect. Try again." + +project_icon: + title: "Change project icon" + action: "Change icon for %{project}" + search: "Filter folders and images…" + this_machine: "This machine" + project_folder: "Project folder" + parent: "Parent folder" + folder: "Folder path" + go: "Go" + loading: "Loading images…" + empty: "No matching folders or images." + hint: "PNG, JPEG, WebP, GIF, BMP, TIFF or ICO · up to 8 MiB" + default: "Use project default" + saving: "Saving…" + choose: "Use image" + load_failed: "Could not load this folder." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 6516a01a..b700acd1 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1052,3 +1052,20 @@ web_auth: failed: "密码错误或暂时锁定,请稍后重试。" conflict: "密码已设置,请使用该密码登录。" network: "无法连接,请重试。" + +project_icon: + title: "更改项目图标" + action: "更改 %{project} 的图标" + search: "筛选文件夹和图片…" + this_machine: "此设备" + project_folder: "项目文件夹" + parent: "上级文件夹" + folder: "文件夹路径" + go: "前往" + loading: "正在加载图片…" + empty: "没有匹配的文件夹或图片。" + hint: "PNG、JPEG、WebP、GIF、BMP、TIFF 或 ICO · 最大 8 MiB" + default: "使用项目默认图标" + saving: "正在保存…" + choose: "使用图片" + load_failed: "无法加载此文件夹。" From cb4f92675b194fd3c561cf78b5ccb98e8b46edc6 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Sun, 13 Sep 2026 09:22:51 -0300 Subject: [PATCH 2/7] Set Tcode's default project icon --- tcode.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tcode.json diff --git a/tcode.json b/tcode.json new file mode 100644 index 00000000..916aa4f8 --- /dev/null +++ b/tcode.json @@ -0,0 +1,3 @@ +{ + "iconPath": "assets/icons/app/tcode.png" +} From 3112daeeeca8549751045c5784f6db3646b7cf8e Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Sun, 13 Sep 2026 11:20:24 -0300 Subject: [PATCH 3/7] Show project icons in compact ungrouped thread rows --- crates/ui/src/sidebar.rs | 27 +++++++++++++++++++++++---- docs/DESIGN.md | 6 +++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 11bf72e8..133916e9 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -3134,6 +3134,15 @@ impl SessionsSidebar { let state = &cached.state; let working = cached.working; let project_name = cached.project_name.clone(); + let project = if project_name.is_some() { + self.store + .read(cx) + .projects() + .into_iter() + .find(|project| Some(&project.id) == meta.project_id.as_ref()) + } else { + None + }; let session_id = state.session_id.clone(); let status = compact_status_line(state, working, cx); let click_id = session_id.clone(); @@ -3174,7 +3183,7 @@ impl SessionsSidebar { this.window_state .update(cx, |state, cx| state.open_thread(cx)); })) - .child(compact_status_glyph(state, working, cx)) + .child(compact_status_glyph(state, working, project.as_ref(), cx)) .child( v_flex() .flex_1() @@ -3283,8 +3292,13 @@ impl SessionsSidebar { } /// The 20×20 status slot at the head of a compact row. The slot is -/// always taken so titles line up whether or not a thread has a status. -fn compact_status_glyph(state: &ThreadRowState, working: bool, cx: &App) -> gpui::AnyElement { +/// always taken so titles line up. Idle ungrouped rows show their project artwork. +fn compact_status_glyph( + state: &ThreadRowState, + working: bool, + project: Option<&tcode_core::project::Project>, + cx: &App, +) -> gpui::AnyElement { let slot = div().flex_none().size(px(20.)).flex().items_center(); if state.waiting_for_approval { return slot @@ -3326,7 +3340,12 @@ fn compact_status_glyph(state: &ThreadRowState, working: bool, cx: &App) -> gpui .child(div().size(px(8.)).rounded_full().bg(cx.theme().primary)) .into_any_element(); } - slot.into_any_element() + slot.justify_center() + .text_color(cx.theme().muted_foreground) + .when_some(project, |slot, project| { + slot.child(crate::project_icon::artwork(project, 20.)) + }) + .into_any_element() } /// Status label and color, or `None` for an idle thread that shows only its time. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index be4bfb6e..5faff07b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -606,6 +606,10 @@ wide and compact group headers, wide flat-row metadata, the start hub, and project actions in the command palette. Missing or invalid artwork falls back to the folder glyph without changing row geometry. +Compact ungrouped rows show a 20px project icon in the leading slot beside the +title. Approval, input, working, and unread indicators take precedence in that +slot. The project name stays in the metadata line without a second icon. + **Change project icon** in a wide project context menu, or **Change icon for ‹project›** in the command palette at either width, opens the same in-app picker. It browses folders on the attached host, identifies that machine, and shows only @@ -623,7 +627,7 @@ managed image after persisting the new project state. The 128px image remains the preview/source. Project glyphs use separately cached Lanczos3 rasters at their displayed physical pixel size: a 16px glyph uses 16px -at 1×, 32px at 2×; compact 14px and metadata 12px glyphs follow the same rule. +at 1×, 32px at 2×; compact 14px/20px and metadata 12px glyphs follow the same rule. Both the initial 128px thumbnail and the small rasters use Lanczos3 with premultiplied alpha to avoid dark fringes around transparent edges, preserving aspect ratio. Moving between display scales selects the From e193aa3ca169b2d7759471dc496de2cab54f1d1a Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Sun, 13 Sep 2026 16:26:42 -0300 Subject: [PATCH 4/7] Fix project icon picker test selection on macOS --- crates/ui/src/project_icon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui/src/project_icon.rs b/crates/ui/src/project_icon.rs index 2b8939bc..81bb1d0c 100644 --- a/crates/ui/src/project_icon.rs +++ b/crates/ui/src/project_icon.rs @@ -495,7 +495,7 @@ mod tests { draw(cx); let path = cx.debug_bounds("icon-path").unwrap(); cx.simulate_click(path.center(), gpui::Modifiers::default()); - cx.simulate_keystrokes("ctrl-a"); + cx.dispatch_action(crate::widgets::input::SelectAll); cx.simulate_input(folder.to_str().unwrap()); cx.simulate_keystrokes("enter"); draw(cx); From 236b36e864bc4196586d3114f0e5f7d2379359df Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 16:29:07 +0800 Subject: [PATCH 5/7] Refine project icon ownership and document project configuration --- README.md | 3 +- crates/protocol/src/lib.rs | 6 +- crates/protocol/src/query.rs | 13 +- crates/runtime/src/app/sessions.rs | 9 + crates/runtime/src/pipe.rs | 28 ++- crates/services/src/lib.rs | 1 + crates/services/src/project_config.rs | 105 +++++++++++ crates/services/src/project_icons.rs | 248 +++++++++++++++++--------- crates/services/src/store.rs | 30 ++++ crates/ui/src/palette.rs | 6 +- crates/ui/src/project_icon.rs | 93 +++++++--- crates/ui/src/sidebar.rs | 26 +-- crates/ui/src/store/images.rs | 217 +++++++++++----------- crates/ui/src/store/mod.rs | 22 ++- docs/DESIGN.md | 31 ++-- docs/project-config.md | 90 ++++++++++ 16 files changed, 662 insertions(+), 266 deletions(-) create mode 100644 crates/services/src/project_config.rs create mode 100644 docs/project-config.md diff --git a/README.md b/README.md index e56dcb0d..1e09b2cf 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,8 @@ Make sure `claude` or `codex` is on your `PATH` — or install an ACP agent from the marketplace once Tcode is running. **3. Add a project and start a thread.** Point Tcode at a directory, type, send. -No API keys, no config file. +No API keys, no config file required. Optional [project configuration](docs/project-config.md) +can provide a shared project icon. The interface is localized and follows your system language; you can override it in Settings. Everything Tcode stores — sessions, settings, installed ACP agents — diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index f5575bcc..eda104bd 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -23,9 +23,9 @@ pub use event::{ }; pub use query::{ ExternalThread, GitDiffResult, GitDiffScope, GitFileText, HostedDevice, HostingAction, - HostingState, MAX_SESSION_HISTORY_BYTES, MAX_THREAD_EXPORT_BYTES, PathEntry, Query, - QueryResponse, RecentDir, SESSION_HISTORY_RECORDS, STORED_OUTPUT_COLS, STORED_OUTPUT_ROWS, - SessionSearchHit, SourceTool, + HostingState, IconImageEntry, MAX_SESSION_HISTORY_BYTES, MAX_THREAD_EXPORT_BYTES, PathEntry, + Query, QueryResponse, RecentDir, SESSION_HISTORY_RECORDS, STORED_OUTPUT_COLS, + STORED_OUTPUT_ROWS, SessionSearchHit, SourceTool, }; pub use terminal::{TerminalDelta, TerminalFrame}; pub use wire::{ diff --git a/crates/protocol/src/query.rs b/crates/protocol/src/query.rs index 13aee97c..ff479fb0 100644 --- a/crates/protocol/src/query.rs +++ b/crates/protocol/src/query.rs @@ -40,6 +40,8 @@ pub enum Query { }, ReadProjectIcon { project_id: String, + /// Physical display size, from 1 through 128 pixels. + pixels: u32, }, ReadFileBytes { path: PathBuf, @@ -106,7 +108,7 @@ pub enum QueryResponse { IconImages { directory: PathBuf, parent: Option, - entries: Vec, + entries: Vec, }, ExternalHistory(Vec), CommitMessage(String), @@ -169,6 +171,15 @@ pub struct GitDiffResult { pub default_base: Option, } +/// A picker entry on the host. Clients return `path` unchanged: their path +/// separator and drive semantics may differ from the host's. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IconImageEntry { + pub path: PathBuf, + pub name: String, + pub is_dir: bool, +} + /// One listable workspace entry (relative to the workspace root). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PathEntry { diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 555d6203..ff28b191 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -137,8 +137,17 @@ impl AppState { } else { None }; + let refresh_default = path.is_none() && project.icon_path.is_none(); project.icon_path = path; let project = project.clone(); + if refresh_default { + // Reset also refreshes edited defaults when the persisted selection is unchanged. + cx.emit(HostEvent::Domain(EventEnvelope { + request_id: None, + topic: Topic::Index, + event: ServerEvent::IndexUpsertProject(project.clone()), + })); + } self.enqueue_store_write(StoreWrite::UpsertProject(project), cx); Ok(()) } diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index ee64e17a..25ec993b 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -654,11 +654,11 @@ fn dispatch_query( .map_err(io_protocol_error) }) } - Query::ReadProjectIcon { project_id } => { + Query::ReadProjectIcon { project_id, pixels } => { let project = app.projects.iter().find(|p| p.id == project_id).cloned(); let task = cx.unblock(move || { let project = project.ok_or_else(|| std::io::Error::other("unknown project"))?; - tcode_services::project_icons::read_project_icon(&project) + tcode_services::project_icons::read_project_icon(&project, pixels) }); cx.spawn_background(async move { task.await @@ -1213,10 +1213,28 @@ mod tests { }); let QueryResponse::FileBytes(png) = smol::block_on(link.query(Query::ReadProjectIcon { project_id: "p".into(), + pixels: 128, })) .unwrap() else { panic!() }; + link.command_blocking(Command::SetProjectIcon { + project_id: "p".into(), + png: None, + }) + .unwrap(); + let refreshed = next_event( + &events, + |event| matches!(&event.event, ServerEvent::IndexUpsertProject(project) if project.id == "p"), + ); + assert_eq!(refreshed.topic, Topic::Index); + assert!( + link.command_blocking(Command::SetProjectIcon { + project_id: "missing".into(), + png: None + }) + .is_err() + ); assert!( link.command_blocking(Command::SetProjectIcon { project_id: "p".into(), @@ -1249,7 +1267,8 @@ mod tests { let link = host.link(); assert!(matches!( smol::block_on(link.query(Query::ReadProjectIcon { - project_id: "p".into() + project_id: "p".into(), + pixels: 128, })) .unwrap(), QueryResponse::FileBytes(_) @@ -1273,7 +1292,8 @@ mod tests { .unwrap(); assert!(matches!( smol::block_on(link.query(Query::ReadProjectIcon { - project_id: "p".into() + project_id: "p".into(), + pixels: 128, })) .unwrap(), QueryResponse::FileBytes(_) diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 17885c8a..e111d03b 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -6,6 +6,7 @@ pub mod export; pub mod git; pub mod import; pub mod process; +pub mod project_config; pub mod project_icons; pub mod provider_auth; pub mod provider_probe; diff --git a/crates/services/src/project_config.rs b/crates/services/src/project_config.rs new file mode 100644 index 00000000..19285cb3 --- /dev/null +++ b/crates/services/src/project_config.rs @@ -0,0 +1,105 @@ +//! Project-root configuration. See docs/project-config.md for the file contract. +use serde::Deserialize; +use std::{ + fs::File, + io::{self, Read}, + path::Path, +}; + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectConfig { + pub icon_path: Option, +} + +/// Read the primary file, consulting the legacy file only if the primary is absent. +/// Unknown fields are ignored; syntax and known-field type errors are reported. +pub fn read(root: &Path) -> io::Result { + let file = match File::open(root.join("tcode.json")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + match File::open(root.join("t3.json")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ProjectConfig::default()); + } + result => result?, + } + } + result => result?, + }; + const MAX_BYTES: u64 = 1024 * 1024; + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(io::Error::other( + "project config exceeds the 1 MiB size limit", + )); + } + // Struct deserialization also accepts positional arrays; configuration is map-only. + let fields = serde_json::from_slice::>(&bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + serde_json::from_value(serde_json::Value::Object(fields)) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn primary_config_is_authoritative_even_when_empty_or_invalid() { + let root = std::env::temp_dir().join(format!("tcode-config-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + assert!(read(&root).unwrap().icon_path.is_none()); + fs::write(root.join("t3.json"), r#"{"iconPath":"legacy.png"}"#).unwrap(); + assert_eq!( + read(&root).unwrap().icon_path.as_deref(), + Some("legacy.png") + ); + for (json, expected) in [ + ( + r#"{"iconPath":"primary.png","scripts":[{"command":"ignored"}],"future":{}}"#, + Some("primary.png"), + ), + ("{}", None), + (r#"{"iconPath":null}"#, None), + ] { + fs::write(root.join("tcode.json"), json).unwrap(); + assert_eq!(read(&root).unwrap().icon_path.as_deref(), expected); + } + for json in [ + "broken json", + "null", + "[]", + r#"["logo.png"]"#, + "[null]", + r#"{"iconPath":42}"#, + r#"{"iconPath":false}"#, + r#"{"iconPath":"a",}"#, + ] { + fs::write(root.join("tcode.json"), json).unwrap(); + assert_eq!( + read(&root).unwrap_err().kind(), + io::ErrorKind::InvalidData, + "{json}" + ); + } + fs::File::create(root.join("tcode.json")) + .unwrap() + .set_len(1024 * 1024 + 1) + .unwrap(); + assert!(read(&root).unwrap_err().to_string().contains("1 MiB")); + fs::remove_file(root.join("tcode.json")).unwrap(); + fs::create_dir(root.join("tcode.json")).unwrap(); + assert!( + read(&root).is_err(), + "an unreadable primary must not use legacy config" + ); + fs::remove_dir(root.join("tcode.json")).unwrap(); + assert_eq!( + read(&root).unwrap().icon_path.as_deref(), + Some("legacy.png") + ); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/services/src/project_icons.rs b/crates/services/src/project_icons.rs index d5c485fb..03b409dc 100644 --- a/crates/services/src/project_icons.rs +++ b/crates/services/src/project_icons.rs @@ -1,13 +1,12 @@ //! Project defaults and bounded image decoding for the host-owned icon picker. -use image::{ImageFormat, ImageReader, Limits}; -use serde::Deserialize; +use image::{ImageDecoder, ImageFormat, ImageReader, Limits}; use std::{ fs, io::{self, Cursor, Read}, path::Path, }; use tcode_core::project::Project; -use tcode_protocol::{PathEntry, QueryResponse}; +use tcode_protocol::{IconImageEntry, QueryResponse}; const MAX_BYTES: u64 = 8 * 1024 * 1024; const ICON_SIZE: u32 = 128; @@ -36,12 +35,13 @@ pub fn browse(directory: &Path) -> io::Result { if !is_dir && (!metadata.is_file() || !supported(&entry.path())) { continue; } - entries.push(PathEntry::from_rel( - entry.file_name().to_string_lossy().into_owned(), + entries.push(IconImageEntry { + path: entry.path(), + name: entry.file_name().to_string_lossy().into_owned(), is_dir, - )); + }); } - entries.sort_by_cached_key(|entry| (!entry.is_dir, entry.basename.to_lowercase())); + entries.sort_by_cached_key(|entry| (!entry.is_dir, entry.name.to_lowercase())); Ok(QueryResponse::IconImages { parent: directory.parent().map(Path::to_path_buf), directory, @@ -55,7 +55,9 @@ fn read_bounded(path: &Path, limit: u64) -> io::Result> { .take(limit + 1) .read_to_end(&mut bytes)?; if bytes.len() as u64 > limit { - return Err(io::Error::other("image exceeds the 8 MiB size limit")); + return Err(io::Error::other(format!( + "file exceeds the {limit} byte size limit" + ))); } Ok(bytes) } @@ -66,12 +68,32 @@ fn decode(bytes: &[u8], max_dimension: u32) -> io::Result { limits.max_image_width = Some(max_dimension); limits.max_image_height = Some(max_dimension); limits.max_alloc = Some(128 * 1024 * 1024); - reader.limits(limits); - reader.decode().map_err(io::Error::other) + reader.limits(limits.clone()); + let mut decoder = reader.into_decoder().map_err(io::Error::other)?; + let (width, height) = decoder.dimensions(); + // RGBA32F needs 16 bytes per pixel beyond the decoder's own allocation. + // Bound the source before decoding or converting, including grayscale input. + if u64::from(width) * u64::from(height) > 4 * 1024 * 1024 { + return Err(io::Error::other("image exceeds the 4 megapixel size limit")); + } + limits + .reserve(decoder.total_bytes()) + .map_err(io::Error::other)?; + decoder.set_limits(limits).map_err(io::Error::other)?; + image::DynamicImage::from_decoder(decoder).map_err(io::Error::other) } /// Decode away from the host event loop and send only a small, static PNG to clients. pub fn thumbnail(path: &Path) -> io::Result> { + raster(path, ICON_SIZE) +} + +fn raster(path: &Path, pixels: u32) -> io::Result> { + if !(1..=ICON_SIZE).contains(&pixels) { + return Err(io::Error::other( + "icon size must be between 1 and 128 pixels", + )); + } let mut rgba = decode(&read_bounded(path, MAX_BYTES)?, 8192)?.into_rgba32f(); // Average premultiplied colors so transparent pixels cannot darken the preview. for pixel in rgba.pixels_mut() { @@ -81,7 +103,7 @@ pub fn thumbnail(path: &Path) -> io::Result> { } } let mut resized = image::DynamicImage::ImageRgba32F(rgba) - .resize(ICON_SIZE, ICON_SIZE, image::imageops::FilterType::Lanczos3) + .resize(pixels, pixels, image::imageops::FilterType::Lanczos3) .into_rgba32f(); for pixel in resized.pixels_mut() { let alpha = pixel[3]; @@ -97,27 +119,16 @@ pub fn thumbnail(path: &Path) -> io::Result> { Ok(png.into_inner()) } -pub fn read_project_icon(project: &Project) -> io::Result> { - if let Some(path) = &project.icon_path { - return thumbnail(path); - } - #[derive(Deserialize)] - struct Config { - #[serde(rename = "iconPath")] - icon_path: Option, - } - let bytes = match read_bounded(&project.root.join("tcode.json"), 1024 * 1024) { - Err(error) if error.kind() == io::ErrorKind::NotFound => { - read_bounded(&project.root.join("t3.json"), 1024 * 1024)? - } - result => result?, +pub fn read_project_icon(project: &Project, pixels: u32) -> io::Result> { + let path = match &project.icon_path { + Some(path) => path.clone(), + None => crate::project_config::read(&project.root)? + .icon_path + .filter(|path| !path.trim().is_empty()) + .map(|path| project.root.join(path)) + .ok_or_else(|| io::Error::other("project has no iconPath"))?, }; - let config: Config = serde_json::from_slice(&bytes).map_err(io::Error::other)?; - let path = config - .icon_path - .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| io::Error::other("project has no iconPath"))?; - thumbnail(&project.root.join(path)) + raster(&path, pixels) } /// Accept only the small PNG produced by the picker, including on remote writes. @@ -151,83 +162,152 @@ mod tests { }) .save(&path) .unwrap(); - let png = thumbnail(&path).unwrap(); + let mut project = Project::from_root(root.clone()); + project.icon_path = Some(path.clone()); + for size in [12, 14, 16, 20, 32, 128] { + let png = if size == 128 { + thumbnail(&path).unwrap() + } else { + read_project_icon(&project, size).unwrap() + }; + let resized = image::load_from_memory(&png).unwrap().into_rgba8(); + assert_eq!(resized.dimensions(), (size, size / 2)); + let edges: Vec<_> = resized + .pixels() + .filter(|pixel| pixel[3] > 0 && pixel[3] < 255) + .collect(); + assert!(!edges.is_empty(), "edge must be antialiased at {size}px"); + assert!( + edges + .iter() + .all(|pixel| pixel[0] >= 254 && pixel[1] == 0 && pixel[2] == 0) + ); + } fs::remove_dir_all(root).unwrap(); - let resized = image::load_from_memory(&png).unwrap().into_rgba8(); - assert_eq!(resized.dimensions(), (128, 64)); - let edge = resized.get_pixel(60, 32); - assert!(edge[3] > 0 && edge[3] < 255); - assert_eq!(&edge.0[..3], &[255, 0, 0]); } #[test] - fn tcode_config_takes_precedence_over_t3_config() { + fn project_icons_resolve_host_paths_and_manual_choices_override_config() { let root = std::env::temp_dir().join(format!("tcode-icons-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(root.join("assets")).unwrap(); for (name, color) in [("red", [255, 0, 0, 255]), ("blue", [0, 0, 255, 255])] { image::RgbaImage::from_pixel(16, 16, image::Rgba(color)) - .save(root.join(format!("{name}.png"))) + .save(root.join("assets").join(format!("{name}.png"))) .unwrap(); } - let project = Project::from_root(root.clone()); - fs::write(root.join("tcode.json"), r#"{"iconPath":"red.png"}"#).unwrap(); - let expected = thumbnail(&root.join("red.png")).unwrap(); - assert_eq!(read_project_icon(&project).unwrap(), expected); - - fs::write(root.join("t3.json"), r#"{"iconPath":"blue.png"}"#).unwrap(); - assert_eq!(read_project_icon(&project).unwrap(), expected); + let mut project = Project::from_root(root.clone()); + for path in [ + Path::new("assets/red.png").to_path_buf(), + root.join("assets/red.png"), + ] { + fs::write( + root.join("tcode.json"), + serde_json::json!({"iconPath": path}).to_string(), + ) + .unwrap(); + let png = read_project_icon(&project, 16).unwrap(); + assert_eq!( + image::load_from_memory(&png) + .unwrap() + .into_rgba8() + .get_pixel(0, 0) + .0, + [255, 0, 0, 255] + ); + } + project.icon_path = Some(root.join("assets/blue.png")); fs::write(root.join("tcode.json"), "broken json").unwrap(); - assert!(read_project_icon(&project).is_err()); - - fs::remove_file(root.join("tcode.json")).unwrap(); + let png = read_project_icon(&project, 16).unwrap(); assert_eq!( - read_project_icon(&project).unwrap(), - thumbnail(&root.join("blue.png")).unwrap() + image::load_from_memory(&png) + .unwrap() + .into_rgba8() + .get_pixel(0, 0) + .0, + [0, 0, 255, 255] ); + fs::remove_file(project.icon_path.as_ref().unwrap()).unwrap(); + assert!(read_project_icon(&project, 16).is_err()); + project.icon_path = None; + for config in [ + "{}", + r#"{"iconPath": " "}"#, + r#"{"iconPath": "missing.png"}"#, + "broken json", + ] { + fs::write(root.join("tcode.json"), config).unwrap(); + assert!(read_project_icon(&project, 16).is_err(), "{config}"); + } fs::remove_dir_all(root).unwrap(); } #[test] - fn picker_filters_files_and_project_defaults_yield_to_custom_images() { + fn picker_returns_host_paths_and_sorts_folders_before_supported_images() { let root = std::env::temp_dir().join(format!("tcode-icons-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(root.join("assets")).unwrap(); - let sample = include_bytes!("../../../assets/icons/app/tcode.png"); - fs::write(root.join("assets/logo.PNG"), sample).unwrap(); - fs::write(root.join("notes.txt"), "notes").unwrap(); - fs::write( - root.join("t3.json"), - r#"{"iconPath":"assets/logo.PNG","scripts":[{"command":"ignored"}]}"#, - ) - .unwrap(); - let QueryResponse::IconImages { entries, .. } = browse(&root).unwrap() else { + fs::create_dir_all(root.join("Z-folder")).unwrap(); + for name in ["a.PNG", "B.jpg", "notes.txt"] { + fs::write(root.join(name), []).unwrap(); + } + let QueryResponse::IconImages { + directory, + parent, + entries, + } = browse(&root).unwrap() + else { panic!() }; + assert_eq!(parent, directory.parent().map(Path::to_path_buf)); assert_eq!( entries .iter() - .map(|entry| entry.basename.as_str()) + .map(|entry| (entry.name.as_str(), entry.is_dir)) .collect::>(), - ["assets"] + [("Z-folder", true), ("a.PNG", false), ("B.jpg", false)] ); - let QueryResponse::IconImages { entries, .. } = browse(&root.join("assets")).unwrap() - else { - panic!() - }; - assert_eq!(entries[0].basename, "logo.PNG"); - let mut project = Project::from_root(root.clone()); - let png = read_project_icon(&project).unwrap(); - let decoded = image::load_from_memory(&png).unwrap(); - assert!(decoded.width() <= 128 && decoded.height() <= 128); - let custom = root.join("custom.png"); - save_override(&custom, &png).unwrap(); - project.icon_path = Some(custom); - fs::write(root.join("t3.json"), "broken json").unwrap(); - assert!(read_project_icon(&project).is_ok()); - project.icon_path = None; - assert!(read_project_icon(&project).is_err()); - fs::write(root.join("t3.json"), r#"{"iconPath":"missing.png"}"#).unwrap(); - assert!(read_project_icon(&project).is_err()); - assert!(save_override(&root.join("bad.png"), b"not an image").is_err()); + for entry in entries { + assert_eq!(entry.path, directory.join(&entry.name)); + } + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn images_over_resource_limits_are_rejected_before_conversion_or_save() { + let root = std::env::temp_dir().join(format!("tcode-icons-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + let path = root.join("large.png"); + // Tiny compressed grayscale input used to expand into an unbounded RGBA32F buffer. + image::GrayImage::new(8192, 513).save(&path).unwrap(); + assert!( + thumbnail(&path) + .unwrap_err() + .to_string() + .contains("megapixel") + ); + image::GrayImage::new(8193, 1).save(&path).unwrap(); + assert!(thumbnail(&path).is_err()); + fs::File::create(&path) + .unwrap() + .set_len(MAX_BYTES + 1) + .unwrap(); + assert!( + thumbnail(&path) + .unwrap_err() + .to_string() + .contains("byte size limit") + ); + let destination = root.join("managed/icon.png"); + let mut png = Cursor::new(Vec::new()); + image::RgbaImage::new(129, 1) + .write_to(&mut png, ImageFormat::Png) + .unwrap(); + for invalid in [ + b"not an image".to_vec(), + vec![0; 128 * 1024 + 1], + png.into_inner(), + ] { + assert!(save_override(&destination, &invalid).is_err()); + assert!(!destination.exists()); + } fs::remove_dir_all(root).unwrap(); } } diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index 78d11d71..64e5c5c2 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -397,6 +397,36 @@ mod tests { p } + #[test] + fn removing_project_cleans_only_managed_icons_after_persisting() { + let root = + std::env::temp_dir().join(format!("tcode-icon-cleanup-{}", uuid::Uuid::new_v4())); + let store = SessionStore::open_at(root.clone()).unwrap(); + fs::create_dir(root.join("project-icons")).unwrap(); + let mut project = Project::from_root(root.join("project")); + for (path, managed) in [ + (root.join("project-icons/icon.png"), true), + (root.join("original.png"), false), + ] { + fs::write(&path, b"stored image").unwrap(); + project.icon_path = Some(path.clone()); + store.upsert_project(&project).unwrap(); + // A failed index replacement must retain the image still referenced on disk. + fs::create_dir(root.join("sessions.json.tmp")).unwrap(); + assert!(store.remove_project(&project.id).is_err()); + assert!(path.exists()); + assert_eq!( + store.read_file().projects[0].icon_path.as_ref(), + Some(&path) + ); + fs::remove_dir(root.join("sessions.json.tmp")).unwrap(); + store.remove_project(&project.id).unwrap(); + assert!(store.read_file().projects.is_empty()); + assert_eq!(path.exists(), !managed); + } + fs::remove_dir_all(root).unwrap(); + } + #[test] fn index_roundtrip_and_sort() { let store = SessionStore::open_at(temp_root()).unwrap(); diff --git a/crates/ui/src/palette.rs b/crates/ui/src/palette.rs index a25b91aa..8e6b72e4 100644 --- a/crates/ui/src/palette.rs +++ b/crates/ui/src/palette.rs @@ -610,11 +610,9 @@ impl Render for CommandPalette { Action::NewThread { project_id, .. } => self .store .read(cx) - .projects() - .into_iter() - .find(|project| &project.id == project_id) + .project(project_id) .map(|project| { - crate::project_icon::artwork(&project, 16.).into_any_element() + crate::project_icon::artwork(project, 16.).into_any_element() }) .unwrap_or_else(|| { Icon::new(item.icon.clone()) diff --git a/crates/ui/src/project_icon.rs b/crates/ui/src/project_icon.rs index 81bb1d0c..00840bef 100644 --- a/crates/ui/src/project_icon.rs +++ b/crates/ui/src/project_icon.rs @@ -19,7 +19,7 @@ use gpui::{ use gpui_base::{h_flex, v_flex}; use std::path::PathBuf; use tcode_core::project::Project; -use tcode_protocol::{PathEntry, QueryResponse}; +use tcode_protocol::{IconImageEntry, QueryResponse}; pub(crate) fn artwork(project: &Project, size: f32) -> impl IntoElement + use<> { img(images::project_icon(project, size)) @@ -58,9 +58,8 @@ struct Picker { project: Project, path: Entity, search: Entity, - directory: PathBuf, parent: Option, - entries: Vec, + entries: Vec, selected: Option, loading: bool, saving: bool, @@ -92,7 +91,6 @@ impl Picker { cx.subscribe(&search, |_, _, _: &InputEvent, cx| cx.notify()), ]; Self { - directory: project.root.clone(), store, project, path, @@ -139,7 +137,6 @@ impl Picker { this.path.update(cx, |input, cx| { input.set_value(directory.to_string_lossy().to_string(), window, cx) }); - this.directory = directory; this.parent = parent; this.entries = entries; } @@ -159,7 +156,6 @@ impl Picker { } self.saving = true; self.error = None; - let reset = path.is_none(); let task = self.store.update(cx, |store, cx| { store.set_project_icon(self.project.id.clone(), path, cx) }); @@ -169,11 +165,6 @@ impl Picker { this.saving = false; match result { Ok(_) => { - if reset { - let mut default_project = this.project.clone(); - default_project.icon_path = None; - images::invalidate_project_icon(&default_project, cx); - } window.close_dialog(cx); } Err(error) => this.error = Some(error.message), @@ -204,10 +195,10 @@ impl Render for Picker { for entry in self .entries .iter() - .filter(|entry| entry.basename.to_lowercase().contains(&search)) + .filter(|entry| entry.name.to_lowercase().contains(&search)) { count += 1; - let path = self.directory.join(&entry.basename); + let path = entry.path.clone(); let is_dir = entry.is_dir; let is_selected = selected.as_ref() == Some(&path); let content = if is_dir { @@ -229,13 +220,14 @@ impl Render for Picker { grid = grid.child( crate::material::accessible_clickable( v_flex(), - SharedString::from(format!("icon-file-{}", entry.basename)), + SharedString::from(format!("icon-file-{}", entry.name)), Role::Button, - entry.basename.clone(), + entry.name.clone(), cx, ) + .aria_selected(is_selected) .debug_selector({ - let name = entry.basename.clone(); + let name = entry.name.clone(); move || format!("icon-file-{name}") }) .w(px(96.)) @@ -271,7 +263,7 @@ impl Render for Picker { .w_full() .truncate() .text_size(px(11.)) - .child(entry.basename.clone()), + .child(entry.name.clone()), ) .on_click(cx.listener(move |this, _, window, cx| { if this.saving { @@ -392,10 +384,10 @@ impl Render for Picker { .text_size(px(12.)) .text_color(cx.theme().muted_foreground) .child( - selected - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().to_string()) + self.entries + .iter() + .find(|entry| Some(&entry.path) == selected.as_ref()) + .map(|entry| entry.name.clone()) .unwrap_or_else(|| crate::tr!("project_icon.hint").into_owned()), ), ) @@ -463,6 +455,65 @@ mod tests { cx.run_until_parked(); } + #[gpui::test] + fn picker_returns_foreign_host_paths_unchanged(cx: &mut TestAppContext) { + use tcode_client::HostLink; + use tcode_protocol::{ClientPayload, Query, decode_client_line}; + cx.update(crate::theme::init); + let (to_host, requests) = async_channel::unbounded(); + let (_replies, from_host) = async_channel::unbounded(); + let link = HostLink::new(to_host, from_host); + let store = cx.new(|cx| { + WorkspaceStore::new_attached( + link, + crate::store::WorkspaceAttachment::Local, + None, + false, + cx, + ) + }); + let (picker, cx) = cx.add_window_view(|window, cx| { + Picker::new( + store.clone(), + Project::from_root(PathBuf::from("/project")), + window, + cx, + ) + }); + // These are opaque host paths, including the opposite platform's separators. + for path in [r"Q:\host\images\logo.png", "/host/images/logo.png"] { + picker.update(cx, |picker, cx| { + picker.entries = vec![IconImageEntry { + path: PathBuf::from(path), + name: "logo.png".into(), + is_dir: false, + }]; + cx.notify(); + }); + draw(cx); + let tile = cx.debug_bounds("icon-file-logo.png").unwrap(); + cx.simulate_click(tile.center(), gpui::Modifiers::default()); + assert_eq!( + picker.read_with(cx, |picker, _| picker.selected.clone()), + Some(PathBuf::from(path)) + ); + draw(cx); + let read = std::iter::from_fn(|| requests.try_recv().ok()) + .filter_map(|line| { + let request = decode_client_line(&line).unwrap(); + match request.payload { + ClientPayload::Query(Query::ReadIconImage { path }) => Some(path), + _ => None, + } + }) + .collect::>(); + assert!( + read.contains(&PathBuf::from(path)), + "thumbnail must use the host path: {read:?}" + ); + } + } + #[gpui::test] fn entering_a_folder_keeps_the_picker_open_and_lists_only_images(cx: &mut TestAppContext) { cx.update(crate::theme::init); diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 1ba6ef8a..ad90cb7b 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -1377,12 +1377,7 @@ impl SessionsSidebar { window: &mut Window, cx: &mut Context, ) { - let project = self - .store - .read(cx) - .projects() - .into_iter() - .find(|p| p.id == action.0); + let project = self.store.read(cx).project(&action.0).cloned(); if let Some(project) = project { crate::project_icon::open(self.store.clone(), project, window, cx); } @@ -2689,11 +2684,9 @@ impl SessionsSidebar { line.child( self.store .read(cx) - .projects() - .into_iter() - .find(|project| Some(&project.id) == meta.project_id.as_ref()) + .project(meta.project_id.as_deref().unwrap_or_default()) .map(|project| { - crate::project_icon::artwork(&project, 12.).into_any_element() + crate::project_icon::artwork(project, 12.).into_any_element() }) .unwrap_or_else(|| { Icon::new(IconName::Folder).size_3().into_any_element() @@ -3264,12 +3257,8 @@ impl SessionsSidebar { .text_size(px(13.)) .text_color(cx.theme().muted_foreground) .when_some( - self.store - .read(cx) - .projects() - .into_iter() - .find(|project| project.id == row.project_id), - |el, project| el.child(crate::project_icon::artwork(&project, 14.)), + self.store.read(cx).project(&row.project_id), + |el, project| el.child(crate::project_icon::artwork(project, 14.)), ) .child( div() @@ -3307,9 +3296,8 @@ impl SessionsSidebar { let project = if project_name.is_some() { self.store .read(cx) - .projects() - .into_iter() - .find(|project| Some(&project.id) == meta.project_id.as_ref()) + .project(meta.project_id.as_deref().unwrap_or_default()) + .cloned() } else { None }; diff --git a/crates/ui/src/store/images.rs b/crates/ui/src/store/images.rs index 1226912b..307edd89 100644 --- a/crates/ui/src/store/images.rs +++ b/crates/ui/src/store/images.rs @@ -21,6 +21,7 @@ enum ImageRequest { Project { id: String, override_path: Option, + pixels: u32, }, } @@ -44,7 +45,10 @@ impl Asset for HostImage { let query = match request { ImageRequest::File(path) => Query::ReadFileBytes { path }, ImageRequest::Thumbnail(path) => Query::ReadIconImage { path }, - ImageRequest::Project { id, .. } => Query::ReadProjectIcon { project_id: id }, + ImageRequest::Project { id, pixels, .. } => Query::ReadProjectIcon { + project_id: id, + pixels, + }, }; let host = host?; #[cfg(test)] @@ -72,106 +76,54 @@ impl Asset for HostImage { } } -/// A separate cache entry for each physical display size keeps the GPU from -/// minifying the 128px preview with a four-sample bilinear lookup. -struct IconRaster; -impl Asset for IconRaster { - type Source = (Arc, u32); - type Output = Result, ImageCacheError>; - - #[expect( - clippy::manual_async_fn, - reason = "async fn would capture the borrowed, non-Send App; Asset requires a Send + 'static future" - )] - fn load( - (image, pixels): Self::Source, - _: &mut App, - ) -> impl std::future::Future + Send + 'static { - async move { rasterize_icon(&image, pixels) } - } -} - -fn rasterize_icon(image: &Image, pixels: u32) -> Result, ImageCacheError> { - let mut rgba = image::load_from_memory(&image.bytes)?.into_rgba32f(); - // Filter premultiplied colors so transparent pixels cannot leave dark fringes. - for pixel in rgba.pixels_mut() { - let alpha = pixel[3]; - for channel in &mut pixel.0[..3] { - *channel *= alpha; - } - } - let mut resized = image::DynamicImage::ImageRgba32F(rgba) - .resize(pixels, pixels, image::imageops::FilterType::Lanczos3) - .into_rgba32f(); - for pixel in resized.pixels_mut() { - let alpha = pixel[3]; - for channel in &mut pixel.0[..3] { - *channel = if alpha > 0. { *channel / alpha } else { 0. }; - } - } - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba32F(resized) - .to_rgba8() - .write_to(&mut png, image::ImageFormat::Png)?; - Ok(Arc::new(Image::from_bytes( - gpui::ImageFormat::Png, - png.into_inner(), - ))) -} - -fn source(request: ImageRequest, logical_size: Option) -> ImageSource { +fn source(request: impl Fn(&gpui::Window) -> ImageRequest + 'static) -> ImageSource { ImageSource::from(move |window: &mut gpui::Window, cx: &mut App| { let namespace = cx.try_global::()?.namespace; - match window.use_asset::(&(namespace, request.clone()), cx)? { - Ok(image) => { - let image = if let Some(size) = logical_size { - let pixels = (size * window.scale_factor()).round().max(1.) as u32; - match window.use_asset::(&(image, pixels), cx)? { - Ok(image) => image, - Err(error) => return Some(Err(error)), - } - } else { - image - }; - image.use_render_image(window, cx).map(Ok) - } + match window.use_asset::(&(namespace, request(window)), cx)? { + Ok(image) => image.use_render_image(window, cx).map(Ok), Err(error) => Some(Err(error)), } }) } pub(crate) fn host_image(path: PathBuf) -> ImageSource { - source(ImageRequest::File(path), None) + source(move |_| ImageRequest::File(path.clone())) } pub(crate) fn icon_thumbnail(path: PathBuf) -> ImageSource { - source(ImageRequest::Thumbnail(path), None) + source(move |_| ImageRequest::Thumbnail(path.clone())) } pub(crate) fn project_icon( project: &tcode_core::project::Project, logical_size: f32, ) -> ImageSource { - source( - ImageRequest::Project { - id: project.id.clone(), - override_path: project.icon_path.clone(), - }, - Some(logical_size), - ) + let id = project.id.clone(); + let override_path = project.icon_path.clone(); + source(move |window| ImageRequest::Project { + id: id.clone(), + override_path: override_path.clone(), + pixels: (logical_size * window.scale_factor()) + .round() + .clamp(1., 128.) as u32, + }) } /// A reset can return to a previously cached default after the project config changes. pub(crate) fn invalidate_project_icon(project: &tcode_core::project::Project, cx: &mut App) { if let Some(images) = cx.try_global::() { - let key = ( - images.namespace, - ImageRequest::Project { - id: project.id.clone(), - override_path: project.icon_path.clone(), - }, - ); - cx.remove_asset::(&key); + let namespace = images.namespace; + // Each display scale has its own host-rendered raster, including cached errors. + for pixels in 1..=128 { + cx.remove_asset::(&( + namespace, + ImageRequest::Project { + id: project.id.clone(), + override_path: project.icon_path.clone(), + pixels, + }, + )); + } } } @@ -186,34 +138,91 @@ mod tests { }; use tcode_protocol::{ClientPayload, HostMessage, decode_client_line, encode_line}; - #[test] - fn small_icon_rasters_preserve_aspect_and_transparent_edge_color() { - // Opaque red beside transparent black exposes dark fringes if the - // downsampling averages straight-alpha colors. - let pixels = image::RgbaImage::from_fn(128, 64, |x, _| { - if x < 61 { - image::Rgba([255, 0, 0, 255]) - } else { - image::Rgba([0, 0, 0, 0]) - } - }); - let mut png = std::io::Cursor::new(Vec::new()); - pixels.write_to(&mut png, image::ImageFormat::Png).unwrap(); - let source = Image::from_bytes(gpui::ImageFormat::Png, png.into_inner()); - for size in [14, 16, 32] { - let raster = rasterize_icon(&source, size).unwrap(); - let resized = image::load_from_memory(&raster.bytes).unwrap().into_rgba8(); - assert_eq!(resized.dimensions(), (size, size / 2)); - let edge: Vec<_> = resized - .pixels() - .filter(|p| p[3] > 0 && p[3] < 255) - .collect(); - assert!(!edge.is_empty(), "the edge should be antialiased"); + #[gpui::test] + async fn project_icon_cache_refreshes_defaults_and_errors_after_reset_or_reconnect( + cx: &mut TestAppContext, + ) { + use crate::store::WorkspaceStore; + use tcode_core::project::Project; + use tcode_protocol::{EventEnvelope, IndexSnapshot, ServerEvent, Topic}; + use tcode_runtime::pipe::{HostServices, spawn_host}; + use tcode_services::store::SessionStore; + + cx.update(crate::theme::init); + let root = std::env::temp_dir().join(format!( + "tcode-icon-cache-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let project = Project::from_root(root.clone()); + let disk = SessionStore::open_at(root.join("data")).unwrap(); + disk.upsert_project(&project).unwrap(); + let host = spawn_host(disk, HostServices::default()).unwrap(); + let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let namespace = cx.update(|cx| cx.global::().namespace); + let key = |pixels| { + ( + namespace, + ImageRequest::Project { + id: project.id.clone(), + override_path: None, + pixels, + }, + ) + }; + for pixels in [16, 32] { assert!( - edge.iter().all(|p| p[0] >= 254 && p[1] == 0 && p[2] == 0), - "transparent black must not darken the red edge" + cx.update(|cx| cx.fetch_asset::(&key(pixels)).0) + .await + .is_err() ); } + std::fs::write(root.join("tcode.json"), r#"{"iconPath":"logo.png"}"#).unwrap(); + for (reconnect, color) in [(true, [255, 0, 0, 255]), (false, [0, 0, 255, 255])] { + image::RgbaImage::from_pixel(32, 32, image::Rgba(color)) + .save(root.join("logo.png")) + .unwrap(); + // Both success and error entries stay cached until the host/connection event. + for pixels in [16, 32] { + assert!(!cx.update(|cx| cx.fetch_asset::(&key(pixels)).1)); + } + store.update(cx, |store, cx| { + let event = if reconnect { + store.apply_connection_state(tcode_client::ConnectionState::Syncing); + ServerEvent::IndexSnapshot(IndexSnapshot { + sessions: vec![], + projects: vec![project.clone()], + activity: Default::default(), + title_generating: Default::default(), + }) + } else { + // This is also delivered to clients that did not open the picker. + ServerEvent::IndexUpsertProject(project.clone()) + }; + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::Index, + event, + }, + cx, + ); + }); + for pixels in [16, 32] { + let image = cx + .update(|cx| cx.fetch_asset::(&key(pixels)).0) + .await + .unwrap(); + let rgba = image::load_from_memory(&image.bytes).unwrap().into_rgba8(); + assert_eq!(rgba.dimensions(), (pixels, pixels)); + assert_eq!(rgba.get_pixel(0, 0).0, color); + } + } + host.shutdown_blocking().unwrap(); + std::fs::remove_dir_all(root).unwrap(); } struct ImageMessage { diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 54d81ff1..f631e9cc 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -902,13 +902,14 @@ impl WorkspaceStore { } (Topic::Index, ServerEvent::IndexSnapshot(snapshot)) => { self.index_hydrated = true; - self.baseline_topics.insert(Topic::Index); + let fresh_baseline = self.baseline_topics.insert(Topic::Index); for project in &snapshot.projects { - if self - .index_replica - .1 - .iter() - .any(|old| old.id == project.id && old.icon_path != project.icon_path) + if fresh_baseline + || self + .index_replica + .1 + .iter() + .any(|old| old.id == project.id && old.icon_path != project.icon_path) { images::invalidate_project_icon(project, cx); } @@ -1693,6 +1694,10 @@ impl WorkspaceStore { order_sessions_with_children(visible) } + pub(crate) fn project(&self, id: &str) -> Option<&Project> { + self.index_replica.1.iter().find(|project| project.id == id) + } + pub fn projects(&self) -> Vec { self.index_replica.1.clone() } @@ -2514,12 +2519,11 @@ impl WorkspaceStore { ) -> Task> { let host = self.host.clone(); #[cfg(test)] - { + if cx.global::().blocking_queries { let result = futures_lite::future::block_on(host.query(Query::BrowseIconImages { directory })); - cx.spawn(async move |_| result) + return cx.spawn(async move |_| result); } - #[cfg(not(test))] cx.spawn(async move |_| host.query(Query::BrowseIconImages { directory }).await) } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0e5049ae..ab6a8086 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -663,24 +663,23 @@ The file grid scrolls, and the dialog fits narrow and short viewports. Selected artwork is copied into host-owned storage as a static PNG of at most 128 × 128 pixels, preserving aspect ratio and transparency. Originals are never modified. Inputs are limited to 8 MiB and 8192 pixels per dimension, with a -bounded decoder allocation. Replacement and project removal clean up the previous +bounded decoder allocation and at most 4,194,304 source pixels. Replacement and project removal clean up the previous managed image after persisting the new project state. -The 128px image remains the preview/source. Project glyphs use separately cached -Lanczos3 rasters at their displayed physical pixel size: a 16px glyph uses 16px -at 1×, 32px at 2×; compact 14px/20px and metadata 12px glyphs follow the same rule. -Both the initial 128px thumbnail and the small rasters use Lanczos3 with -premultiplied alpha to avoid dark fringes around transparent edges, preserving -aspect ratio. Moving between display scales selects the -matching raster without changing the project's saved image. - -Without a custom override, the host reads only `iconPath` from the project's -`tcode.json`, falling back to `t3.json` only when `tcode.json` is absent, and -resolves relative paths against the project root. Both files use the same schema. Other fields, -including scripts, are ignored. **Use project default** clears the override. -Defaults are loaded when first displayed and refreshed on reset, after reconnecting, -or after restarting the client; live watching of config/image edits is not part of this -contract. The picker never edits either config file. +The 128px image remains the saved source. The host produces Lanczos3 rasters at +requested physical display sizes, cached by the client: a 16px glyph requests +16px at 1× and 32px at 2×, up to 128px. The host owns both thumbnail and glyph +resizing, with premultiplied alpha to avoid dark fringes at transparent edges. +Images preserve aspect ratio; display-scale changes select the matching raster. + +Project config location, `iconPath`, legacy fallback, limits and failure handling +are specified in [Project configuration](project-config.md). **Use project +default** clears the override and refreshes connected clients even when no +manual icon was selected. Defaults also refresh after reconnecting or restarting +the client; config/image edits are not watched live. The picker never edits +config files. Folder entries carry complete host paths; clients do not assemble +paths using their own operating system's separators. Image selection is exposed +to assistive technology as well as shown by its border. ### Chat header diff --git a/docs/project-config.md b/docs/project-config.md new file mode 100644 index 00000000..f1fbaadb --- /dev/null +++ b/docs/project-config.md @@ -0,0 +1,90 @@ +# Project configuration + +A project can keep a `tcode.json` file directly in the project root registered +with Tcode. It belongs to the project on the attached host, including when the +client is on another device. Tcode does not search parent directories or use a +thread's worktree directory instead of that root. This file is separate from +Tcode's application settings and session index. + +For example, commit this alongside an image in the repository: + +```json +{ + "iconPath": "assets/logo.png" +} +``` + +## File selection and JSON + +Tcode reads `tcode.json` first. Only if that file is absent does it read +`t3.json` in the same directory, using the same rules. The files are never +merged. An existing but empty, unreadable, oversized or malformed `tcode.json` +does not fall back to `t3.json`. Neither does a primary file with no icon, or +one pointing to an invalid image. If both files are absent, the project has +no configured default icon. + +The file must be a JSON object, at most 1 MiB. Standard JSON applies: comments +and trailing commas are not supported. Unknown fields are ignored, including +`scripts`; Tcode does not execute anything from this file. Invalid JSON or a +known field with the wrong type makes the configuration invalid. A bad +configuration does not prevent the project or its threads from opening; its +icon falls back to the folder glyph unless a manual icon overrides it. + +The reader and schema live in +[`crates/services/src/project_config.rs`](../crates/services/src/project_config.rs). + +## `iconPath` + +`iconPath` is an optional string. An absent field, `null`, an empty string or a +whitespace-only string means no configured icon. Any other string is used as +written; leading or trailing spaces are part of the filename. + +Relative paths resolve against the registered project root. Absolute paths are +accepted according to the host operating system's path rules. For example, +`assets/logo.png` refers to that file inside the project. `../shared/logo.png` +can refer outside it. `~`, environment variables and URLs are not expanded. +Prefer a relative path to a checked-in image when sharing a project across +machines. Windows paths in JSON need escaped backslashes, for example +`"C:\\Artwork\\logo.png"`. + +Supported image formats are PNG, JPEG, WebP, GIF, BMP, TIFF and ICO. Images must +be at most 8 MiB, at most 8192 pixels on either side, and at most 4,194,304 pixels +in total. The decoder also has a 128 MiB allocation limit; the pixel limit +bounds the additional floating-point resize buffers. Animated images use a +static frame. A missing, unreadable, unsupported or invalid image uses the +folder glyph. + +## Manual icons and refresh + +**Change project icon** in the project menu, or **Change icon for ‹project›** in +the command palette, opens a picker for the attached host's files. Selecting an +image creates a static PNG copy of at most 128 × 128 pixels in the host's +Tcode data directory (`project-icons/`). The session index records that copy; +Tcode does not modify the original image or either project config file. +Removing the original therefore does not break the manual icon. Replacing, +resetting or removing the project cleans up the previous managed copy after +the updated index is persisted. + +A manual selection takes precedence over both config files, even if the config +is invalid. If the managed image itself becomes unavailable, the folder glyph +is shown. **Use project default** clears the manual choice and reloads the +configuration, including on other connected clients. It can also refresh an +already selected default after editing its config or image. + +Defaults load when displayed, and refresh on reset, reconnect, or client +restart. Tcode does not watch config or image files for live changes. Rendering, +geometry and the picker's interaction contract are documented in +[DESIGN.md](DESIGN.md#project-icons). + +## Future configuration management + +Tcode currently reads these files; it does not generate, migrate or edit them. +The standalone configuration reader is the owner for future project settings, +so new consumers do not invent different fallback or parsing rules. + +A future configuration editor should write the canonical `tcode.json`, preserve +unknown fields, validate known fields before saving, and replace the file +atomically. It must report invalid existing JSON rather than overwriting it +with an empty configuration. Creating a primary file shadows the entire legacy +file, so migration from `t3.json` must preserve its other fields deliberately. +These are requirements for future management, not a currently available editor. From c40758fdbe06c8e7c44f80a6e39538f24c8543e8 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 16:48:48 +0800 Subject: [PATCH 6/7] test: exercise project icons through asynchronous protocol fixtures --- crates/ui/src/project_icon.rs | 90 ++++++++++++----- crates/ui/src/store/images.rs | 177 +++++++++++++++++++--------------- crates/ui/src/store/mod.rs | 10 -- 3 files changed, 166 insertions(+), 111 deletions(-) diff --git a/crates/ui/src/project_icon.rs b/crates/ui/src/project_icon.rs index 00840bef..acffe40e 100644 --- a/crates/ui/src/project_icon.rs +++ b/crates/ui/src/project_icon.rs @@ -438,8 +438,6 @@ impl Render for Picker { mod tests { use super::*; use gpui::{TestAppContext, VisualTestContext, size}; - use tcode_runtime::pipe::{HostServices, spawn_host}; - use tcode_services::store::SessionStore; struct EmptyView; impl Render for EmptyView { @@ -515,28 +513,75 @@ mod tests { } #[gpui::test] - fn entering_a_folder_keeps_the_picker_open_and_lists_only_images(cx: &mut TestAppContext) { + fn entering_a_folder_keeps_the_picker_open_and_displays_the_host_listing( + cx: &mut TestAppContext, + ) { + use tcode_client::HostLink; + use tcode_protocol::{ClientPayload, HostMessage, Query, decode_client_line, encode_line}; cx.update(crate::theme::init); - let root = std::env::temp_dir().join(format!( - "tcode-icon-picker-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + let root = PathBuf::from("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/host/project"); let folder = root.join("pictures"); - std::fs::create_dir_all(&folder).unwrap(); - std::fs::write( - folder.join("logo.png"), - include_bytes!("../../../assets/icons/app/tcode.png"), - ) - .unwrap(); - std::fs::write(folder.join("notes.txt"), "not an image").unwrap(); let project = Project::from_root(root.clone()); - let disk = SessionStore::open_at(root.join("data")).unwrap(); - disk.upsert_project(&project).unwrap(); - let host = spawn_host(disk, HostServices::default()).unwrap(); - let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let (to_host, requests) = async_channel::unbounded(); + let (replies, from_host) = async_channel::unbounded(); + let link = HostLink::new(to_host, from_host); + let store = cx.new(|cx| { + WorkspaceStore::new_attached( + link.clone(), + crate::store::WorkspaceAttachment::Local, + None, + false, + cx, + ) + }); + let executor = cx.background_executor.clone(); + let _pump = cx.background_executor.spawn(async move { + link.pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) + .await; + }); + let requested_folder = folder.clone(); + let _host = cx.background_executor.spawn(async move { + while let Ok(line) = requests.recv().await { + let request = decode_client_line(&line).unwrap(); + let response = match request.payload { + ClientPayload::Query(Query::BrowseIconImages { directory }) => { + let entries = if directory == root { + vec![IconImageEntry { + path: requested_folder.clone(), + name: "pictures".into(), + is_dir: true, + }] + } else { + assert_eq!(directory, requested_folder); + vec![IconImageEntry { + path: directory.join("logo.png"), + name: "logo.png".into(), + is_dir: false, + }] + }; + QueryResponse::IconImages { + parent: directory.parent().map(PathBuf::from), + directory, + entries, + } + } + ClientPayload::Query(Query::ReadIconImage { .. }) => QueryResponse::FileBytes( + include_bytes!("../../../assets/icons/app/tcode.png").to_vec(), + ), + _ => continue, + }; + replies + .send( + encode_line(&HostMessage::QueryResult { + id: request.id, + result: Ok(response), + }) + .unwrap(), + ) + .await + .unwrap(); + } + }); let (_, cx) = cx.add_window_view(|window, cx| { let view = cx.new(|_| EmptyView); crate::overlay::OverlayHost::new(view, window, cx) @@ -555,9 +600,6 @@ mod tests { "Enter dismissed the picker" ); assert!(cx.debug_bounds("icon-file-logo.png").is_some()); - assert!(cx.debug_bounds("icon-file-notes.txt").is_none()); cx.update(|window, cx| window.close_dialog(cx)); - host.shutdown_blocking().unwrap(); - std::fs::remove_dir_all(root).unwrap(); } } diff --git a/crates/ui/src/store/images.rs b/crates/ui/src/store/images.rs index 307edd89..a1319f44 100644 --- a/crates/ui/src/store/images.rs +++ b/crates/ui/src/store/images.rs @@ -7,10 +7,6 @@ use tcode_protocol::{Query, QueryResponse}; pub(super) struct HostImages { pub link: Option, pub namespace: u64, - /// Real host fixtures pump on an OS thread; do not give that thread a - /// deterministic GPUI scheduler waker. Scripted image fixtures stay async. - #[cfg(test)] - pub blocking_queries: bool, } impl Global for HostImages {} @@ -39,8 +35,6 @@ impl Asset for HostImage { .clone() .filter(|_| images.namespace == namespace) .ok_or_else(|| std::io::Error::other("image belongs to a detached host")); - #[cfg(test)] - let blocking_queries = images.blocking_queries; async move { let query = match request { ImageRequest::File(path) => Query::ReadFileBytes { path }, @@ -51,15 +45,7 @@ impl Asset for HostImage { }, }; let host = host?; - #[cfg(test)] - let result = if blocking_queries { - futures_lite::future::block_on(host.query(query)) - } else { - host.query(query).await - }; - #[cfg(not(test))] - let result = host.query(query).await; - let bytes = match result { + let bytes = match host.query(query).await { Ok(QueryResponse::FileBytes(bytes)) => bytes, result => { return Err(std::io::Error::other(format!( @@ -142,26 +128,27 @@ mod tests { async fn project_icon_cache_refreshes_defaults_and_errors_after_reset_or_reconnect( cx: &mut TestAppContext, ) { - use crate::store::WorkspaceStore; + use crate::store::{WorkspaceAttachment, WorkspaceStore}; use tcode_core::project::Project; - use tcode_protocol::{EventEnvelope, IndexSnapshot, ServerEvent, Topic}; - use tcode_runtime::pipe::{HostServices, spawn_host}; - use tcode_services::store::SessionStore; + use tcode_protocol::{EventEnvelope, IndexSnapshot, ProtocolError, ServerEvent, Topic}; cx.update(crate::theme::init); - let root = std::env::temp_dir().join(format!( - "tcode-icon-cache-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&root).unwrap(); - let project = Project::from_root(root.clone()); - let disk = SessionStore::open_at(root.join("data")).unwrap(); - disk.upsert_project(&project).unwrap(); - let host = spawn_host(disk, HostServices::default()).unwrap(); - let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let (to_host, requests) = async_channel::unbounded(); + let (replies, from_host) = async_channel::unbounded(); + let link = HostLink::new(to_host, from_host); + let store = cx.new(|cx| { + WorkspaceStore::new_attached(link.clone(), WorkspaceAttachment::Local, None, false, cx) + }); + store.update(cx, |store, _| { + // Keep navigation out of this cache test when its Index baseline arrives. + store.selected_session_id = Some("selected".into()); + }); + let executor = cx.background_executor.clone(); + let _pump = cx.background_executor.spawn(async move { + link.pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) + .await; + }); + let project = Project::from_root(PathBuf::from("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/host/project")); let namespace = cx.update(|cx| cx.global::().namespace); let key = |pixels| { ( @@ -173,56 +160,93 @@ mod tests { }, ) }; - for pixels in [16, 32] { - assert!( - cx.update(|cx| cx.fetch_asset::(&key(pixels)).0) - .await - .is_err() - ); - } - std::fs::write(root.join("tcode.json"), r#"{"iconPath":"logo.png"}"#).unwrap(); - for (reconnect, color) in [(true, [255, 0, 0, 255]), (false, [0, 0, 255, 255])] { - image::RgbaImage::from_pixel(32, 32, image::Rgba(color)) - .save(root.join("logo.png")) - .unwrap(); - // Both success and error entries stay cached until the host/connection event. - for pixels in [16, 32] { - assert!(!cx.update(|cx| cx.fetch_asset::(&key(pixels)).1)); + for (reconnect, color) in [ + (None, None), + (Some(true), Some([255, 0, 0, 255])), + (Some(false), Some([0, 0, 255, 255])), + ] { + if let Some(reconnect) = reconnect { + store.update(cx, |store, cx| { + let event = if reconnect { + store.apply_connection_state(tcode_client::ConnectionState::Syncing); + ServerEvent::IndexSnapshot(IndexSnapshot { + sessions: vec![], + projects: vec![project.clone()], + activity: Default::default(), + title_generating: Default::default(), + }) + } else { + // Also delivered to clients that did not open the picker. + ServerEvent::IndexUpsertProject(project.clone()) + }; + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::Index, + event, + }, + cx, + ); + }); } - store.update(cx, |store, cx| { - let event = if reconnect { - store.apply_connection_state(tcode_client::ConnectionState::Syncing); - ServerEvent::IndexSnapshot(IndexSnapshot { - sessions: vec![], - projects: vec![project.clone()], - activity: Default::default(), - title_generating: Default::default(), + for pixels in [16, 32] { + let (image, requested) = cx.update(|cx| cx.fetch_asset::(&key(pixels))); + assert!( + requested, + "a host event must invalidate both success and error entries" + ); + cx.run_until_parked(); + let request = std::iter::from_fn(|| requests.try_recv().ok()) + .map(|line| decode_client_line(&line).unwrap()) + .find(|request| { + matches!( + request.payload, + ClientPayload::Query(Query::ReadProjectIcon { .. }) + ) + }) + .expect("an uncached project icon must query the host"); + assert_eq!( + request.payload, + ClientPayload::Query(Query::ReadProjectIcon { + project_id: project.id.clone(), + pixels, }) - } else { - // This is also delivered to clients that did not open the picker. - ServerEvent::IndexUpsertProject(project.clone()) - }; - store.apply_domain_event( - &EventEnvelope { - request_id: None, - topic: Topic::Index, - event, - }, - cx, ); - }); - for pixels in [16, 32] { - let image = cx - .update(|cx| cx.fetch_asset::(&key(pixels)).0) - .await + let result = match color { + Some(color) => { + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(pixels, pixels, image::Rgba(color)) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + Ok(QueryResponse::FileBytes(png.into_inner())) + } + None => Err(ProtocolError { + code: "not_found".into(), + message: "no default icon".into(), + }), + }; + replies + .send_blocking( + encode_line(&HostMessage::QueryResult { + id: request.id, + result, + }) + .unwrap(), + ) .unwrap(); - let rgba = image::load_from_memory(&image.bytes).unwrap().into_rgba8(); - assert_eq!(rgba.dimensions(), (pixels, pixels)); - assert_eq!(rgba.get_pixel(0, 0).0, color); + cx.run_until_parked(); + match color { + Some(color) => { + let image = image.await.unwrap(); + let rgba = image::load_from_memory(&image.bytes).unwrap().into_rgba8(); + assert_eq!(rgba.dimensions(), (pixels, pixels)); + assert_eq!(rgba.get_pixel(0, 0).0, color); + } + None => assert!(image.await.is_err()), + } + assert!(!cx.update(|cx| cx.fetch_asset::(&key(pixels)).1)); } } - host.shutdown_blocking().unwrap(); - std::fs::remove_dir_all(root).unwrap(); } struct ImageMessage { @@ -251,7 +275,6 @@ mod tests { cx.set_global(HostImages { link: Some(link.clone()), namespace: 1, - blocking_queries: false, }); }); let executor = cx.background_executor.clone(); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index f631e9cc..d5bd7cf6 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -282,8 +282,6 @@ impl WorkspaceStore { cx.set_global(images::HostImages { link: Some(host.clone()), namespace: image_namespace, - #[cfg(test)] - blocking_queries: seed_blocking, }); let client_preferences = client_host .as_ref() @@ -780,8 +778,6 @@ impl WorkspaceStore { cx.set_global(images::HostImages { link: None, namespace: self.image_namespace, - #[cfg(test)] - blocking_queries: false, }); } } @@ -2518,12 +2514,6 @@ impl WorkspaceStore { cx: &mut App, ) -> Task> { let host = self.host.clone(); - #[cfg(test)] - if cx.global::().blocking_queries { - let result = - futures_lite::future::block_on(host.query(Query::BrowseIconImages { directory })); - return cx.spawn(async move |_| result); - } cx.spawn(async move |_| host.query(Query::BrowseIconImages { directory }).await) } From b8e54fe607b9fda56df264e4c099352574210a0e Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 16:53:13 +0800 Subject: [PATCH 7/7] test: establish the icon cache baseline before reconnecting --- crates/ui/src/store/images.rs | 64 ++++++++++++++++++++++------------- crates/ui/src/store/mod.rs | 4 +++ 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/crates/ui/src/store/images.rs b/crates/ui/src/store/images.rs index a1319f44..c15b7ccc 100644 --- a/crates/ui/src/store/images.rs +++ b/crates/ui/src/store/images.rs @@ -7,6 +7,10 @@ use tcode_protocol::{Query, QueryResponse}; pub(super) struct HostImages { pub link: Option, pub namespace: u64, + /// Existing live-host UI fixtures pump on an OS thread. Keep its wakeups + /// outside GPUI's deterministic scheduler; scripted fixtures stay async. + #[cfg(test)] + pub blocking_queries: bool, } impl Global for HostImages {} @@ -35,6 +39,8 @@ impl Asset for HostImage { .clone() .filter(|_| images.namespace == namespace) .ok_or_else(|| std::io::Error::other("image belongs to a detached host")); + #[cfg(test)] + let blocking_queries = images.blocking_queries; async move { let query = match request { ImageRequest::File(path) => Query::ReadFileBytes { path }, @@ -45,7 +51,15 @@ impl Asset for HostImage { }, }; let host = host?; - let bytes = match host.query(query).await { + #[cfg(test)] + let result = if blocking_queries { + futures_lite::future::block_on(host.query(query)) + } else { + host.query(query).await + }; + #[cfg(not(test))] + let result = host.query(query).await; + let bytes = match result { Ok(QueryResponse::FileBytes(bytes)) => bytes, result => { return Err(std::io::Error::other(format!( @@ -165,30 +179,31 @@ mod tests { (Some(true), Some([255, 0, 0, 255])), (Some(false), Some([0, 0, 255, 255])), ] { - if let Some(reconnect) = reconnect { - store.update(cx, |store, cx| { - let event = if reconnect { + store.update(cx, |store, cx| { + let event = if reconnect == Some(false) { + // Also delivered to clients that did not open the picker. + ServerEvent::IndexUpsertProject(project.clone()) + } else { + if reconnect == Some(true) { store.apply_connection_state(tcode_client::ConnectionState::Syncing); - ServerEvent::IndexSnapshot(IndexSnapshot { - sessions: vec![], - projects: vec![project.clone()], - activity: Default::default(), - title_generating: Default::default(), - }) - } else { - // Also delivered to clients that did not open the picker. - ServerEvent::IndexUpsertProject(project.clone()) - }; - store.apply_domain_event( - &EventEnvelope { - request_id: None, - topic: Topic::Index, - event, - }, - cx, - ); - }); - } + } + // Seed a baseline before caching the error, then replace it on reconnect. + ServerEvent::IndexSnapshot(IndexSnapshot { + sessions: vec![], + projects: vec![project.clone()], + activity: Default::default(), + title_generating: Default::default(), + }) + }; + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::Index, + event, + }, + cx, + ); + }); for pixels in [16, 32] { let (image, requested) = cx.update(|cx| cx.fetch_asset::(&key(pixels))); assert!( @@ -275,6 +290,7 @@ mod tests { cx.set_global(HostImages { link: Some(link.clone()), namespace: 1, + blocking_queries: false, }); }); let executor = cx.background_executor.clone(); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index d5bd7cf6..52b63d52 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -282,6 +282,8 @@ impl WorkspaceStore { cx.set_global(images::HostImages { link: Some(host.clone()), namespace: image_namespace, + #[cfg(test)] + blocking_queries: seed_blocking, }); let client_preferences = client_host .as_ref() @@ -778,6 +780,8 @@ impl WorkspaceStore { cx.set_global(images::HostImages { link: None, namespace: self.image_namespace, + #[cfg(test)] + blocking_queries: false, }); } }