a very good jj gui
0
fork

Configure Feed

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

Sprint 1: add conflict badges and conflict resolution flow and global error boundary and inline revision/diff error states

+1515 -167
+489 -50
apps/desktop/src-tauri/src/lib.rs
··· 16 16 use std::path::{Path, PathBuf}; 17 17 use std::sync::Arc; 18 18 use storage::{AppLayout, Project, Storage, get_storage}; 19 - use tauri::{AppHandle, Emitter, Manager}; 20 19 use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; 20 + use tauri::{AppHandle, Emitter, Manager}; 21 21 use tauri_plugin_dialog::DialogExt; 22 22 use watcher::{WatcherManager, get_watcher_manager}; 23 23 ··· 61 61 async fn get_status(repo_path: String) -> Result<WorkingCopyStatus, String> { 62 62 let path = Path::new(&repo_path); 63 63 repo::status::fetch_status(path).map_err(|e| format!("Failed to fetch status: {}", e)) 64 + } 65 + 66 + #[tauri::command] 67 + async fn get_conflict_paths(repo_path: String, change_id: String) -> Result<Vec<String>, String> { 68 + let path = Path::new(&repo_path); 69 + let jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 70 + jj_repo 71 + .get_conflict_paths(&change_id) 72 + .map_err(|e| format!("Failed to get conflict paths: {}", e)) 64 73 } 65 74 66 75 #[tauri::command] ··· 362 371 } 363 372 364 373 /// Compute changed files for a single revision (helper function for batch processing) 365 - fn compute_revision_changes_inner(jj_repo: &JjRepo, change_id: &str) -> Result<Vec<ChangedFile>, String> { 374 + fn compute_revision_changes_inner( 375 + jj_repo: &JjRepo, 376 + change_id: &str, 377 + ) -> Result<Vec<ChangedFile>, String> { 366 378 use jj_lib::backend::TreeValue; 367 379 use jj_lib::matchers::EverythingMatcher; 368 380 ··· 439 451 // Process sequentially since JjRepo is not Sync 440 452 let results: Vec<RevisionDiff> = change_ids 441 453 .iter() 442 - .filter_map(|change_id| { 443 - match compute_revision_diff_inner(&jj_repo, change_id) { 454 + .filter_map( 455 + |change_id| match compute_revision_diff_inner(&jj_repo, change_id) { 444 456 Ok(diff) => Some(RevisionDiff { 445 457 change_id: change_id.clone(), 446 458 diff, 447 459 }), 448 460 Err(_) => None, 449 - } 450 - }) 461 + }, 462 + ) 451 463 .collect(); 452 464 453 465 let total_ms = batch_start.elapsed().as_millis(); ··· 459 471 batch_size, 460 472 total_ms, 461 473 open_repo_ms, 462 - if batch_size > 0 { total_ms / batch_size as u128 } else { 0 } 474 + if batch_size > 0 { 475 + total_ms / batch_size as u128 476 + } else { 477 + 0 478 + } 463 479 ); 464 480 } 465 481 ··· 477 493 // Process sequentially since JjRepo is not Sync 478 494 let results: Vec<RevisionChanges> = change_ids 479 495 .iter() 480 - .filter_map(|change_id| { 481 - match compute_revision_changes_inner(&jj_repo, change_id) { 496 + .filter_map( 497 + |change_id| match compute_revision_changes_inner(&jj_repo, change_id) { 482 498 Ok(files) => Some(RevisionChanges { 483 499 change_id: change_id.clone(), 484 500 files, 485 501 }), 486 502 Err(_) => None, 487 - } 488 - }) 503 + }, 504 + ) 489 505 .collect(); 490 506 491 507 Ok(results) ··· 599 615 } 600 616 601 617 #[tauri::command] 618 + async fn jj_describe( 619 + repo_path: String, 620 + change_id: String, 621 + description: String, 622 + ) -> Result<MutationResult, String> { 623 + let path = Path::new(&repo_path); 624 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 625 + jj_repo 626 + .describe_revision(&change_id, description) 627 + .map_err(|e| format!("Failed to describe revision: {}", e)) 628 + } 629 + 630 + #[tauri::command] 631 + async fn jj_git_fetch(repo_path: String, remote: Option<String>) -> Result<MutationResult, String> { 632 + let path = Path::new(&repo_path); 633 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 634 + jj_repo 635 + .git_fetch(remote) 636 + .map_err(|e| format!("Failed to fetch from git remote: {}", e)) 637 + } 638 + 639 + #[tauri::command] 640 + async fn jj_git_push( 641 + repo_path: String, 642 + remote: Option<String>, 643 + bookmark_names: Vec<String>, 644 + ) -> Result<MutationResult, String> { 645 + let path = Path::new(&repo_path); 646 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 647 + jj_repo 648 + .git_push(remote, bookmark_names) 649 + .map_err(|e| format!("Failed to push bookmarks to git remote: {}", e)) 650 + } 651 + 652 + #[tauri::command] 602 653 async fn get_operations(repo_path: String, limit: usize) -> Result<Vec<Operation>, String> { 603 654 let path = Path::new(&repo_path); 604 655 let jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; ··· 648 699 649 700 let results: Vec<LineageResult> = change_ids 650 701 .iter() 651 - .filter_map(|change_id| { 652 - repo::log::get_lineage(path, change_id).ok() 653 - }) 702 + .filter_map(|change_id| repo::log::get_lineage(path, change_id).ok()) 654 703 .collect(); 655 704 656 705 Ok(results) ··· 679 728 680 729 let content = match version.as_str() { 681 730 "current" => jj.get_file_content(&commit, &file_path).unwrap_or_default(), 682 - "parent" => jj.get_parent_file_content(&commit, &file_path).unwrap_or_default(), 731 + "parent" => jj 732 + .get_parent_file_content(&commit, &file_path) 733 + .unwrap_or_default(), 683 734 _ => return Err("Invalid version: use 'current' or 'parent'".to_string()), 684 735 }; 685 736 ··· 692 743 /// Handle "Open Project" menu action: show folder picker, find jj repo, save project, emit event 693 744 fn handle_open_project(app_handle: &AppHandle) { 694 745 let handle = app_handle.clone(); 695 - 746 + 696 747 app_handle.dialog().file().pick_folder(move |folder_path| { 697 748 let Some(folder) = folder_path else { return }; 698 749 let path_str = folder.to_string(); 699 - 750 + 700 751 // Find jj repo root 701 752 let Some(repo_path) = repo::find_jj_repo(&PathBuf::from(&path_str)) else { 702 753 // TODO: Could show an error dialog here 703 754 return; 704 755 }; 705 756 let repo_path_str = repo_path.to_string_lossy().to_string(); 706 - 757 + 707 758 // Save project and emit event for frontend navigation 708 759 let handle_clone = handle.clone(); 709 760 tauri::async_runtime::spawn(async move { 710 761 let storage = get_storage(&handle_clone); 711 - 762 + 712 763 // Check if project already exists 713 - let existing = storage.find_project_by_path(&repo_path_str).await.ok().flatten(); 714 - let project_id = existing.as_ref().map(|p| p.id.clone()) 764 + let existing = storage 765 + .find_project_by_path(&repo_path_str) 766 + .await 767 + .ok() 768 + .flatten(); 769 + let project_id = existing 770 + .as_ref() 771 + .map(|p| p.id.clone()) 715 772 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); 716 - 717 - let name = repo_path.file_name() 773 + 774 + let name = repo_path 775 + .file_name() 718 776 .and_then(|n| n.to_str()) 719 777 .unwrap_or("Unknown") 720 778 .to_string(); 721 - 779 + 722 780 let project = Project { 723 781 id: project_id.clone(), 724 782 path: repo_path_str, ··· 726 784 last_opened_at: chrono::Utc::now().timestamp_millis(), 727 785 revset_preset: None, 728 786 }; 729 - 787 + 730 788 if let Err(e) = storage.upsert_project(&project).await { 731 789 eprintln!("Failed to save project: {}", e); 732 790 return; 733 791 } 734 - 792 + 735 793 // Emit event for frontend to navigate 736 794 let _ = handle_clone.emit("open-project", project_id); 737 795 }); ··· 739 797 } 740 798 741 799 fn build_app_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> { 742 - let open_project = MenuItem::with_id(app, "open-project", "Open Project...", true, Some("Ctrl+Cmd+O"))?; 743 - 800 + let open_project = MenuItem::with_id( 801 + app, 802 + "open-project", 803 + "Open Project...", 804 + true, 805 + Some("Ctrl+Cmd+O"), 806 + )?; 807 + 744 808 let file_menu = SubmenuBuilder::new(app, "File") 745 809 .item(&open_project) 746 810 .separator() ··· 757 821 .select_all() 758 822 .build()?; 759 823 760 - let view_menu = SubmenuBuilder::new(app, "View") 761 - .fullscreen() 762 - .build()?; 824 + let view_menu = SubmenuBuilder::new(app, "View").fullscreen().build()?; 763 825 764 826 let window_menu = SubmenuBuilder::new(app, "Window") 765 827 .minimize() ··· 770 832 771 833 #[cfg(debug_assertions)] 772 834 let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?; 773 - 835 + 774 836 #[cfg(debug_assertions)] 775 837 let debug_menu = SubmenuBuilder::new(app, "Debug") 776 838 .item(&reload_item) ··· 821 883 } 822 884 823 885 // Handle menu events 824 - app.on_menu_event(|app_handle, event| { 825 - match event.id().0.as_str() { 826 - "open-project" => handle_open_project(app_handle), 827 - #[cfg(debug_assertions)] 828 - "reload" => { 829 - if let Some(window) = app_handle.get_webview_window("main") { 830 - let _ = window.eval("window.location.reload()"); 831 - } 886 + app.on_menu_event(|app_handle, event| match event.id().0.as_str() { 887 + "open-project" => handle_open_project(app_handle), 888 + #[cfg(debug_assertions)] 889 + "reload" => { 890 + if let Some(window) = app_handle.get_webview_window("main") { 891 + let _ = window.eval("window.location.reload()"); 832 892 } 833 - _ => {} 834 893 } 894 + _ => {} 835 895 }); 836 896 837 897 Ok(()) ··· 840 900 find_repository, 841 901 get_revisions, 842 902 get_status, 903 + get_conflict_paths, 843 904 get_file_diff, 844 905 get_revision_diff, 845 906 get_revision_changes, ··· 861 922 jj_new, 862 923 jj_edit, 863 924 jj_abandon, 925 + jj_describe, 926 + jj_git_fetch, 927 + jj_git_push, 864 928 get_operations, 865 929 undo_operation, 866 930 ]) ··· 909 973 /// Get the working copy change ID. 910 974 fn get_wc_change_id(repo_path: &Path) -> String { 911 975 let jj_repo = JjRepo::open(repo_path).expect("Failed to open repo"); 912 - let repo = jj_repo.repo_loader().load_at_head().expect("Failed to load repo"); 976 + let repo = jj_repo 977 + .repo_loader() 978 + .load_at_head() 979 + .expect("Failed to load repo"); 913 980 let wc_commit_id = repo 914 981 .view() 915 982 .get_wc_commit_id(jj_repo.workspace_name()) 916 983 .expect("No working copy"); 917 - let wc_commit = repo.store().get_commit(wc_commit_id).expect("Failed to get commit"); 984 + let wc_commit = repo 985 + .store() 986 + .get_commit(wc_commit_id) 987 + .expect("Failed to get commit"); 918 988 wc_commit.change_id().reverse_hex() 919 989 } 920 990 991 + fn create_conflicted_working_copy(repo_path: &Path) -> String { 992 + fs::write(repo_path.join("f.txt"), "base\n").expect("Failed to write base file"); 993 + snapshot_working_copy(repo_path); 994 + let base_change_id = get_wc_change_id(repo_path); 995 + 996 + let left_new = Command::new("jj") 997 + .args(["new", "-r", "@"]) 998 + .current_dir(repo_path) 999 + .status() 1000 + .expect("Failed to create left branch"); 1001 + assert!(left_new.success(), "jj new for left branch failed"); 1002 + 1003 + fs::write(repo_path.join("f.txt"), "left\n").expect("Failed to write left file"); 1004 + snapshot_working_copy(repo_path); 1005 + let left_change_id = get_wc_change_id(repo_path); 1006 + 1007 + let edit_base = Command::new("jj") 1008 + .args(["edit", &base_change_id]) 1009 + .current_dir(repo_path) 1010 + .status() 1011 + .expect("Failed to return to base"); 1012 + assert!(edit_base.success(), "jj edit base failed"); 1013 + 1014 + let right_new = Command::new("jj") 1015 + .args(["new", "-r", "@"]) 1016 + .current_dir(repo_path) 1017 + .status() 1018 + .expect("Failed to create right branch"); 1019 + assert!(right_new.success(), "jj new for right branch failed"); 1020 + 1021 + fs::write(repo_path.join("f.txt"), "right\n").expect("Failed to write right file"); 1022 + snapshot_working_copy(repo_path); 1023 + let right_change_id = get_wc_change_id(repo_path); 1024 + 1025 + let merge = Command::new("jj") 1026 + .args(["new", &left_change_id, &right_change_id]) 1027 + .current_dir(repo_path) 1028 + .status() 1029 + .expect("Failed to create merge conflict"); 1030 + assert!(merge.success(), "jj new merge failed"); 1031 + 1032 + get_wc_change_id(repo_path) 1033 + } 1034 + 1035 + #[test] 1036 + fn test_get_conflict_paths() { 1037 + let (_temp_dir, repo_path) = create_test_repo(); 1038 + let conflicted_change_id = create_conflicted_working_copy(&repo_path); 1039 + 1040 + let jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1041 + let conflict_paths = jj_repo 1042 + .get_conflict_paths(&conflicted_change_id) 1043 + .expect("Failed to get conflict paths"); 1044 + 1045 + assert_eq!(conflict_paths, vec!["f.txt".to_string()]); 1046 + } 1047 + 1048 + #[test] 1049 + fn test_working_copy_status_has_conflict() { 1050 + let (_temp_dir, repo_path) = create_test_repo(); 1051 + create_conflicted_working_copy(&repo_path); 1052 + 1053 + let status = repo::status::fetch_status(&repo_path).expect("Failed to fetch status"); 1054 + assert!(status.has_conflict, "Working copy status should report conflict"); 1055 + } 1056 + 921 1057 #[test] 922 1058 fn test_compute_revision_diff_inner_with_changes() { 923 1059 let (temp_dir, repo_path) = create_test_repo(); ··· 939 1075 940 1076 let diff = result.unwrap(); 941 1077 // The diff should show the new file 942 - assert!(diff.contains("test.txt"), "Diff should contain the filename"); 943 - assert!(diff.contains("Hello, world!"), "Diff should contain the file content"); 1078 + assert!( 1079 + diff.contains("test.txt"), 1080 + "Diff should contain the filename" 1081 + ); 1082 + assert!( 1083 + diff.contains("Hello, world!"), 1084 + "Diff should contain the file content" 1085 + ); 944 1086 945 1087 drop(temp_dir); // Cleanup 946 1088 } ··· 955 1097 956 1098 // Compute diff for empty commit 957 1099 let result = compute_revision_diff_inner(&jj_repo, &change_id); 958 - assert!(result.is_ok(), "compute_revision_diff_inner should succeed for empty commit"); 1100 + assert!( 1101 + result.is_ok(), 1102 + "compute_revision_diff_inner should succeed for empty commit" 1103 + ); 959 1104 960 1105 let diff = result.unwrap(); 961 - assert!(diff.is_empty(), "Diff should be empty for commit with no changes"); 1106 + assert!( 1107 + diff.is_empty(), 1108 + "Diff should be empty for commit with no changes" 1109 + ); 962 1110 963 1111 drop(temp_dir); 964 1112 } ··· 980 1128 981 1129 // Compute changes 982 1130 let result = compute_revision_changes_inner(&jj_repo, &change_id); 983 - assert!(result.is_ok(), "compute_revision_changes_inner should succeed"); 1131 + assert!( 1132 + result.is_ok(), 1133 + "compute_revision_changes_inner should succeed" 1134 + ); 984 1135 985 1136 let files = result.unwrap(); 986 1137 assert_eq!(files.len(), 1, "Should have exactly one changed file"); ··· 1000 1151 1001 1152 // Compute changes for empty commit 1002 1153 let result = compute_revision_changes_inner(&jj_repo, &change_id); 1003 - assert!(result.is_ok(), "compute_revision_changes_inner should succeed for empty commit"); 1154 + assert!( 1155 + result.is_ok(), 1156 + "compute_revision_changes_inner should succeed for empty commit" 1157 + ); 1004 1158 1005 1159 let files = result.unwrap(); 1006 - assert!(files.is_empty(), "Should have no changed files for empty commit"); 1160 + assert!( 1161 + files.is_empty(), 1162 + "Should have no changed files for empty commit" 1163 + ); 1007 1164 1008 1165 drop(temp_dir); 1009 1166 } ··· 1139 1296 assert_eq!(results[0].files[0].status, "added"); 1140 1297 1141 1298 drop(temp_dir); 1299 + } 1300 + 1301 + fn get_root_change_id(repo_path: &Path) -> String { 1302 + let jj_repo = JjRepo::open(repo_path).expect("Failed to open repo"); 1303 + let repo = jj_repo 1304 + .repo_loader() 1305 + .load_at_head() 1306 + .expect("Failed to load repo"); 1307 + let root_commit = repo 1308 + .store() 1309 + .get_commit(repo.store().root_commit_id()) 1310 + .expect("Failed to get root commit"); 1311 + root_commit.change_id().reverse_hex() 1312 + } 1313 + 1314 + fn setup_git_remote_with_main_branch() -> (TempDir, PathBuf) { 1315 + let temp_dir = TempDir::new().expect("Failed to create temp directory for git remote"); 1316 + let remote_path = temp_dir.path().join("remote.git"); 1317 + let seed_path = temp_dir.path().join("seed"); 1318 + 1319 + let init_remote = Command::new("git") 1320 + .args([ 1321 + "init", 1322 + "--bare", 1323 + remote_path.to_str().expect("Invalid remote path"), 1324 + ]) 1325 + .status() 1326 + .expect("Failed to initialize bare git remote"); 1327 + assert!(init_remote.success(), "git init --bare failed"); 1328 + 1329 + fs::create_dir_all(&seed_path).expect("Failed to create seed repo dir"); 1330 + 1331 + let init_seed = Command::new("git") 1332 + .args(["init"]) 1333 + .current_dir(&seed_path) 1334 + .status() 1335 + .expect("Failed to initialize seed git repo"); 1336 + assert!(init_seed.success(), "git init failed for seed repo"); 1337 + 1338 + let config_name = Command::new("git") 1339 + .args(["config", "user.name", "Tatami Test"]) 1340 + .current_dir(&seed_path) 1341 + .status() 1342 + .expect("Failed to configure git user.name"); 1343 + assert!(config_name.success(), "git config user.name failed"); 1344 + 1345 + let config_email = Command::new("git") 1346 + .args(["config", "user.email", "tatami-tests@example.com"]) 1347 + .current_dir(&seed_path) 1348 + .status() 1349 + .expect("Failed to configure git user.email"); 1350 + assert!(config_email.success(), "git config user.email failed"); 1351 + 1352 + fs::write(seed_path.join("README.md"), "seed\n").expect("Failed to write seed file"); 1353 + 1354 + let add = Command::new("git") 1355 + .args(["add", "README.md"]) 1356 + .current_dir(&seed_path) 1357 + .status() 1358 + .expect("Failed to git add seed file"); 1359 + assert!(add.success(), "git add failed"); 1360 + 1361 + let commit = Command::new("git") 1362 + .args(["commit", "-m", "seed commit"]) 1363 + .current_dir(&seed_path) 1364 + .status() 1365 + .expect("Failed to create seed commit"); 1366 + assert!(commit.success(), "git commit failed"); 1367 + 1368 + let rename_branch = Command::new("git") 1369 + .args(["branch", "-M", "main"]) 1370 + .current_dir(&seed_path) 1371 + .status() 1372 + .expect("Failed to rename seed branch to main"); 1373 + assert!(rename_branch.success(), "git branch -M main failed"); 1374 + 1375 + let add_remote = Command::new("git") 1376 + .args([ 1377 + "remote", 1378 + "add", 1379 + "origin", 1380 + remote_path.to_str().expect("Invalid remote path"), 1381 + ]) 1382 + .current_dir(&seed_path) 1383 + .status() 1384 + .expect("Failed to add origin remote in seed repo"); 1385 + assert!(add_remote.success(), "git remote add origin failed"); 1386 + 1387 + let push_main = Command::new("git") 1388 + .args(["push", "-u", "origin", "main"]) 1389 + .current_dir(&seed_path) 1390 + .status() 1391 + .expect("Failed to push seed main branch"); 1392 + assert!(push_main.success(), "git push origin main failed"); 1393 + 1394 + (temp_dir, remote_path) 1395 + } 1396 + 1397 + #[test] 1398 + fn test_describe_revision() { 1399 + let (temp_dir, repo_path) = create_test_repo(); 1400 + 1401 + // Create a file so we have a non-root working-copy commit to describe 1402 + let file_path = repo_path.join("describe.txt"); 1403 + fs::write(&file_path, "describe me\n").expect("Failed to write file"); 1404 + snapshot_working_copy(&repo_path); 1405 + 1406 + let change_id = get_wc_change_id(&repo_path); 1407 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1408 + 1409 + let result = jj_repo 1410 + .describe_revision(&change_id, "New description".to_string()) 1411 + .expect("describe_revision should succeed"); 1412 + 1413 + assert!( 1414 + !result.operation_id.is_empty(), 1415 + "Mutation result should include operation_id" 1416 + ); 1417 + 1418 + // Change ID should stay the same after describe rewrite 1419 + assert_eq!(get_wc_change_id(&repo_path), change_id); 1420 + 1421 + let described_commit = jj_repo 1422 + .get_commit(&change_id) 1423 + .expect("Failed to load described commit"); 1424 + assert_eq!(described_commit.description(), "New description"); 1425 + 1426 + let operations = jj_repo 1427 + .list_operations(20) 1428 + .expect("Failed to list operations"); 1429 + assert!( 1430 + operations.iter().any(|op| op.id == result.operation_id), 1431 + "Describe operation should appear in operation log" 1432 + ); 1433 + 1434 + drop(temp_dir); 1435 + } 1436 + 1437 + #[test] 1438 + fn test_describe_immutable_rejected() { 1439 + let (temp_dir, repo_path) = create_test_repo(); 1440 + 1441 + let root_change_id = get_root_change_id(&repo_path); 1442 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1443 + 1444 + let error = jj_repo 1445 + .describe_revision(&root_change_id, "should fail".to_string()) 1446 + .expect_err("Describing immutable root commit should fail"); 1447 + 1448 + assert!( 1449 + error.to_string().contains("immutable"), 1450 + "Error should mention immutable" 1451 + ); 1452 + 1453 + drop(temp_dir); 1454 + } 1455 + 1456 + #[test] 1457 + fn test_describe_empty_message() { 1458 + let (temp_dir, repo_path) = create_test_repo(); 1459 + 1460 + // Create a file so we have a mutable commit to describe 1461 + let file_path = repo_path.join("empty-message.txt"); 1462 + fs::write(&file_path, "empty\n").expect("Failed to write file"); 1463 + snapshot_working_copy(&repo_path); 1464 + 1465 + let change_id = get_wc_change_id(&repo_path); 1466 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1467 + 1468 + jj_repo 1469 + .describe_revision(&change_id, "".to_string()) 1470 + .expect("Empty description should be allowed"); 1471 + 1472 + let described_commit = jj_repo 1473 + .get_commit(&change_id) 1474 + .expect("Failed to load described commit"); 1475 + assert_eq!(described_commit.description(), ""); 1476 + 1477 + drop(temp_dir); 1478 + } 1479 + 1480 + #[test] 1481 + fn test_git_fetch_no_remote() { 1482 + let (_temp_dir, repo_path) = create_test_repo(); 1483 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1484 + 1485 + let err = jj_repo 1486 + .git_fetch(None) 1487 + .expect_err("git_fetch should fail when no remotes are configured"); 1488 + assert!( 1489 + err.to_string().contains("No git remotes configured"), 1490 + "Error should mention missing remotes" 1491 + ); 1492 + } 1493 + 1494 + #[test] 1495 + fn test_git_fetch_with_remote() { 1496 + let (_temp_dir, repo_path) = create_test_repo(); 1497 + let (_remote_temp_dir, remote_path) = setup_git_remote_with_main_branch(); 1498 + 1499 + let add_remote = Command::new("git") 1500 + .args([ 1501 + "remote", 1502 + "add", 1503 + "origin", 1504 + remote_path.to_str().expect("Invalid remote path"), 1505 + ]) 1506 + .current_dir(&repo_path) 1507 + .status() 1508 + .expect("Failed to add origin remote to jj repo"); 1509 + assert!(add_remote.success(), "git remote add origin failed"); 1510 + 1511 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1512 + let result = jj_repo 1513 + .git_fetch(None) 1514 + .expect("git_fetch should succeed with a configured remote"); 1515 + 1516 + assert!( 1517 + !result.operation_id.is_empty(), 1518 + "git_fetch should return an operation id" 1519 + ); 1520 + 1521 + let repo = jj_repo 1522 + .repo_loader() 1523 + .load_at_head() 1524 + .expect("Failed to reload repo after fetch"); 1525 + assert!( 1526 + repo.view().all_remote_bookmarks().any(|(symbol, _)| { 1527 + symbol.remote.as_str() == "origin" && symbol.name.as_str() == "main" 1528 + }), 1529 + "Expected fetched main@origin remote bookmark to exist after fetch" 1530 + ); 1531 + } 1532 + 1533 + #[test] 1534 + fn test_git_push_bookmark() { 1535 + let (_temp_dir, repo_path) = create_test_repo(); 1536 + let (_remote_temp_dir, remote_path) = setup_git_remote_with_main_branch(); 1537 + 1538 + let add_remote = Command::new("git") 1539 + .args([ 1540 + "remote", 1541 + "add", 1542 + "origin", 1543 + remote_path.to_str().expect("Invalid remote path"), 1544 + ]) 1545 + .current_dir(&repo_path) 1546 + .status() 1547 + .expect("Failed to add origin remote to jj repo"); 1548 + assert!(add_remote.success(), "git remote add origin failed"); 1549 + 1550 + let set_bookmark = Command::new("jj") 1551 + .args(["bookmark", "set", "feature", "-r", "@"]) 1552 + .current_dir(&repo_path) 1553 + .status() 1554 + .expect("Failed to create feature bookmark"); 1555 + assert!(set_bookmark.success(), "jj bookmark set failed"); 1556 + 1557 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1558 + let result = jj_repo 1559 + .git_push(None, vec!["feature".to_string()]) 1560 + .expect("git_push should succeed for existing bookmark"); 1561 + 1562 + assert!( 1563 + !result.operation_id.is_empty(), 1564 + "git_push should return an operation id" 1565 + ); 1566 + 1567 + let remote_ref = Command::new("git") 1568 + .args([ 1569 + "--git-dir", 1570 + remote_path.to_str().expect("Invalid remote path"), 1571 + "rev-parse", 1572 + "--verify", 1573 + "refs/heads/feature", 1574 + ]) 1575 + .status() 1576 + .expect("Failed to verify feature branch on remote"); 1577 + assert!( 1578 + remote_ref.success(), 1579 + "Expected pushed feature branch to exist on remote" 1580 + ); 1142 1581 } 1143 1582 }
+269 -42
apps/desktop/src-tauri/src/repo/jj.rs
··· 2 2 use jj_lib::backend::{ChangeId, CommitId}; 3 3 use jj_lib::commit::Commit; 4 4 use jj_lib::config::ConfigSource; 5 + use jj_lib::git; 5 6 use jj_lib::merged_tree::MergedTree; 6 7 use jj_lib::object_id::{HexPrefix, ObjectId, PrefixResolution}; 7 8 use jj_lib::op_store::OperationId; ··· 11 12 use jj_lib::settings::UserSettings; 12 13 use jj_lib::workspace::{Workspace, default_working_copy_factories}; 13 14 use serde::Serialize; 14 - use std::collections::HashMap; 15 + use std::collections::{HashMap, HashSet}; 15 16 use std::path::Path; 16 17 use tokio::io::AsyncReadExt; 17 18 ··· 105 106 let repo = self.workspace.repo_loader().load_at_head()?; 106 107 let commit_id = self.resolve_change_id(repo.as_ref(), change_id)?; 107 108 Ok(repo.store().get_commit(&commit_id)?) 109 + } 110 + 111 + pub fn get_conflict_paths(&self, change_id: &str) -> Result<Vec<String>> { 112 + let repo = self.workspace.repo_loader().load_at_head()?; 113 + let commit_id = self.resolve_change_id(repo.as_ref(), change_id)?; 114 + let commit = repo.store().get_commit(&commit_id)?; 115 + let tree = commit.tree()?; 116 + 117 + let mut paths = Vec::new(); 118 + for (path, value) in tree.conflicts() { 119 + value?; 120 + paths.push(path.as_internal_file_string().to_string()); 121 + } 122 + 123 + paths.sort(); 124 + paths.dedup(); 125 + Ok(paths) 108 126 } 109 127 110 128 #[allow(dead_code)] // May be used in future features ··· 142 160 match file_value.into_resolved() { 143 161 Ok(Some(value)) => match value { 144 162 TreeValue::File { id, .. } => { 145 - let mut reader = pollster::block_on(async { 146 - repo.store().read_file(repo_path, &id).await 147 - })?; 163 + let mut reader = 164 + pollster::block_on(async { repo.store().read_file(repo_path, &id).await })?; 148 165 let mut content = Vec::new(); 149 166 pollster::block_on(async { reader.read_to_end(&mut content).await })?; 150 167 Ok(content) ··· 255 272 let mut parent_commits = Vec::new(); 256 273 for change_id in parent_change_ids { 257 274 let commit_id = self.resolve_change_id(repo.as_ref(), &change_id)?; 258 - let commit = repo.store().get_commit(&commit_id) 275 + let commit = repo 276 + .store() 277 + .get_commit(&commit_id) 259 278 .map_err(|e| anyhow::anyhow!("Failed to get commit: {}", e))?; 260 279 parent_commits.push(commit); 261 280 } ··· 273 292 274 293 // Set pre-generated change ID if provided 275 294 if let Some(ref cid) = change_id { 276 - let parsed = ChangeId::try_from_reverse_hex(cid) 277 - .context("Invalid change ID format")?; 295 + let parsed = ChangeId::try_from_reverse_hex(cid).context("Invalid change ID format")?; 278 296 commit_builder = commit_builder.set_change_id(parsed); 279 297 } 280 298 ··· 294 312 // Get old tree for checkout 295 313 let old_commit = repo 296 314 .store() 297 - .get_commit(repo.view().get_wc_commit_id(self.workspace.workspace_name()) 298 - .context("No working copy commit")?) 315 + .get_commit( 316 + repo.view() 317 + .get_wc_commit_id(self.workspace.workspace_name()) 318 + .context("No working copy commit")?, 319 + ) 299 320 .map_err(|e| anyhow::anyhow!("Failed to get old commit: {}", e))?; 300 321 let old_tree_id = old_commit.tree_id().clone(); 301 322 ··· 320 341 321 342 // Resolve change ID to commit 322 343 let commit_id = self.resolve_change_id(repo.as_ref(), change_id)?; 323 - let commit = repo.store().get_commit(&commit_id) 344 + let commit = repo 345 + .store() 346 + .get_commit(&commit_id) 324 347 .map_err(|e| anyhow::anyhow!("Failed to get commit: {}", e))?; 325 348 326 349 // Get the current working copy info before changes ··· 338 361 339 362 // If we abandoned the working copy, check out the parent 340 363 let final_op_id = if is_abandoning_wc { 341 - let parent_id = commit.parent_ids().first().cloned() 364 + let parent_id = commit 365 + .parent_ids() 366 + .first() 367 + .cloned() 342 368 .context("Abandoned commit has no parent")?; 343 369 let parent_commit = repo.store().get_commit(&parent_id)?; 344 370 ··· 357 383 self.workspace 358 384 .check_out(operation_id.clone(), Some(&old_tree_id), &parent_commit) 359 385 .context("Failed to check out parent commit")?; 360 - 386 + 361 387 operation_id 362 388 } else { 363 389 // Finalize transaction ··· 377 403 378 404 // Resolve change ID to commit 379 405 let commit_id = self.resolve_change_id(repo.as_ref(), &change_id)?; 380 - let commit = repo.store().get_commit(&commit_id) 406 + let commit = repo 407 + .store() 408 + .get_commit(&commit_id) 381 409 .map_err(|e| anyhow::anyhow!("Failed to get commit: {}", e))?; 382 410 383 411 // Set as working copy ··· 389 417 // Get old tree for checkout 390 418 let old_commit = repo 391 419 .store() 392 - .get_commit(repo.view().get_wc_commit_id(self.workspace.workspace_name()) 393 - .context("No working copy commit")?) 420 + .get_commit( 421 + repo.view() 422 + .get_wc_commit_id(self.workspace.workspace_name()) 423 + .context("No working copy commit")?, 424 + ) 394 425 .map_err(|e| anyhow::anyhow!("Failed to get old commit: {}", e))?; 395 426 let old_tree_id = old_commit.tree_id().clone(); 396 427 ··· 409 440 }) 410 441 } 411 442 443 + pub fn describe_revision( 444 + &mut self, 445 + change_id: &str, 446 + description: String, 447 + ) -> Result<MutationResult> { 448 + let repo = self.workspace.repo_loader().load_at_head()?; 449 + let mut tx = repo.start_transaction(); 450 + 451 + // Resolve change ID to commit 452 + let commit_id = self.resolve_change_id(repo.as_ref(), change_id)?; 453 + let commit = repo 454 + .store() 455 + .get_commit(&commit_id) 456 + .map_err(|e| anyhow::anyhow!("Failed to get commit: {}", e))?; 457 + 458 + // For now, only root commit is immutable in this app's revset model 459 + if commit.id() == repo.store().root_commit_id() { 460 + anyhow::bail!("Cannot describe immutable commit"); 461 + } 462 + 463 + let workspace_name = self.workspace.workspace_name(); 464 + let wc_commit_id = repo.view().get_wc_commit_id(workspace_name).cloned(); 465 + let is_describing_wc = wc_commit_id.as_ref() == Some(commit.id()); 466 + let old_tree_id = if is_describing_wc { 467 + Some(commit.tree_id().clone()) 468 + } else { 469 + None 470 + }; 471 + 472 + let new_commit = tx 473 + .repo_mut() 474 + .rewrite_commit(&commit) 475 + .set_description(description) 476 + .write() 477 + .map_err(|e| anyhow::anyhow!("Failed to write rewritten commit: {}", e))?; 478 + 479 + tx.repo_mut().rebase_descendants()?; 480 + 481 + let new_repo = tx.commit("describe")?; 482 + let operation_id = new_repo.operation().id().clone(); 483 + 484 + // Keep working copy metadata in sync if we rewrote the checked-out commit. 485 + // Tree content is unchanged, so this won't modify files on disk. 486 + if is_describing_wc { 487 + self.workspace 488 + .check_out(operation_id.clone(), old_tree_id.as_ref(), &new_commit) 489 + .context("Failed to keep working copy in sync after describe")?; 490 + } 491 + 492 + Ok(MutationResult { 493 + operation_id: operation_id.hex(), 494 + change_id: None, 495 + }) 496 + } 497 + 498 + pub fn git_fetch(&mut self, remote: Option<String>) -> Result<MutationResult> { 499 + let repo = self.workspace.repo_loader().load_at_head()?; 500 + let remote_name: jj_lib::ref_name::RemoteNameBuf = 501 + remote.unwrap_or_else(|| "origin".to_string()).into(); 502 + 503 + let remotes = git::get_all_remote_names(repo.store())?; 504 + if remotes.is_empty() { 505 + anyhow::bail!("No git remotes configured"); 506 + } 507 + if !remotes.iter().any(|r| r == &remote_name) { 508 + anyhow::bail!("No git remote named '{}'", remote_name.as_str()); 509 + } 510 + 511 + let git_settings = self 512 + .user_settings 513 + .git_settings() 514 + .context("Failed to load git settings")?; 515 + let git_repo = git::get_git_repo(repo.store())?; 516 + let (_, refspecs) = git::expand_default_fetch_refspecs(remote_name.as_ref(), &git_repo)?; 517 + 518 + let mut tx = repo.start_transaction(); 519 + let mut git_fetch = git::GitFetch::new(tx.repo_mut(), &git_settings)?; 520 + git_fetch.fetch( 521 + remote_name.as_ref(), 522 + refspecs, 523 + git::RemoteCallbacks::default(), 524 + None, 525 + None, 526 + )?; 527 + git_fetch.import_refs()?; 528 + 529 + let new_repo = tx.commit(format!("fetch from git remote {}", remote_name.as_str()))?; 530 + 531 + Ok(MutationResult { 532 + operation_id: new_repo.operation().id().hex(), 533 + change_id: None, 534 + }) 535 + } 536 + 537 + pub fn git_push( 538 + &mut self, 539 + remote: Option<String>, 540 + bookmark_names: Vec<String>, 541 + ) -> Result<MutationResult> { 542 + let repo = self.workspace.repo_loader().load_at_head()?; 543 + let remote_name: jj_lib::ref_name::RemoteNameBuf = 544 + remote.unwrap_or_else(|| "origin".to_string()).into(); 545 + 546 + let remotes = git::get_all_remote_names(repo.store())?; 547 + if remotes.is_empty() { 548 + anyhow::bail!("No git remotes configured"); 549 + } 550 + if !remotes.iter().any(|r| r == &remote_name) { 551 + anyhow::bail!("No git remote named '{}'", remote_name.as_str()); 552 + } 553 + 554 + let requested: HashSet<&str> = bookmark_names.iter().map(String::as_str).collect(); 555 + let mut found_any = false; 556 + let mut branch_updates = Vec::new(); 557 + for (name, targets) in repo.view().local_remote_bookmarks(remote_name.as_ref()) { 558 + if !requested.contains(name.as_str()) { 559 + continue; 560 + } 561 + found_any = true; 562 + 563 + match jj_lib::refs::classify_bookmark_push_action(targets) { 564 + jj_lib::refs::BookmarkPushAction::Update(update) => { 565 + branch_updates.push((name.to_owned(), update)); 566 + } 567 + jj_lib::refs::BookmarkPushAction::AlreadyMatches => {} 568 + jj_lib::refs::BookmarkPushAction::LocalConflicted => { 569 + anyhow::bail!("Cannot push conflicted local bookmark '{}'", name.as_str()) 570 + } 571 + jj_lib::refs::BookmarkPushAction::RemoteConflicted => { 572 + anyhow::bail!( 573 + "Cannot push bookmark '{}' because remote is conflicted", 574 + name.as_str() 575 + ) 576 + } 577 + jj_lib::refs::BookmarkPushAction::RemoteUntracked => { 578 + anyhow::bail!( 579 + "Cannot push bookmark '{}' because remote bookmark is untracked", 580 + name.as_str() 581 + ) 582 + } 583 + } 584 + } 585 + 586 + if !found_any { 587 + anyhow::bail!( 588 + "No matching bookmarks found to push: {}", 589 + bookmark_names.join(", ") 590 + ); 591 + } 592 + 593 + if branch_updates.is_empty() { 594 + anyhow::bail!( 595 + "No bookmark updates to push for remote '{}': {}", 596 + remote_name.as_str(), 597 + bookmark_names.join(", ") 598 + ); 599 + } 600 + 601 + let git_settings = self 602 + .user_settings 603 + .git_settings() 604 + .context("Failed to load git settings")?; 605 + 606 + let mut tx = repo.start_transaction(); 607 + git::push_branches( 608 + tx.repo_mut(), 609 + &git_settings, 610 + remote_name.as_ref(), 611 + &git::GitBranchPushTargets { branch_updates }, 612 + git::RemoteCallbacks::default(), 613 + )?; 614 + 615 + let new_repo = tx.commit(format!("push to git remote {}", remote_name.as_str()))?; 616 + 617 + Ok(MutationResult { 618 + operation_id: new_repo.operation().id().hex(), 619 + change_id: None, 620 + }) 621 + } 622 + 412 623 /// Walk the operation log to find when each commit was last the working copy. 413 624 /// Returns a map of commit_id (hex) -> timestamp_millis. 414 625 /// This is used to determine "recency" for branch ordering. ··· 468 679 469 680 // Get the working copy change ID from this operation's view 470 681 let working_copy_change_id = op.view().ok().and_then(|view| { 471 - view.wc_commit_ids().get(workspace_name).and_then(|commit_id| { 472 - // Look up the commit to get its change ID 473 - repo.store().get_commit(commit_id).ok().map(|commit| { 474 - commit.change_id().reverse_hex() 682 + view.wc_commit_ids() 683 + .get(workspace_name) 684 + .and_then(|commit_id| { 685 + // Look up the commit to get its change ID 686 + repo.store() 687 + .get_commit(commit_id) 688 + .ok() 689 + .map(|commit| commit.change_id().reverse_hex()) 475 690 }) 476 - }) 477 691 }); 478 692 479 693 // Format timestamp as ISO 8601 480 - let timestamp = chrono::DateTime::from_timestamp_millis(metadata.time.start.timestamp.0) 481 - .map(|dt| dt.to_rfc3339()) 482 - .unwrap_or_else(|| "unknown".to_string()); 694 + let timestamp = 695 + chrono::DateTime::from_timestamp_millis(metadata.time.start.timestamp.0) 696 + .map(|dt| dt.to_rfc3339()) 697 + .unwrap_or_else(|| "unknown".to_string()); 483 698 484 699 operations.push(Operation { 485 700 id: op.id().hex(), ··· 498 713 /// Undo a specific operation by reverting it (3-way merge to invert just that op) 499 714 pub fn undo_operation(&mut self, op_id_hex: &str) -> Result<()> { 500 715 let repo = self.workspace.repo_loader().load_at_head()?; 501 - 716 + 502 717 // Parse the operation ID 503 - let op_id = OperationId::try_from_hex(op_id_hex) 504 - .context("Invalid operation ID format")?; 505 - 718 + let op_id = OperationId::try_from_hex(op_id_hex).context("Invalid operation ID format")?; 719 + 506 720 // Load the operation to undo 507 - let bad_op = repo.loader().load_operation(&op_id) 721 + let bad_op = repo 722 + .loader() 723 + .load_operation(&op_id) 508 724 .context("Failed to load operation to undo")?; 509 - 725 + 510 726 // Get the parent operation (the state before the bad op) 511 - let parent_op = bad_op.parents() 727 + let parent_op = bad_op 728 + .parents() 512 729 .next() 513 730 .context("Operation has no parent (cannot undo root operation)")? 514 731 .context("Failed to load parent operation")?; 515 - 732 + 516 733 // Start a transaction for the revert 517 734 let mut tx = repo.start_transaction(); 518 - 735 + 519 736 // Load repos at both states for 3-way merge 520 737 let repo_loader = tx.base_repo().loader(); 521 - let bad_repo = repo_loader.load_at(&bad_op) 738 + let bad_repo = repo_loader 739 + .load_at(&bad_op) 522 740 .context("Failed to load repo at bad operation")?; 523 - let parent_repo = repo_loader.load_at(&parent_op) 741 + let parent_repo = repo_loader 742 + .load_at(&parent_op) 524 743 .context("Failed to load repo at parent operation")?; 525 - 744 + 526 745 // Perform 3-way merge to revert the operation 527 746 tx.repo_mut().merge(&bad_repo, &parent_repo)?; 528 - 747 + 529 748 // Get old tree for checkout (current WC) 530 749 let old_wc_commit_id = repo 531 750 .view() ··· 535 754 .as_ref() 536 755 .and_then(|id| repo.store().get_commit(id).ok()) 537 756 .map(|c| c.tree_id().clone()); 538 - 757 + 539 758 // Commit the revert 540 - let new_repo = tx.commit(format!("undo operation {}", &op_id_hex[..12.min(op_id_hex.len())]))?; 759 + let new_repo = tx.commit(format!( 760 + "undo operation {}", 761 + &op_id_hex[..12.min(op_id_hex.len())] 762 + ))?; 541 763 let new_op_id = new_repo.operation().id().clone(); 542 - 764 + 543 765 // Update working copy if it changed 544 - if let Some(new_wc_commit_id) = new_repo.view().get_wc_commit_id(self.workspace.workspace_name()) { 766 + if let Some(new_wc_commit_id) = new_repo 767 + .view() 768 + .get_wc_commit_id(self.workspace.workspace_name()) 769 + { 545 770 if old_wc_commit_id.as_ref() != Some(new_wc_commit_id) { 546 - let new_wc_commit = new_repo.store().get_commit(new_wc_commit_id) 771 + let new_wc_commit = new_repo 772 + .store() 773 + .get_commit(new_wc_commit_id) 547 774 .context("Failed to get new working copy commit")?; 548 775 self.workspace 549 776 .check_out(new_op_id, old_tree_id.as_ref(), &new_wc_commit) 550 777 .context("Failed to check out after undo")?; 551 778 } 552 779 } 553 - 780 + 554 781 Ok(()) 555 782 } 556 783 }
+2
apps/desktop/src-tauri/src/repo/status.rs
··· 13 13 pub commit_id: String, 14 14 pub description: String, 15 15 pub files: Vec<ChangedFile>, 16 + pub has_conflict: bool, 16 17 } 17 18 18 19 #[derive(Clone, Debug, serde::Serialize)] ··· 80 81 commit_id: hex::encode(&wc_commit_id.to_bytes()[..6]), 81 82 description, 82 83 files, 84 + has_conflict: wc_commit.has_conflict(), 83 85 }) 84 86 } 85 87
+79 -8
apps/desktop/src/components/AppShell.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 + import { useQuery } from "@tanstack/react-query"; 3 4 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 4 5 import { Profiler, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 5 6 import { Route as ProjectRoute } from "@/routes/project.$projectId"; ··· 34 35 35 36 import { 36 37 abandonRevision, 38 + describeRevision, 37 39 editRevision, 38 40 emptyCommitRecencyCollection, 39 41 emptyRevisionsCollection, ··· 42 44 getRevisionsCollection, 43 45 newRevision, 44 46 repositoriesCollection, 47 + syncRepository, 45 48 } from "@/db"; 46 49 import { useAddRepository } from "@/hooks/useAddRepository"; 47 50 import { useAppTitle } from "@/hooks/useAppTitle"; 48 51 import { useKeyboardNavigation, useKeyboardShortcut, useKeySequence } from "@/hooks/useKeyboard"; 49 52 import { useSelectedRevision } from "@/hooks/useSelectedRevision"; 50 - import type { Repository, Revision } from "@/tauri-commands"; 53 + import { getStatus, type Repository, type Revision } from "@/tauri-commands"; 51 54 import { onRenderCallback } from "@/lib/trace"; 52 55 53 56 // Wrapper component that handles the case when no project is selected ··· 101 104 <div className="flex-1 min-h-0 flex items-center justify-center text-muted-foreground"> 102 105 <p>Select or add a repository to get started</p> 103 106 </div> 104 - <StatusBar branch={null} isConnected={false} /> 107 + <StatusBar branch={null} isConnected={false} hasConflict={false} /> 105 108 </div> 106 109 </> 107 110 ); ··· 116 119 const [viewMode, setViewMode] = useAtom(viewModeAtom); 117 120 const [, setSearchOpen] = useAtom(searchOpenAtom); 118 121 const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); 122 + const [editingChangeId, setEditingChangeId] = useState<string | null>(null); 119 123 const [projectPickerOpen, setProjectPickerOpen] = useState(false); 120 124 const [isSyncing, setIsSyncing] = useState(false); 121 125 const revisionGraphRef = useRef<RevisionGraphHandle>(null); ··· 134 138 135 139 const activeProject = repositories.find((p) => p.id === projectId) ?? null; 136 140 141 + const { data: workingCopyStatus } = useQuery({ 142 + queryKey: ["status", activeProject?.path], 143 + queryFn: () => getStatus(activeProject?.path ?? ""), 144 + enabled: !!activeProject?.path, 145 + retry: false, 146 + }); 147 + 137 148 useAppTitle(activeProject ? `Tatami - ${activeProject.path}` : "Tatami"); 138 149 139 150 const revisionsCollection = activeProject 140 151 ? getRevisionsCollection(activeProject.path, activeProject.revset_preset ?? "full_history") 141 152 : emptyRevisionsCollection; 142 153 143 - const { data: revisions = [], isLoading = false } = useLiveQuery(revisionsCollection); 154 + const { 155 + data: revisions = [], 156 + isLoading = false, 157 + isError: revisionsLoadFailed, 158 + status: revisionsStatus, 159 + collection: revisionsLiveCollection, 160 + } = useLiveQuery(revisionsCollection); 144 161 145 162 // Fetch commit recency data for branch ordering 146 163 const commitRecencyCollection = activeProject?.path ··· 173 190 }, [revisions, orderedRevisions, expandedStacks]); 174 191 175 192 const selectedRevision = useSelectedRevision(revisions, rev); 193 + const revisionsErrorMessage = 194 + revisionsStatus === "error" || revisionsLoadFailed 195 + ? "Could not fetch revisions from jj." 196 + : null; 197 + 198 + const handleRetryRevisions = () => { 199 + void revisionsLiveCollection.preload(); 200 + }; 201 + 202 + useEffect(() => { 203 + if (!editingChangeId) return; 204 + if (!selectedRevision || getRevisionKey(selectedRevision) !== editingChangeId) { 205 + setEditingChangeId(null); 206 + } 207 + }, [editingChangeId, selectedRevision]); 176 208 177 209 // Debounce the changeId passed to DiffPanel to avoid expensive re-renders during rapid navigation 178 210 // DiffPanel only updates when navigation settles (200ms without movement) ··· 283 315 enabled: !!activeProject && !!selectedRevision, 284 316 }); 285 317 318 + useKeyboardShortcut({ 319 + key: "d", 320 + onPress: handleStartDescribe, 321 + enabled: !!selectedRevision && !selectedRevision.is_immutable, 322 + }); 323 + 324 + function handleDescribe(changeId: string, description: string) { 325 + if (!activeProject) return; 326 + const revision = revisions.find((r) => getRevisionKey(r) === changeId); 327 + if (!revision || revision.is_immutable) return; 328 + describeRevision(revisionsCollection, activeProject.path, revision, description); 329 + setEditingChangeId(null); 330 + } 331 + 332 + function handleStartDescribe() { 333 + if (!selectedRevision || selectedRevision.is_immutable) return; 334 + setEditingChangeId(getRevisionKey(selectedRevision)); 335 + } 336 + 337 + function handleCancelDescribe() { 338 + setEditingChangeId(null); 339 + } 340 + 286 341 function handleAbandon() { 287 342 if (!activeProject || !selectedRevision) return; 288 343 // Don't abandon immutable revisions (trunk ancestors) ··· 372 427 return null; 373 428 })(); 374 429 375 - function handleSync() { 430 + async function handleSync() { 376 431 if (!activeProject || isSyncing) return; 377 432 setIsSyncing(true); 378 - // TODO: Implement jj git fetch 379 - // For now, just simulate a sync delay 380 - setTimeout(() => setIsSyncing(false), 1000); 433 + try { 434 + await syncRepository(activeProject.path, activeProject.revset_preset ?? "full_history"); 435 + } finally { 436 + setIsSyncing(false); 437 + } 381 438 } 382 439 383 440 function handleOpenSearch() { ··· 435 492 selectedRevision={selectedRevision} 436 493 onSelectRevision={handleSelectRevision} 437 494 isLoading={isLoading} 495 + errorMessage={revisionsErrorMessage} 496 + onRetry={handleRetryRevisions} 438 497 flash={flash} 439 498 repoPath={activeProject?.path ?? null} 440 499 pendingAbandon={pendingAbandon} 500 + editingChangeId={editingChangeId} 501 + onDescribe={handleDescribe} 502 + onCancelDescribe={handleCancelDescribe} 441 503 diffPanelRef={diffPanelRef} 442 504 /> 443 505 </Profiler> ··· 459 521 selectedRevision={selectedRevision} 460 522 onSelectRevision={handleSelectRevision} 461 523 isLoading={isLoading} 524 + errorMessage={revisionsErrorMessage} 525 + onRetry={handleRetryRevisions} 462 526 flash={flash} 463 527 repoPath={activeProject?.path ?? null} 464 528 pendingAbandon={pendingAbandon} 529 + editingChangeId={editingChangeId} 530 + onDescribe={handleDescribe} 531 + onCancelDescribe={handleCancelDescribe} 465 532 diffPanelRef={diffPanelRef} 466 533 /> 467 534 </Profiler> ··· 487 554 </ResizablePanelGroup> 488 555 )} 489 556 </div> 490 - <StatusBar branch={closestBookmark} isConnected={!!activeProject} /> 557 + <StatusBar 558 + branch={closestBookmark} 559 + isConnected={!!activeProject} 560 + hasConflict={workingCopyStatus?.has_conflict ?? false} 561 + /> 491 562 </div> 492 563 </> 493 564 );
+58 -7
apps/desktop/src/components/DiffPanel.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { PatchDiff } from "@pierre/diffs/react"; 3 + import { useQuery } from "@tanstack/react-query"; 3 4 import { Columns2Icon, Loader2, RowsIcon } from "lucide-react"; 4 5 import type { FocusEvent, RefObject } from "react"; 5 6 import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; ··· 15 16 import { traceLog } from "@/lib/trace"; 16 17 import { cn } from "@/lib/utils"; 17 18 import type { ChangedFileStatus } from "@/schemas"; 18 - import type { Revision } from "@/tauri-commands"; 19 + import { getConflictPaths, getRevisionDiff, type Revision } from "@/tauri-commands"; 19 20 import { isImageFile } from "@/utils/file-types"; 20 21 21 22 interface DiffPanelProps { ··· 280 281 281 282 // Read diff from unified collection 282 283 const diffRecord = useDiff(repoPath ?? "", deferredChangeId); 283 - const revisionDiff = diffRecord?.content ?? ""; 284 + const { 285 + data: fallbackDiff, 286 + error: diffError, 287 + refetch: retryDiff, 288 + isFetching: isRetryingDiff, 289 + } = useQuery({ 290 + queryKey: ["diff-fallback", repoPath, deferredChangeId], 291 + queryFn: () => getRevisionDiff(repoPath ?? "", deferredChangeId ?? ""), 292 + enabled: !!repoPath && !!deferredChangeId && !diffRecord, 293 + retry: false, 294 + }); 295 + const revisionDiff = diffRecord?.content ?? fallbackDiff ?? ""; 296 + 297 + const { data: conflictPaths = [] } = useQuery({ 298 + queryKey: ["conflict-paths", repoPath, deferredChangeId], 299 + queryFn: () => getConflictPaths(repoPath ?? "", deferredChangeId ?? ""), 300 + enabled: !!repoPath && !!deferredChangeId && !!revision?.has_conflict, 301 + retry: false, 302 + }); 303 + const conflictPathSet = useMemo(() => new Set(conflictPaths), [conflictPaths]); 284 304 285 305 // Trigger prefetch when selection changes 286 306 useEffect(() => { ··· 442 462 ); 443 463 } 444 464 465 + if (diffError && !diffRecord) { 466 + return ( 467 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 468 + <div 469 + ref={setRefs} 470 + tabIndex={-1} 471 + onFocus={() => setHasFocus(true)} 472 + onBlur={handleBlur} 473 + className="flex h-full items-center justify-center outline-none" 474 + > 475 + <div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-center"> 476 + <p className="text-sm text-destructive">Failed to load diff</p> 477 + <button 478 + type="button" 479 + onClick={() => void retryDiff()} 480 + className="mt-3 rounded border border-border px-2 py-1 text-xs hover:bg-muted" 481 + disabled={isRetryingDiff} 482 + > 483 + {isRetryingDiff ? "Retrying..." : "Retry"} 484 + </button> 485 + </div> 486 + </div> 487 + ); 488 + } 489 + 445 490 // Only show "No changes" if we have no displayed state to show 446 491 if (changedFiles.length === 0 && !displayedState) { 447 492 return ( ··· 473 518 {/* Revision header */} 474 519 {revision && ( 475 520 <div className="px-4 pt-2 pb-2 shrink-0"> 476 - <RevisionHeader revision={revision} /> 521 + <RevisionHeader revision={revision} conflictPaths={conflictPaths} /> 477 522 </div> 478 523 )} 479 524 480 525 {/* Toolbar */} 481 526 <div className="flex items-center justify-end px-3 py-2 border-b border-border bg-background shrink-0 min-w-0"> 482 - <div 527 + <fieldset 483 528 className="relative flex items-center rounded-md border border-border/70 bg-muted/60 p-0.5 shadow-inner shrink-0" 484 529 aria-label="Diff style" 485 530 > ··· 496 541 aria-label="Unified diff view" 497 542 disabled={effectiveSelectedFiles.size === 0} 498 543 > 499 - <RowsIcon className={`size-3 ${effectiveDiffStyle === "unified" ? "text-foreground" : ""}`} /> 544 + <RowsIcon 545 + className={`size-3 ${effectiveDiffStyle === "unified" ? "text-foreground" : ""}`} 546 + /> 500 547 </button> 501 548 <button 502 549 type="button" ··· 506 553 aria-label="Split diff view" 507 554 disabled={effectiveSelectedFiles.size === 0} 508 555 > 509 - <Columns2Icon className={`size-3 ${effectiveDiffStyle === "split" ? "text-foreground" : ""}`} /> 556 + <Columns2Icon 557 + className={`size-3 ${effectiveDiffStyle === "split" ? "text-foreground" : ""}`} 558 + /> 510 559 </button> 511 - </div> 560 + </fieldset> 512 561 </div> 513 562 514 563 {/* Two-column layout wrapper */} ··· 522 571 <ResizablePanel id="diff-file-list" defaultSize="30%" minSize="15%" maxSize="50%"> 523 572 <div className="h-full w-full min-w-0"> 524 573 <FileList 574 + repoPath={repoPath} 525 575 files={changedFiles} 526 576 selectedFiles={effectiveSelectedFiles} 527 577 onSelectFiles={setSelectedFiles} 528 578 totalAdditions={totalAdditions} 529 579 totalDeletions={totalDeletions} 530 580 hasFocus={hasFocus} 581 + conflictPaths={conflictPathSet} 531 582 /> 532 583 </div> 533 584 </ResizablePanel>
+33
apps/desktop/src/components/ErrorBoundary.test.tsx
··· 1 + import { describe, expect, test } from "vitest"; 2 + import { renderToStaticMarkup } from "react-dom/server"; 3 + import { ErrorBoundary, ErrorBoundaryFallback, formatErrorForCopy } from "./ErrorBoundary"; 4 + 5 + describe("ErrorBoundary", () => { 6 + test("renders error fallback UI", () => { 7 + const html = renderToStaticMarkup( 8 + <ErrorBoundaryFallback 9 + error={new Error("Boom")} 10 + componentStack={"at Thrower"} 11 + onReload={() => {}} 12 + onCopyError={() => {}} 13 + />, 14 + ); 15 + 16 + expect(html).toContain("Something went wrong"); 17 + expect(html).toContain("Boom"); 18 + expect(html).toContain("Reload"); 19 + }); 20 + 21 + test("marks state as failed when an error is thrown", () => { 22 + const state = ErrorBoundary.getDerivedStateFromError(new Error("kaboom")); 23 + expect(state.hasError).toBe(true); 24 + }); 25 + 26 + test("formats copy payload with stack and component stack", () => { 27 + const error = new Error("Broken"); 28 + error.stack = "Error: Broken\n at SomePlace"; 29 + const text = formatErrorForCopy(error, "\n at Thrower"); 30 + expect(text).toContain("Error: Broken"); 31 + expect(text).toContain("Component stack:"); 32 + }); 33 + });
+98
apps/desktop/src/components/ErrorBoundary.tsx
··· 1 + import { Copy, RefreshCw } from "lucide-react"; 2 + import { Component, type ErrorInfo, type ReactNode } from "react"; 3 + import { Button } from "@/components/ui/button"; 4 + 5 + interface ErrorBoundaryState { 6 + hasError: boolean; 7 + error: Error | null; 8 + componentStack: string; 9 + } 10 + 11 + export function formatErrorForCopy(error: Error | null, componentStack: string): string { 12 + if (!error) return "Unknown error"; 13 + const parts = [error.toString()]; 14 + if (error.stack) parts.push(error.stack); 15 + if (componentStack) parts.push(`Component stack:${componentStack}`); 16 + return parts.join("\n\n"); 17 + } 18 + 19 + export function ErrorBoundaryFallback({ 20 + error, 21 + componentStack, 22 + onReload, 23 + onCopyError, 24 + }: { 25 + error: Error | null; 26 + componentStack: string; 27 + onReload: () => void; 28 + onCopyError: () => void; 29 + }) { 30 + return ( 31 + <div className="h-screen w-full flex items-center justify-center p-6 bg-background"> 32 + <div className="w-full max-w-2xl rounded-md border border-border bg-card p-4 space-y-3"> 33 + <div> 34 + <h1 className="text-sm font-semibold">Something went wrong</h1> 35 + <p className="text-xs text-muted-foreground mt-1"> 36 + {error?.message ?? "An unexpected error occurred."} 37 + </p> 38 + </div> 39 + <div className="flex items-center gap-2"> 40 + <Button size="sm" onClick={onReload}> 41 + <RefreshCw className="size-3" /> 42 + Reload 43 + </Button> 44 + <Button size="sm" variant="outline" onClick={onCopyError}> 45 + <Copy className="size-3" /> 46 + Copy error 47 + </Button> 48 + </div> 49 + {import.meta.env.DEV && componentStack ? ( 50 + <pre className="text-xs bg-muted p-3 rounded overflow-auto max-h-[40vh] whitespace-pre-wrap"> 51 + {componentStack} 52 + </pre> 53 + ) : null} 54 + </div> 55 + </div> 56 + ); 57 + } 58 + 59 + export class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> { 60 + state: ErrorBoundaryState = { 61 + hasError: false, 62 + error: null, 63 + componentStack: "", 64 + }; 65 + 66 + static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> { 67 + return { hasError: true, error }; 68 + } 69 + 70 + componentDidCatch(error: Error, errorInfo: ErrorInfo) { 71 + this.setState({ error, componentStack: errorInfo.componentStack ?? "" }); 72 + } 73 + 74 + private handleReload = () => { 75 + window.location.reload(); 76 + }; 77 + 78 + private handleCopyError = async () => { 79 + await navigator.clipboard.writeText( 80 + formatErrorForCopy(this.state.error, this.state.componentStack), 81 + ); 82 + }; 83 + 84 + render() { 85 + if (this.state.hasError) { 86 + return ( 87 + <ErrorBoundaryFallback 88 + error={this.state.error} 89 + componentStack={this.state.componentStack} 90 + onReload={this.handleReload} 91 + onCopyError={this.handleCopyError} 92 + /> 93 + ); 94 + } 95 + 96 + return this.props.children; 97 + } 98 + }
+11 -1
apps/desktop/src/components/StatusBar.tsx
··· 6 6 interface StatusBarProps { 7 7 branch: string | null; 8 8 isConnected: boolean; 9 + hasConflict: boolean; 9 10 } 10 11 11 - export function StatusBar({ branch, isConnected }: StatusBarProps) { 12 + export function StatusBar({ branch, isConnected, hasConflict }: StatusBarProps) { 12 13 const { theme, cycleTheme } = useTheme(); 13 14 const ThemeIcon = theme === "system" ? Laptop : theme === "dark" ? Moon : Sun; 14 15 ··· 34 35 </> 35 36 )} 36 37 38 + {hasConflict && ( 39 + <> 40 + <div className="flex items-center gap-1.5 text-destructive"> 41 + <Circle className="h-2 w-2 fill-current" /> 42 + <span>Conflicts</span> 43 + </div> 44 + <Separator orientation="vertical" className="h-4" /> 45 + </> 46 + )} 37 47 <div className="flex items-center gap-1.5"> 38 48 <Circle 39 49 className={`h-2 w-2 fill-current ${isConnected ? "text-green-500" : "text-red-500"}`}
+31
apps/desktop/src/components/diff/FileDiffSection.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 + import { join } from "@tauri-apps/api/path"; 3 + import { open } from "@tauri-apps/plugin-shell"; 2 4 import { PatchDiff } from "@pierre/diffs/react"; 3 5 import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, RowsIcon } from "lucide-react"; 4 6 import { type DiffStyle, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 7 + import { Badge } from "@/components/ui/badge"; 5 8 import { Button } from "@/components/ui/button"; 6 9 7 10 interface FileDiffSectionProps { 8 11 patch: string; 9 12 filePath: string; 13 + repoPath?: string; 14 + isConflicted?: boolean; 10 15 isSelected?: boolean; 11 16 fileRef?: React.RefObject<HTMLDivElement | null>; 12 17 } ··· 14 19 export function FileDiffSection({ 15 20 patch, 16 21 filePath, 22 + repoPath, 23 + isConflicted = false, 17 24 isSelected = false, 18 25 fileRef, 19 26 }: FileDiffSectionProps) { ··· 45 52 }); 46 53 } 47 54 55 + async function handleOpenInEditor() { 56 + if (!repoPath) return; 57 + const absolutePath = await join(repoPath, filePath); 58 + await open(absolutePath); 59 + } 60 + 48 61 return ( 49 62 <div 50 63 ref={fileRef} ··· 74 87 <code className="font-mono text-xs text-foreground text-left flex-1 truncate min-w-0"> 75 88 {filePath} 76 89 </code> 90 + {isConflicted && ( 91 + <Badge variant="destructive" className="h-4 px-1 text-[10px]"> 92 + Conflict 93 + </Badge> 94 + )} 77 95 78 96 {/* Per-file diff style toggle buttons */} 79 97 <span className="flex items-center gap-0.5"> 98 + {isConflicted && repoPath && ( 99 + <Button 100 + variant="ghost" 101 + size="sm" 102 + className="h-6 px-2 text-[11px]" 103 + onClick={(e) => { 104 + e.stopPropagation(); 105 + void handleOpenInEditor(); 106 + }} 107 + > 108 + Open in editor 109 + </Button> 110 + )} 80 111 <Button 81 112 variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 82 113 size="icon-xs"
+111 -51
apps/desktop/src/components/diff/FileList.tsx
··· 1 + import { join } from "@tauri-apps/api/path"; 2 + import { open } from "@tauri-apps/plugin-shell"; 1 3 import { 2 4 ChevronDownIcon, 3 5 ChevronRightIcon, ··· 12 14 SearchIcon, 13 15 } from "lucide-react"; 14 16 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 17 + import { Badge } from "@/components/ui/badge"; 18 + import { Button } from "@/components/ui/button"; 15 19 import { Input } from "@/components/ui/input"; 16 20 import { ScrollArea } from "@/components/ui/scroll-area"; 17 21 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; ··· 19 23 import type { ChangedFile, ChangedFileStatus } from "@/schemas"; 20 24 21 25 interface FileListProps { 26 + repoPath: string; 22 27 files: ChangedFile[]; 23 28 selectedFiles: Set<string>; 24 29 onSelectFiles: (filePaths: Set<string>) => void; 25 30 totalAdditions: number; 26 31 totalDeletions: number; 27 32 hasFocus: boolean; 33 + conflictPaths: Set<string>; 28 34 } 29 35 30 36 function getFileStatusIcon(status: ChangedFileStatus) { ··· 165 171 toggleDir: (path: string) => void; 166 172 itemRefs: React.RefObject<Map<string, HTMLButtonElement>>; 167 173 hasFocus: boolean; 174 + conflictPaths: Set<string>; 175 + repoPath: string; 168 176 } 169 177 170 178 // Collect all file paths under a tree node ··· 177 185 paths.push(...collectFilePaths(child)); 178 186 } 179 187 return paths; 188 + } 189 + 190 + async function openFileInEditor(repoPath: string, filePath: string): Promise<void> { 191 + const absolutePath = await join(repoPath, filePath); 192 + await open(absolutePath); 180 193 } 181 194 182 195 function TreeNodeComponent({ ··· 189 202 toggleDir, 190 203 itemRefs, 191 204 hasFocus, 205 + conflictPaths, 206 + repoPath, 192 207 }: TreeNodeComponentProps) { 193 208 const isExpanded = expandedDirs.has(node.path); 194 209 const sortedChildren = getSortedChildren(node); ··· 235 250 toggleDir={toggleDir} 236 251 itemRefs={itemRefs} 237 252 hasFocus={hasFocus} 253 + conflictPaths={conflictPaths} 254 + repoPath={repoPath} 238 255 /> 239 256 ))} 240 257 </div> ··· 245 262 246 263 // File node 247 264 const isSelected = selectedFiles.has(node.path); 265 + const isConflicted = conflictPaths.has(node.path); 248 266 // Add extra padding to align with folder text (chevron width + gap) 249 267 const fileIndent = depth * 12 + 12 + 18; 250 268 return ( 251 - <button 252 - key={node.path} 253 - ref={(el) => { 254 - if (el) itemRefs.current?.set(node.path, el); 255 - else itemRefs.current?.delete(node.path); 256 - }} 257 - type="button" 258 - onClick={(e) => onSelectFile(node.path, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey })} 259 - className={cn( 260 - "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", 261 - isSelected 262 - ? hasFocus 263 - ? "bg-accent/40 text-foreground" 264 - : "bg-muted text-foreground" 265 - : "text-muted-foreground", 269 + <div className="flex items-center gap-2 pr-2"> 270 + <button 271 + key={node.path} 272 + ref={(el) => { 273 + if (el) itemRefs.current?.set(node.path, el); 274 + else itemRefs.current?.delete(node.path); 275 + }} 276 + type="button" 277 + onClick={(e) => onSelectFile(node.path, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey })} 278 + className={cn( 279 + "min-w-0 flex-1 flex items-center gap-2 px-3 py-1.5 text-left text-sm", 280 + isSelected 281 + ? hasFocus 282 + ? "bg-accent/40 text-foreground" 283 + : "bg-muted text-foreground" 284 + : "text-muted-foreground", 285 + )} 286 + style={{ paddingLeft: `${fileIndent}px` }} 287 + > 288 + {node.file && getFileStatusIcon(node.file.status)} 289 + <span className="truncate font-medium">{node.name}</span> 290 + {isConflicted && ( 291 + <Badge variant="destructive" className="h-4 px-1 text-[10px]"> 292 + Conflict 293 + </Badge> 294 + )} 295 + </button> 296 + {isConflicted && ( 297 + <Button 298 + variant="ghost" 299 + size="sm" 300 + className="h-6 px-2 text-[11px]" 301 + onClick={() => { 302 + void openFileInEditor(repoPath, node.path); 303 + }} 304 + > 305 + Open in editor 306 + </Button> 266 307 )} 267 - style={{ paddingLeft: `${fileIndent}px` }} 268 - > 269 - {node.file && getFileStatusIcon(node.file.status)} 270 - <span className="truncate font-medium">{node.name}</span> 271 - </button> 308 + </div> 272 309 ); 273 310 } 274 311 275 312 export function FileList({ 313 + repoPath, 276 314 files, 277 315 selectedFiles, 278 316 onSelectFiles, 279 317 totalAdditions, 280 318 totalDeletions, 281 319 hasFocus, 320 + conflictPaths, 282 321 }: FileListProps) { 283 322 const listRef = useRef<HTMLDivElement>(null); 284 323 const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); ··· 642 681 ? // Flat list view 643 682 filteredFiles.map((file, index) => { 644 683 const isSelected = selectedFiles.has(file.path); 684 + const isConflicted = conflictPaths.has(file.path); 645 685 const fileName = getFileName(file.path); 646 686 const directory = getFileDirectory(file.path); 647 687 648 688 return ( 649 - <button 650 - key={file.path} 651 - ref={(el) => { 652 - if (el) itemRefs.current.set(file.path, el); 653 - else itemRefs.current.delete(file.path); 654 - }} 655 - type="button" 656 - onClick={(e) => 657 - handleSelectFile(file.path, { 658 - shift: e.shiftKey, 659 - meta: e.metaKey || e.ctrlKey, 660 - }) 661 - } 662 - className={cn( 663 - "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", 664 - isSelected 665 - ? hasFocus 666 - ? "bg-accent/40 text-foreground" 667 - : "bg-muted text-foreground" 668 - : index % 2 === 1 669 - ? "bg-muted/30 text-muted-foreground" 670 - : "text-muted-foreground", 689 + <div key={file.path} className="flex items-center gap-2 pr-2"> 690 + <button 691 + ref={(el) => { 692 + if (el) itemRefs.current.set(file.path, el); 693 + else itemRefs.current.delete(file.path); 694 + }} 695 + type="button" 696 + onClick={(e) => 697 + handleSelectFile(file.path, { 698 + shift: e.shiftKey, 699 + meta: e.metaKey || e.ctrlKey, 700 + }) 701 + } 702 + className={cn( 703 + "min-w-0 flex-1 flex items-center gap-2 px-3 py-1.5 text-left text-sm", 704 + isSelected 705 + ? hasFocus 706 + ? "bg-accent/40 text-foreground" 707 + : "bg-muted text-foreground" 708 + : index % 2 === 1 709 + ? "bg-muted/30 text-muted-foreground" 710 + : "text-muted-foreground", 711 + )} 712 + > 713 + {getFileStatusIcon(file.status)} 714 + <span className="flex-1 min-w-0 truncate"> 715 + <span className="font-medium">{fileName}</span> 716 + {directory && ( 717 + <span className="text-muted-foreground ml-1 text-xs">{directory}</span> 718 + )} 719 + </span> 720 + {isConflicted && ( 721 + <Badge variant="destructive" className="h-4 px-1 text-[10px]"> 722 + Conflict 723 + </Badge> 724 + )} 725 + </button> 726 + {isConflicted && ( 727 + <Button 728 + variant="ghost" 729 + size="sm" 730 + className="h-6 px-2 text-[11px]" 731 + onClick={() => { 732 + void openFileInEditor(repoPath, file.path); 733 + }} 734 + > 735 + Open in editor 736 + </Button> 671 737 )} 672 - > 673 - {getFileStatusIcon(file.status)} 674 - <span className="flex-1 min-w-0 truncate"> 675 - <span className="font-medium">{fileName}</span> 676 - {directory && ( 677 - <span className="text-muted-foreground ml-1 text-xs">{directory}</span> 678 - )} 679 - </span> 680 - </button> 738 + </div> 681 739 ); 682 740 }) 683 741 : // Tree view ··· 693 751 toggleDir={toggleDir} 694 752 itemRefs={itemRefs} 695 753 hasFocus={hasFocus} 754 + conflictPaths={conflictPaths} 755 + repoPath={repoPath} 696 756 /> 697 757 ))} 698 758 </div>
+14 -1
apps/desktop/src/components/diff/RevisionHeader.tsx
··· 4 4 5 5 interface RevisionHeaderProps { 6 6 revision: Revision; 7 + conflictPaths?: string[]; 7 8 } 8 9 9 - export function RevisionHeader({ revision }: RevisionHeaderProps) { 10 + export function RevisionHeader({ revision, conflictPaths = [] }: RevisionHeaderProps) { 10 11 const commitIdShort = revision.commit_id.substring(0, 12); 11 12 const [isExpanded, setIsExpanded] = useState(false); 12 13 ··· 35 36 <span className="text-muted-foreground ml-4">at</span>{" "} 36 37 <span className="text-foreground">{revision.timestamp}</span> 37 38 </div> 39 + {conflictPaths.length > 0 && ( 40 + <div className="rounded-md border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive"> 41 + <div className="font-semibold">⚠ {conflictPaths.length} conflicted file(s)</div> 42 + <div className="mt-1 flex flex-wrap gap-x-2 gap-y-1"> 43 + {conflictPaths.map((path) => ( 44 + <code key={path} className="rounded bg-destructive/10 px-1 py-0.5 text-[11px]"> 45 + {path} 46 + </code> 47 + ))} 48 + </div> 49 + </div> 50 + )} 38 51 {title && ( 39 52 <div className="mt-2 pt-2"> 40 53 <div className="flex items-start gap-1">
+54 -5
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 - import { useRef, useState } from "react"; 2 + import { useEffect, useRef, useState } from "react"; 3 3 import { draggingBookmarkAtom } from "@/atoms"; 4 4 import { getRevisionKey } from "@/db"; 5 + import { Badge } from "@/components/ui/badge"; 5 6 import type { Revision } from "@/tauri-commands"; 6 7 import { BookmarkTag } from "./BookmarkTag"; 7 8 import { ROW_HEIGHT, LANE_PADDING, LANE_WIDTH, NODE_RADIUS, laneToX, laneColor } from "./constants"; ··· 18 19 isDimmed: boolean; 19 20 isFocused: boolean; 20 21 isPendingAbandon: boolean; 22 + isEditing: boolean; 23 + onDescribe: (changeId: string, description: string) => void; 24 + onCancelDescribe: () => void; 21 25 jumpModeActive: boolean; 22 26 jumpQuery: string; 23 27 jumpHint: string | null; ··· 40 44 isDimmed, 41 45 isFocused, 42 46 isPendingAbandon, 47 + isEditing, 48 + onDescribe, 49 + onCancelDescribe, 43 50 jumpModeActive, 44 51 jumpQuery, 45 52 jumpHint, 46 53 onMoveBookmark, 47 54 hasFocus, 48 55 }: RevisionRowProps) { 56 + const revisionKey = getRevisionKey(revision); 49 57 const firstLine = revision.description.split("\n")[0] || "(no description)"; 58 + const [draftDescription, setDraftDescription] = useState(revision.description); 50 59 const [isDragOver, setIsDragOver] = useState(false); 51 60 const [showDropPlaceholder, setShowDropPlaceholder] = useState(false); 52 61 const dragOverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 53 62 const dragEnterCountRef = useRef(0); 63 + const textareaRef = useRef<HTMLTextAreaElement | null>(null); 54 64 const [draggingBookmark] = useAtom(draggingBookmarkAtom); 55 65 56 66 // Calculate the node position area - leaves space for graph edges on the left ··· 60 70 61 71 const nodeSize = revision.is_working_copy ? NODE_RADIUS * 2 + 14 : NODE_RADIUS * 2 + 8; 62 72 73 + useEffect(() => { 74 + if (!isEditing) return; 75 + setDraftDescription(revision.description); 76 + requestAnimationFrame(() => { 77 + const textarea = textareaRef.current; 78 + if (!textarea) return; 79 + textarea.focus({ preventScroll: true }); 80 + textarea.select(); 81 + }); 82 + }, [isEditing, revision.description]); 83 + 63 84 return ( 64 85 // biome-ignore lint/a11y/useSemanticElements: Complex styling requires div 65 86 <div ··· 79 100 data-checked={isChecked || undefined} 80 101 data-change-id={revision.change_id} 81 102 onClick={(e) => { 103 + if (isEditing) return; 82 104 // Prevent text selection on shift+click 83 105 if (e.shiftKey) { 84 106 e.preventDefault(); 85 107 window.getSelection()?.removeAllRanges(); 86 108 } 87 - onSelect(getRevisionKey(revision), { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 109 + onSelect(revisionKey, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 88 110 }} 89 111 onKeyDown={(e) => { 112 + if (isEditing) return; 90 113 if (e.key === "Enter" || e.key === " ") { 91 - onSelect(getRevisionKey(revision), { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 114 + onSelect(revisionKey, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 92 115 } 93 116 }} 94 117 onDragEnter={(e) => { ··· 198 221 ) : ( 199 222 revision.change_id_short 200 223 )} 201 - {revision.has_conflict && <span className="ml-1 text-destructive">⚠</span>} 224 + {revision.has_conflict && ( 225 + <Badge variant="destructive" className="ml-1 h-4 px-1 text-[10px]"> 226 + Conflicts 227 + </Badge> 228 + )} 202 229 </code> 203 230 {/* Bookmarks - middle column */} 204 231 <div className="flex items-center gap-1 min-w-0 overflow-hidden"> ··· 221 248 {revision.author.split("@")[0]} · {revision.timestamp} 222 249 </span> 223 250 </div> 224 - <div className="text-sm mt-1 truncate">{firstLine}</div> 251 + {isEditing ? ( 252 + <textarea 253 + ref={textareaRef} 254 + value={draftDescription} 255 + onChange={(e) => setDraftDescription(e.target.value)} 256 + onClick={(e) => e.stopPropagation()} 257 + onKeyDown={(e) => { 258 + e.stopPropagation(); 259 + if (e.key === "Escape") { 260 + e.preventDefault(); 261 + onCancelDescribe(); 262 + return; 263 + } 264 + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { 265 + e.preventDefault(); 266 + onDescribe(revisionKey, draftDescription); 267 + } 268 + }} 269 + className="mt-1 w-full min-h-16 rounded border border-border bg-background px-2 py-1 text-sm leading-5 resize-none focus:outline-none focus:ring-1 focus:ring-ring" 270 + /> 271 + ) : ( 272 + <div className="text-sm mt-1 truncate">{firstLine}</div> 273 + )} 225 274 </div> 226 275 227 276 {isPendingAbandon && (
+37
apps/desktop/src/components/revision-graph/index.tsx
··· 64 64 selectedRevision: Revision | null; 65 65 onSelectRevision: (revision: Revision) => void; 66 66 isLoading: boolean; 67 + errorMessage?: string | null; 68 + onRetry?: () => void; 67 69 flash?: { changeId: string; key: number } | null; 68 70 repoPath: string | null; 69 71 pendingAbandon?: Revision | null; 72 + editingChangeId: string | null; 73 + onDescribe: (changeId: string, description: string) => void; 74 + onCancelDescribe: () => void; 70 75 diffPanelRef: RefObject<HTMLElement | null>; 71 76 } 72 77 ··· 328 333 selectedRevision, 329 334 onSelectRevision, 330 335 isLoading, 336 + errorMessage, 337 + onRetry, 331 338 flash, 332 339 repoPath, 333 340 pendingAbandon, 341 + editingChangeId, 342 + onDescribe, 343 + onCancelDescribe, 334 344 diffPanelRef, 335 345 }, 336 346 ref, ··· 1080 1090 return () => window.removeEventListener("keydown", handleJumpKey); 1081 1091 }, [aceJumpMode, aceJumpQuery, setAceJumpQuery, revisions, onSelectRevision]); 1082 1092 1093 + if (errorMessage) { 1094 + return ( 1095 + <div 1096 + ref={containerRef} 1097 + tabIndex={-1} 1098 + className="flex h-full items-center justify-center bg-background outline-none" 1099 + > 1100 + <div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-center"> 1101 + <p className="text-sm text-destructive">Failed to load revisions</p> 1102 + <p className="mt-1 text-xs text-muted-foreground">{errorMessage}</p> 1103 + {onRetry ? ( 1104 + <button 1105 + type="button" 1106 + onClick={onRetry} 1107 + className="mt-3 rounded border border-border px-2 py-1 text-xs hover:bg-muted" 1108 + > 1109 + Retry 1110 + </button> 1111 + ) : null} 1112 + </div> 1113 + </div> 1114 + ); 1115 + } 1116 + 1083 1117 if (revisions.length === 0) { 1084 1118 return ( 1085 1119 <div ··· 1264 1298 isFlashing={isFlashing} 1265 1299 isDimmed={isDimmed} 1266 1300 isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 1301 + isEditing={editingChangeId === getRevisionKey(row.revision)} 1302 + onDescribe={onDescribe} 1303 + onCancelDescribe={onCancelDescribe} 1267 1304 jumpModeActive={aceJumpMode} 1268 1305 jumpQuery={aceJumpQuery ?? ""} 1269 1306 jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null}
+104
apps/desktop/src/db.ts
··· 13 13 getRevisionDiff, 14 14 getRevisions, 15 15 jjAbandon, 16 + jjDescribe, 16 17 jjEdit, 18 + jjGitFetch, 19 + jjGitPush, 17 20 jjNew, 18 21 removeRepository, 19 22 undoOperation, ··· 138 141 await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 139 142 await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 140 143 await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 144 + await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 145 + await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 141 146 // DISABLED: Lineage calculations commented out 142 147 // await queryClient.invalidateQueries({ queryKey: ["lineage"] }); 143 148 } 144 149 }); 145 150 146 151 repoWatchers.set(repoPath, { unlisten, refCount: 1 }); 152 + } 153 + 154 + function isAuthError(errorText: string): boolean { 155 + const text = errorText.toLowerCase(); 156 + return ( 157 + text.includes("auth") || 158 + text.includes("authentication") || 159 + text.includes("permission denied") || 160 + text.includes("publickey") || 161 + text.includes("credential") || 162 + text.includes("forbidden") 163 + ); 164 + } 165 + 166 + async function invalidateRepositoryQueries(repoPath: string): Promise<void> { 167 + await queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 168 + await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 169 + await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 170 + await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 171 + await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 172 + await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 173 + } 174 + 175 + export async function syncRepository(repoPath: string, preset?: string): Promise<void> { 176 + const limit = preset === "full_history" ? 10000 : 100; 177 + 178 + try { 179 + await jjGitFetch(repoPath, "origin"); 180 + await invalidateRepositoryQueries(repoPath); 181 + 182 + const revisions = await getRevisions(repoPath, limit, undefined, preset); 183 + const aheadBookmarks = Array.from( 184 + new Set( 185 + revisions.flatMap((revision) => 186 + revision.bookmarks 187 + .filter((bookmark) => bookmark.is_ahead) 188 + .map((bookmark) => bookmark.name), 189 + ), 190 + ), 191 + ); 192 + 193 + if (aheadBookmarks.length > 0) { 194 + await jjGitPush(repoPath, aheadBookmarks, "origin"); 195 + await invalidateRepositoryQueries(repoPath); 196 + } 197 + } catch (error) { 198 + const message = error instanceof Error ? error.message : String(error); 199 + if (isAuthError(message)) { 200 + toast.error("Sync failed: authentication error. Check SSH keys or credential helper.", { 201 + description: message, 202 + duration: Number.POSITIVE_INFINITY, 203 + }); 204 + return; 205 + } 206 + 207 + toast.error(`Sync failed: ${message}`, { 208 + duration: Number.POSITIVE_INFINITY, 209 + }); 210 + } 147 211 } 148 212 149 213 // ============================================================================ ··· 419 483 collection.utils.writeUpsert([revision]); 420 484 } 421 485 toast.error(`Failed to abandon revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 486 + }); 487 + } 488 + 489 + export function describeRevision( 490 + collection: RevisionsCollection, 491 + repoPath: string, 492 + revision: Revision, 493 + description: string, 494 + ) { 495 + const mutationId = `describe-${Date.now()}-${Math.random()}`; 496 + const previousDescription = revision.description; 497 + 498 + // Optimistic update 499 + collection.utils.writeUpsert([{ ...revision, description }]); 500 + 501 + trackMutation(mutationId, jjDescribe(repoPath, revision.change_id_short, description)) 502 + .then((result) => { 503 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 504 + toast.success(`Updated description for ${revision.change_id_short}`, { 505 + action: { 506 + label: "Undo", 507 + onClick: () => { 508 + undoOperation(repoPath, result.operation_id) 509 + .then(() => { 510 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 511 + toast.success("Undo successful"); 512 + }) 513 + .catch((err) => { 514 + toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 515 + }); 516 + }, 517 + }, 518 + }); 519 + }) 520 + .catch((error) => { 521 + // Revert optimistic update on failure 522 + collection.utils.writeUpsert([{ ...revision, description: previousDescription }]); 523 + toast.error(`Failed to update description: ${error}`, { 524 + duration: Number.POSITIVE_INFINITY, 525 + }); 422 526 }); 423 527 } 424 528
+86 -1
apps/desktop/src/mocks/setup.ts
··· 877 877 // Calculate shortest unique prefixes for all change IDs 878 878 // Made mutable so mutation handlers can update it 879 879 let mockRevisions: Revision[] = calculateShortIds(mockRevisionsRaw); 880 + let mockOperationCounter = 0; 881 + 882 + function nextOperationId(): string { 883 + mockOperationCounter += 1; 884 + return `mock-op-${mockOperationCounter}`; 885 + } 886 + 887 + function delay(ms: number): Promise<void> { 888 + return new Promise((resolve) => setTimeout(resolve, ms)); 889 + } 880 890 881 891 const mockChangedFiles: ChangedFile[] = [ 882 892 { path: "src/main.rs", status: "modified" }, ··· 893 903 { path: "tests/integration/api/users.test.ts", status: "modified" }, 894 904 ]; 895 905 896 - type MockHandler = (args: Record<string, unknown>) => unknown; 906 + type MockHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>; 897 907 898 908 const handlers: Record<string, MockHandler> = { 899 909 get_projects: () => mockProjects, ··· 926 936 repo_path: "/Users/demo/projects/tatami", 927 937 change_id: wc?.change_id ?? "klnmopqrstuv", 928 938 files: mockChangedFiles, 939 + has_conflict: wc?.has_conflict ?? false, 929 940 }; 930 941 }, 942 + get_conflict_paths: () => [], 931 943 get_file_diff: (): string => `--- a/src/main.rs 932 944 +++ b/src/main.rs 933 945 @@ -1,3 +1,4 @@ ··· 1104 1116 } 1105 1117 1106 1118 return undefined; 1119 + }, 1120 + jj_describe: (args) => { 1121 + const changeId = args.changeId as string; 1122 + const description = args.description as string; 1123 + const revisionIndex = mockRevisions.findIndex( 1124 + (r) => r.change_id.startsWith(changeId) || r.change_id_short === changeId, 1125 + ); 1126 + if (revisionIndex < 0) { 1127 + return { operation_id: nextOperationId(), change_id: null }; 1128 + } 1129 + 1130 + const target = mockRevisions[revisionIndex]; 1131 + mockRevisions[revisionIndex] = { ...target, description }; 1132 + 1133 + return { 1134 + operation_id: nextOperationId(), 1135 + change_id: target.change_id, 1136 + }; 1137 + }, 1138 + jj_git_fetch: async (_args) => { 1139 + await delay(500); 1140 + 1141 + const mainHead = mockRevisions.find((revision) => revision.commit_id === "main0100000000"); 1142 + const alreadyFetched = mockRevisions.some( 1143 + (revision) => revision.commit_id === "remote0110000000", 1144 + ); 1145 + 1146 + if (!alreadyFetched && mainHead) { 1147 + const remoteRevision: Omit<Revision, "change_id_short" | "children_ids"> = { 1148 + commit_id: "remote0110000000", 1149 + change_id: generateChangeId(), 1150 + parent_edges: [{ parent_id: mainHead.commit_id, edge_type: "direct" }], 1151 + description: "chore: fetched remote updates", 1152 + author: "origin@example.com", 1153 + timestamp: new Date().toISOString(), 1154 + is_working_copy: false, 1155 + is_immutable: true, 1156 + is_mine: false, 1157 + is_trunk: true, 1158 + is_divergent: false, 1159 + divergent_index: null, 1160 + has_conflict: false, 1161 + bookmarks: [mockBookmark("origin/main", { remote: "origin" })], 1162 + }; 1163 + 1164 + const allRevisionsRaw = [ 1165 + ...mockRevisions.map(({ change_id_short: _, children_ids: __, ...revision }) => revision), 1166 + remoteRevision, 1167 + ]; 1168 + mockRevisions = calculateShortIds(allRevisionsRaw); 1169 + } 1170 + 1171 + return { 1172 + operation_id: nextOperationId(), 1173 + change_id: null, 1174 + }; 1175 + }, 1176 + jj_git_push: async (args) => { 1177 + await delay(500); 1178 + const bookmarkNames = (args.bookmarkNames as string[] | undefined) ?? []; 1179 + if (bookmarkNames.length > 0) { 1180 + mockRevisions = mockRevisions.map((revision) => ({ 1181 + ...revision, 1182 + bookmarks: revision.bookmarks.map((bookmark) => 1183 + bookmarkNames.includes(bookmark.name) ? { ...bookmark, is_ahead: false } : bookmark, 1184 + ), 1185 + })); 1186 + } 1187 + 1188 + return { 1189 + operation_id: nextOperationId(), 1190 + change_id: null, 1191 + }; 1107 1192 }, 1108 1193 get_commit_recency: () => ({}), 1109 1194 resolve_revset: (args) => {
+4 -1
apps/desktop/src/routes/__root.tsx
··· 1 1 import { createRootRoute, Outlet } from "@tanstack/react-router"; 2 + import { ErrorBoundary } from "@/components/ErrorBoundary"; 2 3 import { Toaster } from "../components/ui/sonner"; 3 4 4 5 export const Route = createRootRoute({ ··· 8 9 function RootComponent() { 9 10 return ( 10 11 <> 11 - <Outlet /> 12 + <ErrorBoundary> 13 + <Outlet /> 14 + </ErrorBoundary> 12 15 <Toaster /> 13 16 </> 14 17 );
+1
apps/desktop/src/schemas.ts
··· 52 52 repo_path: Schema.String, 53 53 change_id: Schema.String, 54 54 files: Schema.Array(ChangedFile), 55 + has_conflict: Schema.Boolean, 55 56 }); 56 57 export type WorkingCopyStatus = typeof WorkingCopyStatus.Type; 57 58
+34
apps/desktop/src/tauri-commands.ts
··· 28 28 return invoke<WorkingCopyStatus>("get_status", { repoPath }); 29 29 } 30 30 31 + export async function getConflictPaths(repoPath: string, changeId: string): Promise<string[]> { 32 + return invoke<string[]>("get_conflict_paths", { repoPath, changeId }); 33 + } 34 + 31 35 export async function getFileDiff( 32 36 repoPath: string, 33 37 changeId: string, ··· 143 147 144 148 export async function jjAbandon(repoPath: string, changeId: string): Promise<MutationResult> { 145 149 return invoke<MutationResult>("jj_abandon", { repoPath, changeId }); 150 + } 151 + 152 + export async function jjDescribe( 153 + repoPath: string, 154 + changeId: string, 155 + description: string, 156 + ): Promise<MutationResult> { 157 + return invoke<MutationResult>("jj_describe", { repoPath, changeId, description }); 158 + } 159 + 160 + export async function jjGitFetch( 161 + repoPath: string, 162 + remote?: string, 163 + ): Promise<MutationResult> { 164 + return invoke<MutationResult>("jj_git_fetch", { 165 + repoPath, 166 + remote: remote ?? null, 167 + }); 168 + } 169 + 170 + export async function jjGitPush( 171 + repoPath: string, 172 + bookmarkNames: string[], 173 + remote?: string, 174 + ): Promise<MutationResult> { 175 + return invoke<MutationResult>("jj_git_push", { 176 + repoPath, 177 + remote: remote ?? null, 178 + bookmarkNames, 179 + }); 146 180 } 147 181 148 182 /** An operation in the jj operation log */