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
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,11 @@ cargo run -p agent --example probe -- claude \

`TCODE_DATA_DIR` points Tcode at a throwaway profile (its own sessions, settings
and installed ACP agents) — useful for demos, screenshots and trying a change
without touching your real threads.
without touching your real threads. The data directory is otherwise `~/.tcode`.
`LEGACY_TCODE_DATA_DIR` names an older data directory that a start moves into
the data directory (by default an earlier version's platform app-data
directory, and only when `TCODE_DATA_DIR` is unset); set both to try the move
on a copy of real data.

**Launch flags**: `--open-latest` reopens the most recent thread,
`--connect <host_id>` starts attached to a paired host, `--pair <invite-url>`
Expand Down
2 changes: 1 addition & 1 deletion 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 @@ -190,7 +190,8 @@ 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 —
lives under your platform's app-data directory.
lives in `~/.tcode` on every platform. A data directory an earlier version kept
in your platform's app-data directory moves there on the first start.

<div align="center">
<img src="docs/images/chat-dark.png" width="840" alt="Tcode in dark mode">
Expand Down
59 changes: 42 additions & 17 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ fn main() {
// The local kernel is process composition, not a property of the window's
// current attachment. Open its store unconditionally and keep it alive even
// when the window starts on, or later switches to, a remote host.
let store = SessionStore::open_default().expect("failed to open tcode data directory");
let store =
SessionStore::open_host(None).unwrap_or_else(|error| exit_with_startup_failure(error));
let data_dir = store.root().clone();
// The UI never resolves a data directory of its own: whatever client-owned
// files it needs (the WebView2 profile) live under this one.
Expand All @@ -431,6 +432,23 @@ fn main() {
);

if let Some(index) = args.iter().position(|arg| arg == "--pair") {
// Pairing writes hosts.json and device.json, at which a later move of
// an older data dir would stop.
match store.pending_relocation() {
Ok(None) => {}
Ok(Some(previous)) => {
eprintln!(
"tcode: {} has not been moved into {} yet; start Tcode once first, then pair",
previous.display(),
data_dir.display()
);
std::process::exit(1);
}
Err(error) => {
eprintln!("tcode: {error}");
std::process::exit(1);
}
}
match pair_command(&args[index + 1..], &native_client) {
Ok(host_id) => println!("{host_id}"),
Err(error) => {
Expand All @@ -441,22 +459,28 @@ fn main() {
return;
}

let initial_target = match arg_value(&args, "--connect") {
Some(host_id) => {
let Some(host) = native_client
.load_hosts()
.into_iter()
.find(|host| host.host_id == host_id)
else {
eprintln!(
"tcode: no added machine with id {host_id:?} in {}/hosts.json; add it first (the sidebar's Machines row, or tcode --pair <tcode://pair?...>)",
data_dir.display()
);
std::process::exit(1);
};
AttachmentTarget::Remote(host)
// Read once the data dir is in place, which may be after the migration.
let connect = arg_value(&args, "--connect");
let initial_target = {
let native_client = native_client.clone();
let data_dir = data_dir.clone();
move || match connect {
Some(host_id) => {
let Some(host) = native_client
.load_hosts()
.into_iter()
.find(|host| host.host_id == host_id)
else {
eprintln!(
"tcode: no added machine with id {host_id:?} in {}/hosts.json; add it first (the sidebar's Machines row, or tcode --pair <tcode://pair?...>)",
data_dir.display()
);
std::process::exit(1);
};
AttachmentTarget::Remote(host)
}
None => AttachmentTarget::Local,
}
None => AttachmentTarget::Local,
};
// An older build's threads move into tcode.db before the kernel starts,
// behind a window of their own. With nothing to migrate, the kernel
Expand Down Expand Up @@ -526,6 +550,7 @@ fn main() {
});

let launch = move |cx: &mut App, kernel: Rc<LocalKernel>| {
let initial_target = initial_target();
let local_settings = kernel.settings();
// Hosting belongs to the process-owned local kernel; it carries no
// current-attachment mode.
Expand Down Expand Up @@ -582,7 +607,7 @@ fn main() {
setup: ShellSetup {
client_host: Some(native_client.clone()),
local: Some(Rc::new(move || local_kernel.transport())),
initial: Some(initial_target.clone()),
initial: Some(initial_target),
initial_pairing_error: None,
// Only here: bootstrap applies locale and theme from the
// host's own settings before the first frame.
Expand Down
41 changes: 32 additions & 9 deletions crates/app/src/migration.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! The one-time migration into `tcode.db`, run before the local kernel starts
//! Preparing the data dir — moving an older build's data dir into it, then
//! migrating older threads into `tcode.db` — before the local kernel starts,
//! behind a window that offers nothing but its progress and Quit.

use std::sync::Arc;
Expand Down Expand Up @@ -26,6 +27,7 @@ enum Status {
struct MigrationView {
status: Status,
cancel: Arc<AtomicBool>,
data_dir: String,
}

/// The view a Quit reaches while the migration window is up.
Expand Down Expand Up @@ -61,6 +63,7 @@ pub(crate) fn run(
let view = cx.new(|_| MigrationView {
status: Status::Running(None),
cancel: cancel.clone(),
data_dir: store.root().display().to_string(),
});
cx.set_global(Running(view.clone()));

Expand Down Expand Up @@ -235,8 +238,8 @@ impl Render for MigrationView {
Status::Running(progress) => (
progress.as_ref().map_or(0., overall_percent),
progress.as_ref().map_or_else(
|| phase_line(MigrationPhase::Scanning, None),
|progress| phase_line(progress.phase, Some(progress)),
|| phase_line(MigrationPhase::Scanning, None, &self.data_dir),
|progress| phase_line(progress.phase, Some(progress), &self.data_dir),
),
false,
),
Expand All @@ -256,7 +259,10 @@ impl Render for MigrationView {
.child(
div()
.text_color(theme.muted_foreground)
.child(tcode_ui::tr!("migration.description")),
.child(tcode_ui::tr!(
"migration.description",
path = self.data_dir.clone()
)),
)
.when(!failed, |column| {
column.child(
Expand All @@ -277,9 +283,11 @@ impl Render for MigrationView {
}
}

/// Import and verification each go through every log once, but import
/// writes and syncs each chunk while verification only reads it back, which
/// takes a fraction of the time. The phases around them take a moment.
/// A move by rename counts entries; a copy across filesystems counts bytes,
/// and gets a bar of its own before the threads migrate. Import and
/// verification each go through every log once, but import writes and syncs
/// each chunk while verification only reads it back, which takes a fraction
/// of the time. The phases around them take a moment.
fn overall_percent(progress: &MigrationProgress) -> f32 {
let fraction = |done: u64, total: u64| {
if total == 0 {
Expand All @@ -290,14 +298,22 @@ fn overall_percent(progress: &MigrationProgress) -> f32 {
};
let phase = fraction(progress.bytes_done, progress.bytes_total);
match progress.phase {
MigrationPhase::Relocating if progress.bytes_total == 0 => {
fraction(progress.threads_done as u64, progress.threads_total as u64) * 100.
}
MigrationPhase::Relocating => phase * 100.,
MigrationPhase::Scanning => 0.,
MigrationPhase::Importing => phase * 85.,
MigrationPhase::Verifying => 85. + phase * 15.,
MigrationPhase::Publishing | MigrationPhase::Archiving => 100.,
}
}

fn phase_line(phase: MigrationPhase, progress: Option<&MigrationProgress>) -> String {
fn phase_line(
phase: MigrationPhase,
progress: Option<&MigrationProgress>,
data_dir: &str,
) -> String {
let counts = |key: &str| {
let progress = progress.copied().unwrap_or(MigrationProgress {
phase,
Expand All @@ -311,11 +327,18 @@ fn phase_line(phase: MigrationPhase, progress: Option<&MigrationProgress>) -> St
done = progress.threads_done,
total = progress.threads_total,
bytes = tcode_ui::format_size(progress.bytes_done as usize),
total_bytes = tcode_ui::format_size(progress.bytes_total as usize)
total_bytes = tcode_ui::format_size(progress.bytes_total as usize),
path = data_dir
)
.into_owned()
};
match phase {
MigrationPhase::Relocating if progress.is_some_and(|progress| progress.bytes_total > 0) => {
counts("migration.relocating_copy")
}
MigrationPhase::Relocating => {
tcode_ui::tr!("migration.relocating", path = data_dir).into_owned()
}
MigrationPhase::Scanning => tcode_ui::tr!("migration.scanning").into_owned(),
MigrationPhase::Importing => counts("migration.importing"),
MigrationPhase::Verifying => counts("migration.verifying"),
Expand Down
85 changes: 50 additions & 35 deletions crates/headless/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,23 +108,21 @@ fn serve_command(args: &[String]) -> Result<(), String> {
"--port",
],
)?;
let store = match data_dir {
Some(path) => SessionStore::open_at(path),
None => SessionStore::open_default(),
}
.map_err(|error| format!("could not open session store: {error}"))?;
let store = SessionStore::open_host(data_dir)
.map_err(|error| format!("could not open session store: {error}"))?;
let remote_data_dir = store.root().clone();
// The password lives in the data dir, which may still have to move in.
install_interrupt_handler();
if migrate_store(&store)? == Migration::Cancelled {
return Ok(());
}
if let Some(password) =
option_value(args, "--password").or_else(|| std::env::var("TCODE_PASSWORD").ok())
{
set_password(&remote_data_dir, &password, false).map_err(|error| error.to_string())?;
}
// Nothing else starts for a bind the listener would refuse anyway.
check_bind(browser_listen, &remote_data_dir).map_err(|error| error.to_string())?;
install_interrupt_handler();
if migrate_store(&store)? == Migration::Cancelled {
return Ok(());
}
let mut services = HostServices {
background_startup_probes: true,
ai_title_generation: true,
Expand Down Expand Up @@ -240,19 +238,28 @@ fn serve_command(args: &[String]) -> Result<(), String> {
Ok(())
}

/// Move an older build's threads into `tcode.db` before the host starts,
/// printing progress; an interrupt cancels it and leaves them as they were.
/// Move an older build's data dir in and its threads into `tcode.db` before
/// the host starts, printing progress; an interrupt cancels it, losing
/// nothing, and the next start continues.
fn migrate_store(store: &SessionStore) -> Result<Migration, String> {
let needed = store
.needs_migration()
.map_err(|error| format!("could not open session store: {error}"))?;
let open_error = |error: std::io::Error| format!("could not open session store: {error}");
let needed = store.needs_migration().map_err(open_error)?;
if !needed {
return Ok(Migration::Completed);
}
println!(
"Migrating threads into {}; the original files are kept in legacy/. Ctrl-C cancels.",
store.root().join("tcode.db").display()
);
match store.pending_relocation().map_err(open_error)? {
Some(previous) => println!(
"Moving {} to {}, then migrating any older threads into tcode.db. Ctrl-C cancels; \
the next start continues.",
previous.display(),
store.root().display()
),
None => println!(
"Migrating threads into {}; each copy is verified before the original files are \
removed. Ctrl-C cancels.",
store.root().join("tcode.db").display()
),
}
let mut last: Option<(MigrationPhase, Instant)> = None;
let outcome = store
.migrate(
Expand All @@ -270,22 +277,25 @@ fn migrate_store(store: &SessionStore) -> Result<Migration, String> {
.map_err(|error| format!("migration failed: {error}"))?;
match outcome {
Migration::Completed => println!("Migration complete"),
Migration::Cancelled => println!("Migration cancelled; no file was changed"),
Migration::Cancelled => {
println!("Migration cancelled; nothing was lost, and the next start continues")
}
}
Ok(outcome)
}

fn migration_line(progress: &MigrationProgress) -> String {
let phase = match progress.phase {
MigrationPhase::Scanning => "scanning",
MigrationPhase::Importing => "importing",
MigrationPhase::Verifying => "verifying",
MigrationPhase::Publishing => "publishing",
MigrationPhase::Archiving => "archiving",
let (phase, items) = match progress.phase {
MigrationPhase::Relocating => ("moving", "entries"),
MigrationPhase::Scanning => ("scanning", "threads"),
MigrationPhase::Importing => ("importing", "threads"),
MigrationPhase::Verifying => ("verifying", "threads"),
MigrationPhase::Publishing => ("publishing", "threads"),
MigrationPhase::Archiving => ("archiving", "threads"),
};
let mib = |bytes: u64| bytes as f64 / (1024. * 1024.);
format!(
"{phase}: {}/{} threads, {:.1}/{:.1} MiB",
"{phase}: {}/{} {items}, {:.1}/{:.1} MiB",
progress.threads_done,
progress.threads_total,
mib(progress.bytes_done),
Expand All @@ -304,11 +314,19 @@ fn set_password_command(args: &[String]) -> Result<(), String> {
let password = option_value(&values, "--password")
.or_else(|| std::env::var("TCODE_PASSWORD").ok())
.ok_or("supply --password or TCODE_PASSWORD")?;
let store = match option_value(&values, "--data-dir") {
Some(path) => SessionStore::open_at(PathBuf::from(path)),
None => SessionStore::open_default(),
let store = SessionStore::open_host(option_value(&values, "--data-dir").map(PathBuf::from))
.map_err(|error| error.to_string())?;
// The move would stop at the password file this writes.
if let Some(previous) = store
.pending_relocation()
.map_err(|error| error.to_string())?
{
return Err(format!(
"{} has not been moved into {} yet; run `tcode-headless serve` once first",
previous.display(),
store.root().display()
));
}
.map_err(|error| error.to_string())?;
set_password(store.root(), &password, revoke).map_err(|error| error.to_string())?;
println!(
"Password changed. {}",
Expand Down Expand Up @@ -356,11 +374,8 @@ fn sync_invitation_file(data_dir: &Path, invitation: Option<&Invitation>) -> Res

fn pair_command(args: &[String]) -> Result<(), String> {
reject_unknown_options(args, &["--data-dir"])?;
let store = match option_value(args, "--data-dir") {
Some(path) => SessionStore::open_at(PathBuf::from(path)),
None => SessionStore::open_default(),
}
.map_err(|error| error.to_string())?;
let store = SessionStore::open_host(option_value(args, "--data-dir").map(PathBuf::from))
.map_err(|error| error.to_string())?;
let path = store.root().join(INVITATION_FILE);
let file: Option<InvitationFile> = std::fs::read(&path)
.ok()
Expand Down
2 changes: 2 additions & 0 deletions crates/headless/tests/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ fn sigint_cancels_the_startup_migration_and_leaves_every_source_as_it_was() {
let mut serve = tcode_services::process::command(env!("CARGO_BIN_EXE_tcode-headless"))
.args(["serve", "--traverse", "off", "--data-dir"])
.arg(&root)
// Would move that directory into `root`.
.env_remove("LEGACY_TCODE_DATA_DIR")
.stdout(Stdio::piped())
.spawn()
.unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/ios/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ tcode-client = { path = "../client" }
tcode-traverse = { path = "../traverse", default-features = false, features = ["native"] }
tcode-ui = { path = "../ui", default-features = false }
async-channel = "2"
dirs = "7"
Loading
Loading