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/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/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 aa200970..585db09b 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/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 474b484a..ff479fb0 100644 --- a/crates/protocol/src/query.rs +++ b/crates/protocol/src/query.rs @@ -32,6 +32,17 @@ pub enum Query { base: Option, ignore_whitespace: bool, }, + BrowseIconImages { + directory: PathBuf, + }, + ReadIconImage { + path: PathBuf, + }, + ReadProjectIcon { + project_id: String, + /// Physical display size, from 1 through 128 pixels. + pixels: u32, + }, ReadFileBytes { path: PathBuf, }, @@ -94,6 +105,11 @@ pub enum QueryResponse { truncated: bool, }, ActiveWorkspace(Vec), + IconImages { + directory: PathBuf, + parent: Option, + entries: Vec, + }, ExternalHistory(Vec), CommitMessage(String), GitDiff(GitDiffResult), @@ -155,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/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 4d23d524..4dc941fb 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..ff28b191 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -114,6 +114,44 @@ 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 + }; + 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(()) + } + /// 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 b0943d05..25ec993b 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)), @@ -637,6 +642,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, 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, pixels) + }); + 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 { @@ -1147,6 +1176,135 @@ 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(), + 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(), + 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(), + pixels: 128, + })) + .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(), + pixels: 128, + })) + .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..e111d03b 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -6,6 +6,8 @@ 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; pub mod provider_usage; 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 new file mode 100644 index 00000000..03b409dc --- /dev/null +++ b/crates/services/src/project_icons.rs @@ -0,0 +1,313 @@ +//! Project defaults and bounded image decoding for the host-owned icon picker. +use image::{ImageDecoder, ImageFormat, ImageReader, Limits}; +use std::{ + fs, + io::{self, Cursor, Read}, + path::Path, +}; +use tcode_core::project::Project; +use tcode_protocol::{IconImageEntry, 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(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.name.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(format!( + "file exceeds the {limit} byte 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.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() { + 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 = 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, 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"))?, + }; + raster(&path, pixels) +} + +/// 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 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(); + } + + #[test] + 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.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("assets").join(format!("{name}.png"))) + .unwrap(); + } + 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(); + let png = read_project_icon(&project, 16).unwrap(); + assert_eq!( + 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_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("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.name.as_str(), entry.is_dir)) + .collect::>(), + [("Z-folder", true), ("a.PNG", false), ("B.jpg", false)] + ); + 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 4dae5124..64e5c5c2 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). @@ -375,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/chat/mod.rs b/crates/ui/src/chat/mod.rs index eaec4adf..48a733b1 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 b707b36b..6d6624ca 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -35,6 +35,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..8e6b72e4 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,28 @@ 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) + .project(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..acffe40e --- /dev/null +++ b/crates/ui/src/project_icon.rs @@ -0,0 +1,605 @@ +//! 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::{IconImageEntry, 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, + 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 { + 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.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 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(_) => { + 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.name.to_lowercase().contains(&search)) + { + count += 1; + let path = entry.path.clone(); + 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.name)), + Role::Button, + entry.name.clone(), + cx, + ) + .aria_selected(is_selected) + .debug_selector({ + let name = entry.name.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.name.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( + 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()), + ), + ) + .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}; + + 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 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_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 = PathBuf::from("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/host/project"); + let folder = root.join("pictures"); + let project = Project::from_root(root.clone()); + 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) + }); + 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.dispatch_action(crate::widgets::input::SelectAll); + 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()); + cx.update(|window, cx| window.close_dialog(cx)); + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 309f1688..46066285 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3240,6 +3240,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 f57e65a2..ad90cb7b 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -530,6 +530,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); @@ -1367,6 +1371,18 @@ impl SessionsSidebar { }); } + fn on_change_project_icon( + &mut self, + action: &ChangeProjectIcon, + window: &mut Window, + cx: &mut Context, + ) { + 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); + } + } + fn on_project_delete( &mut self, action: &ProjectDelete, @@ -1905,11 +1921,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() @@ -1972,7 +1984,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, @@ -2666,10 +2682,15 @@ 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) + .project(meta.project_id.as_deref().unwrap_or_default()) + .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)) }) @@ -3235,7 +3256,10 @@ 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).project(&row.project_id), + |el, project| el.child(crate::project_icon::artwork(project, 14.)), + ) .child( div() .flex_1() @@ -3269,6 +3293,14 @@ 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) + .project(meta.project_id.as_deref().unwrap_or_default()) + .cloned() + } else { + None + }; let session_id = state.session_id.clone(); let status = compact_status_line(state, working, cx); let click_id = session_id.clone(); @@ -3309,7 +3341,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() @@ -3418,8 +3450,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 @@ -3461,7 +3498,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. @@ -3717,6 +3759,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..c15b7ccc 100644 --- a/crates/ui/src/store/images.rs +++ b/crates/ui/src/store/images.rs @@ -7,15 +7,30 @@ 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 {} +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ImageRequest { + File(PathBuf), + Thumbnail(PathBuf), + Project { + id: String, + override_path: Option, + pixels: u32, + }, +} + 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 +39,27 @@ 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, pixels, .. } => Query::ReadProjectIcon { + project_id: id, + pixels, + }, + }; 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 +76,57 @@ impl Asset for HostImage { } } -pub(crate) fn host_image(path: PathBuf) -> 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, path.clone()), cx)? { + 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(move |_| ImageRequest::File(path.clone())) +} + +pub(crate) fn icon_thumbnail(path: PathBuf) -> ImageSource { + source(move |_| ImageRequest::Thumbnail(path.clone())) +} + +pub(crate) fn project_icon( + project: &tcode_core::project::Project, + logical_size: f32, +) -> ImageSource { + 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 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, + }, + )); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -64,6 +138,132 @@ mod tests { }; use tcode_protocol::{ClientPayload, HostMessage, decode_client_line, encode_line}; + #[gpui::test] + async fn project_icon_cache_refreshes_defaults_and_errors_after_reset_or_reconnect( + cx: &mut TestAppContext, + ) { + use crate::store::{WorkspaceAttachment, WorkspaceStore}; + use tcode_core::project::Project; + use tcode_protocol::{EventEnvelope, IndexSnapshot, ProtocolError, ServerEvent, Topic}; + + 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.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| { + ( + namespace, + ImageRequest::Project { + id: project.id.clone(), + override_path: None, + pixels, + }, + ) + }; + for (reconnect, color) in [ + (None, None), + (Some(true), Some([255, 0, 0, 255])), + (Some(false), Some([0, 0, 255, 255])), + ] { + 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); + } + // 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!( + 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, + }) + ); + 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(); + 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)); + } + } + } + struct ImageMessage { markdown: Entity, cwd: PathBuf, @@ -90,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 535dc7f6..52b63d52 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; @@ -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, }); } } @@ -859,6 +863,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 @@ -897,7 +902,18 @@ 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 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); + } + } self.index_replica = (snapshot.sessions.clone(), snapshot.projects.clone()); self.title_generating = snapshot.title_generating.clone(); // Client state for a conversation the index no longer lists has @@ -1678,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() } @@ -2492,6 +2512,41 @@ impl WorkspaceStore { Some(read(&workspace)) } + pub(crate) fn browse_icon_images( + &self, + directory: PathBuf, + cx: &mut App, + ) -> Task> { + let host = self.host.clone(); + 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(); @@ -3447,6 +3502,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 e265fa4d..ab6a8086 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -610,7 +610,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 @@ -640,6 +640,47 @@ 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. + +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 +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 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 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 52px. The first control is the **sidebar toggle**, immediately left of the 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. diff --git a/locales/en.yml b/locales/en.yml index c68ef9fc..686ba318 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1064,3 +1064,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 c9b804b1..5fe94545 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1058,3 +1058,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: "无法加载此文件夹。" 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" +}