a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add operation tracking and undo support

- Add MutationResult type returning operation_id from all mutations
- Add Operation type for operation log entries
- Add list_operations() and undo_operation() using jj-lib op revert
- Add Undo button to abandon toast that reverts the operation
- Export getOperations() and undoOperation() Tauri commands

+234 -27
+24 -4
apps/desktop/src-tauri/src/lib.rs
··· 3 3 mod watcher; 4 4 5 5 use repo::diff; 6 - use repo::jj::JjRepo; 6 + use repo::jj::{JjRepo, MutationResult, Operation}; 7 7 use repo::log::{Revision, RevsetResult}; 8 8 use repo::status::WorkingCopyStatus; 9 9 use serde::Serialize; ··· 290 290 repo_path: String, 291 291 parent_change_ids: Vec<String>, 292 292 change_id: Option<String>, 293 - ) -> Result<String, String> { 293 + ) -> Result<MutationResult, String> { 294 294 let path = Path::new(&repo_path); 295 295 let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 296 296 jj_repo ··· 299 299 } 300 300 301 301 #[tauri::command] 302 - async fn jj_edit(repo_path: String, change_id: String) -> Result<(), String> { 302 + async fn jj_edit(repo_path: String, change_id: String) -> Result<MutationResult, String> { 303 303 let path = Path::new(&repo_path); 304 304 let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 305 305 jj_repo ··· 308 308 } 309 309 310 310 #[tauri::command] 311 - async fn jj_abandon(repo_path: String, change_id: String) -> Result<(), String> { 311 + async fn jj_abandon(repo_path: String, change_id: String) -> Result<MutationResult, String> { 312 312 let path = Path::new(&repo_path); 313 313 let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 314 314 jj_repo 315 315 .abandon_revision(&change_id) 316 316 .map_err(|e| format!("Failed to abandon revision: {}", e)) 317 + } 318 + 319 + #[tauri::command] 320 + async fn get_operations(repo_path: String, limit: usize) -> Result<Vec<Operation>, String> { 321 + let path = Path::new(&repo_path); 322 + let jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 323 + jj_repo 324 + .list_operations(limit) 325 + .map_err(|e| format!("Failed to list operations: {}", e)) 326 + } 327 + 328 + #[tauri::command] 329 + async fn undo_operation(repo_path: String, operation_id: String) -> Result<(), String> { 330 + let path = Path::new(&repo_path); 331 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 332 + jj_repo 333 + .undo_operation(&operation_id) 334 + .map_err(|e| format!("Failed to undo operation: {}", e)) 317 335 } 318 336 319 337 /// Get recency data for commits by walking the operation log. ··· 506 524 jj_new, 507 525 jj_edit, 508 526 jj_abandon, 527 + get_operations, 528 + undo_operation, 509 529 ]) 510 530 .run(tauri::generate_context!()) 511 531 .expect("error while running tauri application");
+157 -12
apps/desktop/src-tauri/src/repo/jj.rs
··· 4 4 use jj_lib::config::ConfigSource; 5 5 use jj_lib::merged_tree::MergedTree; 6 6 use jj_lib::object_id::{HexPrefix, ObjectId, PrefixResolution}; 7 + use jj_lib::op_store::OperationId; 7 8 use jj_lib::op_walk; 8 9 use jj_lib::repo::{Repo, StoreFactories}; 9 10 use jj_lib::repo_path::RepoPath; 10 11 use jj_lib::settings::UserSettings; 11 12 use jj_lib::workspace::{Workspace, default_working_copy_factories}; 13 + use serde::Serialize; 12 14 use std::collections::HashMap; 13 15 use std::path::Path; 14 16 use tokio::io::AsyncReadExt; 17 + 18 + /// Result of a mutation operation, containing both the result and the operation ID for undo 19 + #[derive(Debug, Clone, Serialize)] 20 + pub struct MutationResult { 21 + /// The operation ID of this mutation (hex-encoded) 22 + pub operation_id: String, 23 + /// For new_revision: the change ID of the new revision 24 + pub change_id: Option<String>, 25 + } 26 + 27 + /// An operation in the jj operation log 28 + #[derive(Debug, Clone, Serialize)] 29 + pub struct Operation { 30 + pub id: String, 31 + pub parent_ids: Vec<String>, 32 + pub description: String, 33 + pub timestamp: String, 34 + pub user: String, 35 + pub hostname: String, 36 + /// The working copy change ID after this operation (if any) 37 + pub working_copy_change_id: Option<String>, 38 + } 15 39 16 40 pub struct JjRepo { 17 41 workspace: Workspace, ··· 199 223 &mut self, 200 224 parent_change_ids: Vec<String>, 201 225 change_id: Option<String>, 202 - ) -> Result<String> { 226 + ) -> Result<MutationResult> { 203 227 let repo = self.workspace.repo_loader().load_at_head()?; 204 228 let mut tx = repo.start_transaction(); 205 229 ··· 257 281 258 282 // Check out the new commit in the working copy 259 283 self.workspace 260 - .check_out(operation_id, Some(&old_tree_id), &new_commit) 284 + .check_out(operation_id.clone(), Some(&old_tree_id), &new_commit) 261 285 .context("Failed to check out new commit")?; 262 286 263 - Ok(actual_change_id) 287 + Ok(MutationResult { 288 + operation_id: operation_id.hex(), 289 + change_id: Some(actual_change_id), 290 + }) 264 291 } 265 292 266 - pub fn abandon_revision(&mut self, change_id: &str) -> Result<()> { 293 + pub fn abandon_revision(&mut self, change_id: &str) -> Result<MutationResult> { 267 294 let repo = self.workspace.repo_loader().load_at_head()?; 268 295 let mut tx = repo.start_transaction(); 269 296 ··· 286 313 tx.repo_mut().rebase_descendants()?; 287 314 288 315 // If we abandoned the working copy, check out the parent 289 - if is_abandoning_wc { 316 + let final_op_id = if is_abandoning_wc { 290 317 let parent_id = commit.parent_ids().first().cloned() 291 318 .context("Abandoned commit has no parent")?; 292 319 let parent_commit = repo.store().get_commit(&parent_id)?; ··· 304 331 // Check out the parent commit 305 332 let old_tree_id = commit.tree_id().clone(); 306 333 self.workspace 307 - .check_out(operation_id, Some(&old_tree_id), &parent_commit) 334 + .check_out(operation_id.clone(), Some(&old_tree_id), &parent_commit) 308 335 .context("Failed to check out parent commit")?; 336 + 337 + operation_id 309 338 } else { 310 339 // Finalize transaction 311 - tx.commit("abandon")?; 312 - } 340 + let new_repo = tx.commit("abandon")?; 341 + new_repo.operation().id().clone() 342 + }; 313 343 314 - Ok(()) 344 + Ok(MutationResult { 345 + operation_id: final_op_id.hex(), 346 + change_id: None, 347 + }) 315 348 } 316 349 317 - pub fn edit_revision(&mut self, change_id: String) -> Result<()> { 350 + pub fn edit_revision(&mut self, change_id: String) -> Result<MutationResult> { 318 351 let repo = self.workspace.repo_loader().load_at_head()?; 319 352 let mut tx = repo.start_transaction(); 320 353 ··· 343 376 344 377 // Check out the commit in the working copy 345 378 self.workspace 346 - .check_out(operation_id, Some(&old_tree_id), &commit) 379 + .check_out(operation_id.clone(), Some(&old_tree_id), &commit) 347 380 .context("Failed to check out commit")?; 348 381 349 - Ok(()) 382 + Ok(MutationResult { 383 + operation_id: operation_id.hex(), 384 + change_id: None, 385 + }) 350 386 } 351 387 352 388 /// Walk the operation log to find when each commit was last the working copy. ··· 383 419 } 384 420 385 421 Ok(recency) 422 + } 423 + 424 + /// List operations from newest to oldest 425 + pub fn list_operations(&self, limit: usize) -> Result<Vec<Operation>> { 426 + let repo = self.workspace.repo_loader().load_at_head()?; 427 + let current_op = repo.operation(); 428 + let workspace_name = self.workspace.workspace_name(); 429 + 430 + let mut operations = Vec::new(); 431 + 432 + let op_iter = op_walk::walk_ancestors(std::slice::from_ref(current_op)); 433 + 434 + for (idx, op_result) in op_iter.enumerate() { 435 + if idx >= limit { 436 + break; 437 + } 438 + 439 + let op = op_result.context("Failed to load operation")?; 440 + let metadata = op.metadata(); 441 + 442 + // Get parent operation IDs 443 + let parent_ids: Vec<String> = op.parent_ids().iter().map(|id| id.hex()).collect(); 444 + 445 + // Get the working copy change ID from this operation's view 446 + let working_copy_change_id = op.view().ok().and_then(|view| { 447 + view.wc_commit_ids().get(workspace_name).and_then(|commit_id| { 448 + // Look up the commit to get its change ID 449 + repo.store().get_commit(commit_id).ok().map(|commit| { 450 + commit.change_id().reverse_hex() 451 + }) 452 + }) 453 + }); 454 + 455 + // Format timestamp as ISO 8601 456 + let timestamp = chrono::DateTime::from_timestamp_millis(metadata.time.start.timestamp.0) 457 + .map(|dt| dt.to_rfc3339()) 458 + .unwrap_or_else(|| "unknown".to_string()); 459 + 460 + operations.push(Operation { 461 + id: op.id().hex(), 462 + parent_ids, 463 + description: metadata.description.clone(), 464 + timestamp, 465 + user: metadata.username.clone(), 466 + hostname: metadata.hostname.clone(), 467 + working_copy_change_id, 468 + }); 469 + } 470 + 471 + Ok(operations) 472 + } 473 + 474 + /// Undo a specific operation by reverting it (3-way merge to invert just that op) 475 + pub fn undo_operation(&mut self, op_id_hex: &str) -> Result<()> { 476 + let repo = self.workspace.repo_loader().load_at_head()?; 477 + 478 + // Parse the operation ID 479 + let op_id = OperationId::try_from_hex(op_id_hex) 480 + .context("Invalid operation ID format")?; 481 + 482 + // Load the operation to undo 483 + let bad_op = repo.loader().load_operation(&op_id) 484 + .context("Failed to load operation to undo")?; 485 + 486 + // Get the parent operation (the state before the bad op) 487 + let parent_op = bad_op.parents() 488 + .next() 489 + .context("Operation has no parent (cannot undo root operation)")? 490 + .context("Failed to load parent operation")?; 491 + 492 + // Start a transaction for the revert 493 + let mut tx = repo.start_transaction(); 494 + 495 + // Load repos at both states for 3-way merge 496 + let repo_loader = tx.base_repo().loader(); 497 + let bad_repo = repo_loader.load_at(&bad_op) 498 + .context("Failed to load repo at bad operation")?; 499 + let parent_repo = repo_loader.load_at(&parent_op) 500 + .context("Failed to load repo at parent operation")?; 501 + 502 + // Perform 3-way merge to revert the operation 503 + tx.repo_mut().merge(&bad_repo, &parent_repo)?; 504 + 505 + // Get old tree for checkout (current WC) 506 + let old_wc_commit_id = repo 507 + .view() 508 + .get_wc_commit_id(self.workspace.workspace_name()) 509 + .cloned(); 510 + let old_tree_id = old_wc_commit_id 511 + .as_ref() 512 + .and_then(|id| repo.store().get_commit(id).ok()) 513 + .map(|c| c.tree_id().clone()); 514 + 515 + // Commit the revert 516 + let new_repo = tx.commit(format!("undo operation {}", &op_id_hex[..12.min(op_id_hex.len())]))?; 517 + let new_op_id = new_repo.operation().id().clone(); 518 + 519 + // Update working copy if it changed 520 + if let Some(new_wc_commit_id) = new_repo.view().get_wc_commit_id(self.workspace.workspace_name()) { 521 + if old_wc_commit_id.as_ref() != Some(new_wc_commit_id) { 522 + let new_wc_commit = new_repo.store().get_commit(new_wc_commit_id) 523 + .context("Failed to get new working copy commit")?; 524 + self.workspace 525 + .check_out(new_op_id, old_tree_id.as_ref(), &new_wc_commit) 526 + .context("Failed to check out after undo")?; 527 + } 528 + } 529 + 530 + Ok(()) 386 531 } 387 532 }
+20 -5
apps/desktop/src/db.ts
··· 16 16 jjEdit, 17 17 jjNew, 18 18 removeRepository, 19 + undoOperation, 19 20 upsertRepository, 20 21 watchRepository, 21 22 } from "@/tauri-commands"; ··· 282 283 283 284 // Track the mutation and fire backend 284 285 trackMutation(mutationId, jjEdit(repoPath, targetRevision.change_id_short)) 285 - .then(() => { 286 + .then((_result) => { 286 287 // Invalidate to get fresh data from backend 287 288 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 288 289 toast.success(`Working copy is now ${targetRevision.change_id_short}`); ··· 349 350 }).pipe(Effect.tapError((error) => Effect.logError("jjNew failed", error))); 350 351 351 352 trackMutation(mutationId, Effect.runPromise(program)) 352 - .then((newChangeId) => { 353 + .then((result) => { 353 354 // Invalidate to get authoritative data (correct commit_id, short_id, etc.) 354 355 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 355 - const shortId = newChangeId.slice(0, 8); 356 + const shortId = result.change_id?.slice(0, 8) ?? "unknown"; 356 357 toast.success(`Working copy is now ${shortId}`, { 357 358 description: "Created new revision", 358 359 }); ··· 384 385 385 386 // Track the mutation and fire backend 386 387 trackMutation(mutationId, jjAbandon(repoPath, revision.change_id_short)) 387 - .then(() => { 388 + .then((result) => { 388 389 // Invalidate to get fresh data (especially for WC abandon which creates new WC) 389 390 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 390 - toast.success(`Abandoned revision ${revision.change_id_short}`); 391 + toast.success(`Abandoned revision ${revision.change_id_short}`, { 392 + action: { 393 + label: "Undo", 394 + onClick: () => { 395 + undoOperation(repoPath, result.operation_id) 396 + .then(() => { 397 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 398 + toast.success("Undo successful"); 399 + }) 400 + .catch((err) => { 401 + toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 402 + }); 403 + }, 404 + }, 405 + }); 391 406 }) 392 407 .catch((error) => { 393 408 // Re-add on failure (only if we deleted it)
+33 -6
apps/desktop/src/tauri-commands.ts
··· 73 73 return invoke<string[]>("generate_change_ids", { repoPath, count }); 74 74 } 75 75 76 + /** Result of a mutation operation */ 77 + export interface MutationResult { 78 + operation_id: string; 79 + change_id: string | null; 80 + } 81 + 76 82 export async function jjNew( 77 83 repoPath: string, 78 84 parentChangeIds: string[], 79 85 changeId?: string, 80 - ): Promise<string> { 81 - return invoke<string>("jj_new", { repoPath, parentChangeIds, changeId: changeId ?? null }); 86 + ): Promise<MutationResult> { 87 + return invoke<MutationResult>("jj_new", { repoPath, parentChangeIds, changeId: changeId ?? null }); 88 + } 89 + 90 + export async function jjEdit(repoPath: string, changeId: string): Promise<MutationResult> { 91 + return invoke<MutationResult>("jj_edit", { repoPath, changeId }); 92 + } 93 + 94 + export async function jjAbandon(repoPath: string, changeId: string): Promise<MutationResult> { 95 + return invoke<MutationResult>("jj_abandon", { repoPath, changeId }); 96 + } 97 + 98 + /** An operation in the jj operation log */ 99 + export interface Operation { 100 + id: string; 101 + parent_ids: string[]; 102 + description: string; 103 + timestamp: string; 104 + user: string; 105 + hostname: string; 106 + working_copy_change_id: string | null; 82 107 } 83 108 84 - export async function jjEdit(repoPath: string, changeId: string): Promise<void> { 85 - return invoke("jj_edit", { repoPath, changeId }); 109 + /** List operations from newest to oldest */ 110 + export async function getOperations(repoPath: string, limit: number): Promise<Operation[]> { 111 + return invoke<Operation[]>("get_operations", { repoPath, limit }); 86 112 } 87 113 88 - export async function jjAbandon(repoPath: string, changeId: string): Promise<void> { 89 - return invoke("jj_abandon", { repoPath, changeId }); 114 + /** Undo a specific operation by reverting it */ 115 + export async function undoOperation(repoPath: string, operationId: string): Promise<void> { 116 + return invoke("undo_operation", { repoPath, operationId }); 90 117 } 91 118 92 119 /** Get recency data for commits - returns commit_id (hex) -> timestamp_millis when last WC */