diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e7531c19..da43a6178 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -367,6 +367,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -449,6 +469,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.18.1" @@ -649,6 +679,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-updater", "tempfile", + "titor", "tokio", "uuid", "walkdir", @@ -822,6 +853,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -831,6 +875,34 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -919,6 +991,20 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-url" version = "0.3.1" @@ -1777,6 +1863,19 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1840,6 +1939,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "gxhash" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ce1bab7aa741d4e7042b2aae415b78741f267a98a7271ea226cd5ba6c43d7d" +dependencies = [ + "rustversion", +] + [[package]] name = "h2" version = "0.4.10" @@ -1913,6 +2021,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -1973,6 +2092,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + [[package]] name = "hyper" version = "1.6.0" @@ -2199,6 +2324,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.9", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.6" @@ -2243,6 +2384,17 @@ dependencies = [ "cfb", ] +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2413,6 +2565,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" +dependencies = [ + "crossbeam", + "rayon", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2551,6 +2713,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4_flex" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +dependencies = [ + "twox-hash", +] + [[package]] name = "mac" version = "0.1.1" @@ -2592,6 +2763,15 @@ dependencies = [ "tendril", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matches" version = "0.1.10" @@ -2769,6 +2949,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2784,6 +2974,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.3" @@ -3157,6 +3357,12 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "pango" version = "0.18.3" @@ -3769,6 +3975,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.12" @@ -3808,8 +4034,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -3820,9 +4055,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -4300,6 +4541,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_child" version = "1.0.2" @@ -5108,6 +5358,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.9.1" @@ -5175,19 +5434,54 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "titor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d884f86ae29c4c3f6be5f9a25dbc76394263576aadc448da6fec5c49cbd62e" +dependencies = [ + "anyhow", + "bincode", + "chrono", + "dashmap", + "globset", + "gxhash", + "hex", + "hostname", + "humantime", + "ignore", + "jwalk", + "lz4_flex", + "num_cpus", + "parking_lot", + "rayon", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.12", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "walkdir", +] + [[package]] name = "tokio" -version = "1.45.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", + "slab", "socket2", "tokio-macros", "tracing", @@ -5375,6 +5669,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5418,6 +5742,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b907da542cbced5261bd3256de1b3a1bf340a3d37f93425a07362a1d687de56" + [[package]] name = "typeid" version = "1.0.3" @@ -5506,6 +5836,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.4" @@ -5560,6 +5896,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -5578,6 +5920,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d3b356e95..9d95b1a2e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,6 +49,7 @@ zstd = "0.13" uuid = { version = "1.6", features = ["v4", "serde"] } walkdir = "2" serde_yaml = "0.9" +titor = "0.1.2" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/checkpoint/commands.rs b/src-tauri/src/checkpoint/commands.rs new file mode 100644 index 000000000..e4132f4cf --- /dev/null +++ b/src-tauri/src/checkpoint/commands.rs @@ -0,0 +1,403 @@ +use tauri::{command, State}; +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use anyhow::Result; +use titor::{CheckpointDiff, GcStats}; +use titor::types::{DiffOptions, DetailedCheckpointDiff, LineChange}; + +use super::manager::{TitorCheckpointManager, CheckpointInfo, TimelineInfo, RestoreResult}; + +/// Global state for managing checkpoints across sessions +pub struct CheckpointState { + /// Map of session ID to checkpoint manager + managers: Arc>>>, +} + +impl CheckpointState { + pub fn new() -> Self { + Self { + managers: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn get_or_create_manager(&self, project_path: PathBuf, session_id: String) -> Result> { + let mut managers = self.managers.lock().await; + + if let Some(manager) = managers.get(&session_id) { + Ok(manager.clone()) + } else { + let manager = Arc::new(TitorCheckpointManager::new(project_path.clone(), session_id.clone()).await?); + managers.insert(session_id.clone(), manager.clone()); + + Ok(manager) + } + } +} + +/// Response type that serializes titor's native types +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TitorDiffResponse { + /// Source checkpoint ID + pub from_id: String, + /// Target checkpoint ID + pub to_id: String, + /// Files added in target + pub added_files: Vec, + /// Files modified between checkpoints + pub modified_files: Vec, + /// Files deleted in target + pub deleted_files: Vec, + /// Change statistics + pub stats: serde_json::Value, +} + +impl From for TitorDiffResponse { + fn from(diff: CheckpointDiff) -> Self { + Self { + from_id: diff.from_id, + to_id: diff.to_id, + added_files: diff.added_files.into_iter() + .map(|f| serde_json::to_value(f).unwrap_or_default()) + .collect(), + modified_files: diff.modified_files.into_iter() + .map(|(old, new)| serde_json::json!({ + "old": serde_json::to_value(old).unwrap_or_default(), + "new": serde_json::to_value(new).unwrap_or_default() + })) + .collect(), + deleted_files: diff.deleted_files.into_iter() + .map(|f| serde_json::to_value(f).unwrap_or_default()) + .collect(), + stats: serde_json::to_value(diff.stats).unwrap_or_default(), + } + } +} + +/// Response type for GC stats +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TitorGcResponse { + /// Serialized GC stats + pub stats: serde_json::Value, +} + +impl From for TitorGcResponse { + fn from(stats: GcStats) -> Self { + Self { + stats: serde_json::to_value(stats).unwrap_or_default(), + } + } +} + +/// Response for a single line change in a diff +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LineChangeResponse { + /// Type of change: "added", "deleted", or "context" + pub change_type: String, + /// Line number in the file + pub line_number: usize, + /// Content of the line + pub content: String, +} + +/// Response for a hunk of changes +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HunkResponse { + /// Starting line in the from file + pub from_line: usize, + /// Number of lines in the from file + pub from_count: usize, + /// Starting line in the to file + pub to_line: usize, + /// Number of lines in the to file + pub to_count: usize, + /// Line changes in this hunk + pub changes: Vec, +} + +/// Response for a file diff with line-level changes +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileDiffResponse { + /// Path of the file + pub path: String, + /// Whether the file is binary + pub is_binary: bool, + /// Hunks of changes + pub hunks: Vec, +} + +/// Response for detailed diff with line-level changes +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetailedDiffResponse { + /// Basic diff information (files added/modified/deleted) + pub basic_diff: TitorDiffResponse, + /// Detailed file diffs with line-level changes + pub file_diffs: Vec, + /// Total lines added across all files + pub total_lines_added: usize, + /// Total lines deleted across all files + pub total_lines_deleted: usize, +} + +impl DetailedDiffResponse { + fn from_detailed_diff(diff: DetailedCheckpointDiff) -> Self { + let basic_diff = TitorDiffResponse::from(diff.basic_diff.clone()); + + let file_diffs = diff.file_diffs.into_iter().map(|fd| { + FileDiffResponse { + path: fd.path.display().to_string(), + is_binary: fd.is_binary, + hunks: fd.hunks.into_iter().map(|hunk| { + HunkResponse { + from_line: hunk.from_line, + from_count: hunk.from_count, + to_line: hunk.to_line, + to_count: hunk.to_count, + changes: hunk.changes.into_iter().map(|change| { + match change { + LineChange::Added(line_num, content) => LineChangeResponse { + change_type: "added".to_string(), + line_number: line_num, + content, + }, + LineChange::Deleted(line_num, content) => LineChangeResponse { + change_type: "deleted".to_string(), + line_number: line_num, + content, + }, + LineChange::Context(line_num, content) => LineChangeResponse { + change_type: "context".to_string(), + line_number: line_num, + content, + }, + } + }).collect(), + } + }).collect(), + } + }).collect(); + + Self { + basic_diff, + file_diffs, + total_lines_added: diff.total_lines_added, + total_lines_deleted: diff.total_lines_deleted, + } + } +} + +// Tauri Commands + +#[command] +pub async fn titor_init_session( + state: State<'_, CheckpointState>, + project_path: String, + session_id: String, +) -> Result<(), String> { + state.get_or_create_manager(PathBuf::from(project_path), session_id) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[command] +pub async fn titor_checkpoint_message( + state: State<'_, CheckpointState>, + session_id: String, + message_index: usize, + message: String, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + let checkpoint_id = manager.checkpoint_message(message_index, &message) + .await + .map_err(|e| e.to_string())?; + + Ok(checkpoint_id) +} + +#[command] +pub async fn titor_get_checkpoint_at_message( + state: State<'_, CheckpointState>, + session_id: String, + message_index: usize, +) -> Result, String> { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + Ok(manager.get_checkpoint_at_message(message_index).await) +} + +#[command] +pub async fn titor_restore_checkpoint( + state: State<'_, CheckpointState>, + session_id: String, + checkpoint_id: String, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + let result = manager.restore_to_checkpoint(&checkpoint_id) + .await + .map_err(|e| e.to_string())?; + + Ok(result) +} + +#[command] +pub async fn titor_get_timeline( + state: State<'_, CheckpointState>, + session_id: String, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + manager.get_timeline_info() + .await + .map_err(|e| e.to_string()) +} + +#[command] +pub async fn titor_list_checkpoints( + state: State<'_, CheckpointState>, + session_id: String, +) -> Result, String> { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + // Get all checkpoints for the project + let all_checkpoints = manager.list_checkpoints() + .await + .map_err(|e| e.to_string())?; + // Filter only those created by this session + let session_checkpoints: Vec = all_checkpoints + .into_iter() + .filter(|cp| cp.session_id.as_deref() == Some(session_id.as_str())) + .collect(); + Ok(session_checkpoints) +} + +#[command] +pub async fn titor_fork_checkpoint( + state: State<'_, CheckpointState>, + session_id: String, + checkpoint_id: String, + description: Option, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + manager.fork_from_checkpoint(&checkpoint_id, description) + .await + .map_err(|e| e.to_string()) +} + +#[command] +pub async fn titor_diff_checkpoints( + state: State<'_, CheckpointState>, + session_id: String, + from_id: String, + to_id: String, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + let diff = manager.diff_checkpoints(&from_id, &to_id) + .await + .map_err(|e| e.to_string())?; + + Ok(diff.into()) +} + +#[command] +pub async fn titor_verify_checkpoint( + state: State<'_, CheckpointState>, + session_id: String, + checkpoint_id: String, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + manager.verify_checkpoint(&checkpoint_id) + .await + .map_err(|e| e.to_string()) +} + +#[command] +pub async fn titor_gc( + state: State<'_, CheckpointState>, + session_id: String, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + let stats = manager.gc() + .await + .map_err(|e| e.to_string())?; + + Ok(stats.into()) +} + +#[command] +pub async fn titor_diff_checkpoints_detailed( + state: State<'_, CheckpointState>, + session_id: String, + from_id: String, + to_id: String, + context_lines: Option, + ignore_whitespace: Option, +) -> Result { + let managers = state.managers.lock().await; + let manager = managers.get(&session_id) + .ok_or("Session not initialized")?; + + let options = DiffOptions { + context_lines: context_lines.unwrap_or(3), + ignore_whitespace: ignore_whitespace.unwrap_or(false), + show_line_numbers: true, + max_file_size: 10 * 1024 * 1024, // 10MB + }; + + let diff = manager.diff_checkpoints_detailed(&from_id, &to_id, options) + .await + .map_err(|e| e.to_string())?; + + Ok(DetailedDiffResponse::from_detailed_diff(diff)) +} + + + +/// List all checkpoints for a project (across all sessions) +#[command] +pub async fn titor_list_all_checkpoints( + _state: State<'_, CheckpointState>, + project_path: String, +) -> Result, String> { + let project_path = PathBuf::from(project_path); + + // Create a temporary manager to list all checkpoints + let temp_manager = TitorCheckpointManager::new(project_path, "temp".to_string()) + .await + .map_err(|e| format!("Failed to create manager: {}", e))?; + + temp_manager.list_checkpoints() + .await + .map_err(|e| format!("Failed to list checkpoints: {}", e)) +} \ No newline at end of file diff --git a/src-tauri/src/checkpoint/manager.rs b/src-tauri/src/checkpoint/manager.rs index d6c0e6f8d..a00a3709c 100644 --- a/src-tauri/src/checkpoint/manager.rs +++ b/src-tauri/src/checkpoint/manager.rs @@ -1,787 +1,361 @@ -use anyhow::{Context, Result}; -use chrono::{DateTime, TimeZone, Utc}; -use log; +use anyhow::Result; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::fs; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::RwLock; - -use super::{ - storage::{self, CheckpointStorage}, - Checkpoint, CheckpointMetadata, CheckpointPaths, CheckpointResult, CheckpointStrategy, - FileSnapshot, FileState, FileTracker, SessionTimeline, -}; +use tokio::sync::{Mutex, RwLock}; +use titor::{Titor, TitorBuilder, CompressionStrategy, CheckpointDiff, GcStats}; +use titor::types::{DiffOptions, DetailedCheckpointDiff}; +use anyhow::anyhow; +use log::{info, debug}; + +/// Information about a checkpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointInfo { + /// The checkpoint ID (from titor) + #[serde(rename = "checkpointId")] + pub id: String, + /// Message index this checkpoint corresponds to + pub message_index: usize, + /// Timestamp when checkpoint was created + #[serde(rename = "timestamp")] + pub created_at: String, + /// Session ID this checkpoint belongs to + pub session_id: Option, + /// Description or summary of the checkpoint + pub description: Option, + /// Number of files in the checkpoint + #[serde(rename = "fileCount")] + pub file_count: usize, + /// Total size of files + #[serde(rename = "totalSize")] + pub total_size: u64, +} -/// Manages checkpoint operations for a session -pub struct CheckpointManager { - project_id: String, - session_id: String, - project_path: PathBuf, - file_tracker: Arc>, - pub storage: Arc, - timeline: Arc>, - current_messages: Arc>>, // JSONL messages +/// Timeline information for UI visualization +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimelineInfo { + /// Current checkpoint ID + pub current_checkpoint_id: Option, + /// Map of message indices to checkpoint IDs + pub checkpoints: Vec, + /// Timeline tree structure (if available from titor) + pub timeline_tree: Option, } -impl CheckpointManager { - /// Create a new checkpoint manager - pub async fn new( - project_id: String, - session_id: String, - project_path: PathBuf, - claude_dir: PathBuf, - ) -> Result { - let storage = Arc::new(CheckpointStorage::new(claude_dir.clone())); +/// Result of a restore operation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreResult { + /// Number of files restored + pub files_restored: usize, + /// Number of files deleted + pub files_deleted: usize, + /// Total bytes written + pub bytes_written: u64, + /// Duration in milliseconds + pub duration_ms: u64, + /// Any warnings during restoration + pub warnings: Vec, + /// Message index this checkpoint corresponds to (for UI truncation) + pub message_index: usize, +} - // Initialize storage - storage.init_storage(&project_id, &session_id)?; +/// Manages Titor checkpoints for a Claude Code session +pub struct TitorCheckpointManager { + /// The Titor instance + titor: Arc>, + /// Map of message index to checkpoint ID + checkpoint_map: Arc>>, + /// Checkpoint metadata cache + checkpoint_cache: Arc>>, + /// Session ID for this manager + session_id: String, +} - // Load or create timeline - let paths = CheckpointPaths::new(&claude_dir, &project_id, &session_id); - let timeline = if paths.timeline_file.exists() { - storage.load_timeline(&paths.timeline_file)? +impl TitorCheckpointManager { + /// Initialize Titor for a project if not already initialized + pub async fn new(project_path: PathBuf, session_id: String) -> Result { + info!("Creating TitorCheckpointManager for session {} at path {:?}", session_id, project_path); + + let storage_path = project_path.join(".titor"); + + // Initialize or open existing Titor repository + let titor = if storage_path.exists() { + info!("Opening existing Titor repository"); + Titor::open(project_path.clone(), storage_path)? } else { - SessionTimeline::new(session_id.clone()) - }; - - let file_tracker = FileTracker { - tracked_files: HashMap::new(), + info!("Creating new Titor repository"); + TitorBuilder::new() + .compression_strategy(CompressionStrategy::Adaptive { + min_size: 4096, + skip_extensions: vec![ + "jpg", "jpeg", "png", "gif", "mp4", "mp3", + "zip", "gz", "bz2", "7z", "rar" + ].iter().map(|s| s.to_string()).collect(), + }) + .ignore_patterns(vec![ + ".git".to_string(), + ".titor".to_string(), + "node_modules".to_string(), + "target".to_string(), + "dist".to_string(), + "build".to_string(), + ".next".to_string(), + "__pycache__".to_string(), + "*.log".to_string(), + ]) + .build(project_path.clone(), storage_path)? }; - - Ok(Self { - project_id, + + let manager = Self { + titor: Arc::new(Mutex::new(titor)), + checkpoint_map: Arc::new(RwLock::new(HashMap::new())), + checkpoint_cache: Arc::new(RwLock::new(Vec::new())), session_id, - project_path, - file_tracker: Arc::new(RwLock::new(file_tracker)), - storage, - timeline: Arc::new(RwLock::new(timeline)), - current_messages: Arc::new(RwLock::new(Vec::new())), - }) + }; + + // Load ALL existing checkpoints for this project (not filtered by session) + manager.refresh_checkpoints().await?; + + Ok(manager) } - - /// Track a new message in the session - pub async fn track_message(&self, jsonl_message: String) -> Result<()> { - let mut messages = self.current_messages.write().await; - messages.push(jsonl_message.clone()); - - // Parse message to check for tool usage - if let Ok(msg) = serde_json::from_str::(&jsonl_message) { - if let Some(content) = msg.get("message").and_then(|m| m.get("content")) { - if let Some(content_array) = content.as_array() { - for item in content_array { - if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") { - if let Some(tool_name) = item.get("name").and_then(|n| n.as_str()) { - if let Some(input) = item.get("input") { - self.track_tool_operation(tool_name, input).await?; - } - } + + /// Refresh checkpoint list from Titor (loads ALL checkpoints) + async fn refresh_checkpoints(&self) -> Result<()> { + let titor = self.titor.lock().await; + let checkpoints = titor.list_checkpoints()?; + + let mut checkpoint_infos = Vec::new(); + let mut checkpoint_map = HashMap::new(); + + for cp in checkpoints { + // Parse session ID and message index from description + let mut parsed_session_id: Option = None; + let mut parsed_message_index: Option = None; + if let Some(desc) = &cp.description { + // Example desc: "[session_id] idx:3 truncated message..." + if let Some(end_bracket_pos) = desc.find(']') { + // Extract session ID between brackets + parsed_session_id = Some(desc[1..end_bracket_pos].to_string()); + // After the bracket, look for "idx:" marker + if let Some(idx_pos) = desc[end_bracket_pos+1..].find("idx:") { + // Calculate the absolute start of the index digits + let idx_start = end_bracket_pos + 1 + idx_pos + "idx:".len(); + let idx_substr = &desc[idx_start..]; + // Collect consecutive digits for the index + let idx_digits: String = idx_substr.chars().take_while(|c| c.is_digit(10)).collect(); + if let Ok(idx) = idx_digits.parse::() { + parsed_message_index = Some(idx); } } } } - } - - Ok(()) - } - - /// Track file operations from tool usage - async fn track_tool_operation(&self, tool: &str, input: &serde_json::Value) -> Result<()> { - match tool.to_lowercase().as_str() { - "edit" | "write" | "multiedit" => { - if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { - self.track_file_modification(file_path).await?; - } - } - "bash" => { - // Try to detect file modifications from bash commands - if let Some(command) = input.get("command").and_then(|c| c.as_str()) { - self.track_bash_side_effects(command).await?; + let (parsed_session_id, message_index) = (parsed_session_id, parsed_message_index); + + // Clean up description: strip prefix and any JSON payload + let description = if let Some(desc) = &cp.description { + // Build prefix marker: '] idx:' + let idx_val = message_index.unwrap_or(0); + let prefix = format!("] idx:{}", idx_val); + if let Some(pos) = desc.find(&prefix) { + // Start after prefix + let mut remainder = &desc[pos + prefix.len()..]; + // Trim leading whitespace + remainder = remainder.trim_start(); + // If there's a JSON object, strip it + if let Some(json_pos) = remainder.find('{') { + remainder = &remainder[..json_pos]; + } + // Truncate to 100 chars + let text = remainder.trim(); + if text.len() > 100 { format!("{}...", &text[..100]) } else { text.to_string() } + } else { + desc.clone() } - } - _ => {} - } - Ok(()) - } - - /// Track a file modification - pub async fn track_file_modification(&self, file_path: &str) -> Result<()> { - let mut tracker = self.file_tracker.write().await; - let full_path = self.project_path.join(file_path); - - // Read current file state - let (hash, exists, _size, modified) = if full_path.exists() { - let content = fs::read_to_string(&full_path).unwrap_or_default(); - let metadata = fs::metadata(&full_path)?; - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| { - Utc.timestamp_opt(d.as_secs() as i64, d.subsec_nanos()) - .unwrap() - }) - .unwrap_or_else(Utc::now); - - ( - storage::CheckpointStorage::calculate_file_hash(&content), - true, - metadata.len(), - modified, - ) - } else { - (String::new(), false, 0, Utc::now()) - }; - - // Check if file has actually changed - let is_modified = - if let Some(existing_state) = tracker.tracked_files.get(&PathBuf::from(file_path)) { - // File is modified if: - // 1. Hash has changed - // 2. Existence state has changed - // 3. It was already marked as modified - existing_state.last_hash != hash - || existing_state.exists != exists - || existing_state.is_modified } else { - // New file is always considered modified - true + String::new() }; - - tracker.tracked_files.insert( - PathBuf::from(file_path), - FileState { - last_hash: hash, - is_modified, - last_modified: modified, - exists, - }, - ); - - Ok(()) - } - - /// Track potential file changes from bash commands - async fn track_bash_side_effects(&self, command: &str) -> Result<()> { - // Common file-modifying commands - let file_commands = [ - "echo", "cat", "cp", "mv", "rm", "touch", "sed", "awk", "npm", "yarn", "pnpm", "bun", - "cargo", "make", "gcc", "g++", - ]; - - // Simple heuristic: if command contains file-modifying operations - for cmd in &file_commands { - if command.contains(cmd) { - // Mark all tracked files as potentially modified - let mut tracker = self.file_tracker.write().await; - for (_, state) in tracker.tracked_files.iter_mut() { - state.is_modified = true; + let info = CheckpointInfo { + id: cp.id.clone(), + created_at: cp.timestamp.to_rfc3339(), + message_index: message_index.unwrap_or(0), + session_id: parsed_session_id.clone(), + // Use sanitized description + description: Some(description), + file_count: cp.metadata.file_count, + total_size: cp.metadata.total_size, + }; + + checkpoint_infos.push(info); + + // Add to map for current session lookups + if let (Some(sid), Some(idx)) = (parsed_session_id, message_index) { + if sid == self.session_id { + checkpoint_map.insert(idx, cp.id); } - break; } } - + + // Sort by timestamp (newest first) for consistent ordering + checkpoint_infos.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + info!("Loaded {} total checkpoints for project", checkpoint_infos.len()); + + *self.checkpoint_cache.write().await = checkpoint_infos; + *self.checkpoint_map.write().await = checkpoint_map; + Ok(()) } - - /// Create a checkpoint - pub async fn create_checkpoint( - &self, - description: Option, - parent_checkpoint_id: Option, - ) -> Result { - let messages = self.current_messages.read().await; - let message_index = messages.len().saturating_sub(1); - - // Extract metadata from the last user message - let (user_prompt, model_used, total_tokens) = - self.extract_checkpoint_metadata(&messages).await?; - - // Ensure every file in the project is tracked so new checkpoints include all files - // Recursively walk the project directory and track each file - fn collect_files( - dir: &std::path::Path, - base: &std::path::Path, - files: &mut Vec, - ) -> Result<(), std::io::Error> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - // Skip hidden directories like .git - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.starts_with('.') { - continue; - } - } - collect_files(&path, base, files)?; - } else if path.is_file() { - // Compute relative path from project root - if let Ok(rel) = path.strip_prefix(base) { - files.push(rel.to_path_buf()); - } - } - } - Ok(()) - } - let mut all_files = Vec::new(); - let project_dir = &self.project_path; - let _ = collect_files(project_dir.as_path(), project_dir.as_path(), &mut all_files); - for rel in all_files { - if let Some(p) = rel.to_str() { - // Track each file for snapshot - let _ = self.track_file_modification(p).await; - } - } - - // Generate checkpoint ID early so snapshots reference it - let checkpoint_id = storage::CheckpointStorage::generate_checkpoint_id(); - - // Create file snapshots - let file_snapshots = self.create_file_snapshots(&checkpoint_id).await?; - - // Generate checkpoint struct - let checkpoint = Checkpoint { - id: checkpoint_id.clone(), - session_id: self.session_id.clone(), - project_id: self.project_id.clone(), - message_index, - timestamp: Utc::now(), - description, - parent_checkpoint_id: { - if let Some(parent_id) = parent_checkpoint_id { - Some(parent_id) - } else { - // Perform an asynchronous read to avoid blocking within the runtime - let timeline = self.timeline.read().await; - timeline.current_checkpoint_id.clone() - } - }, - metadata: CheckpointMetadata { - total_tokens, - model_used, - user_prompt, - file_changes: file_snapshots.len(), - snapshot_size: storage::CheckpointStorage::estimate_checkpoint_size( - &messages.join("\n"), - &file_snapshots, - ), - }, + + /// Create checkpoint after each Claude message/response + pub async fn checkpoint_message(&self, message_index: usize, message: &str) -> Result { + let mut titor = self.titor.lock().await; + + // Build description with session ID prefix and message index + let truncated_msg = if message.len() > 100 { + format!("{}...", &message[..100]) + } else { + message.to_string() }; - - // Save checkpoint - let messages_content = messages.join("\n"); - let result = self.storage.save_checkpoint( - &self.project_id, - &self.session_id, - &checkpoint, - file_snapshots, - &messages_content, - )?; - - // Reload timeline from disk so in-memory timeline has updated nodes and total_checkpoints - let claude_dir = self.storage.claude_dir.clone(); - let paths = CheckpointPaths::new(&claude_dir, &self.project_id, &self.session_id); - let updated_timeline = self.storage.load_timeline(&paths.timeline_file)?; + + // Include session ID and message index in description for filtering + let description = format!("[{}] idx:{} {}", self.session_id, message_index, truncated_msg); + + debug!("Creating checkpoint with description: {}", description); + + let checkpoint = titor.checkpoint(Some(description.clone())) + .map_err(|e| anyhow!("Failed to create checkpoint: {}", e))?; + let id = checkpoint.id.clone(); + + info!("Created checkpoint {} for session {} at message index {}", id, self.session_id, message_index); + + // Update checkpoint map { - let mut timeline_lock = self.timeline.write().await; - *timeline_lock = updated_timeline; - } - - // Update timeline (current checkpoint only) - let mut timeline = self.timeline.write().await; - timeline.current_checkpoint_id = Some(checkpoint_id); - - // Reset file tracker - let mut tracker = self.file_tracker.write().await; - for (_, state) in tracker.tracked_files.iter_mut() { - state.is_modified = false; - } - - Ok(result) - } - - /// Extract metadata from messages for checkpoint - async fn extract_checkpoint_metadata( - &self, - messages: &[String], - ) -> Result<(String, String, u64)> { - let mut user_prompt = String::new(); - let mut model_used = String::from("unknown"); - let mut total_tokens = 0u64; - - // Iterate through messages in reverse to find the last user prompt - for msg_str in messages.iter().rev() { - if let Ok(msg) = serde_json::from_str::(msg_str) { - // Check for user message - if msg.get("type").and_then(|t| t.as_str()) == Some("user") { - if let Some(content) = msg - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - { - for item in content { - if item.get("type").and_then(|t| t.as_str()) == Some("text") { - if let Some(text) = item.get("text").and_then(|t| t.as_str()) { - user_prompt = text.to_string(); - break; - } - } - } - } - } - - // Extract model info - if let Some(model) = msg.get("model").and_then(|m| m.as_str()) { - model_used = model.to_string(); - } - - // Also check for model in message.model (assistant messages) - if let Some(message) = msg.get("message") { - if let Some(model) = message.get("model").and_then(|m| m.as_str()) { - model_used = model.to_string(); - } - } - - // Count tokens - check both top-level and nested usage - // First check for usage in message.usage (assistant messages) - if let Some(message) = msg.get("message") { - if let Some(usage) = message.get("usage") { - if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) { - total_tokens += input; - } - if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) { - total_tokens += output; - } - // Also count cache tokens - if let Some(cache_creation) = usage - .get("cache_creation_input_tokens") - .and_then(|t| t.as_u64()) - { - total_tokens += cache_creation; - } - if let Some(cache_read) = usage - .get("cache_read_input_tokens") - .and_then(|t| t.as_u64()) - { - total_tokens += cache_read; - } - } - } - - // Then check for top-level usage (result messages) - if let Some(usage) = msg.get("usage") { - if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) { - total_tokens += input; - } - if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) { - total_tokens += output; - } - // Also count cache tokens - if let Some(cache_creation) = usage - .get("cache_creation_input_tokens") - .and_then(|t| t.as_u64()) - { - total_tokens += cache_creation; - } - if let Some(cache_read) = usage - .get("cache_read_input_tokens") - .and_then(|t| t.as_u64()) - { - total_tokens += cache_read; - } - } - } + let mut map = self.checkpoint_map.write().await; + map.insert(message_index, id.clone()); } - - Ok((user_prompt, model_used, total_tokens)) - } - - /// Create file snapshots for all tracked modified files - async fn create_file_snapshots(&self, checkpoint_id: &str) -> Result> { - let tracker = self.file_tracker.read().await; - let mut snapshots = Vec::new(); - - for (rel_path, state) in &tracker.tracked_files { - // Skip files that haven't been modified - if !state.is_modified { - continue; - } - - let full_path = self.project_path.join(rel_path); - - let (content, exists, permissions, size, current_hash) = if full_path.exists() { - let content = fs::read_to_string(&full_path).unwrap_or_default(); - let current_hash = storage::CheckpointStorage::calculate_file_hash(&content); - - // Don't skip based on hash - if is_modified is true, we should snapshot it - // The hash check in track_file_modification already determined if it changed - - let metadata = fs::metadata(&full_path)?; - let permissions = { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - Some(metadata.permissions().mode()) - } - #[cfg(not(unix))] - { - None - } - }; - (content, true, permissions, metadata.len(), current_hash) - } else { - (String::new(), false, None, 0, String::new()) - }; - - snapshots.push(FileSnapshot { - checkpoint_id: checkpoint_id.to_string(), - file_path: rel_path.clone(), - content, - hash: current_hash, - is_deleted: !exists, - permissions, - size, + + // Update cache + { + let mut cache = self.checkpoint_cache.write().await; + cache.push(CheckpointInfo { + id: id.clone(), + message_index, + created_at: checkpoint.timestamp.to_rfc3339(), + session_id: Some(self.session_id.clone()), + description: Some(truncated_msg), // Store the truncated message without prefix + file_count: checkpoint.metadata.file_count, + total_size: checkpoint.metadata.total_size, }); } - - Ok(snapshots) + + Ok(id) } - - /// Restore a checkpoint - pub async fn restore_checkpoint(&self, checkpoint_id: &str) -> Result { - // Load checkpoint data - let (checkpoint, file_snapshots, messages) = - self.storage - .load_checkpoint(&self.project_id, &self.session_id, checkpoint_id)?; - - // First, collect all files currently in the project to handle deletions - fn collect_all_project_files( - dir: &std::path::Path, - base: &std::path::Path, - files: &mut Vec, - ) -> Result<(), std::io::Error> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - // Skip hidden directories like .git - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.starts_with('.') { - continue; - } - } - collect_all_project_files(&path, base, files)?; - } else if path.is_file() { - // Compute relative path from project root - if let Ok(rel) = path.strip_prefix(base) { - files.push(rel.to_path_buf()); - } - } - } - Ok(()) - } - - let mut current_files = Vec::new(); - let _ = - collect_all_project_files(&self.project_path, &self.project_path, &mut current_files); - - // Create a set of files that should exist after restore - let mut checkpoint_files = std::collections::HashSet::new(); - for snapshot in &file_snapshots { - if !snapshot.is_deleted { - checkpoint_files.insert(snapshot.file_path.clone()); - } - } - - // Delete files that exist now but shouldn't exist in the checkpoint - let mut warnings = Vec::new(); - let mut files_processed = 0; - - for current_file in current_files { - if !checkpoint_files.contains(¤t_file) { - // This file exists now but not in the checkpoint, so delete it - let full_path = self.project_path.join(¤t_file); - match fs::remove_file(&full_path) { - Ok(_) => { - files_processed += 1; - log::info!("Deleted file not in checkpoint: {:?}", current_file); - } - Err(e) => { - warnings.push(format!( - "Failed to delete {}: {}", - current_file.display(), - e - )); - } - } - } - } - - // Clean up empty directories - fn remove_empty_dirs( - dir: &std::path::Path, - base: &std::path::Path, - ) -> Result { - if dir == base { - return Ok(false); // Don't remove the base directory - } - - let mut is_empty = true; - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - if !remove_empty_dirs(&path, base)? { - is_empty = false; - } - } else { - is_empty = false; - } - } - - if is_empty { - fs::remove_dir(dir)?; - Ok(true) - } else { - Ok(false) - } - } - - // Clean up any empty directories left after file deletion - let _ = remove_empty_dirs(&self.project_path, &self.project_path); - - // Restore files from checkpoint - for snapshot in &file_snapshots { - match self.restore_file_snapshot(snapshot).await { - Ok(_) => files_processed += 1, - Err(e) => warnings.push(format!( - "Failed to restore {}: {}", - snapshot.file_path.display(), - e - )), - } - } - - // Update current messages - let mut current_messages = self.current_messages.write().await; - current_messages.clear(); - for line in messages.lines() { - current_messages.push(line.to_string()); - } - - // Update timeline - let mut timeline = self.timeline.write().await; - timeline.current_checkpoint_id = Some(checkpoint_id.to_string()); - - // Update file tracker - let mut tracker = self.file_tracker.write().await; - tracker.tracked_files.clear(); - for snapshot in &file_snapshots { - if !snapshot.is_deleted { - tracker.tracked_files.insert( - snapshot.file_path.clone(), - FileState { - last_hash: snapshot.hash.clone(), - is_modified: false, - last_modified: Utc::now(), - exists: true, - }, - ); - } - } - - Ok(CheckpointResult { - checkpoint: checkpoint.clone(), - files_processed, - warnings, - }) + + /// Get checkpoint for a specific message index + pub async fn get_checkpoint_at_message(&self, message_index: usize) -> Option { + let map = self.checkpoint_map.read().await; + map.get(&message_index).cloned() } - - /// Restore a single file from snapshot - async fn restore_file_snapshot(&self, snapshot: &FileSnapshot) -> Result<()> { - let full_path = self.project_path.join(&snapshot.file_path); - - if snapshot.is_deleted { - // Delete the file if it exists - if full_path.exists() { - fs::remove_file(&full_path).context("Failed to delete file")?; - } - } else { - // Create parent directories if needed - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent).context("Failed to create parent directories")?; - } - - // Write file content - fs::write(&full_path, &snapshot.content).context("Failed to write file")?; - - // Restore permissions if available - #[cfg(unix)] - if let Some(mode) = snapshot.permissions { - use std::os::unix::fs::PermissionsExt; - let permissions = std::fs::Permissions::from_mode(mode); - fs::set_permissions(&full_path, permissions) - .context("Failed to set file permissions")?; - } - } - - Ok(()) + + /// Restore to checkpoint and update session JSONL + pub async fn restore_to_checkpoint(&self, checkpoint_id: &str) -> Result { + let mut titor = self.titor.lock().await; + + let start = std::time::Instant::now(); + let result = titor.restore(checkpoint_id)?; + let duration = start.elapsed(); + + // Determine the message index for this checkpoint so the UI can trim history + let msg_index = { + let map = self.checkpoint_map.read().await; + map.iter() + .find_map(|(idx, id)| if id == checkpoint_id { Some(*idx) } else { None }) + .unwrap_or_default() + }; + + // IMPORTANT: We do NOT clear checkpoints after the restore point + // All checkpoints remain valid and accessible for time travel + // The UI should allow navigating to any checkpoint, regardless of current position + + Ok(RestoreResult { + files_restored: result.files_restored, + files_deleted: result.files_deleted, + bytes_written: result.bytes_written, + duration_ms: duration.as_millis() as u64, + warnings: result.warnings, + message_index: msg_index, + }) } - - /// Get the current timeline - pub async fn get_timeline(&self) -> SessionTimeline { - self.timeline.read().await.clone() + + /// Get timeline information for UI + pub async fn get_timeline_info(&self) -> Result { + let titor = self.titor.lock().await; + let timeline = titor.get_timeline()?; + + // Get current checkpoint + let current_checkpoint_id = timeline.current_checkpoint_id.clone(); + + // Get cached checkpoint info + let checkpoints = { + let cache = self.checkpoint_cache.read().await; + cache.clone() + }; + + // Convert timeline tree to JSON for visualization + let timeline_tree = serde_json::to_value(&timeline)?; + + Ok(TimelineInfo { + current_checkpoint_id, + checkpoints, + timeline_tree: Some(timeline_tree), + }) } - + /// List all checkpoints - pub async fn list_checkpoints(&self) -> Vec { - let timeline = self.timeline.read().await; - let mut checkpoints = Vec::new(); - - if let Some(root) = &timeline.root_node { - Self::collect_checkpoints_from_node(root, &mut checkpoints); - } - - checkpoints + pub async fn list_checkpoints(&self) -> Result> { + let cache = self.checkpoint_cache.read().await; + Ok(cache.clone()) } - - /// Recursively collect checkpoints from timeline tree - fn collect_checkpoints_from_node( - node: &super::TimelineNode, - checkpoints: &mut Vec, - ) { - checkpoints.push(node.checkpoint.clone()); - for child in &node.children { - Self::collect_checkpoints_from_node(child, checkpoints); - } - } - + /// Fork from a checkpoint - pub async fn fork_from_checkpoint( - &self, - checkpoint_id: &str, - description: Option, - ) -> Result { - // Load the checkpoint to fork from - let (_base_checkpoint, _, _) = - self.storage - .load_checkpoint(&self.project_id, &self.session_id, checkpoint_id)?; - - // Restore to that checkpoint first - self.restore_checkpoint(checkpoint_id).await?; - - // Create a new checkpoint with the fork - let fork_description = - description.unwrap_or_else(|| format!("Fork from checkpoint {}", &checkpoint_id[..8])); - - self.create_checkpoint(Some(fork_description), Some(checkpoint_id.to_string())) - .await + pub async fn fork_from_checkpoint(&self, checkpoint_id: &str, description: Option) -> Result { + let mut titor = self.titor.lock().await; + + // Include session ID in fork description + let fork_description = description.map(|desc| { + format!("[{}] {}", self.session_id, desc) + }); + + let fork = titor.fork(checkpoint_id, fork_description)?; + Ok(fork.id) } - - /// Check if auto-checkpoint should be triggered - pub async fn should_auto_checkpoint(&self, message: &str) -> bool { - let timeline = self.timeline.read().await; - - if !timeline.auto_checkpoint_enabled { - return false; - } - - match timeline.checkpoint_strategy { - CheckpointStrategy::Manual => false, - CheckpointStrategy::PerPrompt => { - // Check if message is a user prompt - if let Ok(msg) = serde_json::from_str::(message) { - msg.get("type").and_then(|t| t.as_str()) == Some("user") - } else { - false - } - } - CheckpointStrategy::PerToolUse => { - // Check if message contains tool use - if let Ok(msg) = serde_json::from_str::(message) { - if let Some(content) = msg - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - { - content.iter().any(|item| { - item.get("type").and_then(|t| t.as_str()) == Some("tool_use") - }) - } else { - false - } - } else { - false - } - } - CheckpointStrategy::Smart => { - // Smart strategy: checkpoint after destructive operations - if let Ok(msg) = serde_json::from_str::(message) { - if let Some(content) = msg - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - { - content.iter().any(|item| { - if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") { - let tool_name = - item.get("name").and_then(|n| n.as_str()).unwrap_or(""); - matches!( - tool_name.to_lowercase().as_str(), - "write" | "edit" | "multiedit" | "bash" | "rm" | "delete" - ) - } else { - false - } - }) - } else { - false - } - } else { - false - } - } - } + + /// Get diff between two checkpoints using titor's native diff + pub async fn diff_checkpoints(&self, from_id: &str, to_id: &str) -> Result { + let titor = self.titor.lock().await; + Ok(titor.diff(from_id, to_id)?) } - - /// Update checkpoint settings - pub async fn update_settings( - &self, - auto_checkpoint_enabled: bool, - checkpoint_strategy: CheckpointStrategy, - ) -> Result<()> { - let mut timeline = self.timeline.write().await; - timeline.auto_checkpoint_enabled = auto_checkpoint_enabled; - timeline.checkpoint_strategy = checkpoint_strategy; - - // Save updated timeline - let claude_dir = self.storage.claude_dir.clone(); - let paths = CheckpointPaths::new(&claude_dir, &self.project_id, &self.session_id); - self.storage - .save_timeline(&paths.timeline_file, &timeline)?; - - Ok(()) + + /// Get detailed diff with line-level changes between two checkpoints + pub async fn diff_checkpoints_detailed(&self, from_id: &str, to_id: &str, options: DiffOptions) -> Result { + let titor = self.titor.lock().await; + Ok(titor.diff_detailed(from_id, to_id, options)?) } - - /// Get files modified since a given timestamp - pub async fn get_files_modified_since(&self, since: DateTime) -> Vec { - let tracker = self.file_tracker.read().await; - tracker - .tracked_files - .iter() - .filter(|(_, state)| state.last_modified > since && state.is_modified) - .map(|(path, _)| path.clone()) - .collect() + + /// Verify checkpoint integrity + pub async fn verify_checkpoint(&self, checkpoint_id: &str) -> Result { + let titor = self.titor.lock().await; + let report = titor.verify_checkpoint(checkpoint_id)?; + Ok(report.is_valid()) } - - /// Get the last modification time of any tracked file - pub async fn get_last_modification_time(&self) -> Option> { - let tracker = self.file_tracker.read().await; - tracker - .tracked_files - .values() - .map(|state| state.last_modified) - .max() + + /// Garbage collect unreferenced objects using titor's native gc + pub async fn gc(&self) -> Result { + let titor = self.titor.lock().await; + Ok(titor.gc()?) } -} +} \ No newline at end of file diff --git a/src-tauri/src/checkpoint/mod.rs b/src-tauri/src/checkpoint/mod.rs index 030418bb6..05042e147 100644 --- a/src-tauri/src/checkpoint/mod.rs +++ b/src-tauri/src/checkpoint/mod.rs @@ -1,262 +1,8 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; +/// Titor-based checkpoint module for advanced time-travel capabilities +/// +/// This module provides automatic checkpointing on each Claude session entry, +/// with content-addressable storage, merkle tree verification, and seamless +/// session forking/branching. pub mod manager; -pub mod state; -pub mod storage; - -/// Represents a checkpoint in the session timeline -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Checkpoint { - /// Unique identifier for the checkpoint - pub id: String, - /// Session ID this checkpoint belongs to - pub session_id: String, - /// Project ID for the session - pub project_id: String, - /// Index of the last message in this checkpoint - pub message_index: usize, - /// Timestamp when checkpoint was created - pub timestamp: DateTime, - /// User-provided description - pub description: Option, - /// Parent checkpoint ID for fork tracking - pub parent_checkpoint_id: Option, - /// Metadata about the checkpoint - pub metadata: CheckpointMetadata, -} - -/// Metadata associated with a checkpoint -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CheckpointMetadata { - /// Total tokens used up to this point - pub total_tokens: u64, - /// Model used for the last operation - pub model_used: String, - /// The user prompt that led to this state - pub user_prompt: String, - /// Number of file changes in this checkpoint - pub file_changes: usize, - /// Size of all file snapshots in bytes - pub snapshot_size: u64, -} - -/// Represents a snapshot of a file at a checkpoint -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSnapshot { - /// Checkpoint this snapshot belongs to - pub checkpoint_id: String, - /// Relative path from project root - pub file_path: PathBuf, - /// Full content of the file (will be compressed) - pub content: String, - /// SHA-256 hash for integrity verification - pub hash: String, - /// Whether this file was deleted at this checkpoint - pub is_deleted: bool, - /// File permissions (Unix mode) - pub permissions: Option, - /// File size in bytes - pub size: u64, -} - -/// Represents a node in the timeline tree -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TimelineNode { - /// The checkpoint at this node - pub checkpoint: Checkpoint, - /// Child nodes (for branches/forks) - pub children: Vec, - /// IDs of file snapshots associated with this checkpoint - pub file_snapshot_ids: Vec, -} - -/// The complete timeline for a session -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionTimeline { - /// Session ID this timeline belongs to - pub session_id: String, - /// Root node of the timeline tree - pub root_node: Option, - /// ID of the current active checkpoint - pub current_checkpoint_id: Option, - /// Whether auto-checkpointing is enabled - pub auto_checkpoint_enabled: bool, - /// Strategy for automatic checkpoints - pub checkpoint_strategy: CheckpointStrategy, - /// Total number of checkpoints in timeline - pub total_checkpoints: usize, -} - -/// Strategy for automatic checkpoint creation -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CheckpointStrategy { - /// Only create checkpoints manually - Manual, - /// Create checkpoint after each user prompt - PerPrompt, - /// Create checkpoint after each tool use - PerToolUse, - /// Create checkpoint after destructive operations - Smart, -} - -/// Tracks the state of files for checkpointing -#[derive(Debug, Clone)] -pub struct FileTracker { - /// Map of file paths to their current state - pub tracked_files: HashMap, -} - -/// State of a tracked file -#[derive(Debug, Clone)] -pub struct FileState { - /// Last known hash of the file - pub last_hash: String, - /// Whether the file has been modified since last checkpoint - pub is_modified: bool, - /// Last modification timestamp - pub last_modified: DateTime, - /// Whether the file currently exists - pub exists: bool, -} - -/// Result of a checkpoint operation -#[derive(Debug, Serialize, Deserialize)] -pub struct CheckpointResult { - /// The created/restored checkpoint - pub checkpoint: Checkpoint, - /// Number of files snapshot/restored - pub files_processed: usize, - /// Any warnings during the operation - pub warnings: Vec, -} - -/// Diff between two checkpoints -#[derive(Debug, Serialize, Deserialize)] -pub struct CheckpointDiff { - /// Source checkpoint ID - pub from_checkpoint_id: String, - /// Target checkpoint ID - pub to_checkpoint_id: String, - /// Files that were modified - pub modified_files: Vec, - /// Files that were added - pub added_files: Vec, - /// Files that were deleted - pub deleted_files: Vec, - /// Token usage difference - pub token_delta: i64, -} - -/// Diff for a single file -#[derive(Debug, Serialize, Deserialize)] -pub struct FileDiff { - /// File path - pub path: PathBuf, - /// Number of additions - pub additions: usize, - /// Number of deletions - pub deletions: usize, - /// Unified diff content (optional) - pub diff_content: Option, -} - -impl Default for CheckpointStrategy { - fn default() -> Self { - CheckpointStrategy::Smart - } -} - -impl SessionTimeline { - /// Create a new empty timeline - pub fn new(session_id: String) -> Self { - Self { - session_id, - root_node: None, - current_checkpoint_id: None, - auto_checkpoint_enabled: false, - checkpoint_strategy: CheckpointStrategy::default(), - total_checkpoints: 0, - } - } - - /// Find a checkpoint by ID in the timeline tree - pub fn find_checkpoint(&self, checkpoint_id: &str) -> Option<&TimelineNode> { - self.root_node - .as_ref() - .and_then(|root| Self::find_in_tree(root, checkpoint_id)) - } - - fn find_in_tree<'a>(node: &'a TimelineNode, checkpoint_id: &str) -> Option<&'a TimelineNode> { - if node.checkpoint.id == checkpoint_id { - return Some(node); - } - - for child in &node.children { - if let Some(found) = Self::find_in_tree(child, checkpoint_id) { - return Some(found); - } - } - - None - } -} - -/// Checkpoint storage paths -pub struct CheckpointPaths { - pub timeline_file: PathBuf, - pub checkpoints_dir: PathBuf, - pub files_dir: PathBuf, -} - -impl CheckpointPaths { - pub fn new(claude_dir: &PathBuf, project_id: &str, session_id: &str) -> Self { - let base_dir = claude_dir - .join("projects") - .join(project_id) - .join(".timelines") - .join(session_id); - - Self { - timeline_file: base_dir.join("timeline.json"), - checkpoints_dir: base_dir.join("checkpoints"), - files_dir: base_dir.join("files"), - } - } - - pub fn checkpoint_dir(&self, checkpoint_id: &str) -> PathBuf { - self.checkpoints_dir.join(checkpoint_id) - } - - pub fn checkpoint_metadata_file(&self, checkpoint_id: &str) -> PathBuf { - self.checkpoint_dir(checkpoint_id).join("metadata.json") - } - - pub fn checkpoint_messages_file(&self, checkpoint_id: &str) -> PathBuf { - self.checkpoint_dir(checkpoint_id).join("messages.jsonl") - } - - #[allow(dead_code)] - pub fn file_snapshot_path(&self, _checkpoint_id: &str, file_hash: &str) -> PathBuf { - // In content-addressable storage, files are stored by hash in the content pool - self.files_dir.join("content_pool").join(file_hash) - } - - #[allow(dead_code)] - pub fn file_reference_path(&self, checkpoint_id: &str, safe_filename: &str) -> PathBuf { - // References are stored per checkpoint - self.files_dir - .join("refs") - .join(checkpoint_id) - .join(format!("{}.json", safe_filename)) - } -} +pub mod commands; \ No newline at end of file diff --git a/src-tauri/src/checkpoint/state.rs b/src-tauri/src/checkpoint/state.rs deleted file mode 100644 index a633ebc3d..000000000 --- a/src-tauri/src/checkpoint/state.rs +++ /dev/null @@ -1,184 +0,0 @@ -use anyhow::Result; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; - -use super::manager::CheckpointManager; - -/// Manages checkpoint managers for active sessions -/// -/// This struct maintains a stateful collection of CheckpointManager instances, -/// one per active session, to avoid recreating them on every command invocation. -/// It provides thread-safe access to managers and handles their lifecycle. -#[derive(Default, Clone)] -pub struct CheckpointState { - /// Map of session_id to CheckpointManager - /// Uses Arc to allow sharing across async boundaries - managers: Arc>>>, - /// The Claude directory path for consistent access - claude_dir: Arc>>, -} - -impl CheckpointState { - /// Creates a new CheckpointState instance - pub fn new() -> Self { - Self { - managers: Arc::new(RwLock::new(HashMap::new())), - claude_dir: Arc::new(RwLock::new(None)), - } - } - - /// Sets the Claude directory path - /// - /// This should be called once during application initialization - pub async fn set_claude_dir(&self, claude_dir: PathBuf) { - let mut dir = self.claude_dir.write().await; - *dir = Some(claude_dir); - } - - /// Gets or creates a CheckpointManager for a session - /// - /// If a manager already exists for the session, it returns the existing one. - /// Otherwise, it creates a new manager and stores it for future use. - /// - /// # Arguments - /// * `session_id` - The session identifier - /// * `project_id` - The project identifier - /// * `project_path` - The path to the project directory - /// - /// # Returns - /// An Arc reference to the CheckpointManager for thread-safe sharing - pub async fn get_or_create_manager( - &self, - session_id: String, - project_id: String, - project_path: PathBuf, - ) -> Result> { - let mut managers = self.managers.write().await; - - // Check if manager already exists - if let Some(manager) = managers.get(&session_id) { - return Ok(Arc::clone(manager)); - } - - // Get Claude directory - let claude_dir = { - let dir = self.claude_dir.read().await; - dir.as_ref() - .ok_or_else(|| anyhow::anyhow!("Claude directory not set"))? - .clone() - }; - - // Create new manager - let manager = - CheckpointManager::new(project_id, session_id.clone(), project_path, claude_dir) - .await?; - - let manager_arc = Arc::new(manager); - managers.insert(session_id, Arc::clone(&manager_arc)); - - Ok(manager_arc) - } - - /// Gets an existing CheckpointManager for a session - /// - /// Returns None if no manager exists for the session - #[allow(dead_code)] - pub async fn get_manager(&self, session_id: &str) -> Option> { - let managers = self.managers.read().await; - managers.get(session_id).map(Arc::clone) - } - - /// Removes a CheckpointManager for a session - /// - /// This should be called when a session ends to free resources - pub async fn remove_manager(&self, session_id: &str) -> Option> { - let mut managers = self.managers.write().await; - managers.remove(session_id) - } - - /// Clears all managers - /// - /// This is useful for cleanup during application shutdown - #[allow(dead_code)] - pub async fn clear_all(&self) { - let mut managers = self.managers.write().await; - managers.clear(); - } - - /// Gets the number of active managers - pub async fn active_count(&self) -> usize { - let managers = self.managers.read().await; - managers.len() - } - - /// Lists all active session IDs - pub async fn list_active_sessions(&self) -> Vec { - let managers = self.managers.read().await; - managers.keys().cloned().collect() - } - - /// Checks if a session has an active manager - #[allow(dead_code)] - pub async fn has_active_manager(&self, session_id: &str) -> bool { - self.get_manager(session_id).await.is_some() - } - - /// Clears all managers and returns the count that were cleared - #[allow(dead_code)] - pub async fn clear_all_and_count(&self) -> usize { - let count = self.active_count().await; - self.clear_all().await; - count - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_checkpoint_state_lifecycle() { - let state = CheckpointState::new(); - let temp_dir = TempDir::new().unwrap(); - let claude_dir = temp_dir.path().to_path_buf(); - - // Set Claude directory - state.set_claude_dir(claude_dir.clone()).await; - - // Create a manager - let session_id = "test-session-123".to_string(); - let project_id = "test-project".to_string(); - let project_path = temp_dir.path().join("project"); - std::fs::create_dir_all(&project_path).unwrap(); - - let manager1 = state - .get_or_create_manager(session_id.clone(), project_id.clone(), project_path.clone()) - .await - .unwrap(); - - // Getting the same session should return the same manager - let manager2 = state - .get_or_create_manager(session_id.clone(), project_id.clone(), project_path.clone()) - .await - .unwrap(); - - assert!(Arc::ptr_eq(&manager1, &manager2)); - assert_eq!(state.active_count().await, 1); - - // Remove the manager - let removed = state.remove_manager(&session_id).await; - assert!(removed.is_some()); - assert_eq!(state.active_count().await, 0); - - // Getting after removal should create a new one - let manager3 = state - .get_or_create_manager(session_id.clone(), project_id, project_path) - .await - .unwrap(); - - assert!(!Arc::ptr_eq(&manager1, &manager3)); - } -} diff --git a/src-tauri/src/checkpoint/storage.rs b/src-tauri/src/checkpoint/storage.rs deleted file mode 100644 index ce82de342..000000000 --- a/src-tauri/src/checkpoint/storage.rs +++ /dev/null @@ -1,460 +0,0 @@ -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::path::{Path, PathBuf}; -use uuid::Uuid; -use zstd::stream::{decode_all, encode_all}; - -use super::{ - Checkpoint, CheckpointPaths, CheckpointResult, FileSnapshot, SessionTimeline, TimelineNode, -}; - -/// Manages checkpoint storage operations -pub struct CheckpointStorage { - pub claude_dir: PathBuf, - compression_level: i32, -} - -impl CheckpointStorage { - /// Create a new checkpoint storage instance - pub fn new(claude_dir: PathBuf) -> Self { - Self { - claude_dir, - compression_level: 3, // Default zstd compression level - } - } - - /// Initialize checkpoint storage for a session - pub fn init_storage(&self, project_id: &str, session_id: &str) -> Result<()> { - let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id); - - // Create directory structure - fs::create_dir_all(&paths.checkpoints_dir) - .context("Failed to create checkpoints directory")?; - fs::create_dir_all(&paths.files_dir).context("Failed to create files directory")?; - - // Initialize empty timeline if it doesn't exist - if !paths.timeline_file.exists() { - let timeline = SessionTimeline::new(session_id.to_string()); - self.save_timeline(&paths.timeline_file, &timeline)?; - } - - Ok(()) - } - - /// Save a checkpoint to disk - pub fn save_checkpoint( - &self, - project_id: &str, - session_id: &str, - checkpoint: &Checkpoint, - file_snapshots: Vec, - messages: &str, // JSONL content up to checkpoint - ) -> Result { - let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id); - let checkpoint_dir = paths.checkpoint_dir(&checkpoint.id); - - // Create checkpoint directory - fs::create_dir_all(&checkpoint_dir).context("Failed to create checkpoint directory")?; - - // Save checkpoint metadata - let metadata_path = paths.checkpoint_metadata_file(&checkpoint.id); - let metadata_json = serde_json::to_string_pretty(checkpoint) - .context("Failed to serialize checkpoint metadata")?; - fs::write(&metadata_path, metadata_json).context("Failed to write checkpoint metadata")?; - - // Save messages (compressed) - let messages_path = paths.checkpoint_messages_file(&checkpoint.id); - let compressed_messages = encode_all(messages.as_bytes(), self.compression_level) - .context("Failed to compress messages")?; - fs::write(&messages_path, compressed_messages) - .context("Failed to write compressed messages")?; - - // Save file snapshots - let mut warnings = Vec::new(); - let mut files_processed = 0; - - for snapshot in &file_snapshots { - match self.save_file_snapshot(&paths, snapshot) { - Ok(_) => files_processed += 1, - Err(e) => warnings.push(format!( - "Failed to save {}: {}", - snapshot.file_path.display(), - e - )), - } - } - - // Update timeline - self.update_timeline_with_checkpoint(&paths.timeline_file, checkpoint, &file_snapshots)?; - - Ok(CheckpointResult { - checkpoint: checkpoint.clone(), - files_processed, - warnings, - }) - } - - /// Save a single file snapshot - fn save_file_snapshot(&self, paths: &CheckpointPaths, snapshot: &FileSnapshot) -> Result<()> { - // Use content-addressable storage: store files by their hash - // This prevents duplication of identical file content across checkpoints - let content_pool_dir = paths.files_dir.join("content_pool"); - fs::create_dir_all(&content_pool_dir).context("Failed to create content pool directory")?; - - // Store the actual content in the content pool - let content_file = content_pool_dir.join(&snapshot.hash); - - // Only write the content if it doesn't already exist - if !content_file.exists() { - // Compress and save file content - let compressed_content = - encode_all(snapshot.content.as_bytes(), self.compression_level) - .context("Failed to compress file content")?; - fs::write(&content_file, compressed_content) - .context("Failed to write file content to pool")?; - } - - // Create a reference in the checkpoint-specific directory - let checkpoint_refs_dir = paths.files_dir.join("refs").join(&snapshot.checkpoint_id); - fs::create_dir_all(&checkpoint_refs_dir) - .context("Failed to create checkpoint refs directory")?; - - // Save file metadata with reference to content - let ref_metadata = serde_json::json!({ - "path": snapshot.file_path, - "hash": snapshot.hash, - "is_deleted": snapshot.is_deleted, - "permissions": snapshot.permissions, - "size": snapshot.size, - }); - - // Use a sanitized filename for the reference - let safe_filename = snapshot - .file_path - .to_string_lossy() - .replace('/', "_") - .replace('\\', "_"); - let ref_path = checkpoint_refs_dir.join(format!("{}.json", safe_filename)); - - fs::write(&ref_path, serde_json::to_string_pretty(&ref_metadata)?) - .context("Failed to write file reference")?; - - Ok(()) - } - - /// Load a checkpoint from disk - pub fn load_checkpoint( - &self, - project_id: &str, - session_id: &str, - checkpoint_id: &str, - ) -> Result<(Checkpoint, Vec, String)> { - let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id); - - // Load checkpoint metadata - let metadata_path = paths.checkpoint_metadata_file(checkpoint_id); - let metadata_json = - fs::read_to_string(&metadata_path).context("Failed to read checkpoint metadata")?; - let checkpoint: Checkpoint = - serde_json::from_str(&metadata_json).context("Failed to parse checkpoint metadata")?; - - // Load messages - let messages_path = paths.checkpoint_messages_file(checkpoint_id); - let compressed_messages = - fs::read(&messages_path).context("Failed to read compressed messages")?; - let messages = String::from_utf8( - decode_all(&compressed_messages[..]).context("Failed to decompress messages")?, - ) - .context("Invalid UTF-8 in messages")?; - - // Load file snapshots - let file_snapshots = self.load_file_snapshots(&paths, checkpoint_id)?; - - Ok((checkpoint, file_snapshots, messages)) - } - - /// Load all file snapshots for a checkpoint - fn load_file_snapshots( - &self, - paths: &CheckpointPaths, - checkpoint_id: &str, - ) -> Result> { - let refs_dir = paths.files_dir.join("refs").join(checkpoint_id); - if !refs_dir.exists() { - return Ok(Vec::new()); - } - - let content_pool_dir = paths.files_dir.join("content_pool"); - let mut snapshots = Vec::new(); - - // Read all reference files - for entry in fs::read_dir(&refs_dir)? { - let entry = entry?; - let path = entry.path(); - - // Skip non-JSON files - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - - // Load reference metadata - let ref_json = fs::read_to_string(&path).context("Failed to read file reference")?; - let ref_metadata: serde_json::Value = - serde_json::from_str(&ref_json).context("Failed to parse file reference")?; - - let hash = ref_metadata["hash"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing hash in reference"))?; - - // Load content from pool - let content_file = content_pool_dir.join(hash); - let content = if content_file.exists() { - let compressed_content = - fs::read(&content_file).context("Failed to read file content from pool")?; - String::from_utf8( - decode_all(&compressed_content[..]) - .context("Failed to decompress file content")?, - ) - .context("Invalid UTF-8 in file content")? - } else { - // Handle missing content gracefully - log::warn!("Content file missing for hash: {}", hash); - String::new() - }; - - snapshots.push(FileSnapshot { - checkpoint_id: checkpoint_id.to_string(), - file_path: PathBuf::from(ref_metadata["path"].as_str().unwrap_or("")), - content, - hash: hash.to_string(), - is_deleted: ref_metadata["is_deleted"].as_bool().unwrap_or(false), - permissions: ref_metadata["permissions"].as_u64().map(|p| p as u32), - size: ref_metadata["size"].as_u64().unwrap_or(0), - }); - } - - Ok(snapshots) - } - - /// Save timeline to disk - pub fn save_timeline(&self, timeline_path: &Path, timeline: &SessionTimeline) -> Result<()> { - let timeline_json = - serde_json::to_string_pretty(timeline).context("Failed to serialize timeline")?; - fs::write(timeline_path, timeline_json).context("Failed to write timeline")?; - Ok(()) - } - - /// Load timeline from disk - pub fn load_timeline(&self, timeline_path: &Path) -> Result { - let timeline_json = fs::read_to_string(timeline_path).context("Failed to read timeline")?; - let timeline: SessionTimeline = - serde_json::from_str(&timeline_json).context("Failed to parse timeline")?; - Ok(timeline) - } - - /// Update timeline with a new checkpoint - fn update_timeline_with_checkpoint( - &self, - timeline_path: &Path, - checkpoint: &Checkpoint, - file_snapshots: &[FileSnapshot], - ) -> Result<()> { - let mut timeline = self.load_timeline(timeline_path)?; - - let new_node = TimelineNode { - checkpoint: checkpoint.clone(), - children: Vec::new(), - file_snapshot_ids: file_snapshots.iter().map(|s| s.hash.clone()).collect(), - }; - - // If this is the first checkpoint - if timeline.root_node.is_none() { - timeline.root_node = Some(new_node); - timeline.current_checkpoint_id = Some(checkpoint.id.clone()); - } else if let Some(parent_id) = &checkpoint.parent_checkpoint_id { - // Check if parent exists before modifying - let parent_exists = timeline.find_checkpoint(parent_id).is_some(); - - if parent_exists { - if let Some(root) = &mut timeline.root_node { - Self::add_child_to_node(root, parent_id, new_node)?; - timeline.current_checkpoint_id = Some(checkpoint.id.clone()); - } - } else { - anyhow::bail!("Parent checkpoint not found: {}", parent_id); - } - } - - timeline.total_checkpoints += 1; - self.save_timeline(timeline_path, &timeline)?; - - Ok(()) - } - - /// Recursively add a child node to the timeline tree - fn add_child_to_node( - node: &mut TimelineNode, - parent_id: &str, - child: TimelineNode, - ) -> Result<()> { - if node.checkpoint.id == parent_id { - node.children.push(child); - return Ok(()); - } - - for child_node in &mut node.children { - if Self::add_child_to_node(child_node, parent_id, child.clone()).is_ok() { - return Ok(()); - } - } - - anyhow::bail!("Parent checkpoint not found: {}", parent_id) - } - - /// Calculate hash of file content - pub fn calculate_file_hash(content: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) - } - - /// Generate a new checkpoint ID - pub fn generate_checkpoint_id() -> String { - Uuid::new_v4().to_string() - } - - /// Estimate storage size for a checkpoint - pub fn estimate_checkpoint_size(messages: &str, file_snapshots: &[FileSnapshot]) -> u64 { - let messages_size = messages.len() as u64; - let files_size: u64 = file_snapshots.iter().map(|s| s.content.len() as u64).sum(); - - // Estimate compressed size (typically 20-30% of original for text) - (messages_size + files_size) / 4 - } - - /// Clean up old checkpoints based on retention policy - pub fn cleanup_old_checkpoints( - &self, - project_id: &str, - session_id: &str, - keep_count: usize, - ) -> Result { - let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id); - let timeline = self.load_timeline(&paths.timeline_file)?; - - // Collect all checkpoint IDs in chronological order - let mut all_checkpoints = Vec::new(); - if let Some(root) = &timeline.root_node { - Self::collect_checkpoints(root, &mut all_checkpoints); - } - - // Sort by timestamp (oldest first) - all_checkpoints.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); - - // Keep only the most recent checkpoints - let to_remove = all_checkpoints.len().saturating_sub(keep_count); - let mut removed_count = 0; - - for checkpoint in all_checkpoints.into_iter().take(to_remove) { - if self.remove_checkpoint(&paths, &checkpoint.id).is_ok() { - removed_count += 1; - } - } - - // Run garbage collection to clean up orphaned content - if removed_count > 0 { - match self.garbage_collect_content(project_id, session_id) { - Ok(gc_count) => { - log::info!("Garbage collected {} orphaned content files", gc_count); - } - Err(e) => { - log::warn!("Failed to garbage collect content: {}", e); - } - } - } - - Ok(removed_count) - } - - /// Collect all checkpoints from the tree in order - fn collect_checkpoints(node: &TimelineNode, checkpoints: &mut Vec) { - checkpoints.push(node.checkpoint.clone()); - for child in &node.children { - Self::collect_checkpoints(child, checkpoints); - } - } - - /// Remove a checkpoint and its associated files - fn remove_checkpoint(&self, paths: &CheckpointPaths, checkpoint_id: &str) -> Result<()> { - // Remove checkpoint metadata directory - let checkpoint_dir = paths.checkpoint_dir(checkpoint_id); - if checkpoint_dir.exists() { - fs::remove_dir_all(&checkpoint_dir).context("Failed to remove checkpoint directory")?; - } - - // Remove file references for this checkpoint - let refs_dir = paths.files_dir.join("refs").join(checkpoint_id); - if refs_dir.exists() { - fs::remove_dir_all(&refs_dir).context("Failed to remove file references")?; - } - - // Note: We don't remove content from the pool here as it might be - // referenced by other checkpoints. Use garbage_collect_content() for that. - - Ok(()) - } - - /// Garbage collect unreferenced content from the content pool - pub fn garbage_collect_content(&self, project_id: &str, session_id: &str) -> Result { - let paths = CheckpointPaths::new(&self.claude_dir, project_id, session_id); - let content_pool_dir = paths.files_dir.join("content_pool"); - let refs_dir = paths.files_dir.join("refs"); - - if !content_pool_dir.exists() { - return Ok(0); - } - - // Collect all referenced hashes - let mut referenced_hashes = std::collections::HashSet::new(); - - if refs_dir.exists() { - for checkpoint_entry in fs::read_dir(&refs_dir)? { - let checkpoint_dir = checkpoint_entry?.path(); - if checkpoint_dir.is_dir() { - for ref_entry in fs::read_dir(&checkpoint_dir)? { - let ref_path = ref_entry?.path(); - if ref_path.extension().and_then(|e| e.to_str()) == Some("json") { - if let Ok(ref_json) = fs::read_to_string(&ref_path) { - if let Ok(ref_metadata) = - serde_json::from_str::(&ref_json) - { - if let Some(hash) = ref_metadata["hash"].as_str() { - referenced_hashes.insert(hash.to_string()); - } - } - } - } - } - } - } - } - - // Remove unreferenced content - let mut removed_count = 0; - for entry in fs::read_dir(&content_pool_dir)? { - let content_file = entry?.path(); - if content_file.is_file() { - if let Some(hash) = content_file.file_name().and_then(|n| n.to_str()) { - if !referenced_hashes.contains(hash) { - if fs::remove_file(&content_file).is_ok() { - removed_count += 1; - } - } - } - } - } - - Ok(removed_count) - } -} diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 1b4df05ca..ec284d350 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -12,6 +12,8 @@ use tokio::sync::Mutex; use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::process::CommandEvent; use regex; +use crate::checkpoint::commands::CheckpointState; +use serde_json::json; /// Global state to track current Claude process pub struct ClaudeProcessState { @@ -1205,6 +1207,9 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String, let stdout_reader = BufReader::new(stdout); let stderr_reader = BufReader::new(stderr); + // Initialize a message counter for checkpoint indexing + let message_counter = Arc::new(Mutex::new(0usize)); + // We'll extract the session ID from Claude's init message let session_id_holder: Arc>> = Arc::new(Mutex::new(None)); let run_id_holder: Arc>> = Arc::new(Mutex::new(None)); @@ -1230,53 +1235,106 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String, let project_path_clone = project_path.clone(); let prompt_clone = prompt.clone(); let model_clone = model.clone(); - let stdout_task = tokio::spawn(async move { - let mut lines = stdout_reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - log::debug!("Claude stdout: {}", line); - - // Parse the line to check for init message with session ID - if let Ok(msg) = serde_json::from_str::(&line) { - if msg["type"] == "system" && msg["subtype"] == "init" { - if let Some(claude_session_id) = msg["session_id"].as_str() { - let mut session_id_guard = session_id_holder_clone.lock().unwrap(); - if session_id_guard.is_none() { - *session_id_guard = Some(claude_session_id.to_string()); - log::info!("Extracted Claude session ID: {}", claude_session_id); + let message_counter_clone = message_counter.clone(); + + // Wrap the stdout_task to capture message_counter and checkpoint state + let stdout_task = tokio::spawn({ + let app_handle = app_handle.clone(); + let session_id_holder = session_id_holder_clone.clone(); + let run_id_holder = run_id_holder_clone.clone(); + let registry_clone = registry_clone.clone(); + let project_path_clone = project_path_clone.clone(); + let prompt_clone = prompt_clone.clone(); + let model_clone = model_clone.clone(); + let message_counter = message_counter_clone.clone(); + async move { + let mut lines = stdout_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + log::debug!("Claude stdout: {}", line); + + // Increment and retrieve message index + let idx = { + let mut guard = message_counter.lock().unwrap(); + let idx = *guard; + *guard += 1; + idx + }; + + + + // Parse and register session once + if let Ok(msg) = serde_json::from_str::(&line) { + if msg["type"] == "system" && msg["subtype"] == "init" { + if let Some(claude_session_id) = msg["session_id"].as_str() { + let should_init = { + let mut sid_guard = session_id_holder.lock().unwrap(); + if sid_guard.is_none() { + *sid_guard = Some(claude_session_id.to_string()); + true + } else { + false + } + }; - // Now register with ProcessRegistry using Claude's session ID - match registry_clone.register_claude_session( - claude_session_id.to_string(), - pid, - project_path_clone.clone(), - prompt_clone.clone(), - model_clone.clone(), - ) { - Ok(run_id) => { + if should_init { + log::info!("Extracted Claude session ID: {}", claude_session_id); + // Register with ProcessRegistry + if let Ok(run_id) = registry_clone.register_claude_session( + claude_session_id.to_string(), pid, + project_path_clone.clone(), prompt_clone.clone(), model_clone.clone() + ) { log::info!("Registered Claude session with run_id: {}", run_id); - let mut run_id_guard = run_id_holder_clone.lock().unwrap(); - *run_id_guard = Some(run_id); + let mut run_guard = run_id_holder.lock().unwrap(); + *run_guard = Some(run_id); } - Err(e) => { - log::error!("Failed to register Claude session: {}", e); + // Initialize Titor for this session + if let Ok(manager) = app_handle.state::() + .get_or_create_manager(PathBuf::from(project_path_clone.clone()), claude_session_id.to_string()) + .await + { + if let Err(e) = manager.checkpoint_message(idx, "Session initialized").await { + log::error!("Failed to create initial checkpoint: {:?}", e); + } } } } } } + + // Store and emit live output + if let Some(run_id) = *run_id_holder.lock().unwrap() { + let _ = registry_clone.append_live_output(run_id, &line); + } + if let Some(ref sid) = *session_id_holder.lock().unwrap() { + let _ = app_handle.emit(&format!("claude-output:{}", sid), &line); + } + let _ = app_handle.emit("claude-output", &line); + + // Automatically checkpoint every entry + let sid_clone = session_id_holder.lock().unwrap().clone(); + if let Some(sid) = sid_clone { + if let Ok(manager) = app_handle.state::() + .get_or_create_manager(PathBuf::from(project_path_clone.clone()), sid.clone()) + .await + { + let desc = if line.len() > 100 { + format!("{}...", &line[..100]) + } else { + line.clone() + }; + if let Ok(checkpoint_id) = manager.checkpoint_message(idx, &desc).await { + // Emit checkpoint created event + let _ = app_handle.emit( + &format!("checkpoint-created:{}", sid), + json!({ + "checkpointId": checkpoint_id, + "messageIndex": idx + }) + ); + } + } + } } - - // Store live output in registry if we have a run_id - if let Some(run_id) = *run_id_holder_clone.lock().unwrap() { - let _ = registry_clone.append_live_output(run_id, &line); - } - - // Emit the line to the frontend with session isolation if we have session ID - if let Some(ref session_id) = *session_id_holder_clone.lock().unwrap() { - let _ = app_handle.emit(&format!("claude-output:{}", session_id), &line); - } - // Also emit to the generic event for backward compatibility - let _ = app_handle.emit("claude-output", &line); } }); @@ -1340,6 +1398,8 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String, if let Some(run_id) = *run_id_holder_clone2.lock().unwrap() { let _ = registry_clone2.unregister_process(run_id); } + + // Clear the process from state *current_process = None; @@ -1373,6 +1433,9 @@ async fn spawn_claude_sidecar( // We'll extract the session ID from Claude's init message let session_id_holder: Arc>> = Arc::new(Mutex::new(None)); let run_id_holder: Arc>> = Arc::new(Mutex::new(None)); + + // Initialize a message counter for checkpoint indexing + let message_counter = Arc::new(tokio::sync::Mutex::new(0usize)); // Register with ProcessRegistry let registry = app.state::(); @@ -1385,6 +1448,7 @@ async fn spawn_claude_sidecar( let app_handle = app.clone(); let session_id_holder_clone = session_id_holder.clone(); let run_id_holder_clone = run_id_holder.clone(); + let message_counter_clone = message_counter.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { @@ -1396,13 +1460,31 @@ async fn spawn_claude_sidecar( if !line_str.is_empty() { log::debug!("Claude sidecar stdout: {}", line_str); + // Increment and retrieve message index + let idx = { + let mut guard = message_counter_clone.lock().await; + let idx = *guard; + *guard += 1; + idx + }; + + + // Parse the line to check for init message with session ID if let Ok(msg) = serde_json::from_str::(line_str) { if msg["type"] == "system" && msg["subtype"] == "init" { if let Some(claude_session_id) = msg["session_id"].as_str() { - let mut session_id_guard = session_id_holder_clone.lock().unwrap(); - if session_id_guard.is_none() { - *session_id_guard = Some(claude_session_id.to_string()); + let should_init = { + let mut session_id_guard = session_id_holder_clone.lock().unwrap(); + if session_id_guard.is_none() { + *session_id_guard = Some(claude_session_id.to_string()); + true + } else { + false + } + }; + + if should_init { log::info!("Extracted Claude session ID: {}", claude_session_id); // Register with ProcessRegistry using Claude's session ID @@ -1422,6 +1504,17 @@ async fn spawn_claude_sidecar( log::error!("Failed to register Claude sidecar session: {}", e); } } + + // Initialize Titor for this session + let manager_result = app_handle.state::() + .get_or_create_manager(PathBuf::from(project_path_clone.clone()), claude_session_id.to_string()) + .await; + + if let Ok(manager) = manager_result { + if let Err(e) = manager.checkpoint_message(idx, "Session initialized").await { + log::error!("Failed to create initial checkpoint: {:?}", e); + } + } } } } @@ -1438,6 +1531,31 @@ async fn spawn_claude_sidecar( } // Also emit to the generic event for backward compatibility let _ = app_handle.emit("claude-output", line_str); + + // Automatically checkpoint every entry + let sid_clone = session_id_holder_clone.lock().unwrap().clone(); + if let Some(sid) = sid_clone { + if let Ok(manager) = app_handle.state::() + .get_or_create_manager(PathBuf::from(project_path_clone.clone()), sid.clone()) + .await + { + let desc = if line_str.len() > 100 { + format!("{}...", &line_str[..100]) + } else { + line_str.to_string() + }; + if let Ok(checkpoint_id) = manager.checkpoint_message(idx, &desc).await { + // Emit checkpoint created event + let _ = app_handle.emit( + &format!("checkpoint-created:{}", sid), + json!({ + "checkpointId": checkpoint_id, + "messageIndex": idx + }) + ); + } + } + } } } CommandEvent::Stderr(line_bytes) => { @@ -1474,6 +1592,8 @@ async fn spawn_claude_sidecar( let _ = registry_clone.unregister_process(run_id); } + + break; } _ => { @@ -1681,526 +1801,6 @@ fn search_files_recursive( Ok(()) } -/// Creates a checkpoint for the current session state -#[tauri::command] -pub async fn create_checkpoint( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - message_index: Option, - description: Option, -) -> Result { - log::info!( - "Creating checkpoint for session: {} in project: {}", - session_id, - project_id - ); - - let manager = app - .get_or_create_manager( - session_id.clone(), - project_id.clone(), - PathBuf::from(&project_path), - ) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - // Always load current session messages from the JSONL file - let session_path = get_claude_dir() - .map_err(|e| e.to_string())? - .join("projects") - .join(&project_id) - .join(format!("{}.jsonl", session_id)); - - if session_path.exists() { - let file = fs::File::open(&session_path) - .map_err(|e| format!("Failed to open session file: {}", e))?; - let reader = BufReader::new(file); - - let mut line_count = 0; - for line in reader.lines() { - if let Some(index) = message_index { - if line_count > index { - break; - } - } - if let Ok(line) = line { - manager - .track_message(line) - .await - .map_err(|e| format!("Failed to track message: {}", e))?; - } - line_count += 1; - } - } - - manager - .create_checkpoint(description, None) - .await - .map_err(|e| format!("Failed to create checkpoint: {}", e)) -} - -/// Restores a session to a specific checkpoint -#[tauri::command] -pub async fn restore_checkpoint( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - checkpoint_id: String, - session_id: String, - project_id: String, - project_path: String, -) -> Result { - log::info!( - "Restoring checkpoint: {} for session: {}", - checkpoint_id, - session_id - ); - - let manager = app - .get_or_create_manager( - session_id.clone(), - project_id.clone(), - PathBuf::from(&project_path), - ) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - let result = manager - .restore_checkpoint(&checkpoint_id) - .await - .map_err(|e| format!("Failed to restore checkpoint: {}", e))?; - - // Update the session JSONL file with restored messages - let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; - let session_path = claude_dir - .join("projects") - .join(&result.checkpoint.project_id) - .join(format!("{}.jsonl", session_id)); - - // The manager has already restored the messages internally, - // but we need to update the actual session file - let (_, _, messages) = manager - .storage - .load_checkpoint(&result.checkpoint.project_id, &session_id, &checkpoint_id) - .map_err(|e| format!("Failed to load checkpoint data: {}", e))?; - - fs::write(&session_path, messages) - .map_err(|e| format!("Failed to update session file: {}", e))?; - - Ok(result) -} - -/// Lists all checkpoints for a session -#[tauri::command] -pub async fn list_checkpoints( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, -) -> Result, String> { - log::info!( - "Listing checkpoints for session: {} in project: {}", - session_id, - project_id - ); - - let manager = app - .get_or_create_manager(session_id, project_id, PathBuf::from(&project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - Ok(manager.list_checkpoints().await) -} - -/// Forks a new timeline branch from a checkpoint -#[tauri::command] -pub async fn fork_from_checkpoint( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - checkpoint_id: String, - session_id: String, - project_id: String, - project_path: String, - new_session_id: String, - description: Option, -) -> Result { - log::info!( - "Forking from checkpoint: {} to new session: {}", - checkpoint_id, - new_session_id - ); - - let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; - - // First, copy the session file to the new session - let source_session_path = claude_dir - .join("projects") - .join(&project_id) - .join(format!("{}.jsonl", session_id)); - let new_session_path = claude_dir - .join("projects") - .join(&project_id) - .join(format!("{}.jsonl", new_session_id)); - - if source_session_path.exists() { - fs::copy(&source_session_path, &new_session_path) - .map_err(|e| format!("Failed to copy session file: {}", e))?; - } - - // Create manager for the new session - let manager = app - .get_or_create_manager( - new_session_id.clone(), - project_id, - PathBuf::from(&project_path), - ) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - manager - .fork_from_checkpoint(&checkpoint_id, description) - .await - .map_err(|e| format!("Failed to fork checkpoint: {}", e)) -} - -/// Gets the timeline for a session -#[tauri::command] -pub async fn get_session_timeline( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, -) -> Result { - log::info!( - "Getting timeline for session: {} in project: {}", - session_id, - project_id - ); - - let manager = app - .get_or_create_manager(session_id, project_id, PathBuf::from(&project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - Ok(manager.get_timeline().await) -} - -/// Updates checkpoint settings for a session -#[tauri::command] -pub async fn update_checkpoint_settings( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - auto_checkpoint_enabled: bool, - checkpoint_strategy: String, -) -> Result<(), String> { - use crate::checkpoint::CheckpointStrategy; - - log::info!("Updating checkpoint settings for session: {}", session_id); - - let strategy = match checkpoint_strategy.as_str() { - "manual" => CheckpointStrategy::Manual, - "per_prompt" => CheckpointStrategy::PerPrompt, - "per_tool_use" => CheckpointStrategy::PerToolUse, - "smart" => CheckpointStrategy::Smart, - _ => { - return Err(format!( - "Invalid checkpoint strategy: {}", - checkpoint_strategy - )) - } - }; - - let manager = app - .get_or_create_manager(session_id, project_id, PathBuf::from(&project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - manager - .update_settings(auto_checkpoint_enabled, strategy) - .await - .map_err(|e| format!("Failed to update settings: {}", e)) -} - -/// Gets diff between two checkpoints -#[tauri::command] -pub async fn get_checkpoint_diff( - from_checkpoint_id: String, - to_checkpoint_id: String, - session_id: String, - project_id: String, -) -> Result { - use crate::checkpoint::storage::CheckpointStorage; - - log::info!( - "Getting diff between checkpoints: {} -> {}", - from_checkpoint_id, - to_checkpoint_id - ); - - let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; - let storage = CheckpointStorage::new(claude_dir); - - // Load both checkpoints - let (from_checkpoint, from_files, _) = storage - .load_checkpoint(&project_id, &session_id, &from_checkpoint_id) - .map_err(|e| format!("Failed to load source checkpoint: {}", e))?; - let (to_checkpoint, to_files, _) = storage - .load_checkpoint(&project_id, &session_id, &to_checkpoint_id) - .map_err(|e| format!("Failed to load target checkpoint: {}", e))?; - - // Build file maps - let mut from_map: std::collections::HashMap = - std::collections::HashMap::new(); - for file in &from_files { - from_map.insert(file.file_path.clone(), file); - } - - let mut to_map: std::collections::HashMap = - std::collections::HashMap::new(); - for file in &to_files { - to_map.insert(file.file_path.clone(), file); - } - - // Calculate differences - let mut modified_files = Vec::new(); - let mut added_files = Vec::new(); - let mut deleted_files = Vec::new(); - - // Check for modified and deleted files - for (path, from_file) in &from_map { - if let Some(to_file) = to_map.get(path) { - if from_file.hash != to_file.hash { - // File was modified - let additions = to_file.content.lines().count(); - let deletions = from_file.content.lines().count(); - - modified_files.push(crate::checkpoint::FileDiff { - path: path.clone(), - additions, - deletions, - diff_content: None, // TODO: Generate actual diff - }); - } - } else { - // File was deleted - deleted_files.push(path.clone()); - } - } - - // Check for added files - for (path, _) in &to_map { - if !from_map.contains_key(path) { - added_files.push(path.clone()); - } - } - - // Calculate token delta - let token_delta = (to_checkpoint.metadata.total_tokens as i64) - - (from_checkpoint.metadata.total_tokens as i64); - - Ok(crate::checkpoint::CheckpointDiff { - from_checkpoint_id, - to_checkpoint_id, - modified_files, - added_files, - deleted_files, - token_delta, - }) -} - -/// Tracks a message for checkpointing -#[tauri::command] -pub async fn track_checkpoint_message( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - message: String, -) -> Result<(), String> { - log::info!("Tracking message for session: {}", session_id); - - let manager = app - .get_or_create_manager(session_id, project_id, PathBuf::from(project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - manager - .track_message(message) - .await - .map_err(|e| format!("Failed to track message: {}", e)) -} - -/// Checks if auto-checkpoint should be triggered -#[tauri::command] -pub async fn check_auto_checkpoint( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - message: String, -) -> Result { - log::info!("Checking auto-checkpoint for session: {}", session_id); - - let manager = app - .get_or_create_manager(session_id.clone(), project_id, PathBuf::from(project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - Ok(manager.should_auto_checkpoint(&message).await) -} - -/// Triggers cleanup of old checkpoints -#[tauri::command] -pub async fn cleanup_old_checkpoints( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - keep_count: usize, -) -> Result { - log::info!( - "Cleaning up old checkpoints for session: {}, keeping {}", - session_id, - keep_count - ); - - let manager = app - .get_or_create_manager( - session_id.clone(), - project_id.clone(), - PathBuf::from(project_path), - ) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - manager - .storage - .cleanup_old_checkpoints(&project_id, &session_id, keep_count) - .map_err(|e| format!("Failed to cleanup checkpoints: {}", e)) -} - -/// Gets checkpoint settings for a session -#[tauri::command] -pub async fn get_checkpoint_settings( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, -) -> Result { - log::info!("Getting checkpoint settings for session: {}", session_id); - - let manager = app - .get_or_create_manager(session_id, project_id, PathBuf::from(project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - let timeline = manager.get_timeline().await; - - Ok(serde_json::json!({ - "auto_checkpoint_enabled": timeline.auto_checkpoint_enabled, - "checkpoint_strategy": timeline.checkpoint_strategy, - "total_checkpoints": timeline.total_checkpoints, - "current_checkpoint_id": timeline.current_checkpoint_id, - })) -} - -/// Clears checkpoint manager for a session (cleanup on session end) -#[tauri::command] -pub async fn clear_checkpoint_manager( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, -) -> Result<(), String> { - log::info!("Clearing checkpoint manager for session: {}", session_id); - - app.remove_manager(&session_id).await; - Ok(()) -} - -/// Gets checkpoint state statistics (for debugging/monitoring) -#[tauri::command] -pub async fn get_checkpoint_state_stats( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, -) -> Result { - let active_count = app.active_count().await; - let active_sessions = app.list_active_sessions().await; - - Ok(serde_json::json!({ - "active_managers": active_count, - "active_sessions": active_sessions, - })) -} - -/// Gets files modified in the last N minutes for a session -#[tauri::command] -pub async fn get_recently_modified_files( - app: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - minutes: i64, -) -> Result, String> { - use chrono::{Duration, Utc}; - - log::info!( - "Getting files modified in the last {} minutes for session: {}", - minutes, - session_id - ); - - let manager = app - .get_or_create_manager(session_id, project_id, PathBuf::from(project_path)) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - let since = Utc::now() - Duration::minutes(minutes); - let modified_files = manager.get_files_modified_since(since).await; - - // Also log the last modification time - if let Some(last_mod) = manager.get_last_modification_time().await { - log::info!("Last file modification was at: {}", last_mod); - } - - Ok(modified_files - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect()) -} - -/// Track session messages from the frontend for checkpointing -#[tauri::command] -pub async fn track_session_messages( - state: tauri::State<'_, crate::checkpoint::state::CheckpointState>, - session_id: String, - project_id: String, - project_path: String, - messages: Vec, -) -> Result<(), String> { - log::info!( - "Tracking {} messages for session {}", - messages.len(), - session_id - ); - - let manager = state - .get_or_create_manager( - session_id.clone(), - project_id.clone(), - PathBuf::from(&project_path), - ) - .await - .map_err(|e| format!("Failed to get checkpoint manager: {}", e))?; - - for message in messages { - manager - .track_message(message) - .await - .map_err(|e| format!("Failed to track message: {}", e))?; - } - - Ok(()) -} - /// Gets hooks configuration from settings at specified scope #[tauri::command] pub async fn get_hooks_config(scope: String, project_path: Option) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a721bc56..0ffc01470 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,10 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Declare modules -pub mod checkpoint; pub mod claude_binary; pub mod commands; pub mod process; +pub mod checkpoint; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1ee996026..148a070ff 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,12 +1,11 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod checkpoint; mod claude_binary; mod commands; mod process; +mod checkpoint; -use checkpoint::state::CheckpointState; use commands::agents::{ cleanup_finished_processes, create_agent, delete_agent, execute_agent, export_agent, export_agent_to_file, fetch_github_agent_content, fetch_github_agents, get_agent, @@ -17,15 +16,13 @@ use commands::agents::{ list_running_sessions, load_agent_session_history, set_claude_binary_path, stream_session_output, update_agent, AgentDb, }; use commands::claude::{ - cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, - clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code, - find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings, - get_checkpoint_state_stats, get_claude_session_output, get_claude_settings, get_project_sessions, - get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, - list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, - open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, + cancel_claude_execution, check_claude_version, + continue_claude_code, execute_claude_code, + find_claude_md_files, get_claude_session_output, get_claude_settings, get_project_sessions, + get_system_prompt, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, + open_new_session, read_claude_md_file, + resume_claude_code, save_claude_md_file, save_claude_settings, save_system_prompt, search_files, - track_checkpoint_message, track_session_messages, update_checkpoint_settings, get_hooks_config, update_hooks_config, validate_hook_command, ClaudeProcessState, }; @@ -42,6 +39,13 @@ use commands::storage::{ storage_list_tables, storage_read_table, storage_update_row, storage_delete_row, storage_insert_row, storage_execute_sql, storage_reset_database, }; +use checkpoint::commands::{ + titor_init_session, titor_checkpoint_message, titor_get_timeline, titor_list_checkpoints, + titor_restore_checkpoint, titor_fork_checkpoint, titor_get_checkpoint_at_message, + titor_verify_checkpoint, titor_diff_checkpoints, titor_diff_checkpoints_detailed, titor_gc, + titor_list_all_checkpoints, + CheckpointState, +}; use process::ProcessRegistryState; use std::sync::Mutex; use tauri::Manager; @@ -59,33 +63,15 @@ fn main() { let conn = init_database(&app.handle()).expect("Failed to initialize agents database"); app.manage(AgentDb(Mutex::new(conn))); - // Initialize checkpoint state - let checkpoint_state = CheckpointState::new(); - - // Set the Claude directory path - if let Ok(claude_dir) = dirs::home_dir() - .ok_or_else(|| "Could not find home directory") - .and_then(|home| { - let claude_path = home.join(".claude"); - claude_path - .canonicalize() - .map_err(|_| "Could not find ~/.claude directory") - }) - { - let state_clone = checkpoint_state.clone(); - tauri::async_runtime::spawn(async move { - state_clone.set_claude_dir(claude_dir).await; - }); - } - - app.manage(checkpoint_state); - // Initialize process registry app.manage(ProcessRegistryState::default()); // Initialize Claude process state app.manage(ClaudeProcessState::default()); + // Initialize checkpoint state + app.manage(CheckpointState::new()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -110,27 +96,10 @@ fn main() { get_claude_session_output, list_directory_contents, search_files, - get_recently_modified_files, get_hooks_config, update_hooks_config, validate_hook_command, - // Checkpoint Management - create_checkpoint, - restore_checkpoint, - list_checkpoints, - fork_from_checkpoint, - get_session_timeline, - update_checkpoint_settings, - get_checkpoint_diff, - track_checkpoint_message, - track_session_messages, - check_auto_checkpoint, - cleanup_old_checkpoints, - get_checkpoint_settings, - clear_checkpoint_manager, - get_checkpoint_state_stats, - // Agent Management list_agents, create_agent, @@ -195,6 +164,20 @@ fn main() { commands::slash_commands::slash_command_get, commands::slash_commands::slash_command_save, commands::slash_commands::slash_command_delete, + + // Titor Checkpoint Management + titor_init_session, + titor_checkpoint_message, + titor_get_timeline, + titor_list_checkpoints, + titor_restore_checkpoint, + titor_fork_checkpoint, + titor_get_checkpoint_at_message, + titor_verify_checkpoint, + titor_diff_checkpoints, + titor_diff_checkpoints_detailed, + titor_gc, + titor_list_all_checkpoints, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/CheckpointSettings.tsx b/src/components/CheckpointSettings.tsx deleted file mode 100644 index 8c429f8d8..000000000 --- a/src/components/CheckpointSettings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { motion } from "framer-motion"; -import { - Settings, - Save, - Trash2, - HardDrive, - AlertCircle -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { SelectComponent, type SelectOption } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { api, type CheckpointStrategy } from "@/lib/api"; -import { cn } from "@/lib/utils"; - -interface CheckpointSettingsProps { - sessionId: string; - projectId: string; - projectPath: string; - onClose?: () => void; - className?: string; -} - -/** - * CheckpointSettings component for managing checkpoint configuration - * - * @example - * - */ -export const CheckpointSettings: React.FC = ({ - sessionId, - projectId, - projectPath, - onClose, - className, -}) => { - const [autoCheckpointEnabled, setAutoCheckpointEnabled] = useState(true); - const [checkpointStrategy, setCheckpointStrategy] = useState("smart"); - const [totalCheckpoints, setTotalCheckpoints] = useState(0); - const [keepCount, setKeepCount] = useState(10); - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - - const strategyOptions: SelectOption[] = [ - { value: "manual", label: "Manual Only" }, - { value: "per_prompt", label: "After Each Prompt" }, - { value: "per_tool_use", label: "After Tool Use" }, - { value: "smart", label: "Smart (Recommended)" }, - ]; - - useEffect(() => { - loadSettings(); - }, [sessionId, projectId, projectPath]); - - const loadSettings = async () => { - try { - setIsLoading(true); - setError(null); - - const settings = await api.getCheckpointSettings(sessionId, projectId, projectPath); - setAutoCheckpointEnabled(settings.auto_checkpoint_enabled); - setCheckpointStrategy(settings.checkpoint_strategy); - setTotalCheckpoints(settings.total_checkpoints); - } catch (err) { - console.error("Failed to load checkpoint settings:", err); - setError("Failed to load checkpoint settings"); - } finally { - setIsLoading(false); - } - }; - - const handleSaveSettings = async () => { - try { - setIsSaving(true); - setError(null); - setSuccessMessage(null); - - await api.updateCheckpointSettings( - sessionId, - projectId, - projectPath, - autoCheckpointEnabled, - checkpointStrategy - ); - - setSuccessMessage("Settings saved successfully"); - setTimeout(() => setSuccessMessage(null), 3000); - } catch (err) { - console.error("Failed to save checkpoint settings:", err); - setError("Failed to save checkpoint settings"); - } finally { - setIsSaving(false); - } - }; - - const handleCleanup = async () => { - try { - setIsLoading(true); - setError(null); - setSuccessMessage(null); - - const removed = await api.cleanupOldCheckpoints( - sessionId, - projectId, - projectPath, - keepCount - ); - - setSuccessMessage(`Removed ${removed} old checkpoints`); - setTimeout(() => setSuccessMessage(null), 3000); - - // Reload settings to get updated count - await loadSettings(); - } catch (err) { - console.error("Failed to cleanup checkpoints:", err); - setError("Failed to cleanup checkpoints"); - } finally { - setIsLoading(false); - } - }; - - return ( - -
-
- -

