a very good jj gui
0
fork

Configure Feed

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

feat: refactor diff_view to Entity-based architecture (TAT-31)

- Convert DiffView from stateless function to Entity<DiffView>
- Add selection_start/selection_end fields for future text selection
- Update Tatami to store Entity<DiffView> instead of Option<FileDiff>
- Update log_view.rs to render DiffView Entity

Enables TAT-28 (text selection) and TAT-29 (clipboard copy)

+1291 -41
+1 -1
.fp/workspace.toml
··· 1 1 # FP Workspace State (local only) 2 2 3 - current_issue = "" 3 + current_issue = "TAT-28"
+233 -1
Cargo.lock
··· 114 114 ] 115 115 116 116 [[package]] 117 + name = "arraydeque" 118 + version = "0.5.1" 119 + source = "registry+https://github.com/rust-lang/crates.io-index" 120 + checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" 121 + 122 + [[package]] 117 123 name = "arrayref" 118 124 version = "0.3.9" 119 125 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 556 562 version = "2.10.0" 557 563 source = "registry+https://github.com/rust-lang/crates.io-index" 558 564 checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 565 + dependencies = [ 566 + "serde_core", 567 + ] 559 568 560 569 [[package]] 561 570 name = "bitstream-io" ··· 990 999 ] 991 1000 992 1001 [[package]] 1002 + name = "config" 1003 + version = "0.15.19" 1004 + source = "registry+https://github.com/rust-lang/crates.io-index" 1005 + checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" 1006 + dependencies = [ 1007 + "async-trait", 1008 + "convert_case 0.6.0", 1009 + "json5", 1010 + "pathdiff", 1011 + "ron", 1012 + "rust-ini", 1013 + "serde-untagged", 1014 + "serde_core", 1015 + "serde_json", 1016 + "toml 0.9.8", 1017 + "winnow", 1018 + "yaml-rust2", 1019 + ] 1020 + 1021 + [[package]] 993 1022 name = "const-random" 994 1023 version = "0.1.18" 995 1024 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1014 1043 version = "0.4.0" 1015 1044 source = "registry+https://github.com/rust-lang/crates.io-index" 1016 1045 checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 1046 + 1047 + [[package]] 1048 + name = "convert_case" 1049 + version = "0.6.0" 1050 + source = "registry+https://github.com/rust-lang/crates.io-index" 1051 + checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" 1052 + dependencies = [ 1053 + "unicode-segmentation", 1054 + ] 1017 1055 1018 1056 [[package]] 1019 1057 name = "core-foundation" ··· 1308 1346 source = "registry+https://github.com/rust-lang/crates.io-index" 1309 1347 checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" 1310 1348 dependencies = [ 1311 - "convert_case", 1349 + "convert_case 0.4.0", 1312 1350 "proc-macro2", 1313 1351 "quote", 1314 1352 "rustc_version", ··· 1401 1439 checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" 1402 1440 dependencies = [ 1403 1441 "libloading", 1442 + ] 1443 + 1444 + [[package]] 1445 + name = "dlv-list" 1446 + version = "0.5.2" 1447 + source = "registry+https://github.com/rust-lang/crates.io-index" 1448 + checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" 1449 + dependencies = [ 1450 + "const-random", 1404 1451 ] 1405 1452 1406 1453 [[package]] ··· 3141 3188 ] 3142 3189 3143 3190 [[package]] 3191 + name = "hashlink" 3192 + version = "0.10.0" 3193 + source = "registry+https://github.com/rust-lang/crates.io-index" 3194 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 3195 + dependencies = [ 3196 + "hashbrown 0.15.5", 3197 + ] 3198 + 3199 + [[package]] 3144 3200 name = "heapless" 3145 3201 version = "0.8.0" 3146 3202 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3219 3275 ] 3220 3276 3221 3277 [[package]] 3278 + name = "hostname" 3279 + version = "0.4.2" 3280 + source = "registry+https://github.com/rust-lang/crates.io-index" 3281 + checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" 3282 + dependencies = [ 3283 + "cfg-if", 3284 + "libc", 3285 + "windows-link 0.2.1", 3286 + ] 3287 + 3288 + [[package]] 3222 3289 name = "http" 3223 3290 version = "1.4.0" 3224 3291 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3774 3841 dependencies = [ 3775 3842 "once_cell", 3776 3843 "wasm-bindgen", 3844 + ] 3845 + 3846 + [[package]] 3847 + name = "json5" 3848 + version = "0.4.1" 3849 + source = "registry+https://github.com/rust-lang/crates.io-index" 3850 + checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" 3851 + dependencies = [ 3852 + "pest", 3853 + "pest_derive", 3854 + "serde", 3777 3855 ] 3778 3856 3779 3857 [[package]] ··· 4654 4732 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 4655 4733 4656 4734 [[package]] 4735 + name = "ordered-multimap" 4736 + version = "0.7.3" 4737 + source = "registry+https://github.com/rust-lang/crates.io-index" 4738 + checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" 4739 + dependencies = [ 4740 + "dlv-list", 4741 + "hashbrown 0.14.5", 4742 + ] 4743 + 4744 + [[package]] 4657 4745 name = "ordered-stream" 4658 4746 version = "0.2.0" 4659 4747 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5447 5535 ] 5448 5536 5449 5537 [[package]] 5538 + name = "ron" 5539 + version = "0.12.0" 5540 + source = "registry+https://github.com/rust-lang/crates.io-index" 5541 + checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" 5542 + dependencies = [ 5543 + "bitflags 2.10.0", 5544 + "once_cell", 5545 + "serde", 5546 + "serde_derive", 5547 + "typeid", 5548 + "unicode-ident", 5549 + ] 5550 + 5551 + [[package]] 5450 5552 name = "roxmltree" 5451 5553 version = "0.20.0" 5452 5554 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5485 5587 "globset", 5486 5588 "sha2", 5487 5589 "walkdir", 5590 + ] 5591 + 5592 + [[package]] 5593 + name = "rust-ini" 5594 + version = "0.21.3" 5595 + source = "registry+https://github.com/rust-lang/crates.io-index" 5596 + checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" 5597 + dependencies = [ 5598 + "cfg-if", 5599 + "ordered-multimap", 5488 5600 ] 5489 5601 5490 5602 [[package]] ··· 5768 5880 ] 5769 5881 5770 5882 [[package]] 5883 + name = "serde-untagged" 5884 + version = "0.1.9" 5885 + source = "registry+https://github.com/rust-lang/crates.io-index" 5886 + checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" 5887 + dependencies = [ 5888 + "erased-serde", 5889 + "serde", 5890 + "serde_core", 5891 + "typeid", 5892 + ] 5893 + 5894 + [[package]] 5771 5895 name = "serde_core" 5772 5896 version = "1.0.228" 5773 5897 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5950 6074 ] 5951 6075 5952 6076 [[package]] 6077 + name = "similar" 6078 + version = "2.7.0" 6079 + source = "registry+https://github.com/rust-lang/crates.io-index" 6080 + checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 6081 + 6082 + [[package]] 5953 6083 name = "simplecss" 5954 6084 version = "0.2.2" 5955 6085 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6094 6224 version = "1.1.0" 6095 6225 source = "registry+https://github.com/rust-lang/crates.io-index" 6096 6226 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 6227 + 6228 + [[package]] 6229 + name = "streaming-iterator" 6230 + version = "0.1.9" 6231 + source = "registry+https://github.com/rust-lang/crates.io-index" 6232 + checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" 6097 6233 6098 6234 [[package]] 6099 6235 name = "strict-num" ··· 6385 6521 version = "0.1.0" 6386 6522 dependencies = [ 6387 6523 "anyhow", 6524 + "config", 6388 6525 "futures", 6389 6526 "gpui", 6527 + "hex", 6528 + "hostname", 6390 6529 "jj-lib", 6391 6530 "notify", 6392 6531 "notify-debouncer-mini", 6532 + "pollster 0.4.0", 6533 + "similar", 6534 + "tokio", 6535 + "toml_edit 0.23.9", 6536 + "tree-sitter", 6537 + "tree-sitter-highlight", 6538 + "tree-sitter-json", 6539 + "tree-sitter-python", 6540 + "tree-sitter-rust", 6541 + "tree-sitter-typescript", 6393 6542 ] 6394 6543 6395 6544 [[package]] ··· 6739 6888 checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" 6740 6889 dependencies = [ 6741 6890 "once_cell", 6891 + ] 6892 + 6893 + [[package]] 6894 + name = "tree-sitter" 6895 + version = "0.26.3" 6896 + source = "registry+https://github.com/rust-lang/crates.io-index" 6897 + checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e" 6898 + dependencies = [ 6899 + "cc", 6900 + "regex", 6901 + "regex-syntax", 6902 + "serde_json", 6903 + "streaming-iterator", 6904 + "tree-sitter-language", 6905 + ] 6906 + 6907 + [[package]] 6908 + name = "tree-sitter-highlight" 6909 + version = "0.26.3" 6910 + source = "registry+https://github.com/rust-lang/crates.io-index" 6911 + checksum = "bb0636662a03005d9289649e0b4a89ff37b75df5033e8d4a16398740ae6496d2" 6912 + dependencies = [ 6913 + "regex", 6914 + "streaming-iterator", 6915 + "thiserror 2.0.17", 6916 + "tree-sitter", 6917 + ] 6918 + 6919 + [[package]] 6920 + name = "tree-sitter-json" 6921 + version = "0.24.8" 6922 + source = "registry+https://github.com/rust-lang/crates.io-index" 6923 + checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" 6924 + dependencies = [ 6925 + "cc", 6926 + "tree-sitter-language", 6927 + ] 6928 + 6929 + [[package]] 6930 + name = "tree-sitter-language" 6931 + version = "0.1.6" 6932 + source = "registry+https://github.com/rust-lang/crates.io-index" 6933 + checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" 6934 + 6935 + [[package]] 6936 + name = "tree-sitter-python" 6937 + version = "0.25.0" 6938 + source = "registry+https://github.com/rust-lang/crates.io-index" 6939 + checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" 6940 + dependencies = [ 6941 + "cc", 6942 + "tree-sitter-language", 6943 + ] 6944 + 6945 + [[package]] 6946 + name = "tree-sitter-rust" 6947 + version = "0.24.0" 6948 + source = "registry+https://github.com/rust-lang/crates.io-index" 6949 + checksum = "4b9b18034c684a2420722be8b2a91c9c44f2546b631c039edf575ccba8c61be1" 6950 + dependencies = [ 6951 + "cc", 6952 + "tree-sitter-language", 6953 + ] 6954 + 6955 + [[package]] 6956 + name = "tree-sitter-typescript" 6957 + version = "0.23.2" 6958 + source = "registry+https://github.com/rust-lang/crates.io-index" 6959 + checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" 6960 + dependencies = [ 6961 + "cc", 6962 + "tree-sitter-language", 6742 6963 ] 6743 6964 6744 6965 [[package]] ··· 7924 8145 version = "0.8.0" 7925 8146 source = "registry+https://github.com/rust-lang/crates.io-index" 7926 8147 checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" 8148 + 8149 + [[package]] 8150 + name = "yaml-rust2" 8151 + version = "0.10.4" 8152 + source = "registry+https://github.com/rust-lang/crates.io-index" 8153 + checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" 8154 + dependencies = [ 8155 + "arraydeque", 8156 + "encoding_rs", 8157 + "hashlink", 8158 + ] 7927 8159 7928 8160 [[package]] 7929 8161 name = "yazi"
+13
Cargo.toml
··· 12 12 13 13 [dependencies] 14 14 anyhow = "1.0.100" 15 + config = "0.15.19" 15 16 futures = "0.3.31" 16 17 gpui = "0.2.2" 18 + hex = "0.4.3" 19 + hostname = "0.4.2" 17 20 jj-lib = "0.35.0" 18 21 notify = "8.2.0" 19 22 notify-debouncer-mini = "0.7.0" 23 + pollster = "0.4.0" 24 + similar = "2.7.0" 25 + tokio = { version = "1.48.0", features = ["io-util"] } 26 + toml_edit = "0.23.9" 27 + tree-sitter = "0.26.3" 28 + tree-sitter-highlight = "0.26.3" 29 + tree-sitter-json = "0.24.8" 30 + tree-sitter-python = "0.25.0" 31 + tree-sitter-rust = "0.24.0" 32 + tree-sitter-typescript = "0.23.2"
+58 -5
src/app.rs
··· 1 1 use futures::StreamExt; 2 2 use gpui::{ 3 - div, px, rgb, size, App, AppContext, Bounds, Context, IntoElement, ParentElement, Render, 4 - Styled, Window, WindowBounds, WindowOptions, 3 + div, px, rgb, size, App, AppContext, Bounds, Context, Entity, IntoElement, ParentElement, 4 + Render, Styled, Window, WindowBounds, WindowOptions, 5 5 }; 6 6 use std::path::PathBuf; 7 7 8 + use crate::repo::diff::FileDiff; 8 9 use crate::repo::RepoState; 10 + use crate::ui::diff_view::DiffView; 9 11 use crate::ui::log_view::render_log_view; 10 12 use crate::ui::theme::{self, Colors, TextSize}; 11 13 use crate::watcher::RepoWatcher; ··· 14 16 repo: RepoState, 15 17 workspace_root: PathBuf, 16 18 selected_revision: Option<usize>, 19 + selected_file: Option<(String, String)>, 20 + diff_view: Option<Entity<DiffView>>, 17 21 _watcher: Option<RepoWatcher>, 18 22 } 19 23 ··· 23 27 repo, 24 28 workspace_root, 25 29 selected_revision: Some(0), 30 + selected_file: None, 31 + diff_view: None, 26 32 _watcher: None, 27 33 } 28 34 } ··· 43 49 } else { 44 50 self.selected_revision = Some(index); 45 51 } 52 + self.selected_file = None; 53 + self.diff_view = None; 46 54 cx.notify(); 47 55 } 56 + 57 + pub fn select_file(&mut self, change_id: String, file_path: String, cx: &mut Context<Self>) { 58 + if self.selected_file == Some((change_id.clone(), file_path.clone())) { 59 + self.selected_file = None; 60 + self.diff_view = None; 61 + } else { 62 + self.selected_file = Some((change_id.clone(), file_path.clone())); 63 + self.load_file_diff(change_id, file_path, cx); 64 + } 65 + cx.notify(); 66 + } 67 + 68 + fn load_file_diff(&mut self, change_id: String, file_path: String, cx: &mut Context<Self>) { 69 + use crate::repo::diff::compute_file_diff; 70 + use crate::repo::jj::JjRepo; 71 + 72 + let file_path_for_closure = file_path.clone(); 73 + let result = (|| -> anyhow::Result<FileDiff> { 74 + let jj_repo = JjRepo::open(&self.workspace_root)?; 75 + let commit = jj_repo.get_commit(&change_id)?; 76 + let new_content = jj_repo.get_file_content(&commit, &file_path_for_closure)?; 77 + let old_content = jj_repo.get_parent_file_content(&commit, &file_path_for_closure)?; 78 + Ok(compute_file_diff( 79 + &old_content, 80 + &new_content, 81 + file_path_for_closure.clone(), 82 + )) 83 + })(); 84 + 85 + match result { 86 + Ok(diff) => { 87 + let diff_view = cx.new(|cx| DiffView::new(&diff, Some(&file_path), cx)); 88 + self.diff_view = Some(diff_view); 89 + } 90 + Err(_) => self.diff_view = None, 91 + } 92 + } 93 + 94 + pub fn diff_view(&self) -> Option<&Entity<DiffView>> { 95 + self.diff_view.as_ref() 96 + } 48 97 } 49 98 50 99 impl Render for Tatami { ··· 54 103 .flex_1() 55 104 .p_3() 56 105 .child(format!("No jj repository at {}", path.display())), 57 - RepoState::Loaded { revisions, .. } => div() 58 - .flex_1() 59 - .child(render_log_view(revisions, self.selected_revision, cx)), 106 + RepoState::Loaded { revisions, .. } => div().flex_1().child(render_log_view( 107 + revisions, 108 + self.selected_revision, 109 + &self.selected_file, 110 + self.diff_view.clone(), 111 + cx, 112 + )), 60 113 RepoState::Error { message } => { 61 114 div().flex_1().p_3().child(format!("Error: {}", message)) 62 115 }
+2
src/repo.rs
··· 1 + pub mod diff; 2 + pub mod jj; 1 3 pub mod log; 2 4 pub mod status; 3 5
+44
src/repo/diff.rs
··· 1 + use similar::{ChangeTag, TextDiff}; 2 + 3 + #[derive(Clone, Debug)] 4 + pub struct FileDiff { 5 + pub path: String, 6 + pub hunks: Vec<DiffDisplayHunk>, 7 + } 8 + 9 + #[derive(Clone, Debug)] 10 + pub struct DiffDisplayHunk { 11 + pub lines: Vec<DiffLine>, 12 + } 13 + 14 + #[derive(Clone, Debug)] 15 + pub enum DiffLine { 16 + Context(String), 17 + Added(String), 18 + Deleted(String), 19 + } 20 + 21 + pub fn compute_file_diff(old_content: &[u8], new_content: &[u8], path: String) -> FileDiff { 22 + let old_text = String::from_utf8_lossy(old_content); 23 + let new_text = String::from_utf8_lossy(new_content); 24 + 25 + let diff = TextDiff::from_lines(&old_text, &new_text); 26 + let mut lines = Vec::new(); 27 + 28 + for change in diff.iter_all_changes() { 29 + let line = change.to_string(); 30 + let diff_line = match change.tag() { 31 + ChangeTag::Delete => DiffLine::Deleted(line), 32 + ChangeTag::Insert => DiffLine::Added(line), 33 + ChangeTag::Equal => DiffLine::Context(line), 34 + }; 35 + lines.push(diff_line); 36 + } 37 + 38 + let hunk = DiffDisplayHunk { lines }; 39 + 40 + FileDiff { 41 + path, 42 + hunks: vec![hunk], 43 + } 44 + }
+168
src/repo/jj.rs
··· 1 + use anyhow::{Context, Result}; 2 + use jj_lib::backend::CommitId; 3 + use jj_lib::commit::Commit; 4 + use jj_lib::config::ConfigSource; 5 + use jj_lib::merged_tree::MergedTree; 6 + use jj_lib::object_id::{HexPrefix, PrefixResolution}; 7 + use jj_lib::repo::{Repo, StoreFactories}; 8 + use jj_lib::repo_path::RepoPath; 9 + use jj_lib::workspace::{default_working_copy_factories, Workspace}; 10 + use std::path::Path; 11 + use tokio::io::AsyncReadExt; 12 + 13 + pub struct JjRepo { 14 + workspace: Workspace, 15 + } 16 + 17 + impl JjRepo { 18 + pub fn open(path: &Path) -> Result<Self> { 19 + let config = Self::load_config()?; 20 + let user_settings = jj_lib::settings::UserSettings::from_config(config) 21 + .context("Failed to create user settings")?; 22 + let store_factories = StoreFactories::default(); 23 + let working_copy_factories = default_working_copy_factories(); 24 + 25 + let workspace = Workspace::load( 26 + &user_settings, 27 + path, 28 + &store_factories, 29 + &working_copy_factories, 30 + ) 31 + .context("Failed to load jj workspace")?; 32 + 33 + Ok(Self { workspace }) 34 + } 35 + 36 + fn load_config() -> Result<jj_lib::config::StackedConfig> { 37 + use jj_lib::config::{ConfigLayer, StackedConfig}; 38 + 39 + // Start with jj-lib's built-in defaults 40 + let mut config = StackedConfig::with_defaults(); 41 + 42 + // Fill in empty values that jj-lib leaves for the CLI to set 43 + let hostname = hostname::get() 44 + .map(|h| h.to_string_lossy().to_string()) 45 + .unwrap_or_else(|_| "localhost".to_string()); 46 + let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string()); 47 + 48 + let env_defaults = format!( 49 + r#" 50 + [user] 51 + name = "{username}" 52 + email = "{username}@localhost" 53 + 54 + [operation] 55 + hostname = "{hostname}" 56 + username = "{username}" 57 + "# 58 + ); 59 + let env_doc: toml_edit::DocumentMut = env_defaults.parse().unwrap(); 60 + config.add_layer(ConfigLayer::with_data(ConfigSource::EnvBase, env_doc)); 61 + 62 + // Load user config (higher priority) 63 + if let Ok(home) = std::env::var("HOME") { 64 + let xdg_config = std::env::var("XDG_CONFIG_HOME") 65 + .map(std::path::PathBuf::from) 66 + .unwrap_or_else(|_| Path::new(&home).join(".config")); 67 + 68 + let jj_config = xdg_config.join("jj/config.toml"); 69 + if jj_config.exists() { 70 + let _ = config.load_file(ConfigSource::User, &jj_config); 71 + } 72 + 73 + let legacy = Path::new(&home).join(".jjconfig.toml"); 74 + if legacy.exists() { 75 + let _ = config.load_file(ConfigSource::User, &legacy); 76 + } 77 + } 78 + 79 + Ok(config) 80 + } 81 + 82 + pub fn get_commit(&self, change_id: &str) -> Result<Commit> { 83 + let repo = self.workspace.repo_loader().load_at_head()?; 84 + let commit_id = self.resolve_change_id(repo.as_ref(), change_id)?; 85 + Ok(repo.store().get_commit(&commit_id)?) 86 + } 87 + 88 + pub fn get_parent_tree(&self, commit: &Commit) -> Result<MergedTree> { 89 + let repo = self.workspace.repo_loader().load_at_head()?; 90 + let parents = commit.parents(); 91 + let parent = parents.into_iter().next().context("Commit has no parent")?; 92 + let parent_commit = repo.store().get_commit(parent?.id())?; 93 + Ok(parent_commit.tree()?) 94 + } 95 + 96 + pub fn get_file_content(&self, commit: &Commit, path: &str) -> Result<Vec<u8>> { 97 + let repo_path = RepoPath::from_internal_string(path).context("Invalid path")?; 98 + let tree = commit.tree()?; 99 + let file_value = tree.path_value(&repo_path)?; 100 + 101 + match file_value.into_resolved() { 102 + Ok(Some(value)) => { 103 + use jj_lib::backend::TreeValue; 104 + match value { 105 + TreeValue::File { id, .. } => { 106 + let repo = self.workspace.repo_loader().load_at_head()?; 107 + let mut reader = 108 + pollster::block_on(async { repo.store().read_file(&repo_path, &id).await })?; 109 + let mut content = Vec::new(); 110 + pollster::block_on(async { reader.read_to_end(&mut content).await })?; 111 + Ok(content) 112 + } 113 + _ => Ok(Vec::new()), 114 + } 115 + } 116 + _ => Ok(Vec::new()), 117 + } 118 + } 119 + 120 + pub fn get_parent_file_content(&self, commit: &Commit, path: &str) -> Result<Vec<u8>> { 121 + let repo_path = RepoPath::from_internal_string(path).context("Invalid path")?; 122 + let repo = self.workspace.repo_loader().load_at_head()?; 123 + let parents = commit.parents(); 124 + let parent = parents.into_iter().next().context("Commit has no parent")?; 125 + let parent_commit = repo.store().get_commit(parent?.id())?; 126 + let parent_tree = parent_commit.tree()?; 127 + let file_value = parent_tree.path_value(&repo_path)?; 128 + 129 + match file_value.into_resolved() { 130 + Ok(Some(value)) => { 131 + use jj_lib::backend::TreeValue; 132 + match value { 133 + TreeValue::File { id, .. } => { 134 + let mut reader = 135 + pollster::block_on(async { repo.store().read_file(&repo_path, &id).await })?; 136 + let mut content = Vec::new(); 137 + pollster::block_on(async { reader.read_to_end(&mut content).await })?; 138 + Ok(content) 139 + } 140 + _ => Ok(Vec::new()), 141 + } 142 + } 143 + _ => Ok(Vec::new()), 144 + } 145 + } 146 + 147 + fn resolve_change_id(&self, repo: &impl Repo, change_id_prefix: &str) -> Result<CommitId> { 148 + // Change IDs use reverse hex (z-k alphabet), not standard hex (0-9a-f) 149 + let prefix = HexPrefix::try_from_reverse_hex(change_id_prefix) 150 + .context("Invalid change ID prefix format")?; 151 + 152 + let resolution = repo 153 + .resolve_change_id_prefix(&prefix) 154 + .context("Failed to resolve change ID")?; 155 + 156 + match resolution { 157 + PrefixResolution::SingleMatch(commit_ids) => { 158 + commit_ids.first().cloned().context("No commit ID found") 159 + } 160 + PrefixResolution::NoMatch => { 161 + anyhow::bail!("Change ID not found: {}", change_id_prefix) 162 + } 163 + PrefixResolution::AmbiguousMatch => { 164 + anyhow::bail!("Ambiguous change ID prefix: {}", change_id_prefix) 165 + } 166 + } 167 + } 168 + }
+267
src/ui/diff_view.rs
··· 1 + use std::cell::RefCell; 2 + 3 + use gpui::{ 4 + div, px, rgb, rgba, uniform_list, Context, IntoElement, InteractiveElement, MouseButton, 5 + MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, Styled, Window, 6 + prelude::FluentBuilder, 7 + }; 8 + 9 + use super::syntax::{StyledSpan, SyntaxHighlighter}; 10 + use super::theme::{Colors, TextSize}; 11 + use crate::repo::diff::{DiffLine, FileDiff}; 12 + 13 + const LINE_HEIGHT: f32 = 20.0; 14 + const MAX_VISIBLE_LINES: usize = 20; 15 + 16 + #[derive(Clone, Debug)] 17 + pub struct TextPosition { 18 + pub line: usize, 19 + pub column: usize, 20 + } 21 + 22 + #[derive(Clone)] 23 + struct HighlightedLine { 24 + text: String, 25 + spans: Vec<StyledSpan>, 26 + bg_color: u32, 27 + prefix: &'static str, 28 + prefix_color: u32, 29 + } 30 + 31 + pub struct DiffView { 32 + lines: Vec<HighlightedLine>, 33 + selection_start: Option<TextPosition>, 34 + selection_end: Option<TextPosition>, 35 + } 36 + 37 + impl DiffView { 38 + pub fn new(diff: &FileDiff, file_path: Option<&str>, _cx: &mut Context<Self>) -> Self { 39 + let raw_lines: Vec<DiffLine> = diff 40 + .hunks 41 + .iter() 42 + .flat_map(|hunk| hunk.lines.clone()) 43 + .collect(); 44 + 45 + let language = file_path.and_then(SyntaxHighlighter::detect_language); 46 + let highlighter = RefCell::new(SyntaxHighlighter::new()); 47 + 48 + let lines: Vec<HighlightedLine> = raw_lines 49 + .iter() 50 + .map(|line| { 51 + let (text, bg_color, prefix_color, prefix) = match line { 52 + DiffLine::Context(content) => { 53 + (content.clone(), Colors::BG_BASE, Colors::TEXT, " ") 54 + } 55 + DiffLine::Added(content) => (content.clone(), 0x0d3a1f, Colors::ADDED, "+"), 56 + DiffLine::Deleted(content) => (content.clone(), 0x3d1014, Colors::DELETED, "-"), 57 + }; 58 + 59 + let trimmed_text = text.trim_end_matches('\n').to_string(); 60 + let spans = if let Some(ref lang) = language { 61 + highlighter.borrow_mut().highlight_line(&trimmed_text, lang) 62 + } else { 63 + vec![StyledSpan { 64 + text: trimmed_text.clone(), 65 + color: Colors::TEXT, 66 + }] 67 + }; 68 + 69 + HighlightedLine { 70 + text: trimmed_text, 71 + spans, 72 + bg_color, 73 + prefix, 74 + prefix_color, 75 + } 76 + }) 77 + .collect(); 78 + 79 + Self { 80 + lines, 81 + selection_start: None, 82 + selection_end: None, 83 + } 84 + } 85 + 86 + fn position_from_point(&self, point: Point<Pixels>) -> TextPosition { 87 + const GUTTER_WIDTH: f32 = 40.0; 88 + const PREFIX_WIDTH: f32 = 16.0; 89 + const CHAR_WIDTH: f32 = 7.5; 90 + 91 + let y_pixels: f32 = point.y.into(); 92 + let line = (y_pixels / LINE_HEIGHT).floor() as usize; 93 + let line = line.min(self.lines.len().saturating_sub(1)); 94 + 95 + let x_pixels: f32 = point.x.into(); 96 + let content_x = x_pixels - GUTTER_WIDTH - PREFIX_WIDTH; 97 + let column = if content_x > 0.0 { 98 + (content_x / CHAR_WIDTH).floor() as usize 99 + } else { 100 + 0 101 + }; 102 + 103 + let column = column.min(self.lines.get(line).map_or(0, |l| l.text.len())); 104 + 105 + TextPosition { line, column } 106 + } 107 + 108 + fn get_selection_for_line(&self, line_idx: usize) -> Option<(usize, usize)> { 109 + let start = self.selection_start.as_ref()?; 110 + let end = self.selection_end.as_ref()?; 111 + 112 + let (start, end) = if start.line < end.line || (start.line == end.line && start.column <= end.column) { 113 + (start, end) 114 + } else { 115 + (end, start) 116 + }; 117 + 118 + if line_idx < start.line || line_idx > end.line { 119 + return None; 120 + } 121 + 122 + let line_len = self.lines.get(line_idx)?.text.len(); 123 + 124 + let start_col = if line_idx == start.line { 125 + start.column.min(line_len) 126 + } else { 127 + 0 128 + }; 129 + 130 + let end_col = if line_idx == end.line { 131 + end.column.min(line_len) 132 + } else { 133 + line_len 134 + }; 135 + 136 + if start_col >= end_col { 137 + return None; 138 + } 139 + 140 + Some((start_col, end_col)) 141 + } 142 + 143 + fn handle_mouse_down(&mut self, event: &MouseDownEvent, _window: &mut Window, cx: &mut Context<Self>) { 144 + let position = self.position_from_point(event.position); 145 + self.selection_start = Some(position.clone()); 146 + self.selection_end = Some(position); 147 + cx.notify(); 148 + } 149 + 150 + fn handle_mouse_move(&mut self, event: &MouseMoveEvent, _window: &mut Window, cx: &mut Context<Self>) { 151 + if event.pressed_button == Some(MouseButton::Left) { 152 + let position = self.position_from_point(event.position); 153 + self.selection_end = Some(position); 154 + cx.notify(); 155 + } 156 + } 157 + } 158 + 159 + impl Render for DiffView { 160 + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 161 + let line_count = self.lines.len(); 162 + let visible_height = (line_count.min(MAX_VISIBLE_LINES) as f32) * LINE_HEIGHT; 163 + let lines = self.lines.clone(); 164 + let selection_ranges: Vec<Option<(usize, usize)>> = (0..line_count) 165 + .map(|idx| self.get_selection_for_line(idx)) 166 + .collect(); 167 + 168 + div() 169 + .flex() 170 + .flex_col() 171 + .bg(rgb(Colors::BG_BASE)) 172 + .rounded_md() 173 + .border_1() 174 + .border_color(rgb(Colors::BORDER_MUTED)) 175 + .overflow_hidden() 176 + .h(px(visible_height)) 177 + .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) 178 + .on_mouse_move(cx.listener(Self::handle_mouse_move)) 179 + .child( 180 + uniform_list("diff-lines", line_count, move |range, _window, _cx| { 181 + range 182 + .map(|idx| { 183 + let line = lines[idx].clone(); 184 + let selection = selection_ranges[idx]; 185 + render_highlighted_line(line, idx + 1, selection) 186 + }) 187 + .collect() 188 + }) 189 + .flex_1() 190 + .text_size(TextSize::XS), 191 + ) 192 + } 193 + } 194 + 195 + fn render_highlighted_line( 196 + line: HighlightedLine, 197 + line_number: usize, 198 + selection: Option<(usize, usize)>, 199 + ) -> impl IntoElement { 200 + const GUTTER_WIDTH: f32 = 40.0; 201 + const PREFIX_WIDTH: f32 = 16.0; 202 + const CHAR_WIDTH: f32 = 7.5; 203 + 204 + div() 205 + .h(px(LINE_HEIGHT)) 206 + .w_full() 207 + .flex() 208 + .items_center() 209 + .bg(rgb(line.bg_color)) 210 + .relative() 211 + .when_some(selection, |element, (start_col, end_col)| { 212 + let x_offset = GUTTER_WIDTH + PREFIX_WIDTH + (start_col as f32 * CHAR_WIDTH); 213 + let width = (end_col - start_col) as f32 * CHAR_WIDTH; 214 + 215 + element.child( 216 + div() 217 + .absolute() 218 + .top_0() 219 + .left(px(x_offset)) 220 + .h(px(LINE_HEIGHT)) 221 + .w(px(width)) 222 + .bg(rgba(0x4a9eff40)) 223 + ) 224 + }) 225 + .child( 226 + div() 227 + .w(px(40.0)) 228 + .flex_shrink_0() 229 + .text_color(rgb(Colors::TEXT_SUBTLE)) 230 + .text_right() 231 + .pr_2() 232 + .child(format!("{line_number}")), 233 + ) 234 + .child( 235 + div() 236 + .w(px(16.0)) 237 + .flex_shrink_0() 238 + .text_color(rgb(line.prefix_color)) 239 + .child(line.prefix), 240 + ) 241 + .child( 242 + div() 243 + .flex_1() 244 + .flex() 245 + .overflow_hidden() 246 + .whitespace_nowrap() 247 + .children(line.spans.into_iter().map(|span| { 248 + div().text_color(rgb(span.color)).child(span.text) 249 + })), 250 + ) 251 + } 252 + 253 + pub fn get_diff_text(diff: &FileDiff) -> String { 254 + diff.hunks 255 + .iter() 256 + .flat_map(|hunk| hunk.lines.iter()) 257 + .map(|line| { 258 + let (prefix, text) = match line { 259 + DiffLine::Context(content) => (" ", content.as_str()), 260 + DiffLine::Added(content) => ("+", content.as_str()), 261 + DiffLine::Deleted(content) => ("-", content.as_str()), 262 + }; 263 + format!("{}{}", prefix, text.trim_end_matches('\n')) 264 + }) 265 + .collect::<Vec<_>>() 266 + .join("\n") 267 + }
+130 -34
src/ui/log_view.rs
··· 1 1 use gpui::{ 2 - div, px, rgb, prelude::FluentBuilder, Context, Hsla, InteractiveElement, IntoElement, 2 + div, px, rgb, prelude::FluentBuilder, Context, Entity, Hsla, InteractiveElement, IntoElement, 3 3 ParentElement, SharedString, StatefulInteractiveElement, Styled, 4 4 }; 5 5 6 + use super::diff_view::DiffView; 6 7 use super::theme::{Colors, TextSize}; 7 8 use crate::app::Tatami; 8 9 use crate::repo::log::{FileStatus, Revision}; ··· 10 11 pub fn render_log_view( 11 12 revisions: &[Revision], 12 13 selected_index: Option<usize>, 14 + selected_file: &Option<(String, String)>, 15 + diff_view: Option<Entity<DiffView>>, 13 16 cx: &mut Context<Tatami>, 14 17 ) -> impl IntoElement { 15 18 let revision_count = revisions.len(); 19 + let selected_file_cloned = selected_file.clone(); 20 + 16 21 let entries: Vec<_> = revisions 17 22 .iter() 18 23 .enumerate() ··· 22 27 let on_click = cx.listener(move |tatami, _event, _window, cx| { 23 28 tatami.select_revision(idx, cx); 24 29 }); 25 - (rev.clone(), is_selected, is_last, on_click) 30 + 31 + let file_handlers: Vec<_> = rev 32 + .files 33 + .iter() 34 + .map(|f| { 35 + let change_id = rev.change_id.clone(); 36 + let file_path = f.path.clone(); 37 + cx.listener(move |tatami, _event, _window, cx| { 38 + tatami.select_file(change_id.clone(), file_path.clone(), cx); 39 + }) 40 + }) 41 + .collect(); 42 + 43 + ( 44 + rev.clone(), 45 + is_selected, 46 + is_last, 47 + on_click, 48 + selected_file_cloned.clone(), 49 + diff_view.clone(), 50 + file_handlers, 51 + ) 26 52 }) 27 53 .collect(); 28 54 29 55 div() 56 + .id("log-view") 30 57 .flex() 31 58 .flex_col() 32 59 .flex_1() 33 - .overflow_hidden() 60 + .overflow_y_scroll() 34 61 .text_size(TextSize::SM) 35 - .children( 36 - entries 37 - .into_iter() 38 - .map(|(rev, is_selected, is_last, on_click)| { 39 - render_revision_entry(rev, is_selected, is_last, on_click) 40 - }), 41 - ) 62 + .children(entries.into_iter().map( 63 + |(rev, is_selected, is_last, on_click, selected_file, file_diff, file_handlers)| { 64 + render_revision_entry( 65 + rev, 66 + is_selected, 67 + is_last, 68 + on_click, 69 + selected_file, 70 + file_diff, 71 + file_handlers, 72 + ) 73 + }, 74 + )) 42 75 } 43 76 44 - fn render_revision_entry<F>( 77 + fn render_revision_entry<F, G>( 45 78 rev: Revision, 46 79 is_selected: bool, 47 80 is_last: bool, 48 81 on_click: F, 82 + selected_file: Option<(String, String)>, 83 + diff_view: Option<Entity<DiffView>>, 84 + file_handlers: Vec<G>, 49 85 ) -> impl IntoElement 50 86 where 51 87 F: Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, 88 + G: Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, 52 89 { 53 90 let id_color = if rev.is_working_copy { 54 91 rgb(Colors::WORKING_COPY) ··· 126 163 ), 127 164 ) 128 165 .when(is_selected, |el| { 129 - el.child(render_expanded_detail(&rev, is_last)) 166 + el.child(render_expanded_detail( 167 + &rev, 168 + is_last, 169 + &selected_file, 170 + diff_view, 171 + file_handlers, 172 + )) 130 173 }) 131 174 } 132 175 ··· 161 204 ) 162 205 } 163 206 164 - fn render_expanded_detail(rev: &Revision, is_last: bool) -> impl IntoElement + use<> { 207 + fn render_expanded_detail<G>( 208 + rev: &Revision, 209 + is_last: bool, 210 + selected_file: &Option<(String, String)>, 211 + diff_view: Option<Entity<DiffView>>, 212 + file_handlers: Vec<G>, 213 + ) -> impl IntoElement 214 + where 215 + G: Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, 216 + { 165 217 let files_content = if rev.files.is_empty() { 166 218 div() 167 219 .text_size(TextSize::XS) 168 220 .text_color(rgb(Colors::TEXT_MUTED)) 169 221 .child("(no file changes)") 170 222 } else { 171 - div() 172 - .flex() 173 - .flex_col() 174 - .gap_1() 175 - .text_size(TextSize::XS) 176 - .children(rev.files.iter().map(|f| { 223 + let change_id = rev.change_id.clone(); 224 + 225 + let file_entries: Vec<_> = rev 226 + .files 227 + .iter() 228 + .enumerate() 229 + .zip(file_handlers.into_iter()) 230 + .map(|((_idx, f), on_file_click)| { 177 231 let (prefix, color) = match f.status { 178 232 FileStatus::Added => ("A", Colors::ADDED), 179 233 FileStatus::Modified => ("M", Colors::MODIFIED), 180 234 FileStatus::Deleted => ("D", Colors::DELETED), 181 235 }; 182 - div() 183 - .flex() 184 - .gap_2() 185 - .child( 186 - div() 187 - .w(px(12.0)) 188 - .text_color(rgb(color)) 189 - .child(prefix), 190 - ) 191 - .child( 192 - div() 193 - .text_color(rgb(Colors::TEXT)) 194 - .child(f.path.clone()), 195 - ) 196 - })) 236 + 237 + let file_path = f.path.clone(); 238 + let is_file_selected = selected_file 239 + .as_ref() 240 + .map(|(cid, path)| cid == &change_id && path == &file_path) 241 + .unwrap_or(false); 242 + 243 + (f.path.clone(), prefix, color, is_file_selected, on_file_click) 244 + }) 245 + .collect(); 246 + 247 + div() 248 + .flex() 249 + .flex_col() 250 + .gap_1() 251 + .text_size(TextSize::XS) 252 + .children(file_entries.into_iter().map( 253 + |(file_path, prefix, color, is_file_selected, on_file_click)| { 254 + let file_id: SharedString = 255 + format!("file-{}-{}", change_id, file_path.replace('/', "-")).into(); 256 + 257 + let show_diff = is_file_selected && diff_view.is_some(); 258 + 259 + div() 260 + .flex() 261 + .flex_col() 262 + .gap_1() 263 + .child( 264 + div() 265 + .id(file_id) 266 + .flex() 267 + .gap_2() 268 + .cursor_pointer() 269 + .hover(|s| s.bg(rgb(Colors::BG_HOVER))) 270 + .on_click(on_file_click) 271 + .child( 272 + div() 273 + .w(px(12.0)) 274 + .text_color(rgb(color)) 275 + .child(prefix), 276 + ) 277 + .child( 278 + div() 279 + .text_color(rgb(Colors::TEXT)) 280 + .child(file_path), 281 + ), 282 + ) 283 + .when(show_diff, |el| { 284 + el.child( 285 + div() 286 + .ml(px(18.0)) 287 + .mt_1() 288 + .child(diff_view.clone().unwrap()), 289 + ) 290 + }) 291 + }, 292 + )) 197 293 }; 198 294 199 295 div()
+2
src/ui/mod.rs
··· 1 + pub mod diff_view; 1 2 pub mod log_view; 3 + pub mod syntax; 2 4 pub mod theme;
+129
src/ui/status_view.rs
··· 1 + use gpui::{ 2 + ClipboardItem, CursorStyle, InteractiveElement, IntoElement, ParentElement, SharedString, 3 + StatefulInteractiveElement, Styled, div, px, rgb, 4 + }; 5 + 6 + use super::theme::{Colors, TextSize}; 7 + use crate::repo::status::{ChangedFile, FileStatus, WorkingCopyStatus}; 8 + 9 + pub fn render_status_view(status: &WorkingCopyStatus) -> impl IntoElement { 10 + div() 11 + .flex_shrink_0() 12 + .h(px(200.0)) 13 + .w_full() 14 + .flex() 15 + .flex_col() 16 + .border_t_1() 17 + .border_color(rgb(Colors::BORDER_MUTED)) 18 + .bg(rgb(Colors::BG_SURFACE)) 19 + .text_size(TextSize::SM) 20 + .child(render_header(status)) 21 + .child(render_file_list(&status.files)) 22 + } 23 + 24 + fn render_header(status: &WorkingCopyStatus) -> impl IntoElement { 25 + let change_id = status.change_id.clone(); 26 + let change_id_for_click = change_id.clone(); 27 + 28 + div() 29 + .flex_shrink_0() 30 + .px_3() 31 + .py_2() 32 + .flex() 33 + .gap_4() 34 + .items_center() 35 + .border_b_1() 36 + .border_color(rgb(Colors::BORDER_MUTED)) 37 + .child( 38 + div() 39 + .flex_shrink_0() 40 + .flex() 41 + .gap_2() 42 + .items_center() 43 + .child( 44 + div() 45 + .id("status-change-id") 46 + .text_color(rgb(Colors::WORKING_COPY)) 47 + .cursor(CursorStyle::PointingHand) 48 + .on_click(move |_event, _window, cx| { 49 + cx.write_to_clipboard(ClipboardItem::new_string( 50 + change_id_for_click.clone(), 51 + )); 52 + }) 53 + .child(format!("@ {}", change_id)), 54 + ) 55 + .child( 56 + div() 57 + .text_color(rgb(Colors::TEXT_SUBTLE)) 58 + .text_size(TextSize::XS) 59 + .child(status.commit_id.chars().take(8).collect::<String>()), 60 + ), 61 + ) 62 + .child( 63 + div() 64 + .flex_1() 65 + .min_w_0() 66 + .text_color(rgb(Colors::TEXT)) 67 + .overflow() 68 + .whitespace_nowrap() 69 + .text_ellipsis() 70 + .child(if status.description.is_empty() { 71 + "(no description)".to_string() 72 + } else { 73 + status.description.clone() 74 + }), 75 + ) 76 + } 77 + 78 + fn render_file_list(files: &[ChangedFile]) -> impl IntoElement { 79 + div() 80 + .flex_1() 81 + .overflow_y_auto() 82 + .px_3() 83 + .py_2() 84 + .flex() 85 + .flex_col() 86 + .gap_1() 87 + .children(files.iter().map(render_file_row).collect::<Vec<_>>()) 88 + } 89 + 90 + fn render_file_row(file: &ChangedFile) -> impl IntoElement { 91 + let (status_char, status_color) = match file.status { 92 + FileStatus::Added => ("A", rgb(Colors::ADDED)), 93 + FileStatus::Modified => ("M", rgb(Colors::MODIFIED)), 94 + FileStatus::Deleted => ("D", rgb(Colors::DELETED)), 95 + }; 96 + 97 + let path: SharedString = file.path.clone().into(); 98 + let path_for_click = path.clone(); 99 + let path_for_child = path.clone(); 100 + 101 + div() 102 + .id(path) 103 + .flex_shrink_0() 104 + .flex() 105 + .gap_2() 106 + .h(px(20.0)) 107 + .items_center() 108 + .cursor(CursorStyle::PointingHand) 109 + .on_click(move |_event, _window, cx| { 110 + cx.write_to_clipboard(ClipboardItem::new_string(path_for_click.to_string())); 111 + }) 112 + .child( 113 + div() 114 + .flex_shrink_0() 115 + .w(px(14.0)) 116 + .text_color(status_color) 117 + .child(status_char), 118 + ) 119 + .child( 120 + div() 121 + .flex_1() 122 + .min_w_0() 123 + .text_color(rgb(Colors::TEXT)) 124 + .overflow() 125 + .whitespace_nowrap() 126 + .text_ellipsis() 127 + .child(path_for_child), 128 + ) 129 + }
+230
src/ui/syntax.rs
··· 1 + use std::collections::HashMap; 2 + use std::path::Path; 3 + 4 + use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; 5 + 6 + use super::theme::Colors; 7 + 8 + const HIGHLIGHT_NAMES: &[&str] = &[ 9 + "attribute", 10 + "comment", 11 + "constant", 12 + "constant.builtin", 13 + "constructor", 14 + "function", 15 + "function.builtin", 16 + "keyword", 17 + "number", 18 + "operator", 19 + "property", 20 + "punctuation", 21 + "punctuation.bracket", 22 + "punctuation.delimiter", 23 + "string", 24 + "type", 25 + "type.builtin", 26 + "variable", 27 + "variable.builtin", 28 + "variable.parameter", 29 + ]; 30 + 31 + #[derive(Clone, Debug)] 32 + pub struct StyledSpan { 33 + pub text: String, 34 + pub color: u32, 35 + } 36 + 37 + pub struct SyntaxHighlighter { 38 + highlighter: Highlighter, 39 + configs: HashMap<String, HighlightConfiguration>, 40 + } 41 + 42 + impl SyntaxHighlighter { 43 + pub fn new() -> Self { 44 + let mut highlighter = Self { 45 + highlighter: Highlighter::new(), 46 + configs: HashMap::new(), 47 + }; 48 + highlighter.load_languages(); 49 + highlighter 50 + } 51 + 52 + fn load_languages(&mut self) { 53 + if let Some(config) = Self::make_rust_config() { 54 + self.configs.insert("rust".to_string(), config); 55 + } 56 + if let Some(config) = Self::make_typescript_config() { 57 + self.configs.insert("typescript".to_string(), config); 58 + } 59 + if let Some(config) = Self::make_typescript_config() { 60 + self.configs.insert("tsx".to_string(), config); 61 + } 62 + if let Some(config) = Self::make_javascript_config() { 63 + self.configs.insert("javascript".to_string(), config); 64 + } 65 + if let Some(config) = Self::make_javascript_config() { 66 + self.configs.insert("jsx".to_string(), config); 67 + } 68 + if let Some(config) = Self::make_python_config() { 69 + self.configs.insert("python".to_string(), config); 70 + } 71 + if let Some(config) = Self::make_json_config() { 72 + self.configs.insert("json".to_string(), config); 73 + } 74 + } 75 + 76 + fn make_rust_config() -> Option<HighlightConfiguration> { 77 + let mut config = HighlightConfiguration::new( 78 + tree_sitter_rust::LANGUAGE.into(), 79 + "rust", 80 + tree_sitter_rust::HIGHLIGHTS_QUERY, 81 + tree_sitter_rust::INJECTIONS_QUERY, 82 + "", 83 + ) 84 + .ok()?; 85 + config.configure(HIGHLIGHT_NAMES); 86 + Some(config) 87 + } 88 + 89 + fn make_typescript_config() -> Option<HighlightConfiguration> { 90 + let mut config = HighlightConfiguration::new( 91 + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), 92 + "typescript", 93 + tree_sitter_typescript::HIGHLIGHTS_QUERY, 94 + "", 95 + tree_sitter_typescript::LOCALS_QUERY, 96 + ) 97 + .ok()?; 98 + config.configure(HIGHLIGHT_NAMES); 99 + Some(config) 100 + } 101 + 102 + fn make_javascript_config() -> Option<HighlightConfiguration> { 103 + let mut config = HighlightConfiguration::new( 104 + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), 105 + "javascript", 106 + tree_sitter_typescript::HIGHLIGHTS_QUERY, 107 + "", 108 + tree_sitter_typescript::LOCALS_QUERY, 109 + ) 110 + .ok()?; 111 + config.configure(HIGHLIGHT_NAMES); 112 + Some(config) 113 + } 114 + 115 + fn make_python_config() -> Option<HighlightConfiguration> { 116 + let mut config = HighlightConfiguration::new( 117 + tree_sitter_python::LANGUAGE.into(), 118 + "python", 119 + tree_sitter_python::HIGHLIGHTS_QUERY, 120 + "", 121 + "", 122 + ) 123 + .ok()?; 124 + config.configure(HIGHLIGHT_NAMES); 125 + Some(config) 126 + } 127 + 128 + fn make_json_config() -> Option<HighlightConfiguration> { 129 + let mut config = HighlightConfiguration::new( 130 + tree_sitter_json::LANGUAGE.into(), 131 + "json", 132 + tree_sitter_json::HIGHLIGHTS_QUERY, 133 + "", 134 + "", 135 + ) 136 + .ok()?; 137 + config.configure(HIGHLIGHT_NAMES); 138 + Some(config) 139 + } 140 + 141 + pub fn detect_language(file_path: &str) -> Option<String> { 142 + let ext = Path::new(file_path).extension()?.to_str()?; 143 + match ext { 144 + "rs" => Some("rust".to_string()), 145 + "ts" => Some("typescript".to_string()), 146 + "tsx" => Some("tsx".to_string()), 147 + "js" => Some("javascript".to_string()), 148 + "jsx" => Some("jsx".to_string()), 149 + "py" => Some("python".to_string()), 150 + "json" => Some("json".to_string()), 151 + _ => None, 152 + } 153 + } 154 + 155 + pub fn highlight_line(&mut self, code: &str, language: &str) -> Vec<StyledSpan> { 156 + let Some(config) = self.configs.get(language) else { 157 + return vec![StyledSpan { 158 + text: code.to_string(), 159 + color: Colors::TEXT, 160 + }]; 161 + }; 162 + 163 + let Ok(highlights) = self.highlighter.highlight(config, code.as_bytes(), None, |_| None) 164 + else { 165 + return vec![StyledSpan { 166 + text: code.to_string(), 167 + color: Colors::TEXT, 168 + }]; 169 + }; 170 + 171 + let mut spans = Vec::new(); 172 + let mut current_color = Colors::TEXT; 173 + let mut color_stack: Vec<u32> = Vec::new(); 174 + 175 + for event in highlights.flatten() { 176 + match event { 177 + HighlightEvent::Source { start, end } => { 178 + if start < end && end <= code.len() { 179 + let text = &code[start..end]; 180 + if !text.is_empty() { 181 + spans.push(StyledSpan { 182 + text: text.to_string(), 183 + color: current_color, 184 + }); 185 + } 186 + } 187 + } 188 + HighlightEvent::HighlightStart(highlight) => { 189 + color_stack.push(current_color); 190 + current_color = highlight_to_color(highlight.0); 191 + } 192 + HighlightEvent::HighlightEnd => { 193 + current_color = color_stack.pop().unwrap_or(Colors::TEXT); 194 + } 195 + } 196 + } 197 + 198 + if spans.is_empty() { 199 + vec![StyledSpan { 200 + text: code.to_string(), 201 + color: Colors::TEXT, 202 + }] 203 + } else { 204 + spans 205 + } 206 + } 207 + } 208 + 209 + fn highlight_to_color(highlight_index: usize) -> u32 { 210 + match HIGHLIGHT_NAMES.get(highlight_index) { 211 + Some(&"attribute") => Colors::SYNTAX_ATTRIBUTE, 212 + Some(&"comment") => Colors::SYNTAX_COMMENT, 213 + Some(&"constant") | Some(&"constant.builtin") => Colors::SYNTAX_CONSTANT, 214 + Some(&"constructor") => Colors::SYNTAX_TYPE, 215 + Some(&"function") | Some(&"function.builtin") => Colors::SYNTAX_FUNCTION, 216 + Some(&"keyword") => Colors::SYNTAX_KEYWORD, 217 + Some(&"number") => Colors::SYNTAX_NUMBER, 218 + Some(&"operator") => Colors::SYNTAX_OPERATOR, 219 + Some(&"property") => Colors::SYNTAX_PROPERTY, 220 + Some(&"punctuation") | Some(&"punctuation.bracket") | Some(&"punctuation.delimiter") => { 221 + Colors::SYNTAX_PUNCTUATION 222 + } 223 + Some(&"string") => Colors::SYNTAX_STRING, 224 + Some(&"type") | Some(&"type.builtin") => Colors::SYNTAX_TYPE, 225 + Some(&"variable") | Some(&"variable.builtin") | Some(&"variable.parameter") => { 226 + Colors::SYNTAX_VARIABLE 227 + } 228 + _ => Colors::TEXT, 229 + } 230 + }
+14
src/ui/theme.rs
··· 44 44 pub const WORKING_COPY: u32 = 0x58a6ff; 45 45 pub const MUTABLE: u32 = 0xe6edf3; 46 46 pub const IMMUTABLE: u32 = 0x6e7681; 47 + 48 + // Syntax highlighting (GitHub Dark theme inspired) 49 + pub const SYNTAX_KEYWORD: u32 = 0xff7b72; 50 + pub const SYNTAX_STRING: u32 = 0xa5d6ff; 51 + pub const SYNTAX_COMMENT: u32 = 0x8b949e; 52 + pub const SYNTAX_FUNCTION: u32 = 0xd2a8ff; 53 + pub const SYNTAX_TYPE: u32 = 0x79c0ff; 54 + pub const SYNTAX_CONSTANT: u32 = 0x79c0ff; 55 + pub const SYNTAX_NUMBER: u32 = 0x79c0ff; 56 + pub const SYNTAX_VARIABLE: u32 = 0xffa657; 57 + pub const SYNTAX_PROPERTY: u32 = 0x7ee787; 58 + pub const SYNTAX_OPERATOR: u32 = 0xff7b72; 59 + pub const SYNTAX_PUNCTUATION: u32 = 0x8b949e; 60 + pub const SYNTAX_ATTRIBUTE: u32 = 0x7ee787; 47 61 }