a very good jj gui
0
fork

Configure Feed

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

+1674 -144
+6 -4
Cargo.lock
··· 4525 4525 4526 4526 [[package]] 4527 4527 name = "rustix" 4528 - version = "1.1.2" 4528 + version = "1.1.3" 4529 4529 source = "registry+https://github.com/rust-lang/crates.io-index" 4530 - checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 4530 + checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" 4531 4531 dependencies = [ 4532 4532 "bitflags 2.10.0", 4533 4533 "errno", ··· 5417 5417 "notify", 5418 5418 "notify-debouncer-mini", 5419 5419 "pollster", 5420 + "rayon", 5420 5421 "serde", 5421 5422 "serde_json", 5422 5423 "similar", ··· 5428 5429 "tauri-plugin-shell", 5429 5430 "tauri-plugin-sql", 5430 5431 "tauri-plugin-store", 5432 + "tempfile", 5431 5433 "tokio", 5432 5434 "toml_edit 0.23.10+spec-1.0.0", 5433 5435 "uuid", ··· 5785 5787 5786 5788 [[package]] 5787 5789 name = "tempfile" 5788 - version = "3.23.0" 5790 + version = "3.24.0" 5789 5791 source = "registry+https://github.com/rust-lang/crates.io-index" 5790 - checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 5792 + checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" 5791 5793 dependencies = [ 5792 5794 "fastrand", 5793 5795 "getrandom 0.3.4",
+7 -2
apps/desktop/package.json
··· 10 10 "tauri": "tauri", 11 11 "typecheck": "tsgo --noEmit", 12 12 "lint": "biome check . && ast-grep scan", 13 - "format": "biome format --write ." 13 + "format": "biome format --write .", 14 + "test": "vitest run", 15 + "test:watch": "vitest" 14 16 }, 15 17 "dependencies": { 16 18 "@base-ui/react": "^1.0.0", ··· 47 49 "tw-animate-css": "^1.4.0" 48 50 }, 49 51 "devDependencies": { 52 + "@ast-grep/cli": "^0.40.5", 50 53 "@biomejs/biome": "^2.3.10", 54 + "@tanstack/devtools-vite": "^0.5.0", 51 55 "@tauri-apps/cli": "^2.1.0", 52 56 "@types/node": "^25.0.3", 53 57 "@types/react": "19", ··· 60 64 "tailwindcss": "^4.1.18", 61 65 "typescript": "^5.6.3", 62 66 "vite": "^7.3.1", 63 - "vite-plugin-agentation": "file:../../../agentation/vite-plugin/" 67 + "vite-plugin-agentation": "file:../../../agentation/vite-plugin/", 68 + "vitest": "^4.0.18" 64 69 } 65 70 }
+4
apps/desktop/src-tauri/Cargo.toml
··· 36 36 chrono = "0.4.42" 37 37 uuid = { version = "1", features = ["v4"] } 38 38 base64 = "0.22" 39 + rayon = "1.11.0" 39 40 40 41 [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 41 42 tauri-plugin-shell = "2.0" 43 + 44 + [dev-dependencies] 45 + tempfile = "3.24.0"
+473
apps/desktop/src-tauri/src/lib.rs
··· 21 21 pub status: String, 22 22 } 23 23 24 + #[derive(Serialize)] 25 + pub struct RevisionDiff { 26 + pub change_id: String, 27 + pub diff: String, 28 + } 29 + 30 + #[derive(Serialize)] 31 + pub struct RevisionChanges { 32 + pub change_id: String, 33 + pub files: Vec<ChangedFile>, 34 + } 35 + 24 36 #[tauri::command] 25 37 fn find_repository(start_path: String) -> Option<String> { 26 38 let path = PathBuf::from(&start_path); ··· 215 227 Ok(files) 216 228 } 217 229 230 + /// Compute diff for a single revision (helper function for batch processing) 231 + fn compute_revision_diff_inner(jj_repo: &JjRepo, change_id: &str) -> Result<String, String> { 232 + use jj_lib::backend::TreeValue; 233 + use jj_lib::matchers::EverythingMatcher; 234 + 235 + let commit = jj_repo 236 + .get_commit(change_id) 237 + .map_err(|e| format!("Failed to get commit: {}", e))?; 238 + 239 + let parent_tree = { 240 + let parents = commit.parents(); 241 + let parent = parents 242 + .into_iter() 243 + .next() 244 + .ok_or_else(|| "Commit has no parent".to_string())?; 245 + parent 246 + .map_err(|e| format!("Failed to get parent: {}", e))? 247 + .tree() 248 + .map_err(|e| format!("Failed to get parent tree: {}", e))? 249 + }; 250 + 251 + let commit_tree = commit 252 + .tree() 253 + .map_err(|e| format!("Failed to get commit tree: {}", e))?; 254 + 255 + let matcher = EverythingMatcher; 256 + let mut diff_iter = parent_tree.diff_stream(&commit_tree, &matcher); 257 + 258 + let repo = jj_repo 259 + .repo_loader() 260 + .load_at_head() 261 + .map_err(|e| format!("Failed to load repo: {}", e))?; 262 + 263 + let mut unified_diffs = Vec::new(); 264 + 265 + pollster::block_on(async { 266 + use futures::StreamExt; 267 + while let Some(entry) = diff_iter.next().await { 268 + let path = entry.path; 269 + let path_str = path.as_internal_file_string(); 270 + 271 + let diff_values = entry 272 + .values 273 + .map_err(|e| format!("Failed to get diff values: {}", e))?; 274 + let before = diff_values.before.removes().next().and_then(|v| v.as_ref()); 275 + let after = diff_values.after.adds().next().and_then(|v| v.as_ref()); 276 + 277 + match (before, after) { 278 + (Some(TreeValue::File { .. }), Some(TreeValue::File { .. })) 279 + | (None, Some(TreeValue::File { .. })) 280 + | (Some(TreeValue::File { .. }), None) => { 281 + let old_content = jj_repo 282 + .get_parent_file_content_with_repo(repo.as_ref(), &commit, path_str) 283 + .unwrap_or_default(); 284 + 285 + let new_content = jj_repo 286 + .get_file_content_with_repo(repo.as_ref(), &commit, path_str) 287 + .unwrap_or_default(); 288 + 289 + let file_diff = diff::compute_file_diff(&old_content, &new_content, path_str) 290 + .map_err(|e| format!("Failed to compute diff: {}", e))?; 291 + 292 + if !file_diff.is_empty() { 293 + unified_diffs.push(file_diff); 294 + } 295 + } 296 + _ => continue, 297 + }; 298 + } 299 + Ok::<(), String>(()) 300 + })?; 301 + 302 + Ok(unified_diffs.join("\n")) 303 + } 304 + 305 + /// Compute changed files for a single revision (helper function for batch processing) 306 + fn compute_revision_changes_inner(jj_repo: &JjRepo, change_id: &str) -> Result<Vec<ChangedFile>, String> { 307 + use jj_lib::backend::TreeValue; 308 + use jj_lib::matchers::EverythingMatcher; 309 + 310 + let commit = jj_repo 311 + .get_commit(change_id) 312 + .map_err(|e| format!("Failed to get commit: {}", e))?; 313 + 314 + let parent_tree = { 315 + let parents = commit.parents(); 316 + let parent = parents 317 + .into_iter() 318 + .next() 319 + .ok_or_else(|| "Commit has no parent".to_string())?; 320 + parent 321 + .map_err(|e| format!("Failed to get parent: {}", e))? 322 + .tree() 323 + .map_err(|e| format!("Failed to get parent tree: {}", e))? 324 + }; 325 + 326 + let commit_tree = commit 327 + .tree() 328 + .map_err(|e| format!("Failed to get commit tree: {}", e))?; 329 + 330 + let matcher = EverythingMatcher; 331 + let mut diff_iter = parent_tree.diff_stream(&commit_tree, &matcher); 332 + 333 + let mut files = Vec::new(); 334 + 335 + pollster::block_on(async { 336 + use futures::StreamExt; 337 + while let Some(entry) = diff_iter.next().await { 338 + let path = entry.path; 339 + let path_str = path.as_internal_file_string(); 340 + 341 + let diff_values = entry 342 + .values 343 + .map_err(|e| format!("Failed to get diff values: {}", e))?; 344 + let before = diff_values.before.removes().next().and_then(|v| v.as_ref()); 345 + let after = diff_values.after.adds().next().and_then(|v| v.as_ref()); 346 + 347 + let status = match (before, after) { 348 + (Some(TreeValue::File { .. }), Some(TreeValue::File { .. })) => "modified", 349 + (None, Some(_)) => "added", 350 + (Some(_), None) => "deleted", 351 + _ => continue, 352 + }; 353 + 354 + files.push(ChangedFile { 355 + path: path_str.to_string(), 356 + status: status.to_string(), 357 + }); 358 + } 359 + Ok::<(), String>(()) 360 + })?; 361 + 362 + Ok(files) 363 + } 364 + 365 + #[tauri::command] 366 + async fn get_diffs_batch( 367 + repo_path: String, 368 + change_ids: Vec<String>, 369 + ) -> Result<Vec<RevisionDiff>, String> { 370 + let path = Path::new(&repo_path); 371 + let jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 372 + 373 + // Process sequentially since JjRepo is not Sync 374 + let results: Vec<RevisionDiff> = change_ids 375 + .iter() 376 + .filter_map(|change_id| { 377 + match compute_revision_diff_inner(&jj_repo, change_id) { 378 + Ok(diff) => Some(RevisionDiff { 379 + change_id: change_id.clone(), 380 + diff, 381 + }), 382 + Err(_) => None, 383 + } 384 + }) 385 + .collect(); 386 + 387 + Ok(results) 388 + } 389 + 390 + #[tauri::command] 391 + async fn get_changes_batch( 392 + repo_path: String, 393 + change_ids: Vec<String>, 394 + ) -> Result<Vec<RevisionChanges>, String> { 395 + let path = Path::new(&repo_path); 396 + let jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 397 + 398 + // Process sequentially since JjRepo is not Sync 399 + let results: Vec<RevisionChanges> = change_ids 400 + .iter() 401 + .filter_map(|change_id| { 402 + match compute_revision_changes_inner(&jj_repo, change_id) { 403 + Ok(files) => Some(RevisionChanges { 404 + change_id: change_id.clone(), 405 + files, 406 + }), 407 + Err(_) => None, 408 + } 409 + }) 410 + .collect(); 411 + 412 + Ok(results) 413 + } 414 + 218 415 #[tauri::command] 219 416 async fn get_projects(app: tauri::AppHandle) -> Result<Vec<Project>, String> { 220 417 let storage = get_storage(&app); ··· 549 746 get_file_diff, 550 747 get_revision_diff, 551 748 get_revision_changes, 749 + get_diffs_batch, 750 + get_changes_batch, 552 751 get_commit_recency, 553 752 resolve_revset, 554 753 get_file_content_base64, ··· 570 769 .run(tauri::generate_context!()) 571 770 .expect("error while running tauri application"); 572 771 } 772 + 773 + #[cfg(test)] 774 + mod tests { 775 + use super::*; 776 + use jj_lib::repo::Repo; 777 + use std::fs; 778 + use std::process::Command; 779 + use tempfile::TempDir; 780 + 781 + /// Creates a test jj repository using the jj CLI. 782 + /// Returns the temp directory (which must be kept alive) and the repo path. 783 + fn create_test_repo() -> (TempDir, PathBuf) { 784 + let temp_dir = TempDir::new().expect("Failed to create temp directory"); 785 + let repo_path = temp_dir.path().to_path_buf(); 786 + 787 + // Initialize jj repo using CLI with git backend (most reliable method) 788 + let status = Command::new("jj") 789 + .args(["git", "init"]) 790 + .current_dir(&repo_path) 791 + .status() 792 + .expect("Failed to run jj git init - is jj installed?"); 793 + 794 + assert!(status.success(), "jj git init failed"); 795 + 796 + (temp_dir, repo_path) 797 + } 798 + 799 + /// Snapshot the working copy to capture file changes using jj CLI. 800 + fn snapshot_working_copy(repo_path: &Path) { 801 + // jj status triggers a snapshot 802 + let status = Command::new("jj") 803 + .args(["status"]) 804 + .current_dir(repo_path) 805 + .status() 806 + .expect("Failed to run jj status"); 807 + 808 + assert!(status.success(), "jj status failed"); 809 + } 810 + 811 + /// Get the working copy change ID. 812 + fn get_wc_change_id(repo_path: &Path) -> String { 813 + let jj_repo = JjRepo::open(repo_path).expect("Failed to open repo"); 814 + let repo = jj_repo.repo_loader().load_at_head().expect("Failed to load repo"); 815 + let wc_commit_id = repo 816 + .view() 817 + .get_wc_commit_id(jj_repo.workspace_name()) 818 + .expect("No working copy"); 819 + let wc_commit = repo.store().get_commit(wc_commit_id).expect("Failed to get commit"); 820 + wc_commit.change_id().reverse_hex() 821 + } 822 + 823 + #[test] 824 + fn test_compute_revision_diff_inner_with_changes() { 825 + let (temp_dir, repo_path) = create_test_repo(); 826 + 827 + // Create a file in the working copy 828 + let file_path = repo_path.join("test.txt"); 829 + fs::write(&file_path, "Hello, world!\n").expect("Failed to write file"); 830 + 831 + // Snapshot to capture the change 832 + snapshot_working_copy(&repo_path); 833 + 834 + // Open repo and get the working copy change ID 835 + let change_id = get_wc_change_id(&repo_path); 836 + let jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 837 + 838 + // Compute diff 839 + let result = compute_revision_diff_inner(&jj_repo, &change_id); 840 + assert!(result.is_ok(), "compute_revision_diff_inner should succeed"); 841 + 842 + let diff = result.unwrap(); 843 + // The diff should show the new file 844 + assert!(diff.contains("test.txt"), "Diff should contain the filename"); 845 + assert!(diff.contains("Hello, world!"), "Diff should contain the file content"); 846 + 847 + drop(temp_dir); // Cleanup 848 + } 849 + 850 + #[test] 851 + fn test_compute_revision_diff_inner_empty_commit() { 852 + let (temp_dir, repo_path) = create_test_repo(); 853 + 854 + // Open repo and get the working copy change ID (no changes yet) 855 + let change_id = get_wc_change_id(&repo_path); 856 + let jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 857 + 858 + // Compute diff for empty commit 859 + let result = compute_revision_diff_inner(&jj_repo, &change_id); 860 + assert!(result.is_ok(), "compute_revision_diff_inner should succeed for empty commit"); 861 + 862 + let diff = result.unwrap(); 863 + assert!(diff.is_empty(), "Diff should be empty for commit with no changes"); 864 + 865 + drop(temp_dir); 866 + } 867 + 868 + #[test] 869 + fn test_compute_revision_changes_inner_with_added_file() { 870 + let (temp_dir, repo_path) = create_test_repo(); 871 + 872 + // Create a file 873 + let file_path = repo_path.join("added.txt"); 874 + fs::write(&file_path, "New file content\n").expect("Failed to write file"); 875 + 876 + // Snapshot to capture the change 877 + snapshot_working_copy(&repo_path); 878 + 879 + // Open repo and get the working copy change ID 880 + let change_id = get_wc_change_id(&repo_path); 881 + let jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 882 + 883 + // Compute changes 884 + let result = compute_revision_changes_inner(&jj_repo, &change_id); 885 + assert!(result.is_ok(), "compute_revision_changes_inner should succeed"); 886 + 887 + let files = result.unwrap(); 888 + assert_eq!(files.len(), 1, "Should have exactly one changed file"); 889 + assert_eq!(files[0].path, "added.txt"); 890 + assert_eq!(files[0].status, "added"); 891 + 892 + drop(temp_dir); 893 + } 894 + 895 + #[test] 896 + fn test_compute_revision_changes_inner_empty_commit() { 897 + let (temp_dir, repo_path) = create_test_repo(); 898 + 899 + // Open repo and get the working copy change ID (no changes) 900 + let change_id = get_wc_change_id(&repo_path); 901 + let jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 902 + 903 + // Compute changes for empty commit 904 + let result = compute_revision_changes_inner(&jj_repo, &change_id); 905 + assert!(result.is_ok(), "compute_revision_changes_inner should succeed for empty commit"); 906 + 907 + let files = result.unwrap(); 908 + assert!(files.is_empty(), "Should have no changed files for empty commit"); 909 + 910 + drop(temp_dir); 911 + } 912 + 913 + #[test] 914 + fn test_batch_chunking_multiple_revisions() { 915 + let (temp_dir, repo_path) = create_test_repo(); 916 + 917 + // Create multiple files to have changes in the working copy 918 + for i in 0..5 { 919 + let file_path = repo_path.join(format!("file{}.txt", i)); 920 + fs::write(&file_path, format!("Content {}\n", i)).expect("Failed to write file"); 921 + } 922 + 923 + // Snapshot to capture the changes 924 + snapshot_working_copy(&repo_path); 925 + 926 + // Get the working copy change ID 927 + let change_id = get_wc_change_id(&repo_path); 928 + 929 + // Test batch processing with the single valid change_id 930 + let change_ids: Vec<String> = vec![change_id.clone()]; 931 + 932 + // Test get_diffs_batch logic using rayon parallel processing 933 + use rayon::prelude::*; 934 + let repo_path_ref = &repo_path; 935 + let results: Vec<RevisionDiff> = change_ids 936 + .par_iter() 937 + .filter_map(|cid| { 938 + let path = Path::new(repo_path_ref); 939 + let jj = JjRepo::open(path).ok()?; 940 + match compute_revision_diff_inner(&jj, cid) { 941 + Ok(diff) => Some(RevisionDiff { 942 + change_id: cid.clone(), 943 + diff, 944 + }), 945 + Err(_) => None, 946 + } 947 + }) 948 + .collect(); 949 + 950 + assert_eq!(results.len(), 1, "Should have one result"); 951 + assert_eq!(results[0].change_id, change_id); 952 + // Diff should contain all the files 953 + for i in 0..5 { 954 + assert!( 955 + results[0].diff.contains(&format!("file{}.txt", i)), 956 + "Diff should contain file{}.txt", 957 + i 958 + ); 959 + } 960 + 961 + drop(temp_dir); 962 + } 963 + 964 + #[test] 965 + fn test_batch_with_invalid_change_id() { 966 + let (temp_dir, repo_path) = create_test_repo(); 967 + 968 + // Get the working copy change ID 969 + let valid_change_id = get_wc_change_id(&repo_path); 970 + 971 + // Mix valid and invalid change IDs 972 + let change_ids: Vec<String> = vec![ 973 + valid_change_id.clone(), 974 + "invalid_change_id_12345".to_string(), 975 + "zzzzzzzz".to_string(), // Another invalid one 976 + ]; 977 + 978 + // Test batch processing - invalid IDs should be filtered out 979 + use rayon::prelude::*; 980 + let repo_path_ref = &repo_path; 981 + let results: Vec<RevisionDiff> = change_ids 982 + .par_iter() 983 + .filter_map(|cid| { 984 + let path = Path::new(repo_path_ref); 985 + let jj = JjRepo::open(path).ok()?; 986 + match compute_revision_diff_inner(&jj, cid) { 987 + Ok(diff) => Some(RevisionDiff { 988 + change_id: cid.clone(), 989 + diff, 990 + }), 991 + Err(_) => None, 992 + } 993 + }) 994 + .collect(); 995 + 996 + // Only the valid change ID should produce a result 997 + assert_eq!(results.len(), 1, "Should have only one valid result"); 998 + assert_eq!(results[0].change_id, valid_change_id); 999 + 1000 + drop(temp_dir); 1001 + } 1002 + 1003 + #[test] 1004 + fn test_batch_changes_parallel_processing() { 1005 + let (temp_dir, repo_path) = create_test_repo(); 1006 + 1007 + // Create a file 1008 + let file_path = repo_path.join("parallel_test.txt"); 1009 + fs::write(&file_path, "Parallel test content\n").expect("Failed to write file"); 1010 + 1011 + // Snapshot to capture the change 1012 + snapshot_working_copy(&repo_path); 1013 + 1014 + // Get the working copy change ID 1015 + let change_id = get_wc_change_id(&repo_path); 1016 + 1017 + // Test get_changes_batch logic with parallel processing 1018 + let change_ids: Vec<String> = vec![change_id.clone()]; 1019 + 1020 + use rayon::prelude::*; 1021 + let repo_path_ref = &repo_path; 1022 + let results: Vec<RevisionChanges> = change_ids 1023 + .par_iter() 1024 + .filter_map(|cid| { 1025 + let path = Path::new(repo_path_ref); 1026 + let jj = JjRepo::open(path).ok()?; 1027 + match compute_revision_changes_inner(&jj, cid) { 1028 + Ok(files) => Some(RevisionChanges { 1029 + change_id: cid.clone(), 1030 + files, 1031 + }), 1032 + Err(_) => None, 1033 + } 1034 + }) 1035 + .collect(); 1036 + 1037 + assert_eq!(results.len(), 1, "Should have one result"); 1038 + assert_eq!(results[0].change_id, change_id); 1039 + assert_eq!(results[0].files.len(), 1); 1040 + assert_eq!(results[0].files[0].path, "parallel_test.txt"); 1041 + assert_eq!(results[0].files[0].status, "added"); 1042 + 1043 + drop(temp_dir); 1044 + } 1045 + }
+37 -30
apps/desktop/src/components/AppShell.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 4 - import { useMemo, useRef, useState, useSyncExternalStore } from "react"; 4 + import { Profiler, useMemo, useRef, useState, useSyncExternalStore } from "react"; 5 5 import { Route as ProjectRoute } from "@/routes/project.$projectId"; 6 6 import { expandedStacksAtom, viewModeAtom } from "@/atoms"; 7 7 ··· 47 47 import { useAppTitle } from "@/hooks/useAppTitle"; 48 48 import { useKeyboardNavigation, useKeyboardShortcut, useKeySequence } from "@/hooks/useKeyboard"; 49 49 import type { Repository, Revision } from "@/tauri-commands"; 50 + import { onRenderCallback } from "@/lib/trace"; 50 51 51 52 // Wrapper component that handles the case when no project is selected 52 53 export function AppShell() { ··· 407 408 className="h-full relative outline-none" 408 409 aria-label="Revision list" 409 410 > 410 - <RevisionGraph 411 - ref={revisionGraphRef} 412 - revisions={revisions} 413 - selectedRevision={selectedRevision} 414 - onSelectRevision={handleSelectRevision} 415 - isLoading={isLoading} 416 - flash={flash} 417 - repoPath={activeProject?.path ?? null} 418 - pendingAbandon={pendingAbandon} 419 - diffPanelRef={diffPanelRef} 420 - /> 411 + <Profiler id="RevisionGraph" onRender={onRenderCallback}> 412 + <RevisionGraph 413 + ref={revisionGraphRef} 414 + revisions={revisions} 415 + selectedRevision={selectedRevision} 416 + onSelectRevision={handleSelectRevision} 417 + isLoading={isLoading} 418 + flash={flash} 419 + repoPath={activeProject?.path ?? null} 420 + pendingAbandon={pendingAbandon} 421 + diffPanelRef={diffPanelRef} 422 + /> 423 + </Profiler> 421 424 </section> 422 425 ) : ( 423 426 // Split mode: revision list + diff panel (vertical on narrow screens) ··· 429 432 className="h-full relative outline-none" 430 433 aria-label="Revision list" 431 434 > 432 - <RevisionGraph 433 - ref={revisionGraphRef} 434 - revisions={revisions} 435 - selectedRevision={selectedRevision} 436 - onSelectRevision={handleSelectRevision} 437 - isLoading={isLoading} 438 - flash={flash} 439 - repoPath={activeProject?.path ?? null} 440 - pendingAbandon={pendingAbandon} 441 - diffPanelRef={diffPanelRef} 442 - /> 435 + <Profiler id="RevisionGraph" onRender={onRenderCallback}> 436 + <RevisionGraph 437 + ref={revisionGraphRef} 438 + revisions={revisions} 439 + selectedRevision={selectedRevision} 440 + onSelectRevision={handleSelectRevision} 441 + isLoading={isLoading} 442 + flash={flash} 443 + repoPath={activeProject?.path ?? null} 444 + pendingAbandon={pendingAbandon} 445 + diffPanelRef={diffPanelRef} 446 + /> 447 + </Profiler> 443 448 </section> 444 449 </ResizablePanel> 445 450 <ResizableHandle withHandle /> 446 451 <ResizablePanel defaultSize={isNarrowScreen ? 60 : 75} minSize={30}> 447 452 <aside className="h-full" aria-label="Diff viewer"> 448 - <PrerenderedDiffPanel 449 - ref={diffPanelRef} 450 - repoPath={activeProject?.path ?? null} 451 - revisions={orderedRevisions} 452 - selectedChangeId={selectedRevision?.change_id ?? null} 453 - revisionsPanelRef={revisionsPanelRef} 454 - /> 453 + <Profiler id="DiffPanel" onRender={onRenderCallback}> 454 + <PrerenderedDiffPanel 455 + ref={diffPanelRef} 456 + repoPath={activeProject?.path ?? null} 457 + revisions={orderedRevisions} 458 + selectedChangeId={selectedRevision?.change_id ?? null} 459 + revisionsPanelRef={revisionsPanelRef} 460 + /> 461 + </Profiler> 455 462 </aside> 456 463 </ResizablePanel> 457 464 </ResizablePanelGroup>
+41 -38
apps/desktop/src/components/DiffPanel.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 - import { useLiveQuery } from "@tanstack/react-db"; 3 2 import { PatchDiff } from "@pierre/diffs/react"; 4 3 import { Columns2Icon, Loader2, RowsIcon } from "lucide-react"; 5 4 import type { FocusEvent, RefObject } from "react"; ··· 7 6 forwardRef, 8 7 useCallback, 9 8 useDeferredValue, 9 + useEffect, 10 10 useMemo, 11 11 useRef, 12 12 useState, ··· 17 17 import { Button } from "@/components/ui/button"; 18 18 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 19 19 import { ScrollArea } from "@/components/ui/scroll-area"; 20 - import { 21 - emptyChangesCollection, 22 - emptyDiffCollection, 23 - getRevisionChangesCollection, 24 - getRevisionDiffCollection, 25 - } from "@/db"; 26 20 import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 21 + import { useChanges, useDiff, usePrefetch } from "@/hooks/useRevisionData"; 27 22 import { extractFilePath, parsePatchStats, splitMultiFileDiff } from "@/lib/diff-utils"; 23 + import { traceLog } from "@/lib/trace"; 24 + import { cn } from "@/lib/utils"; 28 25 import type { ChangedFileStatus } from "@/schemas"; 29 26 import type { Revision } from "@/tauri-commands"; 30 27 import { isImageFile } from "@/utils/file-types"; 31 - import { cn } from "@/lib/utils"; 32 28 33 29 interface DiffPanelProps { 34 30 repoPath: string | null; ··· 201 197 // Keyboard navigation 202 198 useDiffPanelKeyboard({ scrollContainerRef, revisionsPanelRef, hasFocus }); 203 199 204 - // Fetch file changes (for the file list with status) 205 - const changesCollection = 206 - repoPath && deferredChangeId 207 - ? getRevisionChangesCollection(repoPath, deferredChangeId) 208 - : emptyChangesCollection; 209 - const { data: changedFiles = [] } = useLiveQuery(changesCollection); 200 + // Prefetch hook for triggering data load 201 + const { prefetchDiffs, prefetchChanges } = usePrefetch(repoPath ?? ""); 210 202 211 - // Fetch full diff (for the diff content) 212 - const diffCollection = 213 - repoPath && deferredChangeId 214 - ? getRevisionDiffCollection(repoPath, deferredChangeId) 215 - : emptyDiffCollection; 216 - const { data: diffEntries = [] } = useLiveQuery(diffCollection); 217 - const revisionDiff = diffEntries[0]?.content ?? ""; 203 + // Trigger prefetch when selection changes 204 + useEffect(() => { 205 + if (repoPath && deferredChangeId) { 206 + traceLog("selection-change", { changeId: deferredChangeId }); 207 + prefetchDiffs([deferredChangeId]); 208 + prefetchChanges([deferredChangeId]); 209 + } 210 + }, [repoPath, deferredChangeId, prefetchDiffs, prefetchChanges]); 218 211 219 - // Timing instrumentation for cache analysis 220 - console.log( 221 - "[DiffPanel] selection:", 222 - changeId?.slice(0, 8), 223 - "deferred:", 224 - deferredChangeId?.slice(0, 8), 225 - "changedFiles:", 226 - changedFiles.length, 227 - "hasDiff:", 228 - !!revisionDiff, 229 - "at:", 230 - performance.now().toFixed(0), 212 + // Read file changes from unified collection 213 + const changesRecords = useChanges(repoPath ?? "", deferredChangeId); 214 + const changedFiles = useMemo( 215 + () => changesRecords.map((c) => ({ path: c.path, status: c.status })), 216 + [changesRecords], 231 217 ); 232 218 219 + // Read diff from unified collection 220 + const diffRecord = useDiff(repoPath ?? "", deferredChangeId); 221 + const revisionDiff = diffRecord?.content ?? ""; 222 + 223 + // Log when data appears (only on actual changes) 224 + useEffect(() => { 225 + if (changedFiles.length > 0 && deferredChangeId) { 226 + traceLog("changes-loaded", { changeId: deferredChangeId, fileCount: changedFiles.length }); 227 + } 228 + }, [changedFiles.length, deferredChangeId]); 229 + 230 + useEffect(() => { 231 + if (diffRecord && deferredChangeId) { 232 + traceLog("diff-loaded", { changeId: deferredChangeId, size: revisionDiff.length }); 233 + } 234 + }, [diffRecord, deferredChangeId, revisionDiff.length]); 235 + 233 236 // Derive effective diffViewState - reset when changeId changes (no useEffect needed) 234 237 const effectiveDiffViewState = getDiffViewState(diffViewState, deferredChangeId); 235 238 ··· 251 254 }, [selectedFiles, changedFiles]); 252 255 253 256 // Get first selected file for style override display 254 - const firstSelectedFile = 255 - effectiveSelectedFiles.size > 0 ? [...effectiveSelectedFiles][0] : null; 257 + const firstSelectedFile = effectiveSelectedFiles.size > 0 ? [...effectiveSelectedFiles][0] : null; 256 258 257 259 // Get effective diff style for first selected file 258 260 const effectiveDiffStyle = firstSelectedFile ··· 297 299 } 298 300 299 301 // Use current data if available, otherwise fall back to last valid state 300 - const displayedState = currentPatches 301 - ? { changeId: deferredChangeId!, patches: currentPatches } 302 - : lastValidStateRef.current; 302 + const displayedState = 303 + currentPatches && deferredChangeId 304 + ? { changeId: deferredChangeId, patches: currentPatches } 305 + : lastValidStateRef.current; 303 306 304 307 // Determine if we're showing stale data 305 308 const isStale = displayedState !== null && displayedState.changeId !== changeId;
+1 -2
apps/desktop/src/components/diff/ImageDiff.tsx
··· 46 46 staleTime: 5 * 60 * 1000, // 5 minutes 47 47 }); 48 48 49 - const loading = 50 - (status !== "deleted" && currentLoading) || (status !== "added" && parentLoading); 49 + const loading = (status !== "deleted" && currentLoading) || (status !== "added" && parentLoading); 51 50 const error = currentError || parentError; 52 51 53 52 if (loading) {
+2 -2
apps/desktop/src/components/revision-graph/GraphEdge.tsx
··· 126 126 strokeOpacity={0.7} 127 127 strokeDasharray="3 6" 128 128 strokeLinecap="round" 129 - className="group-hover:[stroke-width:3] group-hover:[stroke-opacity:1] transition-[stroke-width,stroke-opacity] duration-150" 129 + className="group-hover:scale-[1.5] group-hover:[stroke-opacity:1] transition-[transform,stroke-opacity] duration-150 origin-center" 130 130 data-edge-type="collapsed-stack" 131 131 data-stack-id={collapsedStackId} 132 132 data-source-revision={sourceRevision.change_id} ··· 183 183 strokeWidth={hoverStrokeWidth} 184 184 strokeOpacity={hoverStrokeOpacity} 185 185 strokeDasharray={isDashed ? "4 4" : undefined} 186 - className="transition-[stroke-width,stroke-opacity] duration-150" 186 + className="" 187 187 data-edge-type={edgeType} 188 188 data-source-revision={sourceRevision.change_id} 189 189 data-target-revision={targetRevision?.change_id}
+2 -2
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 72 72 role="button" 73 73 tabIndex={0} 74 74 style={{ height: ROW_HEIGHT }} 75 - className={`flex relative select-none outline-none ${ 75 + className={`flex relative select-none outline-none contain-layout contain-style ${ 76 76 revision.is_immutable ? "opacity-60" : "" 77 77 } ${isDimmed ? "opacity-40" : ""}`} 78 78 data-selected={isSelected || undefined} ··· 162 162 <div className="shrink-0" style={{ width: nodeAreaWidth + 8 }} /> 163 163 {/* Content area with visual styling - full row height */} 164 164 <div 165 - className={`relative flex-1 mr-2 min-w-0 overflow-hidden text-card-foreground flex flex-col justify-center py-1 border-b transition-colors rounded-md ${ 165 + className={`relative flex-1 mr-2 min-w-0 overflow-hidden text-card-foreground flex flex-col justify-center py-1 border-b rounded-md ${ 166 166 isDragOver 167 167 ? "bg-primary/20 border-primary/50" 168 168 : isChecked || isFocused
+118 -32
apps/desktop/src/components/revision-graph/index.tsx
··· 11 11 useRef, 12 12 } from "react"; 13 13 import { Route } from "@/routes/project.$projectId"; 14 + import { traceEnd, traceLog, traceStart } from "@/lib/trace"; 14 15 import { 15 16 debugOverlayEnabledAtom, 16 17 expandedStacksAtom, ··· 23 24 computeRevisionAncestry, 24 25 type RevisionStack, 25 26 } from "@/components/revision-graph-utils"; 26 - import { getRevisionKey, prefetchRevisionDiffs } from "@/db"; 27 + import { getRevisionKey } from "@/db"; 27 28 import { useFocusWithin } from "@/hooks/useFocusWithin"; 29 + import { usePrefetch } from "@/hooks/useRevisionData"; 28 30 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 29 31 import { useRevisionGraphNavigation } from "@/hooks/useRevisionGraphNavigation"; 30 32 import type { Revision } from "@/tauri-commands"; ··· 92 94 } 93 95 94 96 function buildGraph(revisions: Revision[]): GraphData { 95 - if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [], edgeBindings: [] }; 97 + const traceId = traceStart("build-graph", { revisionCount: revisions.length }); 98 + if (revisions.length === 0) { 99 + traceEnd(traceId, { rowCount: 0, edgeCount: 0 }); 100 + return { nodes: [], laneCount: 1, rows: [], edgeBindings: [] }; 101 + } 96 102 97 103 // Map commit_id -> Revision for ancestry lookups 98 104 const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); ··· 308 314 } 309 315 } 310 316 317 + traceEnd(traceId, { rowCount: rows.length, edgeCount: edgeBindings.length }); 311 318 return { nodes, laneCount: globalMaxLane + 1, rows, edgeBindings }; 312 319 } 313 320 314 321 // Compute related revisions (ancestors + descendants) of a selected revision 315 322 function getRelatedRevisions(revisions: Revision[], selectedChangeId: string | null): Set<string> { 316 - if (!selectedChangeId) return new Set(); 323 + const traceId = traceStart("get-related-revisions", { 324 + selectedChangeId, 325 + revisionCount: revisions.length, 326 + }); 327 + 328 + if (!selectedChangeId) { 329 + traceEnd(traceId, { relatedCount: 0 }); 330 + return new Set(); 331 + } 317 332 318 333 const related = new Set<string>(); 319 334 const commitIdToChangeId = new Map<string, string>(); ··· 337 352 } 338 353 339 354 const selectedCommitId = changeIdToCommitId.get(selectedChangeId); 340 - if (!selectedCommitId) return new Set(); 355 + if (!selectedCommitId) { 356 + traceEnd(traceId, { relatedCount: 0 }); 357 + return new Set(); 358 + } 341 359 342 360 // BFS to find ancestors 343 361 const ancestorQueue = [selectedCommitId]; ··· 369 387 } 370 388 } 371 389 390 + traceEnd(traceId, { relatedCount: related.size }); 372 391 return related; 373 392 } 374 393 ··· 388 407 ) { 389 408 const parentRef = useRef<HTMLDivElement>(null); 390 409 const containerRef = useRef<HTMLDivElement>(null); 410 + const prefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 411 + 412 + // Stabilize revisions array - only update ref when content changes (by length + endpoints) 413 + const stableRevisionsRef = useRef(revisions); 414 + if ( 415 + revisions.length !== stableRevisionsRef.current.length || 416 + revisions[0]?.change_id !== stableRevisionsRef.current[0]?.change_id 417 + ) { 418 + stableRevisionsRef.current = revisions; 419 + } 420 + const stableRevisions = stableRevisionsRef.current; 391 421 392 422 // Use native focus tracking 393 423 const hasFocus = useFocusWithin(containerRef); ··· 396 426 laneCount, 397 427 rows: allRows, 398 428 edgeBindings, 399 - } = useMemo(() => buildGraph(revisions), [revisions]); 429 + } = useMemo(() => buildGraph(stableRevisions), [stableRevisions]); 400 430 const search = useSearch({ from: Route.fullPath }); 401 431 const navigate = useNavigate({ from: Route.fullPath }); 402 432 const [inlineJumpQuery, setInlineJumpQuery] = useAtom(inlineJumpQueryAtom); 403 433 const inlineJumpMode = inlineJumpQuery !== null; 404 434 405 435 // Detect collapsible stacks 406 - const stacks = useMemo(() => detectStacks(revisions), [revisions]); 436 + const stacks = useMemo(() => detectStacks(stableRevisions), [stableRevisions]); 407 437 408 - // Prefetch diffs for all revisions in background 409 - // This eagerly creates TanStack DB collections which trigger async fetches 410 - useMemo(() => { 411 - if (repoPath && revisions.length > 0) { 412 - const changeIds = revisions.map((r) => r.change_id); 413 - prefetchRevisionDiffs(repoPath, changeIds); 414 - } 415 - }, [repoPath, revisions]); 438 + // Setup prefetch hooks for visible revision data 439 + const { prefetchDiffs, prefetchChanges } = usePrefetch(repoPath ?? ""); 416 440 417 441 // Track which stacks are expanded (empty = all collapsed by default) 418 442 const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); ··· 482 506 483 507 // Filter rows to hide collapsed intermediate revisions and replace with a single collapsed stack row 484 508 const displayRows = useMemo(() => { 509 + const traceId = traceStart("compute-display-rows", { 510 + allRowsCount: allRows.length, 511 + }); 512 + 485 513 const result: DisplayRow[] = []; 486 514 487 515 for (const row of allRows) { ··· 507 535 } 508 536 } 509 537 538 + traceEnd(traceId, { displayRowCount: result.length }); 510 539 return result; 511 540 }, [allRows, stackByChangeId, intermediateChangeIds, expandedStacks, changeIdToLane]); 512 541 ··· 557 586 } 558 587 559 588 // Maps for lookups - by revision key for UI, by commit_id for graph edges 560 - const revisionMapByKey = new Map(revisions.map((r) => [getRevisionKey(r), r])); 561 - const revisionMapByCommitId = new Map(revisions.map((r) => [r.commit_id, r])); 562 - 563 - // Compute related revisions for dimming logic 564 - // When a stack is focused, use the stack's top and bottom as the "selected" revisions 565 - const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 589 + const revisionMapByKey = new Map(stableRevisions.map((r) => [getRevisionKey(r), r])); 590 + const revisionMapByCommitId = new Map(stableRevisions.map((r) => [r.commit_id, r])); 566 591 567 592 // Defer the selected ID so dimming computation doesn't block selection highlight 568 593 const deferredSelectedChangeId = useDeferredValue(selectedRevision?.change_id ?? null); 569 594 595 + // Compute related revisions for dimming logic 596 + // Use focusedStackId (string) as dependency instead of focusedStack (object) for stability 570 597 const relatedRevisions = useMemo(() => { 598 + const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 571 599 if (focusedStack) { 572 600 // When stack is focused, highlight the stack endpoints and their ancestors/descendants 573 - const topRelated = getRelatedRevisions(revisions, focusedStack.topChangeId); 574 - const bottomRelated = getRelatedRevisions(revisions, focusedStack.bottomChangeId); 601 + const topRelated = getRelatedRevisions(stableRevisions, focusedStack.topChangeId); 602 + const bottomRelated = getRelatedRevisions(stableRevisions, focusedStack.bottomChangeId); 575 603 // Union of both sets 576 604 return new Set([...topRelated, ...bottomRelated]); 577 605 } 578 - return getRelatedRevisions(revisions, deferredSelectedChangeId); 579 - }, [revisions, focusedStack, deferredSelectedChangeId]); 606 + return getRelatedRevisions(stableRevisions, deferredSelectedChangeId); 607 + }, [stableRevisions, stackById, focusedStackId, deferredSelectedChangeId]); 580 608 581 609 // Build revision key -> displayRow index map for scrolling and edge positioning 582 610 // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning ··· 594 622 // Create a mapping of change_id -> commit_id for edge remapping 595 623 const changeIdToCommitId = useMemo(() => { 596 624 const map = new Map<string, string>(); 597 - for (const rev of revisions) { 625 + for (const rev of stableRevisions) { 598 626 map.set(rev.change_id, rev.commit_id); 599 627 } 600 628 return map; 601 - }, [revisions]); 629 + }, [stableRevisions]); 602 630 603 631 // Filter edge bindings to handle collapsed/expanded stacks 604 632 // When a stack is collapsed, edges from/to intermediates should be remapped ··· 691 719 692 720 // Keyboard navigation (j/k/J/K/arrows/g/G/Home/End/l/Space/Enter/Escape) 693 721 useRevisionGraphNavigation({ 694 - revisions, 722 + revisions: stableRevisions, 695 723 displayRows, 696 724 changeIdToIndex, 697 725 selectedRevision, ··· 747 775 } 748 776 return ROW_HEIGHT; 749 777 }, 750 - overscan: 10, 778 + overscan: 5, 751 779 debug: debugEnabled, 752 780 }); 753 781 ··· 796 824 })); 797 825 798 826 function handleSelect(revisionKey: string, modifiers: { shift: boolean; meta: boolean }) { 827 + traceLog("handle-select", { revisionKey, shift: modifiers.shift, meta: modifiers.meta }); 828 + 799 829 const revision = revisionMapByKey.get(revisionKey); 800 830 if (!revision) return; 801 831 ··· 872 902 rowOffsets.set(item.index, item.start); 873 903 } 874 904 905 + // Prefetch diffs and changes for visible and nearby revisions 906 + // Uses a buffer to prefetch ahead of scroll for smoother experience 907 + const PREFETCH_BUFFER = 20; 908 + useEffect(() => { 909 + if (!repoPath || displayRows.length === 0) return; 910 + 911 + // Clear previous timer 912 + if (prefetchDebounceRef.current) { 913 + clearTimeout(prefetchDebounceRef.current); 914 + } 915 + 916 + // Debounce prefetch by 200ms to avoid jitter during rapid navigation 917 + prefetchDebounceRef.current = setTimeout(() => { 918 + // Calculate range including buffer 919 + const startIdx = Math.max(0, visibleStartRow - PREFETCH_BUFFER); 920 + const endIdx = Math.min(displayRows.length - 1, visibleEndRow + PREFETCH_BUFFER); 921 + 922 + // Collect change IDs from display rows in the prefetch range 923 + const changeIds: string[] = []; 924 + for (let i = startIdx; i <= endIdx; i++) { 925 + const displayRow = displayRows[i]; 926 + if (displayRow.type === "revision") { 927 + changeIds.push(displayRow.row.revision.change_id); 928 + } else if (displayRow.type === "collapsed-stack") { 929 + // Only prefetch representative revision from collapsed stack 930 + // (user hasn't expanded it, so don't load all) 931 + if (displayRow.stack.changeIds.length > 0) { 932 + changeIds.push(displayRow.stack.changeIds[0]); 933 + } 934 + } 935 + } 936 + 937 + // Also prefetch the selected revision if it exists 938 + if (selectedRevision) { 939 + changeIds.push(selectedRevision.change_id); 940 + } 941 + 942 + // Dedupe and cap to avoid overwhelming IPC with large repos 943 + const uniqueIds = [...new Set(changeIds)].slice(0, 50); 944 + prefetchDiffs(uniqueIds); 945 + prefetchChanges(uniqueIds); 946 + }, 200); 947 + 948 + // Cleanup on unmount or when deps change 949 + return () => { 950 + if (prefetchDebounceRef.current) { 951 + clearTimeout(prefetchDebounceRef.current); 952 + } 953 + }; 954 + }, [ 955 + repoPath, 956 + visibleStartRow, 957 + visibleEndRow, 958 + displayRows, 959 + selectedRevision, 960 + prefetchDiffs, 961 + prefetchChanges, 962 + ]); 963 + 875 964 // Compute jump hints for visible rows based on change ID prefix matching 876 965 const { jumpHintsMap, matchingRevisions } = useMemo(() => { 877 966 const hints = new Map<string, string>(); ··· 932 1021 const matchingRevisionsRef = useRef(matchingRevisions); 933 1022 matchingRevisionsRef.current = matchingRevisions; 934 1023 935 - // Handle jump hint letter key presses (DOM event subscription - legitimate useEffect) 936 - // biome-ignore lint/correctness/useExhaustiveDependencies: keyboard event handler pattern 1024 + // Handle jump hint letter key presses (DOM event subscription) 937 1025 useEffect(() => { 938 1026 if (!inlineJumpMode) return; 939 1027 ··· 1083 1171 return ( 1084 1172 <div 1085 1173 key={`collapsed-${stack.id}`} 1086 - ref={rowVirtualizer.measureElement} 1087 1174 data-index={virtualRow.index} 1088 1175 className="absolute left-0 w-full" 1089 1176 style={{ ··· 1166 1253 return ( 1167 1254 <div 1168 1255 key={getRevisionKey(row.revision)} 1169 - ref={rowVirtualizer.measureElement} 1170 1256 data-index={virtualRow.index} 1171 1257 className="absolute left-0 w-full" 1172 1258 style={{
+66 -26
apps/desktop/src/db.ts
··· 435 435 queryClient, 436 436 queryKey: ["revision-changes", repoPath, changeId], 437 437 queryFn: async () => { 438 - console.log( 439 - "[DB] FETCHING changes for", 440 - changeId.slice(0, 8), 441 - "at:", 442 - performance.now().toFixed(0), 443 - ); 444 - const changes = await getRevisionChanges(repoPath, changeId); 445 - console.log( 446 - "[DB] FETCHED changes for", 447 - changeId.slice(0, 8), 448 - "at:", 449 - performance.now().toFixed(0), 450 - ); 451 - return changes; 438 + return await getRevisionChanges(repoPath, changeId); 452 439 }, 453 440 getKey: (file: ChangedFile) => file.path, 454 441 }), ··· 497 484 queryClient, 498 485 queryKey: ["revision-diff", repoPath, changeId], 499 486 queryFn: async () => { 500 - console.log( 501 - "[DB] FETCHING diff for", 502 - changeId.slice(0, 8), 503 - "at:", 504 - performance.now().toFixed(0), 505 - ); 506 487 const diff = await getRevisionDiff(repoPath, changeId); 507 - console.log( 508 - "[DB] FETCHED diff for", 509 - changeId.slice(0, 8), 510 - "at:", 511 - performance.now().toFixed(0), 512 - ); 513 488 return [{ id: "diff" as const, content: diff }]; 514 489 }, 515 490 getKey: (entry: DiffEntry) => entry.id, ··· 616 591 getKey: (entry: CommitRecencyEntry) => entry.id, 617 592 }), 618 593 }); 594 + 595 + // ============================================================================ 596 + // Unified Diffs Collection (single collection for all revision diffs) 597 + // ============================================================================ 598 + 599 + /** 600 + * Unified diff record - stores diff content keyed by repoPath:changeId. 601 + * This replaces the per-revision collection pattern which caused GC issues. 602 + */ 603 + export interface DiffRecord { 604 + repoPath: string; 605 + changeId: string; 606 + content: string; 607 + } 608 + 609 + function getDiffRecordKey(d: DiffRecord): string { 610 + return `${d.repoPath}:${d.changeId}`; 611 + } 612 + 613 + const diffsQueryKey = ["diffs"] as const; 614 + 615 + export const diffsCollection = createCollection({ 616 + ...queryCollectionOptions({ 617 + queryClient, 618 + queryKey: diffsQueryKey, 619 + queryFn: async () => [] as DiffRecord[], 620 + getKey: getDiffRecordKey, 621 + }), 622 + startSync: true, 623 + }); 624 + 625 + export type DiffsCollection = typeof diffsCollection; 626 + 627 + // ============================================================================ 628 + // Unified Changes Collection (single collection for all revision file lists) 629 + // ============================================================================ 630 + 631 + /** 632 + * Unified change record - stores changed files keyed by repoPath:changeId:path. 633 + * This replaces the per-revision collection pattern which caused GC issues. 634 + */ 635 + export interface ChangeRecord { 636 + repoPath: string; 637 + changeId: string; 638 + path: string; 639 + status: "added" | "modified" | "deleted"; 640 + } 641 + 642 + function getChangeRecordKey(c: ChangeRecord): string { 643 + return `${c.repoPath}:${c.changeId}:${c.path}`; 644 + } 645 + 646 + const changesQueryKey = ["changes"] as const; 647 + 648 + export const changesCollection = createCollection({ 649 + ...queryCollectionOptions({ 650 + queryClient, 651 + queryKey: changesQueryKey, 652 + queryFn: async () => [] as ChangeRecord[], 653 + getKey: getChangeRecordKey, 654 + }), 655 + startSync: true, 656 + }); 657 + 658 + export type ChangesCollection = typeof changesCollection;
+240
apps/desktop/src/hooks/useRevisionData.ts
··· 1 + /** 2 + * Revision data hooks for diffs and changes. 3 + * 4 + * Provides React hooks that connect BatchLoader instances to TanStack DB collections, 5 + * enabling efficient batched fetching with instant reads from local state. 6 + */ 7 + 8 + import { useLiveQuery } from "@tanstack/react-db"; 9 + import { useMemo, useRef } from "react"; 10 + import { type ChangeRecord, changesCollection, type DiffRecord, diffsCollection } from "@/db"; 11 + import { type BatchLoader, createBatchLoader } from "@/lib/batch-loader"; 12 + import { traceLog } from "@/lib/trace"; 13 + import { 14 + getChangesBatchEffect, 15 + getDiffsBatchEffect, 16 + type RevisionChanges, 17 + type RevisionDiff, 18 + } from "@/tauri-commands"; 19 + 20 + // ============================================================================ 21 + // Loader Factory Functions 22 + // ============================================================================ 23 + 24 + /** 25 + * Creates a BatchLoader for revision diffs. 26 + * The loader batches IPC calls and syncs results to the unified diffsCollection. 27 + */ 28 + function createDiffLoader(repoPath: string): BatchLoader { 29 + return createBatchLoader<RevisionDiff>({ 30 + debounceMs: 50, 31 + maxBatchSize: 20, 32 + fetchBatch: (ids) => getDiffsBatchEffect(repoPath, ids), 33 + syncToCollection: (diffs) => { 34 + // Wait for collection to be ready before writing 35 + if (diffsCollection.status !== "ready") return; 36 + const records: DiffRecord[] = diffs.map((d) => ({ 37 + repoPath, 38 + changeId: d.change_id, 39 + content: d.diff, 40 + })); 41 + diffsCollection.utils.writeUpsert(records); 42 + 43 + // LRU eviction: keep only last 100 diffs 44 + const MAX_DIFFS = 100; 45 + const allRecords = Array.from(diffsCollection.state.values()); 46 + if (allRecords.length > MAX_DIFFS) { 47 + // Remove oldest entries (first ones in the Map iteration order) 48 + const toRemove = allRecords.slice(0, allRecords.length - MAX_DIFFS); 49 + for (const record of toRemove) { 50 + const key = `${record.repoPath}:${record.changeId}`; 51 + diffsCollection.state.delete(key); 52 + } 53 + } 54 + }, 55 + isLoaded: (id) => { 56 + if (diffsCollection.status !== "ready") return false; 57 + const key = `${repoPath}:${id}`; 58 + return diffsCollection.state.has(key); 59 + }, 60 + }); 61 + } 62 + 63 + /** 64 + * Creates a BatchLoader for revision changes (file lists). 65 + * The loader batches IPC calls and syncs results to the unified changesCollection. 66 + */ 67 + function createChangesLoader(repoPath: string): BatchLoader { 68 + return createBatchLoader<RevisionChanges>({ 69 + debounceMs: 50, 70 + maxBatchSize: 20, 71 + fetchBatch: (ids) => getChangesBatchEffect(repoPath, ids), 72 + syncToCollection: (changesList) => { 73 + // Wait for collection to be ready before writing 74 + if (changesCollection.status !== "ready") return; 75 + const records: ChangeRecord[] = []; 76 + for (const changes of changesList) { 77 + for (const file of changes.files) { 78 + records.push({ 79 + repoPath, 80 + changeId: changes.change_id, 81 + path: file.path, 82 + status: file.status, 83 + }); 84 + } 85 + } 86 + changesCollection.utils.writeUpsert(records); 87 + 88 + // LRU eviction: keep only last 500 change records 89 + const MAX_CHANGES = 500; 90 + const allRecords = Array.from(changesCollection.state.values()); 91 + if (allRecords.length > MAX_CHANGES) { 92 + // Remove oldest entries (first ones in the Map iteration order) 93 + const toRemove = allRecords.slice(0, allRecords.length - MAX_CHANGES); 94 + for (const record of toRemove) { 95 + const key = `${record.repoPath}:${record.changeId}:${record.path}`; 96 + changesCollection.state.delete(key); 97 + } 98 + } 99 + }, 100 + isLoaded: (id) => { 101 + // Wait for collection to be ready 102 + if (changesCollection.status !== "ready") return false; 103 + // Check if we have any record for this changeId in this repo 104 + for (const [key] of changesCollection.state) { 105 + if (key.startsWith(`${repoPath}:${id}:`)) { 106 + return true; 107 + } 108 + } 109 + return false; 110 + }, 111 + }); 112 + } 113 + 114 + // ============================================================================ 115 + // Loader Instance Cache 116 + // ============================================================================ 117 + 118 + // Cache loaders per repoPath to avoid creating multiple instances 119 + const diffLoaders = new Map<string, BatchLoader>(); 120 + const changesLoaders = new Map<string, BatchLoader>(); 121 + 122 + /** 123 + * Clean up loaders for repos we're no longer viewing. 124 + * Called when the active repoPath changes to prevent memory leaks. 125 + */ 126 + function cleanupLoadersExcept(currentRepoPath: string): void { 127 + for (const [path] of diffLoaders) { 128 + if (path !== currentRepoPath) { 129 + diffLoaders.delete(path); 130 + } 131 + } 132 + for (const [path] of changesLoaders) { 133 + if (path !== currentRepoPath) { 134 + changesLoaders.delete(path); 135 + } 136 + } 137 + } 138 + 139 + function getDiffLoader(repoPath: string): BatchLoader { 140 + let loader = diffLoaders.get(repoPath); 141 + if (!loader) { 142 + loader = createDiffLoader(repoPath); 143 + diffLoaders.set(repoPath, loader); 144 + } 145 + return loader; 146 + } 147 + 148 + function getChangesLoader(repoPath: string): BatchLoader { 149 + let loader = changesLoaders.get(repoPath); 150 + if (!loader) { 151 + loader = createChangesLoader(repoPath); 152 + changesLoaders.set(repoPath, loader); 153 + } 154 + return loader; 155 + } 156 + 157 + // ============================================================================ 158 + // React Hooks 159 + // ============================================================================ 160 + 161 + /** 162 + * Read a single diff from local DB. Returns undefined if not yet loaded. 163 + * Call prefetchDiffs to trigger loading. 164 + */ 165 + export function useDiff(repoPath: string, changeId: string | null): DiffRecord | undefined { 166 + const { data: allDiffs = [] } = useLiveQuery(diffsCollection); 167 + 168 + return useMemo(() => { 169 + if (!changeId) return undefined; 170 + const key = `${repoPath}:${changeId}`; 171 + return allDiffs.find((d) => `${d.repoPath}:${d.changeId}` === key); 172 + }, [allDiffs, repoPath, changeId]); 173 + } 174 + 175 + /** 176 + * Read changes (file list) for a revision from local DB. 177 + * Returns empty array if not yet loaded. Call prefetchChanges to trigger loading. 178 + */ 179 + export function useChanges(repoPath: string, changeId: string | null): ChangeRecord[] { 180 + const { data: allChanges = [] } = useLiveQuery(changesCollection); 181 + 182 + return useMemo(() => { 183 + if (!changeId) return []; 184 + return allChanges.filter((c) => c.repoPath === repoPath && c.changeId === changeId); 185 + }, [allChanges, repoPath, changeId]); 186 + } 187 + 188 + /** 189 + * Prefetch hook for components that need to load data ahead of user interaction. 190 + * Returns functions to queue prefetch requests and flush them immediately if needed. 191 + */ 192 + export function usePrefetch(repoPath: string): { 193 + prefetchDiffs: (ids: string[]) => void; 194 + prefetchChanges: (ids: string[]) => void; 195 + flushDiffs: () => Promise<void>; 196 + flushChanges: () => Promise<void>; 197 + } { 198 + // Use refs to avoid recreating loaders on each render 199 + const diffLoaderRef = useRef<BatchLoader | null>(null); 200 + const changesLoaderRef = useRef<BatchLoader | null>(null); 201 + const currentRepoPathRef = useRef<string>(repoPath); 202 + 203 + // Reset loaders if repoPath changes 204 + if (currentRepoPathRef.current !== repoPath) { 205 + currentRepoPathRef.current = repoPath; 206 + diffLoaderRef.current = null; 207 + changesLoaderRef.current = null; 208 + // Clean up loaders for other repos to prevent memory leaks 209 + cleanupLoadersExcept(repoPath); 210 + } 211 + 212 + return useMemo(() => { 213 + function getDiffLoaderInstance(): BatchLoader { 214 + if (!diffLoaderRef.current) { 215 + diffLoaderRef.current = getDiffLoader(repoPath); 216 + } 217 + return diffLoaderRef.current; 218 + } 219 + 220 + function getChangesLoaderInstance(): BatchLoader { 221 + if (!changesLoaderRef.current) { 222 + changesLoaderRef.current = getChangesLoader(repoPath); 223 + } 224 + return changesLoaderRef.current; 225 + } 226 + 227 + return { 228 + prefetchDiffs: (ids: string[]) => { 229 + traceLog("prefetch-diffs", { count: ids.length, ids }); 230 + getDiffLoaderInstance().queueMany(ids); 231 + }, 232 + prefetchChanges: (ids: string[]) => { 233 + traceLog("prefetch-changes", { count: ids.length, ids }); 234 + getChangesLoaderInstance().queueMany(ids); 235 + }, 236 + flushDiffs: () => getDiffLoaderInstance().flushPromise(), 237 + flushChanges: () => getChangesLoaderInstance().flushPromise(), 238 + }; 239 + }, [repoPath]); 240 + }
+92
apps/desktop/src/lib/batch-loader.test.ts
··· 1 + import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; 2 + import { Effect } from "effect"; 3 + import { createBatchLoader } from "./batch-loader"; 4 + 5 + describe("BatchLoader", () => { 6 + beforeEach(() => { 7 + vi.useFakeTimers(); 8 + }); 9 + 10 + afterEach(() => { 11 + vi.useRealTimers(); 12 + }); 13 + 14 + test("queues IDs and flushes after debounce", async () => { 15 + const synced: string[] = []; 16 + const loader = createBatchLoader({ 17 + debounceMs: 50, 18 + maxBatchSize: 10, 19 + fetchBatch: (ids) => Effect.succeed(ids.map((id) => ({ id }))), 20 + syncToCollection: (items) => synced.push(...items.map((i) => i.id)), 21 + isLoaded: () => false, 22 + }); 23 + 24 + loader.queue("a"); 25 + loader.queue("b"); 26 + expect(synced).toEqual([]); 27 + 28 + vi.advanceTimersByTime(60); 29 + await vi.runAllTimersAsync(); 30 + 31 + expect(synced).toContain("a"); 32 + expect(synced).toContain("b"); 33 + }); 34 + 35 + test("skips already-loaded IDs", () => { 36 + const loaded = new Set(["existing"]); 37 + const loader = createBatchLoader({ 38 + debounceMs: 50, 39 + maxBatchSize: 10, 40 + fetchBatch: (ids) => Effect.succeed(ids.map((id) => ({ id }))), 41 + syncToCollection: () => {}, 42 + isLoaded: (id) => loaded.has(id), 43 + }); 44 + 45 + loader.queue("existing"); 46 + expect(loader.pendingCount()).toBe(0); 47 + 48 + loader.queue("new"); 49 + expect(loader.pendingCount()).toBe(1); 50 + }); 51 + 52 + test("chunks large batches", async () => { 53 + const batchSizes: number[] = []; 54 + const loader = createBatchLoader({ 55 + debounceMs: 10, 56 + maxBatchSize: 3, 57 + fetchBatch: (ids) => { 58 + batchSizes.push(ids.length); 59 + return Effect.succeed(ids.map((id) => ({ id }))); 60 + }, 61 + syncToCollection: () => {}, 62 + isLoaded: () => false, 63 + }); 64 + 65 + loader.queueMany(["a", "b", "c", "d", "e", "f", "g"]); 66 + await loader.flushPromise(); 67 + 68 + expect(batchSizes).toEqual([3, 3, 1]); 69 + }); 70 + 71 + test("requeues failed IDs for retry", async () => { 72 + let failCount = 0; 73 + const loader = createBatchLoader({ 74 + debounceMs: 10, 75 + maxBatchSize: 10, 76 + fetchBatch: (ids) => { 77 + if (failCount++ < 1) { 78 + return Effect.fail(new Error("Network error")); 79 + } 80 + return Effect.succeed(ids.map((id) => ({ id }))); 81 + }, 82 + syncToCollection: () => {}, 83 + isLoaded: () => false, 84 + }); 85 + 86 + loader.queue("retry-me"); 87 + await loader.flushPromise(); 88 + 89 + // Failed ID should be back in pending 90 + expect(loader.pendingCount()).toBe(1); 91 + }); 92 + });
+189
apps/desktop/src/lib/batch-loader.ts
··· 1 + /** 2 + * BatchLoader - Queues IDs and flushes them in batched IPC calls with debouncing. 3 + * 4 + * Uses Effect for fetch operations, with a single Promise bridge at the React boundary. 5 + */ 6 + 7 + import { Effect, pipe } from "effect"; 8 + import { traceEnd, traceLog, traceStart } from "@/lib/trace"; 9 + 10 + export interface BatchLoaderConfig<T> { 11 + /** Debounce delay in milliseconds before flushing queued IDs */ 12 + debounceMs: number; 13 + /** Maximum number of IDs to include in a single batch fetch */ 14 + maxBatchSize: number; 15 + /** Fetch function that retrieves data for a batch of IDs (Effect-based) */ 16 + fetchBatch: (ids: string[]) => Effect.Effect<T[], Error>; 17 + /** Callback to sync fetched items to storage (e.g., TanStack DB collection) */ 18 + syncToCollection: (items: T[]) => void; 19 + /** Check if an ID is already loaded (to avoid redundant fetches) */ 20 + isLoaded: (id: string) => boolean; 21 + } 22 + 23 + export interface BatchLoader { 24 + /** Queue a single ID for loading */ 25 + queue(id: string): void; 26 + /** Queue multiple IDs for loading */ 27 + queueMany(ids: string[]): void; 28 + /** Force immediate flush of all pending IDs (Effect-based) */ 29 + flush(): Effect.Effect<void, Error>; 30 + /** Force immediate flush, returning Promise for React interop */ 31 + flushPromise(): Promise<void>; 32 + /** Check if an ID is already loaded */ 33 + has(id: string): boolean; 34 + /** Get the count of pending IDs waiting to be loaded */ 35 + pendingCount(): number; 36 + } 37 + 38 + /** 39 + * Creates a BatchLoader instance with the given configuration. 40 + * 41 + * The loader queues IDs and automatically flushes them after the debounce 42 + * period expires. It handles chunking for large batches and protects 43 + * against concurrent flush operations. 44 + */ 45 + export function createBatchLoader<T>(config: BatchLoaderConfig<T>): BatchLoader { 46 + const { debounceMs, maxBatchSize, fetchBatch, syncToCollection, isLoaded } = config; 47 + 48 + const pending = new Set<string>(); 49 + let debounceTimer: ReturnType<typeof setTimeout> | null = null; 50 + let flushInProgress: Promise<void> | null = null; 51 + 52 + function scheduleFlush(): void { 53 + if (debounceTimer !== null) { 54 + clearTimeout(debounceTimer); 55 + } 56 + debounceTimer = setTimeout(() => { 57 + debounceTimer = null; 58 + Effect.runPromise(flush()); 59 + }, debounceMs); 60 + } 61 + 62 + function flush(): Effect.Effect<void, Error> { 63 + return Effect.gen(function* () { 64 + // Wait for any in-progress flush to complete first 65 + if (flushInProgress !== null) { 66 + yield* Effect.tryPromise({ 67 + try: () => flushInProgress as Promise<void>, 68 + catch: () => new Error("Previous flush failed"), 69 + }); 70 + } 71 + 72 + // Cancel any scheduled debounce since we're flushing now 73 + if (debounceTimer !== null) { 74 + clearTimeout(debounceTimer); 75 + debounceTimer = null; 76 + } 77 + 78 + // Filter out already-loaded IDs and grab the pending set 79 + const idsToFetch = Array.from(pending).filter((id) => !isLoaded(id)); 80 + pending.clear(); 81 + 82 + if (idsToFetch.length === 0) { 83 + return; 84 + } 85 + 86 + const flushSpanId = traceStart("batch-flush", { pending: idsToFetch.length }); 87 + 88 + // Process in chunks if exceeding max batch size 89 + const chunks: string[][] = []; 90 + for (let i = 0; i < idsToFetch.length; i += maxBatchSize) { 91 + chunks.push(idsToFetch.slice(i, i + maxBatchSize)); 92 + } 93 + 94 + const failedIds: string[] = []; 95 + 96 + // Process chunks sequentially 97 + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { 98 + const chunk = chunks[chunkIndex]; 99 + const chunkSpanId = traceStart("batch-chunk", { 100 + index: chunkIndex, 101 + size: chunk.length, 102 + total: chunks.length, 103 + }); 104 + const result = yield* pipe( 105 + fetchBatch(chunk), 106 + Effect.tap((items) => 107 + Effect.sync(() => { 108 + traceLog("collection-write", { records: items.length }); 109 + syncToCollection(items); 110 + }), 111 + ), 112 + Effect.matchEffect({ 113 + onSuccess: () => { 114 + traceEnd(chunkSpanId, { success: true }); 115 + return Effect.succeed("ok" as const); 116 + }, 117 + onFailure: () => { 118 + // Re-queue failed IDs for retry 119 + for (const id of chunk) { 120 + failedIds.push(id); 121 + } 122 + traceEnd(chunkSpanId, { success: false }); 123 + return Effect.succeed("failed" as const); 124 + }, 125 + }), 126 + ); 127 + // result is used to track success/failure, logged implicitly by match 128 + void result; 129 + } 130 + 131 + // Re-add failed IDs to pending for retry 132 + for (const id of failedIds) { 133 + pending.add(id); 134 + } 135 + 136 + traceEnd(flushSpanId, { 137 + fetched: idsToFetch.length - failedIds.length, 138 + failed: failedIds.length, 139 + }); 140 + }); 141 + } 142 + 143 + function flushPromise(): Promise<void> { 144 + const promise = Effect.runPromise(flush()); 145 + flushInProgress = promise; 146 + promise.finally(() => { 147 + flushInProgress = null; 148 + }); 149 + return promise; 150 + } 151 + 152 + function queue(id: string): void { 153 + if (isLoaded(id)) { 154 + return; 155 + } 156 + pending.add(id); 157 + scheduleFlush(); 158 + } 159 + 160 + function queueMany(ids: string[]): void { 161 + let addedAny = false; 162 + for (const id of ids) { 163 + if (!isLoaded(id)) { 164 + pending.add(id); 165 + addedAny = true; 166 + } 167 + } 168 + if (addedAny) { 169 + scheduleFlush(); 170 + } 171 + } 172 + 173 + function has(id: string): boolean { 174 + return isLoaded(id); 175 + } 176 + 177 + function pendingCount(): number { 178 + return pending.size; 179 + } 180 + 181 + return { 182 + queue, 183 + queueMany, 184 + flush, 185 + flushPromise, 186 + has, 187 + pendingCount, 188 + }; 189 + }
+149
apps/desktop/src/lib/trace.ts
··· 1 + /** 2 + * Simple console tracing for debugging performance. 3 + * Enable via: 4 + * - Environment variable: VITE_TRACE=1 5 + * - localStorage: localStorage.setItem('TRACE', '1') 6 + * - Settings UI toggle 7 + */ 8 + 9 + // Check env var at module load time (set by Vite) 10 + const ENV_TRACE_ENABLED = import.meta.env.VITE_TRACE === "1"; 11 + 12 + export function isTraceEnabled(): boolean { 13 + return ( 14 + ENV_TRACE_ENABLED || 15 + (typeof localStorage !== "undefined" && localStorage.getItem("TRACE") === "1") 16 + ); 17 + } 18 + 19 + export function setTraceEnabled(enabled: boolean): void { 20 + if (typeof localStorage !== "undefined") { 21 + if (enabled) { 22 + localStorage.setItem("TRACE", "1"); 23 + } else { 24 + localStorage.removeItem("TRACE"); 25 + } 26 + } 27 + } 28 + 29 + const TRACE_ENABLED = isTraceEnabled; 30 + 31 + const spans = new Map<string, { start: number; parent?: string }>(); 32 + let currentSpan: string | null = null; 33 + 34 + export function traceStart(name: string, metadata?: Record<string, unknown>): string { 35 + if (!TRACE_ENABLED()) return name; 36 + 37 + const id = `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 38 + const parent = currentSpan; 39 + spans.set(id, { start: performance.now(), parent: parent ?? undefined }); 40 + currentSpan = id; 41 + 42 + // Add performance mark for Safari DevTools Timeline 43 + performance.mark(`${id}-start`); 44 + 45 + const indent = parent ? " → " : ""; 46 + const meta = metadata ? ` ${JSON.stringify(metadata)}` : ""; 47 + console.log(`%c[TRACE]%c ${indent}${name} start${meta}`, "color: #888", "color: inherit"); 48 + 49 + return id; 50 + } 51 + 52 + export function traceEnd(id: string, metadata?: Record<string, unknown>): void { 53 + if (!TRACE_ENABLED()) return; 54 + 55 + const span = spans.get(id); 56 + if (!span) return; 57 + 58 + // Add performance mark and measure for Safari DevTools Timeline 59 + performance.mark(`${id}-end`); 60 + try { 61 + performance.measure(id.split("-")[0], `${id}-start`, `${id}-end`); 62 + } catch { 63 + // Marks may have been cleared 64 + } 65 + 66 + const duration = performance.now() - span.start; 67 + const name = id.split("-")[0]; 68 + const indent = span.parent ? " ← " : ""; 69 + const meta = metadata ? ` ${JSON.stringify(metadata)}` : ""; 70 + 71 + console.log( 72 + `%c[TRACE]%c ${indent}${name} end %c${duration.toFixed(1)}ms%c${meta}`, 73 + "color: #888", 74 + "color: inherit", 75 + duration > 50 ? "color: #f90; font-weight: bold" : "color: #0a0", 76 + "color: #888", 77 + ); 78 + 79 + spans.delete(id); 80 + currentSpan = span.parent ?? null; 81 + } 82 + 83 + export function traceLog(message: string, metadata?: Record<string, unknown>): void { 84 + if (!TRACE_ENABLED()) return; 85 + 86 + const indent = currentSpan ? " " : ""; 87 + const meta = metadata ? ` ${JSON.stringify(metadata)}` : ""; 88 + console.log(`%c[TRACE]%c ${indent}${message}${meta}`, "color: #888", "color: #666"); 89 + } 90 + 91 + /** 92 + * React Profiler callback for measuring component render times. 93 + * Logs renders that take longer than 5ms, with warnings for renders exceeding 16ms (one frame). 94 + */ 95 + export function onRenderCallback( 96 + id: string, 97 + phase: "mount" | "update" | "nested-update", 98 + actualDuration: number, 99 + baseDuration: number, 100 + _startTime: number, 101 + _commitTime: number, 102 + ): void { 103 + if (!TRACE_ENABLED()) return; 104 + if (actualDuration > 5) { 105 + console.log( 106 + `%c[PROFILER]%c ${id} ${phase}: %c${actualDuration.toFixed(1)}ms%c (base: ${baseDuration.toFixed(1)}ms)`, 107 + "color: #888", 108 + "color: inherit", 109 + actualDuration > 16 ? "color: #f00; font-weight: bold" : "color: #f90", 110 + "color: #888", 111 + ); 112 + } 113 + } 114 + 115 + /** 116 + * Wrap an async function with tracing 117 + */ 118 + export function traced<T>( 119 + name: string, 120 + fn: () => Promise<T>, 121 + metadata?: Record<string, unknown>, 122 + ): Promise<T> { 123 + const id = traceStart(name, metadata); 124 + return fn().finally(() => traceEnd(id)); 125 + } 126 + 127 + /** 128 + * Wrap an Effect with tracing (returns modified Effect) 129 + */ 130 + export function traceEffect<A, E>( 131 + name: string, 132 + metadata?: Record<string, unknown>, 133 + ): <R>(effect: import("effect").Effect.Effect<A, E, R>) => import("effect").Effect.Effect<A, E, R> { 134 + return (effect) => { 135 + // Dynamic import to avoid circular deps 136 + const { Effect } = require("effect"); 137 + return Effect.gen(function* () { 138 + const id = traceStart(name, metadata); 139 + try { 140 + const result = yield* effect; 141 + traceEnd(id); 142 + return result; 143 + } catch (e) { 144 + traceEnd(id, { error: true }); 145 + throw e; 146 + } 147 + }); 148 + }; 149 + }
+21
apps/desktop/src/mocks/setup.ts
··· 984 984 +This is a new line 985 985 Welcome to the project`, 986 986 get_revision_changes: (): ChangedFile[] => mockChangedFiles, 987 + get_diffs_batch: (args): { change_id: string; diff: string }[] => { 988 + const changeIds = args.changeIds as string[]; 989 + return changeIds.map((change_id) => ({ 990 + change_id, 991 + diff: `--- a/src/main.rs 992 + +++ b/src/main.rs 993 + @@ -1,3 +1,4 @@ 994 + fn main() { 995 + - println!("old"); 996 + + println!("new"); 997 + + println!("extra"); 998 + }`, 999 + })); 1000 + }, 1001 + get_changes_batch: (args): { change_id: string; files: ChangedFile[] }[] => { 1002 + const changeIds = args.changeIds as string[]; 1003 + return changeIds.map((change_id) => ({ 1004 + change_id, 1005 + files: mockChangedFiles, 1006 + })); 1007 + }, 987 1008 watch_repository: () => undefined, 988 1009 unwatch_repository: () => undefined, 989 1010 generate_change_ids: (args) => {
+29 -1
apps/desktop/src/routes/settings.tsx
··· 1 1 import { createRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { ArrowLeft } from "lucide-react"; 3 + import { useState } from "react"; 4 + import { Checkbox } from "@/components/ui/checkbox"; 5 + import { Label } from "@/components/ui/label"; 3 6 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 7 + import { isTraceEnabled, setTraceEnabled } from "@/lib/trace"; 4 8 import { Route as rootRoute } from "./__root"; 5 9 6 10 export const Route = createRoute({ ··· 11 15 12 16 function SettingsPage() { 13 17 const navigate = useNavigate(); 18 + const [traceEnabled, setTraceEnabledState] = useState(isTraceEnabled); 14 19 15 20 useKeyboardShortcut({ 16 21 key: "Escape", 17 22 onPress: () => navigate({ to: "/" }), 18 23 }); 19 24 25 + const handleTraceToggle = (checked: boolean) => { 26 + setTraceEnabled(checked); 27 + setTraceEnabledState(checked); 28 + }; 29 + 20 30 return ( 21 31 <div className="flex flex-col h-screen bg-background"> 22 32 {/* Header */} ··· 34 44 {/* Content */} 35 45 <div className="flex-1 overflow-auto p-6"> 36 46 <div className="max-w-2xl space-y-8"> 37 - <div className="text-muted-foreground text-sm">Settings coming soon...</div> 47 + {/* Developer section */} 48 + <section> 49 + <h2 className="text-sm font-medium text-muted-foreground mb-4">Developer</h2> 50 + <div className="space-y-4"> 51 + <label className="flex items-center gap-3 cursor-pointer"> 52 + <Checkbox 53 + checked={traceEnabled} 54 + onCheckedChange={handleTraceToggle} 55 + className="size-4" 56 + /> 57 + <div className="space-y-0.5"> 58 + <Label className="cursor-pointer">Performance tracing</Label> 59 + <p className="text-sm text-muted-foreground"> 60 + Log timing info to console for debugging 61 + </p> 62 + </div> 63 + </label> 64 + </div> 65 + </section> 38 66 </div> 39 67 </div> 40 68 </div>
+46
apps/desktop/src/tauri-commands.ts
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 + import { Effect } from "effect"; 3 + import { traceEnd, traceStart } from "@/lib/trace"; 2 4 3 5 export type { 4 6 ChangedFile, ··· 43 45 changeId: string, 44 46 ): Promise<ChangedFile[]> { 45 47 return invoke<ChangedFile[]>("get_revision_changes", { repoPath, changeId }); 48 + } 49 + 50 + /** Result of fetching diff for a single revision in a batch */ 51 + export interface RevisionDiff { 52 + change_id: string; 53 + diff: string; 54 + } 55 + 56 + /** Result of fetching changed files for a single revision in a batch */ 57 + export interface RevisionChanges { 58 + change_id: string; 59 + files: ChangedFile[]; 60 + } 61 + 62 + /** Fetch diffs for multiple revisions in a single IPC call */ 63 + export function getDiffsBatchEffect( 64 + repoPath: string, 65 + changeIds: string[], 66 + ): Effect.Effect<RevisionDiff[], Error> { 67 + return Effect.tryPromise({ 68 + try: async () => { 69 + const spanId = traceStart("ipc-get-diffs-batch", { count: changeIds.length }); 70 + const result = await invoke<RevisionDiff[]>("get_diffs_batch", { repoPath, changeIds }); 71 + traceEnd(spanId, { count: result.length }); 72 + return result; 73 + }, 74 + catch: (error) => new Error(`Failed to fetch diffs batch: ${error}`), 75 + }); 76 + } 77 + 78 + /** Fetch changed files for multiple revisions in a single IPC call */ 79 + export function getChangesBatchEffect( 80 + repoPath: string, 81 + changeIds: string[], 82 + ): Effect.Effect<RevisionChanges[], Error> { 83 + return Effect.tryPromise({ 84 + try: async () => { 85 + const spanId = traceStart("ipc-get-changes-batch", { count: changeIds.length }); 86 + const result = await invoke<RevisionChanges[]>("get_changes_batch", { repoPath, changeIds }); 87 + traceEnd(spanId, { count: result.length }); 88 + return result; 89 + }, 90 + catch: (error) => new Error(`Failed to fetch changes batch: ${error}`), 91 + }); 46 92 } 47 93 48 94 export async function getRepositories(): Promise<Repository[]> {
+9 -5
apps/desktop/vite.config.ts
··· 4 4 import tailwindcss from "@tailwindcss/vite"; 5 5 import agentation from "vite-plugin-agentation"; 6 6 import { consoleForwardPlugin } from "./dev/vite-plugin-console-forward"; 7 + import { devtools as tanstackDevtools } from "@tanstack/devtools-vite"; 7 8 8 9 const host = process.env.TAURI_DEV_HOST; 9 10 ··· 15 16 babel: { 16 17 plugins: [["babel-plugin-react-compiler", reactCompilerConfig]], 17 18 }, 19 + }), 20 + tanstackDevtools({ 21 + consolePiping: { enabled: true }, 18 22 }), 19 23 tailwindcss(), 20 24 agentation(), 21 - consoleForwardPlugin() 25 + // consoleForwardPlugin() 22 26 ], 23 27 resolve: { 24 28 alias: { ··· 32 36 host: host || false, 33 37 hmr: host 34 38 ? { 35 - protocol: "ws", 36 - host, 37 - port: 4545, 38 - } 39 + protocol: "ws", 40 + host, 41 + port: 4545, 42 + } 39 43 : undefined, 40 44 watch: { 41 45 ignored: ["**/src-tauri/**"],
+13
apps/desktop/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + import { resolve } from "path"; 3 + 4 + export default defineConfig({ 5 + test: { 6 + environment: "node", 7 + }, 8 + resolve: { 9 + alias: { 10 + "@": resolve(__dirname, "./src"), 11 + }, 12 + }, 13 + });
+129
bun.lock
··· 51 51 "tw-animate-css": "^1.4.0", 52 52 }, 53 53 "devDependencies": { 54 + "@ast-grep/cli": "^0.40.5", 54 55 "@biomejs/biome": "^2.3.10", 56 + "@tanstack/devtools-vite": "^0.5.0", 55 57 "@tauri-apps/cli": "^2.1.0", 56 58 "@types/node": "^25.0.3", 57 59 "@types/react": "19", ··· 65 67 "typescript": "^5.6.3", 66 68 "vite": "^7.3.1", 67 69 "vite-plugin-agentation": "file:../../../agentation/vite-plugin/", 70 + "vitest": "^4.0.18", 68 71 }, 69 72 }, 70 73 "packages/vite-plugin-annotator": { ··· 89 92 }, 90 93 "packages": { 91 94 "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], 95 + 96 + "@ast-grep/cli": ["@ast-grep/cli@0.40.5", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.5", "@ast-grep/cli-darwin-x64": "0.40.5", "@ast-grep/cli-linux-arm64-gnu": "0.40.5", "@ast-grep/cli-linux-x64-gnu": "0.40.5", "@ast-grep/cli-win32-arm64-msvc": "0.40.5", "@ast-grep/cli-win32-ia32-msvc": "0.40.5", "@ast-grep/cli-win32-x64-msvc": "0.40.5" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-yVXL7Gz0WIHerQLf+MVaVSkhIhidtWReG5akNVr/JS9OVCVkSdz7gWm7H8jVv2M9OO1tauuG76K3UaRGBPu5lQ=="], 97 + 98 + "@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T9CzwJ1GqQhnANdsu6c7iT1akpvTVMK+AZrxnhIPv33Ze5hrXUUkqan+j4wUAukRJDqU7u94EhXLSLD+5tcJ8g=="], 99 + 100 + "@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ez9b2zKvXU8f4ghhjlqYvbx6tWCKJTuVlNVqDDfjqwwhGeiTYfnzMlSVat4ElYRMd21gLtXZIMy055v2f21Ztg=="], 101 + 102 + "@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VXa2L1IEYD66AMb0GuG7VlMMbPmEGoJUySWDcwSZo/D9neiry3MJ41LQR5oTG2HyhIPBsf9umrXnmuRq66BviA=="], 103 + 104 + "@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-GQC5162eIOWXR2eQQ6Knzg7/8Trp5E1ODJkaErf0IubdQrZBGqj5AAcQPcWgPbbnmktjIp0H4NraPpOJ9eJ22A=="], 105 + 106 + "@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-YiZdnQZsSlXQTMsZJop/Ux9MmUGfuRvC2x/UbFgrt5OBSYxND+yoiMc0WcA3WG+wU+tt4ZkB5HUea3r/IkOLYA=="], 107 + 108 + "@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-MHkCxCITVTr8sY9CcVqNKbfUzMa3Hc6IilGXad0Clnw2vNmPfWqSky+hU/UTerr5YHWwWfAVURH7ANZgirtx0Q=="], 109 + 110 + "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/MJ5un7yxlClaaxou9eYl+Kr2xr/yTtYtTq5aLBWjPWA6dmmJ1nAJgx5zKHVuplFXFBrFDQk3paEgAETMTGcrA=="], 92 111 93 112 "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], 94 113 ··· 448 467 449 468 "@tanstack/db-ivm": ["@tanstack/db-ivm@0.1.14", "", { "dependencies": { "fractional-indexing": "^3.2.0", "sorted-btree": "^1.8.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-GluhFsd/Z1E/MZTf60l9dZpKNpmdxtV4izPRnj1RK6TYrhC9LMndN+ywk1VDFBrjtBq/CTShz4CilECLwVlTGg=="], 450 469 470 + "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0" } }, "sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA=="], 471 + 472 + "@tanstack/devtools-event-bus": ["@tanstack/devtools-event-bus@0.4.0", "", { "dependencies": { "ws": "^8.18.3" } }, "sha512-1t+/csFuDzi+miDxAOh6Xv7VDE80gJEItkTcAZLjV5MRulbO/W8ocjHLI2Do/p2r2/FBU0eKCRTpdqvXaYoHpQ=="], 473 + 474 + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], 475 + 476 + "@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.5.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-Ew+ZdTnmTlVjm4q+/XY/dolx/E1BWMYpiRDyU/MXqHf5epri4MLl5C4UZJaO+ZuUCsKPpsW+ufoM99E2Z4rhug=="], 477 + 451 478 "@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="], 452 479 453 480 "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], ··· 520 547 521 548 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], 522 549 550 + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], 551 + 552 + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], 553 + 523 554 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 524 555 525 556 "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], ··· 558 589 559 590 "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], 560 591 592 + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], 593 + 594 + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], 595 + 596 + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], 597 + 598 + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], 599 + 600 + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], 601 + 602 + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], 603 + 604 + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], 605 + 561 606 "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], 562 607 563 608 "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], ··· 581 626 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 582 627 583 628 "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], 629 + 630 + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], 584 631 585 632 "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], 586 633 ··· 617 664 "caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="], 618 665 619 666 "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], 667 + 668 + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], 620 669 621 670 "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], 622 671 ··· 732 781 733 782 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 734 783 784 + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], 785 + 735 786 "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 736 787 737 788 "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], ··· 744 795 745 796 "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], 746 797 798 + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], 799 + 747 800 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 748 801 749 802 "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], ··· 751 804 "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], 752 805 753 806 "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], 807 + 808 + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 754 809 755 810 "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], 756 811 ··· 917 972 "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], 918 973 919 974 "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 975 + 976 + "launch-editor": ["launch-editor@2.12.0", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg=="], 920 977 921 978 "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], 922 979 ··· 1032 1089 1033 1090 "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], 1034 1091 1092 + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], 1093 + 1035 1094 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], 1036 1095 1037 1096 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], ··· 1174 1233 1175 1234 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 1176 1235 1236 + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], 1237 + 1177 1238 "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], 1178 1239 1179 1240 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], ··· 1183 1244 "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], 1184 1245 1185 1246 "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], 1247 + 1248 + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], 1186 1249 1187 1250 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 1188 1251 ··· 1201 1264 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 1202 1265 1203 1266 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], 1267 + 1268 + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], 1204 1269 1205 1270 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], 1206 1271 1272 + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 1273 + 1207 1274 "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], 1208 1275 1209 1276 "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], ··· 1240 1307 1241 1308 "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], 1242 1309 1310 + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], 1311 + 1243 1312 "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], 1244 1313 1245 1314 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 1315 + 1316 + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], 1246 1317 1247 1318 "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], 1248 1319 ··· 1321 1392 "vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="], 1322 1393 1323 1394 "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], 1395 + 1396 + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], 1324 1397 1325 1398 "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], 1326 1399 1327 1400 "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], 1328 1401 1402 + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 1403 + 1329 1404 "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], 1330 1405 1331 1406 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 1407 + 1408 + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], 1332 1409 1333 1410 "wsl-utils": ["wsl-utils@0.3.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ=="], 1334 1411 ··· 1421 1498 "tsup/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], 1422 1499 1423 1500 "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], 1501 + 1502 + "vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], 1503 + 1504 + "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], 1424 1505 1425 1506 "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 1426 1507 ··· 1550 1631 1551 1632 "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], 1552 1633 1634 + "vitest/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], 1635 + 1553 1636 "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 1554 1637 1555 1638 "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], ··· 1607 1690 "desktop/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], 1608 1691 1609 1692 "desktop/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], 1693 + 1694 + "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], 1695 + 1696 + "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], 1697 + 1698 + "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], 1699 + 1700 + "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], 1701 + 1702 + "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], 1703 + 1704 + "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], 1705 + 1706 + "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], 1707 + 1708 + "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], 1709 + 1710 + "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], 1711 + 1712 + "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], 1713 + 1714 + "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], 1715 + 1716 + "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], 1717 + 1718 + "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], 1719 + 1720 + "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], 1721 + 1722 + "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], 1723 + 1724 + "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], 1725 + 1726 + "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], 1727 + 1728 + "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], 1729 + 1730 + "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], 1731 + 1732 + "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], 1733 + 1734 + "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], 1735 + 1736 + "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], 1737 + 1738 + "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], 1610 1739 1611 1740 "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 1612 1741