Checkpoint Settings

-
- {onClose && ( - - )} -
- - {/* Experimental Feature Warning */} -
-
- -
-

Experimental Feature

-

- Checkpointing may affect directory structure or cause data loss. Use with caution. -

-
-
-
- - {error && ( - -
- - {error} -
-
- )} - - {successMessage && ( - - {successMessage} - - )} - -
- {/* Auto-checkpoint toggle */} -
-
- -

- Automatically create checkpoints based on the selected strategy -

-
- -
- - {/* Checkpoint strategy */} -
- - setCheckpointStrategy(value as CheckpointStrategy)} - options={strategyOptions} - disabled={isLoading || !autoCheckpointEnabled} - /> -

- {checkpointStrategy === "manual" && "Checkpoints will only be created manually"} - {checkpointStrategy === "per_prompt" && "A checkpoint will be created after each user prompt"} - {checkpointStrategy === "per_tool_use" && "A checkpoint will be created after each tool use"} - {checkpointStrategy === "smart" && "Checkpoints will be created after destructive operations"} -

-
- - {/* Save button */} - -
- -
-
-
- -

- Total checkpoints: {totalCheckpoints} -

-
- -
- - {/* Cleanup settings */} -
- -
- setKeepCount(parseInt(e.target.value) || 10)} - disabled={isLoading} - className="flex-1" - /> - -
-

- Remove old checkpoints, keeping only the most recent {keepCount} -

-
-
-
- ); -}; \ No newline at end of file diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 2dc9a9b60..59b4f6004 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft, @@ -6,7 +6,6 @@ import { FolderOpen, Copy, ChevronDown, - GitBranch, Settings, ChevronUp, X, @@ -24,13 +23,11 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; -import { TimelineNavigator } from "./TimelineNavigator"; -import { CheckpointSettings } from "./CheckpointSettings"; import { SlashCommandsManager } from "./SlashCommandsManager"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; +import { TimelineNavigator } from "./TimelineNavigator"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -85,13 +82,7 @@ export const ClaudeCodeSession: React.FC = ({ const [totalTokens, setTotalTokens] = useState(0); const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null); const [claudeSessionId, setClaudeSessionId] = useState(null); - const [showTimeline, setShowTimeline] = useState(false); - const [timelineVersion, setTimelineVersion] = useState(0); - const [showSettings, setShowSettings] = useState(false); - const [showForkDialog, setShowForkDialog] = useState(false); const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false); - const [forkCheckpointId, setForkCheckpointId] = useState(null); - const [forkSessionName, setForkSessionName] = useState(""); // Queued prompts state const [queuedPrompts, setQueuedPrompts] = useState>([]); @@ -105,19 +96,30 @@ export const ClaudeCodeSession: React.FC = ({ // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const [checkpoints, setCheckpoints] = useState>(new Map()); // messageIndex -> checkpointId const parentRef = useRef(null); + // Keep a mutable counter of how many messages we have rendered so far. This is **critical** + // for accurate checkpoint indexing because closures in event listeners capture stale + // versions of `messages.length`. We update this ref whenever we mutate the `messages` state. + const messageCountRef = useRef(0); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); const queuedPromptsRef = useRef>([]); const isMountedRef = useRef(true); const isListeningRef = useRef(false); + const isInitializedRef = useRef(false); - // Keep ref in sync with state + // Keep refs in sync with state useEffect(() => { queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); + + useEffect(() => { + isInitializedRef.current = isInitialized; + }, [isInitialized]); // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { @@ -228,11 +230,104 @@ export const ClaudeCodeSession: React.FC = ({ if (isMountedRef.current) { await checkForActiveSession(); } + + // Also initialize titor for resumed sessions + if (session && projectPath && !isInitialized) { + try { + await api.titorInit(session.id, projectPath); + setIsInitialized(true); + isInitializedRef.current = true; + console.log('[ClaudeCodeSession] Titor initialized for resumed session:', session.id); + + // Load existing checkpoints + loadCheckpoints(); + } catch (err) { + console.error('[ClaudeCodeSession] Failed to initialize titor for resumed session:', err); + } + } }; initializeSession(); } - }, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount + }, [session, projectPath, isInitialized]); // Add dependencies + + // Initialize titor checkpoint system + useEffect(() => { + if (effectiveSession && projectPath && !isInitialized) { + const initTitor = async () => { + try { + await api.titorInit(effectiveSession.id, projectPath); + setIsInitialized(true); + isInitializedRef.current = true; + console.log('[ClaudeCodeSession] Titor initialized for session:', effectiveSession.id); + + // Load existing checkpoints + loadCheckpoints(); + } catch (err) { + console.error('[ClaudeCodeSession] Failed to initialize titor:', err); + } + }; + initTitor(); + } + }, [effectiveSession, projectPath, isInitialized]); + + // Reload checkpoints when session ID changes + useEffect(() => { + if (claudeSessionId && isInitialized) { + console.log('[ClaudeCodeSession] Session ID changed, reloading checkpoints for:', claudeSessionId); + loadCheckpoints(); + } + }, [claudeSessionId, isInitialized]); + + // Load checkpoints from titor + const loadCheckpoints = async () => { + // Use the actual session ID from state, not just effectiveSession + const sessionId = claudeSessionId || effectiveSession?.id; + if (!sessionId) return; + + try { + const checkpointList = await api.titorListCheckpoints(sessionId); + const checkpointMap = new Map(); + + // Build a map of messageIndex -> checkpointId + checkpointList.forEach(cp => { + checkpointMap.set(cp.messageIndex, cp.checkpointId); + }); + + setCheckpoints(checkpointMap); + console.log('[ClaudeCodeSession] Loaded checkpoints for session:', sessionId, 'count:', checkpointList.length); + } catch (err) { + console.error('[ClaudeCodeSession] Failed to load checkpoints:', err); + } + }; + + /** + * Reload the entire session history from the updated JSONL file. + * This is required after a checkpoint restore that moves *forward* in time + * because the in-memory `messages` array may currently be shorter than the + * file that was just restored by the backend. + */ + const reloadSessionHistory = useCallback(async () => { + if (!effectiveSession) return; + try { + const history = await api.loadSessionHistory( + effectiveSession.id, + effectiveSession.project_id + ); + + const loadedMessages: ClaudeStreamMessage[] = history.map((entry: any) => ({ + ...entry, + type: entry.type || "assistant", + })); + + setMessages(loadedMessages); + setRawJsonlOutput(history.map((h: any) => JSON.stringify(h))); + messageCountRef.current = loadedMessages.length; + } catch (err) { + console.error('[ClaudeCodeSession] Failed to reload session history:', err); + setError('Failed to reload session history'); + } + }, [effectiveSession]); // Report streaming state changes useEffect(() => { @@ -276,6 +371,8 @@ export const ClaudeCodeSession: React.FC = ({ })); setMessages(loadedMessages); + // Update live counter so future streamed messages have the correct index + messageCountRef.current = loadedMessages.length; setRawJsonlOutput(history.map(h => JSON.stringify(h))); // After loading history, we're continuing a conversation @@ -350,9 +447,13 @@ export const ClaudeCodeSession: React.FC = ({ // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; setMessages(prev => [...prev, message]); - } catch (err) { + messageCountRef.current += 1; + + // Auto-checkpoint on assistant messages + // REMOVED: Frontend checkpoint creation logic + } catch (err) { console.error("Failed to parse message:", err, event.payload); - } + } }); const errorUnlisten = await listen(`claude-error:${sessionId}`, (event) => { @@ -370,7 +471,17 @@ export const ClaudeCodeSession: React.FC = ({ } }); - unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; + // Subscribe to backend-emitted checkpoint events + const checkpointUnlisten = await listen<{ checkpointId: string; messageIndex: number }>( + `checkpoint-created:${sessionId}`, + (event) => { + const { checkpointId, messageIndex } = event.payload; + setCheckpoints(prev => new Map(prev).set(messageIndex, checkpointId)); + console.log('[ClaudeCodeSession] Checkpoint created event received:', { checkpointId, messageIndex }); + } + ); + + unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, checkpointUnlisten]; // Mark as loading to show the session is active if (isMountedRef.current) { @@ -468,12 +579,21 @@ export const ClaudeCodeSession: React.FC = ({ const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, (evt) => { console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload); - processComplete(evt.payload); + processComplete(); }); + // Subscribe to backend-emitted checkpoint events + const checkpointUnlistenScoped = await listen<{ checkpointId: string; messageIndex: number }>( + `checkpoint-created:${sid}`, + (event) => { + const { checkpointId, messageIndex } = event.payload; + setCheckpoints(prev => new Map(prev).set(messageIndex, checkpointId)); + } + ); + // Replace existing unlisten refs with these new ones (after cleaning up) unlistenRefs.current.forEach((u) => u()); - unlistenRefs.current = [specificOutputUnlisten, specificErrorUnlisten, specificCompleteUnlisten]; + unlistenRefs.current = [specificOutputUnlisten, specificErrorUnlisten, specificCompleteUnlisten, checkpointUnlistenScoped]; }; // Generic listeners (catch-all) @@ -495,6 +615,23 @@ export const ClaudeCodeSession: React.FC = ({ setExtractedSessionInfo({ sessionId: msg.session_id, projectId }); } + // Initialize titor for this new session + // Note: We always initialize for a new session, even if we had initialized for a previous one + if (projectPath) { + try { + await api.titorInit(msg.session_id, projectPath); + setIsInitialized(true); + isInitializedRef.current = true; + console.log('[ClaudeCodeSession] Titor initialized for new session:', msg.session_id); + + // Clear previous checkpoints and load for new session + setCheckpoints(new Map()); + await loadCheckpoints(); + } catch (err) { + console.error('[ClaudeCodeSession] Failed to initialize titor for new session:', err); + } + } + // Switch to session-specific listeners await attachSessionSpecificListeners(msg.session_id); } @@ -505,7 +642,7 @@ export const ClaudeCodeSession: React.FC = ({ }); // Helper to process any JSONL stream message string - function handleStreamMessage(payload: string) { + const handleStreamMessage = (payload: string) => { try { // Don't process if component unmounted if (!isMountedRef.current) return; @@ -515,40 +652,22 @@ export const ClaudeCodeSession: React.FC = ({ const message = JSON.parse(payload) as ClaudeStreamMessage; setMessages((prev) => [...prev, message]); - } catch (err) { + // Increment the counter *after* pushing the message so it stays in sync + messageCountRef.current += 1; + + // Auto-checkpoint on assistant messages + // REMOVED: Frontend checkpoint creation logic + } catch (err) { console.error('Failed to parse message:', err, payload); - } } + }; // Helper to handle completion events (both generic and scoped) - const processComplete = async (success: boolean) => { + const processComplete = async () => { setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; // Reset listening state - if (effectiveSession && success) { - try { - const settings = await api.getCheckpointSettings( - effectiveSession.id, - effectiveSession.project_id, - projectPath - ); - - if (settings.auto_checkpoint_enabled) { - await api.checkAutoCheckpoint( - effectiveSession.id, - effectiveSession.project_id, - projectPath, - prompt - ); - // Reload timeline to show new checkpoint - setTimelineVersion((v) => v + 1); - } - } catch (err) { - console.error('Failed to check auto checkpoint:', err); - } - } - // Process queued prompts after completion if (queuedPromptsRef.current.length > 0) { const [nextPrompt, ...remainingPrompts] = queuedPromptsRef.current; @@ -566,16 +685,16 @@ export const ClaudeCodeSession: React.FC = ({ setError(evt.payload); }); - const genericCompleteUnlisten = await listen('claude-complete', (evt) => { - console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload); - processComplete(evt.payload); + const genericCompleteUnlisten = await listen('claude-complete', (_evt) => { + console.log('[ClaudeCodeSession] Received claude-complete (generic)'); + processComplete(); }); // Store the generic unlisteners for now; they may be replaced later. unlistenRefs.current = [genericOutputUnlisten, genericErrorUnlisten, genericCompleteUnlisten]; // -------------------------------------------------------------------- - // 2️⃣ Auto-checkpoint logic moved after listener setup (unchanged) + // 2️⃣ Execute command after listener setup // -------------------------------------------------------------------- // Add the user message immediately to the UI (after setting up listeners) @@ -688,13 +807,6 @@ export const ClaudeCodeSession: React.FC = ({ setCopyPopoverOpen(false); }; - const handleCheckpointSelect = async () => { - // Reload messages from the checkpoint - await loadSessionHistory(); - // Ensure timeline reloads to highlight current checkpoint - setTimelineVersion((v) => v + 1); - }; - const handleCancelExecution = async () => { if (!claudeSessionId || !isLoading) return; @@ -747,44 +859,6 @@ export const ClaudeCodeSession: React.FC = ({ } }; - const handleFork = (checkpointId: string) => { - setForkCheckpointId(checkpointId); - setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`); - setShowForkDialog(true); - }; - - const handleConfirmFork = async () => { - if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return; - - try { - setIsLoading(true); - setError(null); - - const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - await api.forkFromCheckpoint( - forkCheckpointId, - effectiveSession.id, - effectiveSession.project_id, - projectPath, - newSessionId, - forkSessionName - ); - - // Open the new forked session - // You would need to implement navigation to the new session - console.log("Forked to new session:", newSessionId); - - setShowForkDialog(false); - setForkCheckpointId(null); - setForkSessionName(""); - } catch (err) { - console.error("Failed to fork checkpoint:", err); - setError("Failed to fork checkpoint"); - } finally { - setIsLoading(false); - } - }; - // Handle URL detection from terminal output const handleLinkDetected = (url: string) => { if (!showPreview && !showPreviewPrompt) { @@ -824,15 +898,8 @@ export const ClaudeCodeSession: React.FC = ({ // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; - - // Clear checkpoint manager when session ends - if (effectiveSession) { - api.clearCheckpointManager(effectiveSession.id).catch(err => { - console.error("Failed to clear checkpoint manager:", err); - }); - } }; - }, [effectiveSession, projectPath]); + }, []); const messagesList = (
= ({ message={message} streamMessages={messages} onLinkDetected={handleLinkDetected} + messageIndex={virtualItem.index} + sessionId={effectiveSession?.id} + onCheckpointJump={async (checkpointId) => { + const currentSessionId = claudeSessionId || effectiveSession?.id; + if (currentSessionId) { + try { + // Cancel current Claude session if running + if (claudeSessionId && isLoading) { + try { + await api.cancelClaudeExecution(claudeSessionId); + + // Clean up listeners + unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current = []; + + // Reset states + setIsLoading(false); + hasActiveSessionRef.current = false; + isListeningRef.current = false; + setError(null); + + // Clear queued prompts + setQueuedPrompts([]); + + // Add a message indicating checkpoint was jumped to + const jumpMessage: ClaudeStreamMessage = { + type: "system", + subtype: "info", + result: "Session terminated to jump to checkpoint", + timestamp: new Date().toISOString() + }; + setMessages(prev => [...prev, jumpMessage]); + } catch (err) { + console.error("Failed to cancel execution during checkpoint jump:", err); + } + } + + const restoreRes = await api.titorRestoreCheckpoint(currentSessionId, checkpointId); + console.log('[ClaudeCodeSession] Checkpoint restore result:', restoreRes); + + // Trim in-memory messages immediately for seamless UX + setMessages(prev => { + const trimmed = prev.slice(0, restoreRes.messageIndex + 1); + console.log(`[ClaudeCodeSession] Trimming messages from ${prev.length} to ${trimmed.length}`); + return trimmed; + }); + messageCountRef.current = restoreRes.messageIndex + 1; + + // IMPORTANT: Do NOT clear checkpoints - they remain valid for time travel + // Reload checkpoints to ensure we have the complete list + await loadCheckpoints(); + + // Refresh from disk so forward restores load additional messages + await reloadSessionHistory(); + } catch (err) { + console.error('Failed to jump to checkpoint:', err); + setError('Failed to jump to checkpoint'); + } + } + }} + checkpointInfo={{ + hasCheckpoint: checkpoints.has(virtualItem.index), + canCreateCheckpoint: message.type === 'assistant', + checkpointId: checkpoints.get(virtualItem.index) + }} /> ); @@ -987,6 +1119,78 @@ export const ClaudeCodeSession: React.FC = ({
+ {/* Timeline Navigator */} + {(claudeSessionId || effectiveSession) && projectPath && isInitialized && ( + { + // Cancel current Claude session if running + if (claudeSessionId && isLoading) { + try { + await api.cancelClaudeExecution(claudeSessionId); + + // Clean up listeners + unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current = []; + + // Reset states + setIsLoading(false); + hasActiveSessionRef.current = false; + isListeningRef.current = false; + setError(null); + + // Clear queued prompts + setQueuedPrompts([]); + + // Add a message indicating checkpoint was restored + const restoreMessage: ClaudeStreamMessage = { + type: "system", + subtype: "info", + result: "Session terminated due to checkpoint restore", + timestamp: new Date().toISOString() + }; + setMessages(prev => [...prev, restoreMessage]); + } catch (err) { + console.error("Failed to cancel execution during restore:", err); + } + } + + // Restore checkpoint and update UI + const currentSessionId = claudeSessionId || effectiveSession?.id || ''; + const restoreRes = await api.titorRestoreCheckpoint(currentSessionId, checkpointId); + console.log('[ClaudeCodeSession] Timeline checkpoint restore result:', restoreRes); + + // Trim in-memory messages immediately + setMessages(prev => { + const trimmed = prev.slice(0, restoreRes.messageIndex + 1); + console.log(`[ClaudeCodeSession] Timeline trimming messages from ${prev.length} to ${trimmed.length}`); + return trimmed; + }); + messageCountRef.current = restoreRes.messageIndex + 1; + + // IMPORTANT: Do NOT clear checkpoints - they remain valid for time travel + // Reload checkpoints to ensure we have the complete list + await loadCheckpoints(); + + // Refresh state from disk (handles forward restores) + await reloadSessionHistory(); + }} + onForkCheckpoint={(forkedCheckpointId, forkMessage) => { + // Add a message indicating fork was created + const forkInfoMessage: ClaudeStreamMessage = { + type: "system", + subtype: "info", + result: `✨ Fork created: "${forkMessage}"\n\nForked checkpoint ID: ${forkedCheckpointId}\n\nNote: The current Claude session is still running. If you want to work from the forked checkpoint, please cancel the current execution and restore to the forked checkpoint.`, + timestamp: new Date().toISOString() + }; + setMessages(prev => [...prev, forkInfoMessage]); + }} + /> + )} + {projectPath && onProjectSettings && ( )}
- {showSettings && ( - - )} - - - - - - -

Checkpoint Settings

-
-
-
- {effectiveSession && ( - - - - - - -

Timeline Navigator

-
-
-
- )} {messages.length > 0 && ( = ({ {/* Main Content Area */} -
+
{showPreview ? ( // Split pane layout when preview is active = ({ )} -
+
= ({
)} - - {/* Timeline */} - - {showTimeline && effectiveSession && ( - -
- {/* Timeline Header */} -
-

Session Timeline

- -
- - {/* Timeline Content */} -
- -
-
-
- )} -
- {/* Fork Dialog */} - - - - Fork Session - - Create a new session branch from the selected checkpoint. - - - -
-
- - setForkSessionName(e.target.value)} - onKeyPress={(e) => { - if (e.key === "Enter" && !isLoading) { - handleConfirmFork(); - } - }} - /> -
-
- - - - - -
-
- - {/* Settings Dialog */} - {showSettings && effectiveSession && ( - - - setShowSettings(false)} - /> - - - )} - {/* Slash Commands Settings Dialog */} {showSlashCommandsSettings && ( @@ -1417,6 +1472,31 @@ export const ClaudeCodeSession: React.FC = ({ )} + + {/* Preview Prompt Dialog */} + + + + Web Preview Available + + A local server is running at {previewUrl}. Would you like to open a preview? + + + + + + + +
); }; diff --git a/src/components/DiffViewer.tsx b/src/components/DiffViewer.tsx new file mode 100644 index 000000000..b5cd34927 --- /dev/null +++ b/src/components/DiffViewer.tsx @@ -0,0 +1,411 @@ +/** + * DiffViewer component for displaying line-level code diffs + * Shows added, deleted, and context lines with appropriate styling + */ + +import React, { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + FilePlus, + FileMinus, + FileEdit, + ChevronDown, + ChevronRight, + FileCode, + Copy, + Check, + Eye, + EyeOff, + Filter +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; +import { ScrollArea } from "./ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { Card, CardContent, CardHeader } from "./ui/card"; +import { Switch } from "./ui/switch"; +import { Label } from "./ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import type { DetailedDiffResponse, FileDiffResponse, HunkResponse, LineChangeResponse } from "../lib/api"; + +interface DiffViewerProps { + /** The detailed diff data from the API */ + diff: DetailedDiffResponse; + /** Optional title for the diff viewer */ + title?: string; + /** Optional description */ + description?: string; + /** Class name for styling */ + className?: string; + /** Whether to show context lines (default: true) */ + showContext?: boolean; + /** Whether to show line numbers (default: true) */ + showLineNumbers?: boolean; + /** Maximum height for the viewer */ + maxHeight?: string; +} + +/** + * Line component for rendering individual diff lines + */ +const DiffLine: React.FC<{ + change: LineChangeResponse; + showLineNumbers: boolean; +}> = ({ change, showLineNumbers }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(change.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const lineClass = cn( + "font-mono text-xs group hover:bg-opacity-50 transition-colors", + change.changeType === "added" && "bg-green-50 dark:bg-green-950/30 text-green-900 dark:text-green-100", + change.changeType === "deleted" && "bg-red-50 dark:bg-red-950/30 text-red-900 dark:text-red-100", + change.changeType === "context" && "text-muted-foreground hover:bg-muted/30" + ); + + const lineSymbol = change.changeType === "added" ? "+" : change.changeType === "deleted" ? "-" : " "; + + return ( +
+ {showLineNumbers && ( + + {change.lineNumber} + + )} + + {lineSymbol} + +
+        {change.content}
+      
+ + + + + + Copy line + + +
+ ); +}; + +/** + * Hunk component for rendering a group of related changes + */ +const DiffHunk: React.FC<{ + hunk: HunkResponse; + showContext: boolean; + showLineNumbers: boolean; +}> = ({ hunk, showContext, showLineNumbers }) => { + const visibleChanges = showContext + ? hunk.changes + : hunk.changes.filter(c => c.changeType !== "context"); + + return ( +
+ {/* Hunk header */} +
+ @@ -{hunk.fromLine},{hunk.fromCount} +{hunk.toLine},{hunk.toCount} @@ +
+ + {/* Lines */} +
+ {visibleChanges.map((change, idx) => ( + + ))} +
+
+ ); +}; + +/** + * File diff component for rendering changes to a single file + */ +const FileDiff: React.FC<{ + fileDiff: FileDiffResponse; + isExpanded: boolean; + onToggle: () => void; + showContext: boolean; + showLineNumbers: boolean; +}> = ({ fileDiff, isExpanded, onToggle, showContext, showLineNumbers }) => { + // Calculate stats + const additions = fileDiff.hunks.reduce( + (sum, hunk) => sum + hunk.changes.filter(c => c.changeType === "added").length, + 0 + ); + const deletions = fileDiff.hunks.reduce( + (sum, hunk) => sum + hunk.changes.filter(c => c.changeType === "deleted").length, + 0 + ); + + return ( + + +
+
+ + {fileDiff.path} + {fileDiff.isBinary && ( + Binary + )} +
+
+ {additions > 0 && ( + + +{additions} + + )} + {deletions > 0 && ( + + -{deletions} + + )} + {isExpanded ? ( + + ) : ( + + )} +
+
+
+ + + {isExpanded && ( + + + {fileDiff.isBinary ? ( +
+ Binary file changed +
+ ) : ( + +
+ {fileDiff.hunks.map((hunk, idx) => ( + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +}; + +/** + * Main DiffViewer component + */ +export const DiffViewer: React.FC = ({ + diff, + title, + description, + className, + showContext = true, + showLineNumbers = true, + maxHeight = "600px", +}) => { + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [localShowContext, setLocalShowContext] = useState(showContext); + const [localShowLineNumbers, setLocalShowLineNumbers] = useState(showLineNumbers); + const [filterMode, setFilterMode] = useState<"all" | "added" | "modified" | "deleted">("all"); + + // Toggle file expansion + const toggleFile = (path: string) => { + setExpandedFiles(prev => { + const newSet = new Set(prev); + if (newSet.has(path)) { + newSet.delete(path); + } else { + newSet.add(path); + } + return newSet; + }); + }; + + // Expand/collapse all + const expandAll = () => { + setExpandedFiles(new Set(diff.fileDiffs.map(fd => fd.path))); + }; + + const collapseAll = () => { + setExpandedFiles(new Set()); + }; + + // Filter files based on mode + const filteredDiffs = diff.fileDiffs.filter(fileDiff => { + if (filterMode === "all") return true; + + // Check if file appears in the corresponding basic diff arrays + const path = fileDiff.path; + + if (filterMode === "added") { + return diff.basicDiff.addedFiles.some((f: any) => + (typeof f === 'string' ? f : f.path) === path + ); + } + + if (filterMode === "modified") { + return diff.basicDiff.modifiedFiles.some((f: any) => { + if (f.old && f.new) { + return f.new.path === path || f.old.path === path; + } + return (typeof f === 'string' ? f : f.path) === path; + }); + } + + if (filterMode === "deleted") { + return diff.basicDiff.deletedFiles.some((f: any) => + (typeof f === 'string' ? f : f.path) === path + ); + } + + return false; + }); + + return ( +
+ {/* Header */} + {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + + {/* Summary stats */} +
+
+ + {diff.basicDiff.addedFiles.length} added +
+
+ + {diff.basicDiff.modifiedFiles.length} modified +
+
+ + {diff.basicDiff.deletedFiles.length} deleted +
+
+ + +{diff.totalLinesAdded} + + + -{diff.totalLinesDeleted} + +
+
+ + {/* Controls */} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {/* File diffs */} + + {filteredDiffs.length === 0 ? ( +
+ No files match the current filter +
+ ) : ( + filteredDiffs.map((fileDiff) => ( + toggleFile(fileDiff.path)} + showContext={localShowContext} + showLineNumbers={localShowLineNumbers} + /> + )) + )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index decf67c40..74b2f349d 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -4,7 +4,10 @@ import { User, Bot, AlertCircle, - CheckCircle2 + CheckCircle2, + History, + GitBranchPlus, + RotateCcw, } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; @@ -13,6 +16,13 @@ import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme"; import type { ClaudeStreamMessage } from "./AgentExecution"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { TodoWidget, TodoReadWidget, @@ -45,12 +55,29 @@ interface StreamMessageProps { className?: string; streamMessages: ClaudeStreamMessage[]; onLinkDetected?: (url: string) => void; + messageIndex?: number; + sessionId?: string; + onCheckpointJump?: (checkpointId: string) => void; + checkpointInfo?: { + checkpointId?: string; + hasCheckpoint: boolean; + canCreateCheckpoint: boolean; + }; } /** * Component to render a single Claude Code stream message */ -const StreamMessageComponent: React.FC = ({ message, className, streamMessages, onLinkDetected }) => { +const StreamMessageComponent: React.FC = ({ + message, + className, + streamMessages, + onLinkDetected, + messageIndex: _messageIndex, + sessionId: _sessionId, + onCheckpointJump: _onCheckpointJump, + checkpointInfo: _checkpointInfo +}) => { // State to track tool results mapped by tool call ID const [toolResults, setToolResults] = useState>(new Map()); @@ -731,4 +758,86 @@ const StreamMessageComponent: React.FC = ({ message, classNa } }; -export const StreamMessage = React.memo(StreamMessageComponent); +export const StreamMessage = React.memo((props: StreamMessageProps) => { + const { checkpointInfo, onCheckpointJump, sessionId, messageIndex } = props; + const [showHoverUI, setShowHoverUI] = useState(false); + + const content = ; + + // Only show hover UI for assistant messages and when checkpoint info is available + if (props.message.type !== 'assistant' || !checkpointInfo || !sessionId) { + return content; + } + + return ( +
setShowHoverUI(true)} + onMouseLeave={() => setShowHoverUI(false)} + > + {content} + + {/* Checkpoint hover UI */} + {showHoverUI && ( +
+ {checkpointInfo.hasCheckpoint && checkpointInfo.checkpointId && ( + + + + + + Restore to this checkpoint + + + )} + + {checkpointInfo.hasCheckpoint && ( + + + + + + Fork from this checkpoint + + + )} + + + + +
+ + + {checkpointInfo.hasCheckpoint ? 'Checkpoint' : 'No checkpoint'} + +
+
+ + {checkpointInfo.hasCheckpoint + ? `Message ${messageIndex !== undefined ? messageIndex + 1 : 'N/A'} has a checkpoint` + : 'No checkpoint at this message'} + +
+
+
+ )} +
+ ); +}); diff --git a/src/components/TimelineNavigator.tsx b/src/components/TimelineNavigator.tsx index 415a3533b..744133fdf 100644 --- a/src/components/TimelineNavigator.tsx +++ b/src/components/TimelineNavigator.tsx @@ -1,313 +1,663 @@ -import React, { useState, useEffect } from "react"; -import { motion } from "framer-motion"; -import { - GitBranch, - Save, - RotateCcw, - GitFork, +/** + * Timeline Navigator for checkpoint management with time-travel capabilities + * Provides visual timeline of checkpoints with restore, fork, and verify actions + */ + +import { useState, useEffect, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { formatDistanceToNow } from "date-fns"; +import { + History, + Clock, + RotateCcw, + GitBranchPlus, + FileText, + Save, + Loader2, AlertCircle, - ChevronDown, - ChevronRight, + Check, Hash, + Terminal, + FilePlus, + FileEdit, + FileMinus, + ChevronRight, + Package, FileCode, - Diff + Image, + FileJson, + File, + FolderOpen, + GitCompare, } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { api, type Checkpoint, type TimelineNode, type SessionTimeline, type CheckpointDiff } from "@/lib/api"; -import { cn } from "@/lib/utils"; -import { formatDistanceToNow } from "date-fns"; +import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; +import { ScrollArea } from "./ui/scroll-area"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { cn } from "../lib/utils"; +import * as api from "../lib/api"; +import { listen } from '@tauri-apps/api/event'; +import type { TitorCheckpointInfo, DetailedDiffResponse } from "../lib/api"; +import type { ClaudeStreamMessage } from "./AgentExecution"; +import { DiffViewer } from "./DiffViewer"; + +/** + * Extended checkpoint info with UI state + */ +interface CheckpointWithUIState extends TitorCheckpointInfo { + verified?: boolean; + parentId?: string; + messageContent?: string; + toolsUsed?: string[]; + filesChanged?: { + added: number; + modified: number; + deleted: number; + }; + detailedFileChanges?: { + added: Array<{ path: string; size?: number }>; + modified: Array<{ path: string; oldSize?: number; newSize?: number }>; + deleted: Array<{ path: string; size?: number }>; + }; +} interface TimelineNavigatorProps { + /** + * Session ID for checkpoint operations + */ sessionId: string; - projectId: string; + /** + * Project path for the session + */ projectPath: string; + /** + * Current message index + */ currentMessageIndex: number; - onCheckpointSelect: (checkpoint: Checkpoint) => void; - onFork: (checkpointId: string) => void; /** - * Incrementing value provided by parent to force timeline reload when checkpoints - * are created elsewhere (e.g., auto-checkpoint after tool execution). + * Messages array to extract content and tool usage + */ + messages: ClaudeStreamMessage[]; + /** + * Callback when a checkpoint is restored + */ + onCheckpointRestore?: (checkpointId: string) => void; + /** + * Callback when a checkpoint is forked + */ + onForkCheckpoint?: (forkedCheckpointId: string, forkMessage: string) => void; + /** + * Whether to show all checkpoints for the project (across all sessions) + */ + showAllSessions?: boolean; + /** + * Optional className for styling */ - refreshVersion?: number; className?: string; } /** - * Visual timeline navigator for checkpoint management + * TimelineNavigator component for checkpoint time-travel navigation + * + * @example + * console.log('Restored:', id)} + * /> */ export const TimelineNavigator: React.FC = ({ sessionId, - projectId, projectPath, currentMessageIndex, - onCheckpointSelect, - onFork, - refreshVersion = 0, - className + messages, + onCheckpointRestore, + onForkCheckpoint, + showAllSessions = false, + className, }) => { - const [timeline, setTimeline] = useState(null); - const [selectedCheckpoint, setSelectedCheckpoint] = useState(null); - const [expandedNodes, setExpandedNodes] = useState>(new Set()); - const [showCreateDialog, setShowCreateDialog] = useState(false); - const [showDiffDialog, setShowDiffDialog] = useState(false); - const [checkpointDescription, setCheckpointDescription] = useState(""); + const [checkpoints, setCheckpoints] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [diff, setDiff] = useState(null); - const [compareCheckpoint, setCompareCheckpoint] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [selectedCheckpoint, setSelectedCheckpoint] = useState(null); + const [showForkDialog, setShowForkDialog] = useState(false); + const [forkMessage, setForkMessage] = useState(""); + const [isCreatingCheckpoint, setIsCreatingCheckpoint] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const [expandedCheckpoints, setExpandedCheckpoints] = useState>(new Set()); + const [showDiffDialog, setShowDiffDialog] = useState(false); + const [diffData, setDiffData] = useState(null); + const [diffLoading, setDiffLoading] = useState(false); + const [diffFromCheckpoint, setDiffFromCheckpoint] = useState(null); + const [diffToCheckpoint, setDiffToCheckpoint] = useState(null); + const timelineRef = useRef(null); - // Load timeline on mount and whenever refreshVersion bumps + // Load checkpoints when dialog opens or after restoration + useEffect(() => { + if (sessionId && isOpen) { + loadCheckpoints(); + } + }, [sessionId, isOpen]); + + // Also reload checkpoints when currentMessageIndex changes (after restoration) useEffect(() => { - loadTimeline(); - }, [sessionId, projectId, projectPath, refreshVersion]); + if (sessionId && isOpen && checkpoints.length > 0) { + // Debounce to avoid multiple calls during message streaming + const timer = setTimeout(() => { + loadCheckpoints(); + }, 500); + return () => clearTimeout(timer); + } + }, [currentMessageIndex, sessionId, isOpen]); + + // Real-time checkpoint updates: listen for backend events + useEffect(() => { + let unlisten: (() => void) | undefined; + if (sessionId && isOpen) { + listen<{ checkpointId: string; messageIndex: number }>( + `checkpoint-created:${sessionId}`, + () => { + loadCheckpoints(); + } + ) + .then((fn) => { unlisten = fn; }) + .catch((err) => { console.error('Failed to listen for checkpoints:', err); }); + } + return () => { if (unlisten) unlisten(); }; + }, [sessionId, isOpen]); + + // Helper function to extract message content and tools from a message + const extractMessageInfo = (messageIndex: number) => { + if (!messages || messageIndex < 0 || messageIndex >= messages.length) { + return { content: '', tools: [] }; + } + + const message = messages[messageIndex]; + let content = ''; + const tools: string[] = []; + + if (message.type === 'assistant' && message.message?.content) { + // Extract text content and tool usage + if (Array.isArray(message.message.content)) { + for (const item of message.message.content) { + if (item.type === 'text') { + let textValue = ''; + if (item.text) { + if (typeof item.text === 'string') { + textValue = item.text; + } else { + textValue = String((item.text as any).text || ''); + } + } + if (textValue && content.length < 150) { + const truncated = textValue.substring(0, 150); + content = truncated + (textValue.length > 150 ? '...' : ''); + } + } else if (item.type === 'tool_use' && item.name) { + tools.push(item.name); + } + } + } else if (typeof message.message.content === 'string') { + const contentStr = String(message.message.content); + content = contentStr.substring(0, 150) + + (contentStr.length > 150 ? '...' : ''); + } + } else if (message.type === 'user' && message.message?.content) { + // Extract user message text + if (Array.isArray(message.message.content)) { + const textContent = message.message.content.find((c: any) => c.type === 'text'); + if (textContent?.text) { + const text = typeof textContent.text === 'string' ? textContent.text : textContent.text.text || ''; + content = text.substring(0, 150) + (text.length > 150 ? '...' : ''); + } + } + } - const loadTimeline = async () => { + return { content: content || 'No content', tools }; + }; + + const loadCheckpoints = async () => { try { setIsLoading(true); setError(null); - const timelineData = await api.getSessionTimeline(sessionId, projectId, projectPath); - setTimeline(timelineData); - // Auto-expand nodes with current checkpoint - if (timelineData.currentCheckpointId && timelineData.rootNode) { - const pathToNode = findPathToCheckpoint(timelineData.rootNode, timelineData.currentCheckpointId); - setExpandedNodes(new Set(pathToNode)); + let result: api.TitorCheckpointInfo[]; + if (showAllSessions && projectPath) { + // Load all checkpoints for the project + result = await api.titorListAllCheckpoints(projectPath); + } else { + // Load only checkpoints for the current session + result = await api.titorListCheckpoints(sessionId); } + + // Sort checkpoints by messageIndex to ensure proper ordering + const sortedCheckpoints = (result || []).sort((a, b) => a.messageIndex - b.messageIndex); + + // Enhance checkpoints with message content, tool info, and file diffs + const enhancedCheckpoints: CheckpointWithUIState[] = await Promise.all( + sortedCheckpoints.map(async (checkpoint, index) => { + const { content, tools } = extractMessageInfo(checkpoint.messageIndex); + + // Initialize file changes + let filesChanged = undefined; + let detailedFileChanges = undefined; + + // Get diff with parent/previous checkpoint + try { + if (index > 0) { + // Compare with previous checkpoint + const prevCheckpoint = sortedCheckpoints[index - 1]; + const diff = await api.titorDiffCheckpoints( + sessionId, + prevCheckpoint.checkpointId, + checkpoint.checkpointId + ); + + // Extract file paths from the diff response + const extractFilePath = (fileObj: any): { path: string; size?: number } => { + if (typeof fileObj === 'string') return { path: fileObj }; + if (fileObj && typeof fileObj === 'object') { + return { + path: fileObj.path || fileObj.file_path || fileObj.name || 'Unknown file', + size: fileObj.size || fileObj.file_size || fileObj.total_size + }; + } + return { path: 'Unknown file' }; + }; + + detailedFileChanges = { + added: diff.addedFiles.map(extractFilePath), + modified: diff.modifiedFiles.map((modPair: any) => { + if (modPair && typeof modPair === 'object' && modPair.old && modPair.new) { + const oldFile = extractFilePath(modPair.old); + const newFile = extractFilePath(modPair.new); + return { + path: newFile.path, + oldSize: oldFile.size, + newSize: newFile.size + }; + } + return extractFilePath(modPair); + }), + deleted: diff.deletedFiles.map(extractFilePath), + }; + + filesChanged = { + added: detailedFileChanges.added.length, + modified: detailedFileChanges.modified.length, + deleted: detailedFileChanges.deleted.length, + }; + } else { + // First checkpoint - all files are "added" + filesChanged = { + added: checkpoint.fileCount, + modified: 0, + deleted: 0, + }; + // For first checkpoint, we don't have detailed file info + detailedFileChanges = { + added: [], + modified: [], + deleted: [] + }; + } + } catch (err) { + console.warn('Failed to get diff for checkpoint:', checkpoint.checkpointId, err); + } + + return { + ...checkpoint, + messageContent: content, + toolsUsed: tools, + filesChanged, + detailedFileChanges, + }; + }) + ); + + // Filter to only show checkpoints with file changes (UI only) + const checkpointsWithFileChanges = enhancedCheckpoints.filter(checkpoint => { + // Check if there are any file changes + if (checkpoint.filesChanged) { + const { added, modified, deleted } = checkpoint.filesChanged; + // Only include if there's at least one file change + return added > 0 || modified > 0 || deleted > 0; + } + + // If we don't have file change info, exclude it to be safe + // (since we're filtering for file changes only) + return false; + }); + + setCheckpoints(checkpointsWithFileChanges); } catch (err) { - console.error("Failed to load timeline:", err); - setError("Failed to load timeline"); + console.error("Failed to load checkpoints:", err); + setError("Failed to load checkpoints"); } finally { setIsLoading(false); } }; - const findPathToCheckpoint = (node: TimelineNode, checkpointId: string, path: string[] = []): string[] => { - if (node.checkpoint.id === checkpointId) { - return path; - } - - for (const child of node.children) { - const childPath = findPathToCheckpoint(child, checkpointId, [...path, node.checkpoint.id]); - if (childPath.length > path.length) { - return childPath; - } - } - - return path; - }; - const handleCreateCheckpoint = async () => { try { - setIsLoading(true); + setIsCreatingCheckpoint(true); setError(null); - await api.createCheckpoint( - sessionId, - projectId, - projectPath, - currentMessageIndex, - checkpointDescription || undefined - ); + const message = `Manual checkpoint at message ${currentMessageIndex + 1}`; + await api.titorCheckpointMessage(sessionId, currentMessageIndex, message); - setCheckpointDescription(""); - setShowCreateDialog(false); - await loadTimeline(); + // Reload checkpoints + await loadCheckpoints(); } catch (err) { console.error("Failed to create checkpoint:", err); setError("Failed to create checkpoint"); } finally { - setIsLoading(false); + setIsCreatingCheckpoint(false); } }; - const handleRestoreCheckpoint = async (checkpoint: Checkpoint) => { - if (!confirm(`Restore to checkpoint "${checkpoint.description || checkpoint.id.slice(0, 8)}"? Current state will be saved as a new checkpoint.`)) { - return; - } + const handleRestoreCheckpoint = (checkpointId: string) => { + // Close the timeline dialog and delegate restoration to the parent component + setIsOpen(false); + onCheckpointRestore?.(checkpointId); + }; + const handleForkCheckpoint = async () => { + if (!selectedCheckpoint || !forkMessage.trim()) return; + try { - setIsLoading(true); + setIsRestoring(true); setError(null); - // First create a checkpoint of current state - await api.createCheckpoint( - sessionId, - projectId, - projectPath, - currentMessageIndex, - "Auto-save before restore" - ); + // Fork checkpoint returns a new checkpoint ID + const forkedCheckpointId = await api.titorForkCheckpoint(sessionId, selectedCheckpoint, forkMessage); - // Then restore - await api.restoreCheckpoint(checkpoint.id, sessionId, projectId, projectPath); + // Reload checkpoints + await loadCheckpoints(); - await loadTimeline(); - onCheckpointSelect(checkpoint); + // Close fork dialog + setShowForkDialog(false); + setForkMessage(""); + setSelectedCheckpoint(null); + + // Close main dialog + setIsOpen(false); + + // Notify parent with the forked checkpoint ID + onForkCheckpoint?.(forkedCheckpointId, forkMessage); } catch (err) { - console.error("Failed to restore checkpoint:", err); - setError("Failed to restore checkpoint"); + console.error("Failed to fork checkpoint:", err); + setError("Failed to fork checkpoint"); } finally { - setIsLoading(false); + setIsRestoring(false); + } + }; + + const handleVerifyCheckpoint = async (checkpointId: string) => { + try { + const isValid = await api.titorVerifyCheckpoint(sessionId, checkpointId); + if (isValid) { + // Show success indicator briefly + const checkpoint = checkpoints.find(cp => cp.checkpointId === checkpointId); + if (checkpoint) { + setCheckpoints(prev => prev.map(cp => + cp.checkpointId === checkpointId + ? { ...cp, verified: true } as CheckpointWithUIState + : cp + )); + + // Reset after 3 seconds + setTimeout(() => { + setCheckpoints(prev => prev.map(cp => + cp.checkpointId === checkpointId + ? { ...cp, verified: undefined } as CheckpointWithUIState + : cp + )); + }, 3000); + } + } else { + setError("Checkpoint verification failed"); + } + } catch (err) { + console.error("Failed to verify checkpoint:", err); + setError("Failed to verify checkpoint"); } }; - const handleFork = async (checkpoint: Checkpoint) => { - onFork(checkpoint.id); + // Scroll to current checkpoint when dialog opens + useEffect(() => { + if (isOpen && timelineRef.current) { + const currentCheckpoint = checkpoints.find(cp => cp.messageIndex <= currentMessageIndex); + if (currentCheckpoint) { + const element = timelineRef.current.querySelector(`[data-checkpoint-id="${currentCheckpoint.checkpointId}"]`); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }, [isOpen, checkpoints, currentMessageIndex]); + + // Helper function to format bytes in human-readable form + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; - const handleCompare = async (checkpoint: Checkpoint) => { - if (!selectedCheckpoint) { - setSelectedCheckpoint(checkpoint); - return; + // Get file icon based on extension + const getFileIcon = (path: string) => { + const ext = path.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'js': + case 'jsx': + case 'ts': + case 'tsx': + case 'py': + case 'rs': + case 'go': + case 'java': + case 'cpp': + case 'c': + case 'h': + case 'hpp': + return ; + case 'json': + case 'yaml': + case 'yml': + case 'toml': + case 'xml': + return ; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'svg': + case 'webp': + return ; + case 'md': + case 'mdx': + case 'txt': + case 'doc': + case 'docx': + return ; + default: + return ; } + }; + + // Toggle expanded state + const toggleExpanded = (checkpointId: string) => { + setExpandedCheckpoints(prev => { + const newSet = new Set(prev); + if (newSet.has(checkpointId)) { + newSet.delete(checkpointId); + } else { + newSet.add(checkpointId); + } + return newSet; + }); + }; + // Show diff between two checkpoints + const showDiff = async (fromCheckpointId: string, toCheckpointId: string) => { try { - setIsLoading(true); - setError(null); + setDiffLoading(true); + setDiffFromCheckpoint(fromCheckpointId); + setDiffToCheckpoint(toCheckpointId); + setShowDiffDialog(true); - const diffData = await api.getCheckpointDiff( - selectedCheckpoint.id, - checkpoint.id, + const diff = await api.titorDiffCheckpointsDetailed( sessionId, - projectId + fromCheckpointId, + toCheckpointId, + 3, // context lines + false // don't ignore whitespace ); - setDiff(diffData); - setCompareCheckpoint(checkpoint); - setShowDiffDialog(true); + setDiffData(diff); } catch (err) { - console.error("Failed to get diff:", err); - setError("Failed to compare checkpoints"); + console.error("Failed to get detailed diff:", err); + setError("Failed to load diff"); + setShowDiffDialog(false); } finally { - setIsLoading(false); - } - }; - - const toggleNodeExpansion = (nodeId: string) => { - const newExpanded = new Set(expandedNodes); - if (newExpanded.has(nodeId)) { - newExpanded.delete(nodeId); - } else { - newExpanded.add(nodeId); + setDiffLoading(false); } - setExpandedNodes(newExpanded); }; - const renderTimelineNode = (node: TimelineNode, depth: number = 0) => { - const isExpanded = expandedNodes.has(node.checkpoint.id); - const hasChildren = node.children.length > 0; - const isCurrent = timeline?.currentCheckpointId === node.checkpoint.id; - const isSelected = selectedCheckpoint?.id === node.checkpoint.id; - - return ( -
- {/* Connection line */} - {depth > 0 && ( -
- )} + const timelineContent = ( +
+ {checkpoints.map((checkpoint, index) => { + const isActive = checkpoint.messageIndex <= currentMessageIndex && + (index === checkpoints.length - 1 || checkpoints[index + 1].messageIndex > currentMessageIndex); + const isExpanded = expandedCheckpoints.has(checkpoint.checkpointId); - {/* Node content */} - 0 && "ml-6" - )} - style={{ paddingLeft: `${depth * 24}px` }} - > - {/* Expand/collapse button */} - {hasChildren && ( - - )} - - {/* Checkpoint card */} - setSelectedCheckpoint(node.checkpoint)} > - -
+ {/* Main content */} +
+
-
- {isCurrent && ( - Current + {/* Header with file count and time */} +
+ + {checkpoint.verified && ( + + + Verified + )} - - {node.checkpoint.id.slice(0, 8)} - - - {formatDistanceToNow(new Date(node.checkpoint.timestamp), { addSuffix: true })} -
- {node.checkpoint.description && ( -

{node.checkpoint.description}

+ {/* Tool usage badges */} + {checkpoint.toolsUsed && checkpoint.toolsUsed.length > 0 && ( +
+ {checkpoint.toolsUsed.map((tool, idx) => ( + + + {tool} + + ))} +
)} -

- {node.checkpoint.metadata.userPrompt || "No prompt"} -

- -
- - - {node.checkpoint.metadata.totalTokens.toLocaleString()} tokens - - - - {node.checkpoint.metadata.fileChanges} files - + {/* Timestamp */} +
+ {formatDistanceToNow(new Date(checkpoint.timestamp), { addSuffix: true })}
{/* Actions */} -
+
+ {index > 0 && ( + + + + + + View changes from previous + + + )} + - Restore to this checkpoint + Restore to this state @@ -315,18 +665,19 @@ export const TimelineNavigator: React.FC = ({ - Fork from this checkpoint + Create branch from here @@ -334,124 +685,227 @@ export const TimelineNavigator: React.FC = ({ - Compare with another checkpoint + Verify integrity
- - - - - {/* Children */} - {isExpanded && hasChildren && ( -
- {/* Vertical line for children */} - {node.children.length > 1 && ( -
- )} - - {node.children.map((child) => - renderTimelineNode(child, depth + 1) - )} + + {/* Expanded file list */} + + {isExpanded && checkpoint.detailedFileChanges && ( + +
+ {/* Added files */} + {checkpoint.detailedFileChanges.added.length > 0 && ( +
+
+ + Added ({checkpoint.detailedFileChanges.added.length}) +
+ {checkpoint.detailedFileChanges.added.map((file, idx) => ( +
+ {getFileIcon(file.path)} + {file.path} + {file.size && ( + ({formatBytes(file.size)}) + )} +
+ ))} +
+ )} + + {/* Modified files */} + {checkpoint.detailedFileChanges.modified.length > 0 && ( +
+
+ + Modified ({checkpoint.detailedFileChanges.modified.length}) +
+ {checkpoint.detailedFileChanges.modified.map((file, idx) => ( +
+ {getFileIcon(file.path)} + {file.path} + {file.oldSize && file.newSize && file.oldSize !== file.newSize && ( + + ({formatBytes(file.oldSize)} → {formatBytes(file.newSize)}) + + )} +
+ ))} +
+ )} + + {/* Deleted files */} + {checkpoint.detailedFileChanges.deleted.length > 0 && ( +
+
+ + Deleted ({checkpoint.detailedFileChanges.deleted.length}) +
+ {checkpoint.detailedFileChanges.deleted.map((file, idx) => ( +
+ {getFileIcon(file.path)} + {file.path} + {file.size && ( + ({formatBytes(file.size)}) + )} +
+ ))} +
+ )} +
+
+ )} +
+
+ + ); + })} + + {/* Current position indicator if needed */} + {checkpoints.length > 0 && !checkpoints.some(cp => cp.messageIndex === currentMessageIndex) && ( + +
+
+ +
+
+
Current position
+
+ No file changes since last checkpoint +
+
- )} -
- ); - }; + + )} +
+ ); return ( -
- {/* Experimental Feature Warning */} -
-
- -
-

Experimental Feature

-

- Checkpointing may affect directory structure or cause data loss. Use with caution. -

-
-
-
+ <> + {/* Timeline Button */} + - {/* Header */} -
-
- -

Timeline

- {timeline && ( - - {timeline.totalCheckpoints} checkpoints - + {/* Timeline Dialog */} + + + + + + Project Timeline + {checkpoints.length > 0 && ( + + {checkpoints.length} states + + )} + + + View and restore your project to any previous state. Each checkpoint represents a set of file changes. + + + + {projectPath && ( +
+ + {projectPath} +
)} -
- - -
- - {/* Error display */} - {error && ( -
- - {error} -
- )} - - {/* Timeline tree */} - {timeline?.rootNode ? ( -
- {renderTimelineNode(timeline.rootNode)} -
- ) : ( -
- {isLoading ? "Loading timeline..." : "No checkpoints yet"} -
- )} + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+
+ ) : checkpoints.length === 0 ? ( +
+ +

No file changes recorded yet

+

+ Checkpoints are created automatically when files are modified +

+
+ ) : ( + timelineContent + )} +
+ +
+ +
+ + - {/* Create checkpoint dialog */} - + {/* Fork Dialog */} + - Create Checkpoint + Create Branch - Save the current state of your session with an optional description. + Create a new branch from this checkpoint. This allows you to explore + alternative implementations while preserving the current timeline.
- + setCheckpointDescription(e.target.value)} - onKeyPress={(e) => { - if (e.key === "Enter" && !isLoading) { - handleCreateCheckpoint(); + id="fork-message" + placeholder="Describe this branch (e.g., 'Try alternative UI approach')" + value={forkMessage} + onChange={(e) => setForkMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && forkMessage.trim()) { + handleForkCheckpoint(); } }} /> @@ -461,123 +915,71 @@ export const TimelineNavigator: React.FC = ({
- {/* Diff dialog */} - - + {/* Diff Viewer Dialog */} + { + setShowDiffDialog(open); + if (!open) { + setDiffData(null); + setDiffFromCheckpoint(null); + setDiffToCheckpoint(null); + } + }}> + - Checkpoint Comparison + + + Checkpoint Changes + - Changes between "{selectedCheckpoint?.description || selectedCheckpoint?.id.slice(0, 8)}" - and "{compareCheckpoint?.description || compareCheckpoint?.id.slice(0, 8)}" + {diffFromCheckpoint && diffToCheckpoint && ( + + {diffFromCheckpoint.substring(0, 8)} → {diffToCheckpoint.substring(0, 8)} + + )} - {diff && ( -
- {/* Summary */} -
- - -
Modified Files
-
{diff.modifiedFiles.length}
-
-
- - -
Added Files
-
{diff.addedFiles.length}
-
-
- - -
Deleted Files
-
{diff.deletedFiles.length}
-
-
+
+ {diffLoading ? ( +
+
- - {/* Token delta */} -
- 0 ? "default" : "secondary"}> - {diff.tokenDelta > 0 ? "+" : ""}{diff.tokenDelta.toLocaleString()} tokens - + ) : diffData ? ( + + ) : ( +
+ No diff data available
- - {/* File lists */} - {diff.modifiedFiles.length > 0 && ( -
-

Modified Files

-
- {diff.modifiedFiles.map((file) => ( -
- {file.path} -
- +{file.additions} - -{file.deletions} -
-
- ))} -
-
- )} - - {diff.addedFiles.length > 0 && ( -
-

Added Files

-
- {diff.addedFiles.map((file) => ( -
- + {file} -
- ))} -
-
- )} - - {diff.deletedFiles.length > 0 && ( -
-

Deleted Files

-
- {diff.deletedFiles.map((file) => ( -
- - {file} -
- ))} -
-
- )} -
- )} - - - - + )} +
-
+ ); -}; +}; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index 3584aa6eb..4f29b1209 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -31,3 +31,4 @@ export * from "./ui/pagination"; export * from "./ui/split-pane"; export * from "./ui/scroll-area"; export * from "./RunningClaudeSessions"; +export * from "./DiffViewer"; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index 15e52d809..a4fb28517 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -26,13 +26,7 @@ export const ScrollArea = React.forwardRef(
{ - return invoke("create_checkpoint", { - sessionId, - projectId, - projectPath, - messageIndex, - description - }); - }, + /** - * Restores a session to a specific checkpoint + * Initialize titor checkpoint for a session + * @param sessionId - The session ID + * @param projectPath - The project path + * @returns Promise resolving to success message */ - async restoreCheckpoint( - checkpointId: string, - sessionId: string, - projectId: string, - projectPath: string - ): Promise { - return invoke("restore_checkpoint", { - checkpointId, - sessionId, - projectId, - projectPath - }); + async titorInit(sessionId: string, projectPath: string): Promise { + try { + return await invoke("titor_init_session", { sessionId, projectPath }); + } catch (error) { + console.error("Failed to initialize titor:", error); + throw error; + } }, /** - * Lists all checkpoints for a session + * Create checkpoint for a message + * @param sessionId - The session ID + * @param messageIndex - Index of the message + * @param message - The message content + * @returns Promise resolving to checkpoint ID */ - async listCheckpoints( - sessionId: string, - projectId: string, - projectPath: string - ): Promise { - return invoke("list_checkpoints", { - sessionId, - projectId, - projectPath - }); + async titorCheckpointMessage(sessionId: string, messageIndex: number, message: string): Promise { + try { + return await invoke("titor_checkpoint_message", { sessionId, messageIndex, message }); + } catch (error) { + console.error("Failed to checkpoint message:", error); + throw error; + } }, /** - * Forks a new timeline branch from a checkpoint + * Get timeline information + * @param sessionId - The session ID + * @returns Promise resolving to timeline info */ - async forkFromCheckpoint( - checkpointId: string, - sessionId: string, - projectId: string, - projectPath: string, - newSessionId: string, - description?: string - ): Promise { - return invoke("fork_from_checkpoint", { - checkpointId, - sessionId, - projectId, - projectPath, - newSessionId, - description - }); + async titorGetTimeline(sessionId: string): Promise { + try { + return await invoke("titor_get_timeline", { sessionId }); + } catch (error) { + console.error("Failed to get timeline:", error); + throw error; + } }, /** - * Gets the timeline for a session + * List checkpoints + * @param sessionId - The session ID + * @returns Promise resolving to list of checkpoints */ - async getSessionTimeline( - sessionId: string, - projectId: string, - projectPath: string - ): Promise { - return invoke("get_session_timeline", { - sessionId, - projectId, - projectPath - }); + async titorListCheckpoints(sessionId: string): Promise { + try { + return await invoke("titor_list_checkpoints", { sessionId }); + } catch (error) { + console.error("Failed to list checkpoints:", error); + throw error; + } }, /** - * Updates checkpoint settings for a session + * Restore to checkpoint + * @param sessionId - The session ID + * @param checkpointId - The checkpoint ID to restore to + * @returns Promise resolving to restore result */ - async updateCheckpointSettings( - sessionId: string, - projectId: string, - projectPath: string, - autoCheckpointEnabled: boolean, - checkpointStrategy: CheckpointStrategy - ): Promise { - return invoke("update_checkpoint_settings", { - sessionId, - projectId, - projectPath, - autoCheckpointEnabled, - checkpointStrategy - }); + async titorRestoreCheckpoint(sessionId: string, checkpointId: string): Promise { + try { + return await invoke("titor_restore_checkpoint", { sessionId, checkpointId }); + } catch (error) { + console.error("Failed to restore checkpoint:", error); + throw error; + } }, /** - * Gets diff between two checkpoints + * Fork from checkpoint + * @param sessionId - The session ID + * @param checkpointId - The checkpoint ID to fork from + * @param newSessionId - The new session ID for the fork + * @param description - Optional description for the fork + * @returns Promise resolving to fork ID */ - async getCheckpointDiff( - fromCheckpointId: string, - toCheckpointId: string, - sessionId: string, - projectId: string - ): Promise { + async titorForkCheckpoint( + sessionId: string, + checkpointId: string, + newSessionId: string, + description?: string + ): Promise { try { - return await invoke("get_checkpoint_diff", { - fromCheckpointId, - toCheckpointId, - sessionId, - projectId + return await invoke("titor_fork_checkpoint", { + sessionId, + checkpointId, + newSessionId, + description }); } catch (error) { - console.error("Failed to get checkpoint diff:", error); + console.error("Failed to fork checkpoint:", error); throw error; } }, /** - * Tracks a message for checkpointing + * Get checkpoint at message index + * @param sessionId - The session ID + * @param messageIndex - The message index + * @returns Promise resolving to checkpoint ID if exists */ - async trackCheckpointMessage( - sessionId: string, - projectId: string, - projectPath: string, - message: string - ): Promise { + async titorGetCheckpointAtMessage(sessionId: string, messageIndex: number): Promise { try { - await invoke("track_checkpoint_message", { - sessionId, - projectId, - projectPath, - message - }); + return await invoke("titor_get_checkpoint_at_message", { sessionId, messageIndex }); } catch (error) { - console.error("Failed to track checkpoint message:", error); + console.error("Failed to get checkpoint at message:", error); throw error; } }, /** - * Checks if auto-checkpoint should be triggered + * Verify checkpoint integrity + * @param sessionId - The session ID + * @param checkpointId - The checkpoint ID + * @returns Promise resolving to validity status */ - async checkAutoCheckpoint( - sessionId: string, - projectId: string, - projectPath: string, - message: string - ): Promise { + async titorVerifyCheckpoint(sessionId: string, checkpointId: string): Promise { try { - return await invoke("check_auto_checkpoint", { - sessionId, - projectId, - projectPath, - message - }); + return await invoke("titor_verify_checkpoint", { sessionId, checkpointId }); } catch (error) { - console.error("Failed to check auto checkpoint:", error); + console.error("Failed to verify checkpoint:", error); throw error; } }, /** - * Triggers cleanup of old checkpoints + * Get diff between checkpoints + * @param sessionId - The session ID + * @param fromCheckpointId - Source checkpoint ID + * @param toCheckpointId - Target checkpoint ID + * @returns Promise resolving to diff result */ - async cleanupOldCheckpoints( - sessionId: string, - projectId: string, - projectPath: string, - keepCount: number - ): Promise { - try { - return await invoke("cleanup_old_checkpoints", { - sessionId, - projectId, - projectPath, - keepCount + async titorDiffCheckpoints( + sessionId: string, + fromCheckpointId: string, + toCheckpointId: string + ): Promise { + try { + return await invoke("titor_diff_checkpoints", { + sessionId, + fromCheckpointId, + toCheckpointId }); } catch (error) { - console.error("Failed to cleanup old checkpoints:", error); + console.error("Failed to diff checkpoints:", error); throw error; } }, /** - * Gets checkpoint settings for a session + * Get detailed diff with line-level changes between checkpoints + * @param sessionId - The session ID + * @param fromCheckpointId - Source checkpoint ID + * @param toCheckpointId - Target checkpoint ID + * @param contextLines - Number of context lines to show (default: 3) + * @param ignoreWhitespace - Whether to ignore whitespace changes (default: false) + * @returns Promise resolving to detailed diff result */ - async getCheckpointSettings( + async titorDiffCheckpointsDetailed( sessionId: string, - projectId: string, - projectPath: string - ): Promise<{ - auto_checkpoint_enabled: boolean; - checkpoint_strategy: CheckpointStrategy; - total_checkpoints: number; - current_checkpoint_id?: string; - }> { - try { - return await invoke("get_checkpoint_settings", { + fromCheckpointId: string, + toCheckpointId: string, + contextLines?: number, + ignoreWhitespace?: boolean + ): Promise { + try { + return await invoke("titor_diff_checkpoints_detailed", { sessionId, - projectId, - projectPath + fromId: fromCheckpointId, + toId: toCheckpointId, + contextLines, + ignoreWhitespace }); } catch (error) { - console.error("Failed to get checkpoint settings:", error); + console.error("Failed to get detailed diff:", error); throw error; } }, /** - * Clears checkpoint manager for a session (cleanup on session end) + * Run garbage collection + * @param sessionId - The session ID + * @returns Promise resolving to GC stats */ - async clearCheckpointManager(sessionId: string): Promise { + async titorGc(sessionId: string): Promise { try { - await invoke("clear_checkpoint_manager", { sessionId }); + return await invoke("titor_gc", { sessionId }); } catch (error) { - console.error("Failed to clear checkpoint manager:", error); + console.error("Failed to run gc:", error); throw error; } }, /** - * Tracks a batch of messages for a session for checkpointing + * Clean up checkpoint manager when session ends + * @param sessionId - The session ID + * @returns Promise resolving when cleanup completes */ - trackSessionMessages: ( - sessionId: string, - projectId: string, - projectPath: string, - messages: string[] - ): Promise => - invoke("track_session_messages", { sessionId, projectId, projectPath, messages }), + async titorCleanupSession(_sessionId: string): Promise { + // Session cleanup is now handled automatically by the backend + return Promise.resolve(); + }, + + /** * Adds a new MCP server @@ -1837,3 +1824,124 @@ export const api = { } } }; + +// Titor Checkpoint APIs + +/** + * Initialize titor checkpoint system for a session + */ +export async function titorInitSession(projectPath: string, sessionId: string): Promise { + return invoke("titor_init_session", { projectPath, sessionId }); +} + +/** + * Create a checkpoint for a message + */ +export async function titorCheckpointMessage( + sessionId: string, + messageIndex: number, + message: string +): Promise { + return invoke("titor_checkpoint_message", { sessionId, messageIndex, message }); +} + +/** + * Get checkpoint ID for a specific message index + */ +export async function titorGetCheckpointAtMessage( + sessionId: string, + messageIndex: number +): Promise { + return invoke("titor_get_checkpoint_at_message", { sessionId, messageIndex }); +} + +/** + * Restore project to a checkpoint + */ +export async function titorRestoreCheckpoint( + sessionId: string, + checkpointId: string +): Promise { + return invoke("titor_restore_checkpoint", { sessionId, checkpointId }); +} + +/** + * Get timeline information for visualization + */ +export async function titorGetTimeline(sessionId: string): Promise { + return invoke("titor_get_timeline", { sessionId }); +} + +/** + * List all checkpoints for a session + */ +export async function titorListCheckpoints(sessionId: string): Promise { + return invoke("titor_list_checkpoints", { sessionId }); +} + +/** + * Fork from a checkpoint + */ +export async function titorForkCheckpoint( + sessionId: string, + checkpointId: string, + description?: string +): Promise { + return invoke("titor_fork_checkpoint", { sessionId, checkpointId, description }); +} + +/** + * Get diff between two checkpoints + */ +export async function titorDiffCheckpoints( + sessionId: string, + fromId: string, + toId: string +): Promise { + return invoke("titor_diff_checkpoints", { sessionId, fromId, toId }); +} + +/** + * Verify checkpoint integrity + */ +export async function titorVerifyCheckpoint( + sessionId: string, + checkpointId: string +): Promise { + return invoke("titor_verify_checkpoint", { sessionId, checkpointId }); +} + +/** + * Run garbage collection + */ +export async function titorGc(sessionId: string): Promise { + return invoke("titor_gc", { sessionId }); +} + +/** + * List all checkpoints for a project (across all sessions) + */ +export async function titorListAllCheckpoints(projectPath: string): Promise { + return invoke("titor_list_all_checkpoints", { projectPath }); +} + +/** + * Get detailed diff with line-level changes between checkpoints + */ +export async function titorDiffCheckpointsDetailed( + sessionId: string, + fromId: string, + toId: string, + contextLines?: number, + ignoreWhitespace?: boolean +): Promise { + return invoke("titor_diff_checkpoints_detailed", { + sessionId, + fromId, + toId, + contextLines, + ignoreWhitespace + }); +} + + diff --git a/src/styles.css b/src/styles.css index 5b1fa9659..af6682eaa 100644 --- a/src/styles.css +++ b/src/styles.css @@ -558,61 +558,147 @@ button:focus-visible, /* --- THEME-MATCHING SCROLLBARS --- */ -/* For Firefox */ +/* Hide all scrollbars globally */ * { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none !important; /* Internet Explorer 10+ */ +} + +*::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; +} + +/* Apply custom scrollbar styling to specific elements */ +.custom-scrollbar, +.overflow-auto, +.overflow-y-auto, +.overflow-x-auto, +.overflow-scroll, +.overflow-y-scroll, +.overflow-x-scroll { scrollbar-width: thin; scrollbar-color: var(--color-muted-foreground) var(--color-background); + -ms-overflow-style: auto; /* Re-enable for IE */ } -/* For Webkit Browsers (Chrome, Safari, Edge) */ -*::-webkit-scrollbar { - width: 12px; - height: 12px; +/* Specific styling for custom-scrollbar class (used by ScrollArea component) */ +.custom-scrollbar::-webkit-scrollbar { + width: 8px !important; + height: 8px !important; } -*::-webkit-scrollbar-track { - background: var(--color-background); +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent !important; } -*::-webkit-scrollbar-thumb { - background-color: var(--color-muted); - border-radius: 6px; - border: 3px solid var(--color-background); +.custom-scrollbar::-webkit-scrollbar-thumb { + background: var(--color-border) !important; + border-radius: 9999px !important; } -*::-webkit-scrollbar-thumb:hover { - background-color: var(--color-muted-foreground); +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: var(--color-muted-foreground) !important; } -*::-webkit-scrollbar-corner { - background: transparent; +/* Show webkit scrollbars for elements with overflow */ +.custom-scrollbar::-webkit-scrollbar, +.overflow-auto::-webkit-scrollbar, +.overflow-y-auto::-webkit-scrollbar, +.overflow-x-auto::-webkit-scrollbar, +.overflow-scroll::-webkit-scrollbar, +.overflow-y-scroll::-webkit-scrollbar, +.overflow-x-scroll::-webkit-scrollbar { + display: block; + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track, +.overflow-auto::-webkit-scrollbar-track, +.overflow-y-auto::-webkit-scrollbar-track, +.overflow-x-auto::-webkit-scrollbar-track, +.overflow-scroll::-webkit-scrollbar-track, +.overflow-y-scroll::-webkit-scrollbar-track, +.overflow-x-scroll::-webkit-scrollbar-track { + background: var(--color-background); +} + +.custom-scrollbar::-webkit-scrollbar-thumb, +.overflow-auto::-webkit-scrollbar-thumb, +.overflow-y-auto::-webkit-scrollbar-thumb, +.overflow-x-auto::-webkit-scrollbar-thumb, +.overflow-scroll::-webkit-scrollbar-thumb, +.overflow-y-scroll::-webkit-scrollbar-thumb, +.overflow-x-scroll::-webkit-scrollbar-thumb { + background: var(--color-muted-foreground); + border-radius: 4px; + border: 1px solid var(--color-background); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover, +.overflow-auto::-webkit-scrollbar-thumb:hover, +.overflow-y-auto::-webkit-scrollbar-thumb:hover, +.overflow-x-auto::-webkit-scrollbar-thumb:hover, +.overflow-scroll::-webkit-scrollbar-thumb:hover, +.overflow-y-scroll::-webkit-scrollbar-thumb:hover, +.overflow-x-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-foreground); +} + +.custom-scrollbar::-webkit-scrollbar-corner, +.overflow-auto::-webkit-scrollbar-corner, +.overflow-y-auto::-webkit-scrollbar-corner, +.overflow-x-auto::-webkit-scrollbar-corner, +.overflow-scroll::-webkit-scrollbar-corner, +.overflow-y-scroll::-webkit-scrollbar-corner, +.overflow-x-scroll::-webkit-scrollbar-corner { + background: var(--color-background); } /* Code blocks and editors specific scrollbar */ +pre, +.w-md-editor-content, +code { + scrollbar-width: thin; + scrollbar-color: rgba(107, 114, 128, 0.3) transparent; +} + pre::-webkit-scrollbar, .w-md-editor-content::-webkit-scrollbar, -code::-webkit-scrollbar, -.overflow-auto::-webkit-scrollbar { +code::-webkit-scrollbar { + display: block; width: 8px; height: 8px; } +pre::-webkit-scrollbar-track, +.w-md-editor-content::-webkit-scrollbar-track, +code::-webkit-scrollbar-track { + background: transparent; +} + pre::-webkit-scrollbar-thumb, .w-md-editor-content::-webkit-scrollbar-thumb, -code::-webkit-scrollbar-thumb, -.overflow-auto::-webkit-scrollbar-thumb { - background-color: rgba(107, 114, 128, 0.2); +code::-webkit-scrollbar-thumb { + background-color: rgba(107, 114, 128, 0.3); + border-radius: 4px; } pre::-webkit-scrollbar-thumb:hover, .w-md-editor-content::-webkit-scrollbar-thumb:hover, -code::-webkit-scrollbar-thumb:hover, -.overflow-auto::-webkit-scrollbar-thumb:hover { - background-color: rgba(107, 114, 128, 0.4); +code::-webkit-scrollbar-thumb:hover { + background-color: rgba(107, 114, 128, 0.5); } /* Syntax highlighter specific */ +.bg-zinc-950 { + scrollbar-width: thin; + scrollbar-color: rgba(107, 114, 128, 0.3) rgba(0, 0, 0, 0.3); +} + .bg-zinc-950 ::-webkit-scrollbar { + display: block; width: 8px; height: 8px; } @@ -716,4 +802,48 @@ code::-webkit-scrollbar-thumb:hover, .image-move-to-input { animation: moveToInput 0.8s ease-in-out forwards; +} + +/* Diff Viewer Styles */ +.diff-line-added { + background-color: rgba(34, 197, 94, 0.1); + color: rgb(34, 197, 94); +} + +.dark .diff-line-added { + background-color: rgba(34, 197, 94, 0.15); + color: rgb(74, 222, 128); +} + +.diff-line-deleted { + background-color: rgba(239, 68, 68, 0.1); + color: rgb(239, 68, 68); +} + +.dark .diff-line-deleted { + background-color: rgba(239, 68, 68, 0.15); + color: rgb(248, 113, 113); +} + +.diff-line-context { + color: rgb(107, 114, 128); +} + +.dark .diff-line-context { + color: rgb(156, 163, 175); +} + +.diff-hunk-header { + background-color: rgba(147, 197, 253, 0.1); + color: rgb(59, 130, 246); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.75rem; + padding: 0.25rem 1rem; + border-top: 1px solid hsl(var(--border)); + border-bottom: 1px solid hsl(var(--border)); +} + +.dark .diff-hunk-header { + background-color: rgba(59, 130, 246, 0.15); + color: rgb(96, 165, 250); } \ No newline at end of file