#![allow(dead_code, unused_imports, unused_variables)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EditCheckpointId(pub u64);
impl EditCheckpointId {
pub fn new(id: u64) -> Self {
Self(id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSnapshot {
pub path: PathBuf,
pub content: String,
pub size: usize,
pub hash: String,
}
impl FileSnapshot {
pub fn new(path: PathBuf, content: String) -> Self {
let size = content.len();
let hash = compute_hash(&content);
Self {
path,
content,
size,
hash,
}
}
pub fn changed_from(&self, other: &FileSnapshot) -> bool {
self.hash != other.hash
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EditAction {
FileCreate { path: PathBuf },
FileEdit { path: PathBuf, tool: String },
FileDelete { path: PathBuf },
MultiFileEdit { paths: Vec<PathBuf>, tool: String },
GitCommit { hash: String, message: String },
Manual { description: String },
SessionStart,
SessionEnd,
}
impl EditAction {
pub fn description(&self) -> String {
match self {
EditAction::FileCreate { path } => {
format!("Created {}", path.display())
}
EditAction::FileEdit { path, tool } => {
format!("{} edited {}", tool, path.display())
}
EditAction::FileDelete { path } => {
format!("Deleted {}", path.display())
}
EditAction::MultiFileEdit { paths, tool } => {
format!("{} edited {} files", tool, paths.len())
}
EditAction::GitCommit { hash, message } => {
format!(
"Commit {}: {}",
&hash[..7.min(hash.len())],
truncate(message, 30)
)
}
EditAction::Manual { description } => {
format!("Manual: {}", truncate(description, 40))
}
EditAction::SessionStart => "Session started".to_string(),
EditAction::SessionEnd => "Session ended".to_string(),
}
}
pub fn icon(&self) -> &'static str {
match self {
EditAction::FileCreate { .. } => "📄",
EditAction::FileEdit { .. } => "✏️",
EditAction::FileDelete { .. } => "🗑️",
EditAction::MultiFileEdit { .. } => "📝",
EditAction::GitCommit { .. } => "🔀",
EditAction::Manual { .. } => "📌",
EditAction::SessionStart => "🚀",
EditAction::SessionEnd => "🏁",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditCheckpoint {
pub id: EditCheckpointId,
pub timestamp: DateTime<Utc>,
pub action: EditAction,
pub files: HashMap<PathBuf, FileSnapshot>,
pub git_hash: Option<String>,
pub parent: Option<EditCheckpointId>,
pub branch: Option<String>,
pub label: Option<String>,
}
impl EditCheckpoint {
pub fn new(id: EditCheckpointId, action: EditAction) -> Self {
Self {
id,
timestamp: Utc::now(),
action,
files: HashMap::new(),
git_hash: None,
parent: None,
branch: None,
label: None,
}
}
pub fn add_file(&mut self, snapshot: FileSnapshot) {
self.files.insert(snapshot.path.clone(), snapshot);
}
pub fn with_git_hash(mut self, hash: String) -> Self {
self.git_hash = Some(hash);
self
}
pub fn with_parent(mut self, parent: EditCheckpointId) -> Self {
self.parent = Some(parent);
self
}
pub fn with_branch(mut self, branch: String) -> Self {
self.branch = Some(branch);
self
}
pub fn with_label(mut self, label: String) -> Self {
self.label = Some(label);
self
}
pub fn formatted_time(&self) -> String {
self.timestamp.format("%H:%M:%S").to_string()
}
pub fn relative_time(&self) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(self.timestamp);
if duration.num_seconds() < 60 {
format!("{}s ago", duration.num_seconds())
} else if duration.num_minutes() < 60 {
format!("{}m ago", duration.num_minutes())
} else if duration.num_hours() < 24 {
format!("{}h ago", duration.num_hours())
} else {
format!("{}d ago", duration.num_days())
}
}
}
pub struct EditHistory {
checkpoints: Vec<EditCheckpoint>,
current: usize,
next_id: u64,
max_checkpoints: usize,
current_branch: Option<String>,
}
impl EditHistory {
pub fn new() -> Self {
Self {
checkpoints: Vec::new(),
current: 0,
next_id: 1,
max_checkpoints: 100,
current_branch: None,
}
}
pub fn with_max_checkpoints(max: usize) -> Self {
Self {
max_checkpoints: max,
..Self::new()
}
}
pub fn create_checkpoint(&mut self, action: EditAction) -> EditCheckpointId {
let id = EditCheckpointId::new(self.next_id);
self.next_id += 1;
let mut checkpoint = EditCheckpoint::new(id, action);
if let Some(parent) = self.current_checkpoint() {
checkpoint = checkpoint.with_parent(parent.id);
}
if let Some(ref branch) = self.current_branch {
checkpoint = checkpoint.with_branch(branch.clone());
}
if self.current < self.checkpoints.len() {
self.checkpoints.truncate(self.current);
self.current = self.current.min(self.checkpoints.len());
}
self.checkpoints.push(checkpoint);
self.current = self.checkpoints.len();
if self.checkpoints.len() > self.max_checkpoints {
let remove_count = self.checkpoints.len() - self.max_checkpoints;
self.checkpoints.drain(0..remove_count);
self.current = self.current.saturating_sub(remove_count);
}
id
}
pub fn add_file_to_current(&mut self, snapshot: FileSnapshot) {
if let Some(checkpoint) = self.checkpoints.last_mut() {
checkpoint.add_file(snapshot);
}
}
pub fn current_checkpoint(&self) -> Option<&EditCheckpoint> {
if self.current == 0 {
return None;
}
self.checkpoints.get(self.current - 1)
}
pub fn get(&self, id: EditCheckpointId) -> Option<&EditCheckpoint> {
self.checkpoints.iter().find(|c| c.id == id)
}
pub fn can_undo(&self) -> bool {
self.current > 1
|| (self.current == 1
&& self
.checkpoints
.first()
.is_some_and(|c| !c.files.is_empty()))
}
pub fn can_redo(&self) -> bool {
self.current < self.checkpoints.len()
}
pub fn undo(&mut self) -> Option<&EditCheckpoint> {
if !self.can_undo() {
return None;
}
let index = self.current - 1;
self.current -= 1;
self.checkpoints.get(index)
}
pub fn redo(&mut self) -> Option<&EditCheckpoint> {
if self.can_redo() {
self.current += 1;
self.current_checkpoint()
} else {
None
}
}
pub fn goto(&mut self, id: EditCheckpointId) -> Option<&EditCheckpoint> {
if let Some(pos) = self.checkpoints.iter().position(|c| c.id == id) {
self.current = pos + 1;
self.current_checkpoint()
} else {
None
}
}
pub fn all(&self) -> &[EditCheckpoint] {
&self.checkpoints
}
pub fn len(&self) -> usize {
self.checkpoints.len()
}
pub fn is_empty(&self) -> bool {
self.checkpoints.is_empty()
}
pub fn position(&self) -> usize {
self.current
}
pub fn create_branch(&mut self, name: &str) {
self.current_branch = Some(name.to_string());
}
pub fn switch_to_main(&mut self) {
self.current_branch = None;
}
pub fn current_branch(&self) -> Option<&str> {
self.current_branch.as_deref()
}
pub fn timeline(&self) -> Vec<TimelineEntry> {
self.checkpoints
.iter()
.enumerate()
.map(|(i, c)| TimelineEntry {
id: c.id,
is_current: i + 1 == self.current,
action: c.action.clone(),
timestamp: c.timestamp,
label: c.label.clone(),
branch: c.branch.clone(),
})
.collect()
}
pub fn clear(&mut self) {
self.checkpoints.clear();
self.current = 0;
self.next_id = 1;
}
}
impl Default for EditHistory {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct TimelineEntry {
pub id: EditCheckpointId,
pub is_current: bool,
pub action: EditAction,
pub timestamp: DateTime<Utc>,
pub label: Option<String>,
pub branch: Option<String>,
}
impl TimelineEntry {
pub fn display_text(&self) -> String {
if let Some(ref label) = self.label {
label.clone()
} else {
self.action.description()
}
}
}
fn compute_hash(content: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(content.as_bytes()))
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let mut end = max.saturating_sub(3);
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}
}
#[cfg(test)]
#[path = "../../tests/unit/session/edit_history/edit_history_test.rs"]
mod tests;