Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
8 changes: 8 additions & 0 deletions crates/core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
pub created_at: u64,
}

Expand All @@ -29,6 +32,7 @@ impl Project {
id: uuid::Uuid::new_v4().to_string(),
name,
root,
icon_path: None,
created_at: now_secs(),
}
}
Expand Down Expand Up @@ -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,
},
];
Expand Down Expand Up @@ -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>| {
Expand Down
5 changes: 5 additions & 0 deletions crates/protocol/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>>,
},
/// 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
Expand Down
6 changes: 3 additions & 3 deletions crates/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
25 changes: 25 additions & 0 deletions crates/protocol/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ pub enum Query {
base: Option<String>,
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,
},
Expand Down Expand Up @@ -94,6 +105,11 @@ pub enum QueryResponse {
truncated: bool,
},
ActiveWorkspace(Vec<PathEntry>),
IconImages {
directory: PathBuf,
parent: Option<PathBuf>,
entries: Vec<IconImageEntry>,
},
ExternalHistory(Vec<RecentDir>),
CommitMessage(String),
GitDiff(GitDiffResult),
Expand Down Expand Up @@ -155,6 +171,15 @@ pub struct GitDiffResult {
pub default_base: Option<String>,
}

/// 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 {
Expand Down
1 change: 1 addition & 0 deletions crates/runtime/src/app/command_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ impl AppState {
));
}
Command::ToggleProjectCollapsed { project_id }
| Command::SetProjectIcon { project_id, .. }
| Command::DeleteProject { project_id }
if !self
.projects
Expand Down
38 changes: 38 additions & 0 deletions crates/runtime/src/app/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>>,
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.
///
Expand Down
158 changes: 158 additions & 0 deletions crates/runtime/src/pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions crates/services/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/services/src/import/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions crates/services/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading