From fe1498dc1d140fdadb7caf8e1a4880ab09c6f162 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 23 May 2022 11:11:51 +0200 Subject: [PATCH 1/9] Fix `worktree::Snapshot::entries(true)` always being empty --- crates/project/src/worktree.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 1c77c3add7ca127bc3dfcde330787ac0ac38ce74..f923c8c178fd41017450eb6aeaf3cae8f0e23cb5 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -1905,6 +1905,7 @@ impl sum_tree::Summary for EntrySummary { fn add_summary(&mut self, rhs: &Self, _: &()) { self.max_path = rhs.max_path.clone(); + self.count += rhs.count; self.visible_count += rhs.visible_count; self.file_count += rhs.file_count; self.visible_file_count += rhs.visible_file_count; @@ -2727,6 +2728,18 @@ mod tests { Path::new("a/c"), ] ); + assert_eq!( + tree.entries(true) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new(""), + Path::new(".gitignore"), + Path::new("a"), + Path::new("a/b"), + Path::new("a/c"), + ] + ); }) } @@ -3072,12 +3085,18 @@ mod tests { } } - let dfs_paths = self + let dfs_paths_via_iter = self .entries_by_path .cursor::<()>() .map(|e| e.path.as_ref()) .collect::>(); - assert_eq!(bfs_paths, dfs_paths); + assert_eq!(bfs_paths, dfs_paths_via_iter); + + let dfs_paths_via_traversal = self + .entries(true) + .map(|e| e.path.as_ref()) + .collect::>(); + assert_eq!(dfs_paths_via_traversal, dfs_paths_via_iter); for (ignore_parent_path, _) in &self.ignores { assert!(self.entry_for_path(ignore_parent_path).is_some()); From 1a6cc6f96437491f4dc19431d543d7d83485ad11 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 23 May 2022 16:37:57 +0200 Subject: [PATCH 2/9] Show ignored entries in project panel --- crates/project_panel/src/project_panel.rs | 11 +++++++++-- crates/theme/src/theme.rs | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 443b165a36d42f258afed45bc09ca0f3d9f13a7e..9905c5e6dda07d801f46648b4c68467171b4b923 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -59,6 +59,7 @@ struct EntryDetails { filename: String, depth: usize, kind: EntryKind, + is_ignored: bool, is_expanded: bool, is_selected: bool, is_editing: bool, @@ -613,7 +614,7 @@ impl ProjectPanel { } let mut visible_worktree_entries = Vec::new(); - let mut entry_iter = snapshot.entries(false); + let mut entry_iter = snapshot.entries(true); while let Some(entry) = entry_iter.entry() { visible_worktree_entries.push(entry.clone()); if Some(entry.id) == new_entry_parent_id { @@ -739,6 +740,7 @@ impl ProjectPanel { .to_string(), depth: entry.path.components().count(), kind: entry.kind, + is_ignored: entry.is_ignored, is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(), is_selected: self.selection.map_or(false, |e| { e.worktree_id == snapshot.id() && e.entry_id == entry.id @@ -784,7 +786,12 @@ impl ProjectPanel { let show_editor = details.is_editing && !details.is_processing; MouseEventHandler::new::(entry_id.to_usize(), cx, |state, _| { let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width; - let style = theme.entry.style_for(state, details.is_selected); + let mut style = theme.entry.style_for(state, details.is_selected).clone(); + // TODO: get style from theme. + if details.is_ignored { + style.text.color.fade_out(0.6); + style.icon_color.fade_out(0.6); + } let row_container_style = if show_editor { theme.filename_editor.container } else { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index a8b2dfbf4e531318e4ef84a23f68a526a94f8436..a307ee2d6f2bf8a6f17c99ec60aabf89df35ebeb 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -227,7 +227,7 @@ pub struct ProjectPanel { pub indent_width: f32, } -#[derive(Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default)] pub struct ProjectPanelEntry { pub height: f32, #[serde(flatten)] From 23ca9dce2ef70890859d66fb8c58ad8d834c1218 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 23 May 2022 16:38:16 +0200 Subject: [PATCH 3/9] WIP: stream ignored entries --- crates/project/src/worktree.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index f923c8c178fd41017450eb6aeaf3cae8f0e23cb5..2d66c7688f2eab8b8b8f7c8eae25b64094e8327d 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -40,6 +40,7 @@ use std::{ ffi::{OsStr, OsString}, fmt, future::Future, + mem, ops::{Deref, DerefMut}, os::unix::prelude::{OsStrExt, OsStringExt}, path::{Path, PathBuf}, @@ -951,9 +952,39 @@ impl LocalWorktree { })?; } + // Stream ignored entries in chunks. + { + let mut ignored_entries = prev_snapshot + .entries_by_path + .iter() + .filter(|e| e.is_ignored); + let mut ignored_entries_to_send = Vec::new(); + loop { + const CHUNK_SIZE: usize = 256; + let entry = ignored_entries.next(); + if ignored_entries_to_send.len() >= CHUNK_SIZE || entry.is_none() { + rpc.request(proto::UpdateWorktree { + project_id, + worktree_id, + root_name: prev_snapshot.root_name().to_string(), + updated_entries: mem::take(&mut ignored_entries_to_send), + removed_entries: Default::default(), + scan_id: prev_snapshot.scan_id as u64, + }) + .await?; + } + + if let Some(entry) = ignored_entries.next() { + ignored_entries_to_send.push(entry.into()); + } else { + break; + } + } + } + while let Ok(snapshot) = snapshots_to_send_rx.recv().await { let message = - snapshot.build_update(&prev_snapshot, project_id, worktree_id, false); + snapshot.build_update(&prev_snapshot, project_id, worktree_id, true); rpc.request(message).await?; prev_snapshot = snapshot; } From c65dae8095de413c8cbc7fef59e40d951dc47b82 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 23 May 2022 19:19:13 +0200 Subject: [PATCH 4/9] Correctly assign ignored status in `refresh_entry` Co-Authored-By: Nathan Sobo --- crates/project/src/worktree.rs | 65 +++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 2d66c7688f2eab8b8b8f7c8eae25b64094e8327d..c730fc0116174b6a986012a8eb17230a4996d163 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -828,8 +828,8 @@ impl LocalWorktree { next_entry_id = snapshot.next_entry_id.clone(); } cx.spawn_weak(|this, mut cx| async move { - let entry = Entry::new( - path, + let mut entry = Entry::new( + path.clone(), &fs.metadata(&abs_path) .await? .ok_or_else(|| anyhow!("could not read saved file metadata"))?, @@ -843,6 +843,9 @@ impl LocalWorktree { let (entry, snapshot, snapshots_tx) = this.read_with(&cx, |this, _| { let this = this.as_local().unwrap(); let mut snapshot = this.background_snapshot.lock(); + entry.is_ignored = snapshot + .ignore_stack_for_path(&path, entry.is_dir()) + .is_path_ignored(&path, entry.is_dir()); if let Some(old_path) = old_path { snapshot.remove_path(&old_path); } @@ -2707,6 +2710,7 @@ mod tests { use anyhow::Result; use client::test::FakeHttpClient; use fs::RealFs; + use gpui::TestAppContext; use rand::prelude::*; use serde_json::json; use std::{ @@ -2717,7 +2721,7 @@ mod tests { use util::test::temp_tree; #[gpui::test] - async fn test_traversal(cx: &mut gpui::TestAppContext) { + async fn test_traversal(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -2775,7 +2779,7 @@ mod tests { } #[gpui::test] - async fn test_rescan_with_gitignore(cx: &mut gpui::TestAppContext) { + async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { let dir = temp_tree(json!({ ".git": {}, ".gitignore": "ignored-dir\n", @@ -2825,6 +2829,59 @@ mod tests { }); } + #[gpui::test] + async fn test_write_file(cx: &mut TestAppContext) { + let dir = temp_tree(json!({ + ".git": {}, + ".gitignore": "ignored-dir\n", + "tracked-dir": {}, + "ignored-dir": {} + })); + + let http_client = FakeHttpClient::with_404_response(); + let client = Client::new(http_client.clone()); + + let tree = Worktree::local( + client, + dir.path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(&cx).await; + + tree.update(cx, |tree, cx| { + tree.as_local().unwrap().write_file( + Path::new("tracked-dir/file.txt"), + "hello".into(), + cx, + ) + }) + .await + .unwrap(); + tree.update(cx, |tree, cx| { + tree.as_local().unwrap().write_file( + Path::new("ignored-dir/file.txt"), + "world".into(), + cx, + ) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap(); + let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap(); + assert_eq!(tracked.is_ignored, false); + assert_eq!(ignored.is_ignored, true); + }); + } + #[gpui::test(iterations = 100)] fn test_random(mut rng: StdRng) { let operations = env::var("OPERATIONS") From 94e70bc1a62e5dfa6e952f6c7bed1ef8b8cf4145 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 23 May 2022 19:39:24 +0200 Subject: [PATCH 5/9] WIP: log received `updated_entries` on remote worktree Co-Authored-By: Nathan Sobo --- crates/project/src/worktree.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index c730fc0116174b6a986012a8eb17230a4996d163..73fbd40fb05cd5c132625fa01394ceff7adb036c 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -1179,6 +1179,7 @@ impl Snapshot { for entry in update.updated_entries { let entry = Entry::try_from((&self.root_char_bag, entry))?; + println!("{:?} = {}", &entry.path, entry.is_ignored); if let Some(PathEntry { path, .. }) = self.entries_by_id.get(&entry.id, &()) { entries_by_path_edits.push(Edit::Remove(PathKey(path.clone()))); } From 85f228dade1bf33e78db6c3a552818198b55ab43 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 24 May 2022 09:03:05 +0200 Subject: [PATCH 6/9] Fix logic error when streaming ignored entries We were calling `next` twice, which led us to skip every other entry. This commit also enhances the `test_share_project` integration test to exercise this new streaming logic. --- crates/collab/src/rpc.rs | 37 ++++++++++++++++++++++++---------- crates/project/src/worktree.rs | 9 ++++++--- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index aa7af0740d453db9083b72ede1446c9aedcaaaca..0756c682171a8f52f733a47b1dcd92303e1db954 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1696,8 +1696,13 @@ mod tests { fs.insert_tree( "/a", json!({ + ".gitignore": "ignored-dir", "a.txt": "a-contents", "b.txt": "b-contents", + "ignored-dir": { + "c.txt": "", + "d.txt": "", + } }), ) .await; @@ -1727,7 +1732,6 @@ mod tests { // Join that project as client B let client_b_peer_id = client_b.peer_id; let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let replica_id_b = project_b.read_with(cx_b, |project, _| { assert_eq!( project @@ -1740,16 +1744,27 @@ mod tests { ); project.replica_id() }); - project_a - .condition(&cx_a, |tree, _| { - tree.collaborators() - .get(&client_b_peer_id) - .map_or(false, |collaborator| { - collaborator.replica_id == replica_id_b - && collaborator.user.github_login == "user_b" - }) - }) - .await; + + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + let client_b_collaborator = project.collaborators().get(&client_b_peer_id).unwrap(); + assert_eq!(client_b_collaborator.replica_id, replica_id_b); + assert_eq!(client_b_collaborator.user.github_login, "user_b"); + }); + project_b.read_with(cx_b, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap().read(cx); + assert_eq!( + worktree.paths().map(AsRef::as_ref).collect::>(), + [ + Path::new(".gitignore"), + Path::new("a.txt"), + Path::new("b.txt"), + Path::new("ignored-dir"), + Path::new("ignored-dir/c.txt"), + Path::new("ignored-dir/d.txt"), + ] + ); + }); // Open the same file as client B and client A. let buffer_b = project_b diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 73fbd40fb05cd5c132625fa01394ceff7adb036c..736ae83afc65dd8901d804b4c8033eaafd747f64 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -963,7 +963,11 @@ impl LocalWorktree { .filter(|e| e.is_ignored); let mut ignored_entries_to_send = Vec::new(); loop { - const CHUNK_SIZE: usize = 256; + #[cfg(any(test, feature = "test-support"))] + const CHUNK_SIZE: usize = 2; + #[cfg(not(any(test, feature = "test-support")))] + const CHUNK_SIZE: usize = 128; + let entry = ignored_entries.next(); if ignored_entries_to_send.len() >= CHUNK_SIZE || entry.is_none() { rpc.request(proto::UpdateWorktree { @@ -977,7 +981,7 @@ impl LocalWorktree { .await?; } - if let Some(entry) = ignored_entries.next() { + if let Some(entry) = entry { ignored_entries_to_send.push(entry.into()); } else { break; @@ -1179,7 +1183,6 @@ impl Snapshot { for entry in update.updated_entries { let entry = Entry::try_from((&self.root_char_bag, entry))?; - println!("{:?} = {}", &entry.path, entry.is_ignored); if let Some(PathEntry { path, .. }) = self.entries_by_id.get(&entry.id, &()) { entries_by_path_edits.push(Edit::Remove(PathKey(path.clone()))); } From 138a0b042d3d31f235b71e8fcd03cae17d8207dc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 24 May 2022 09:12:57 +0200 Subject: [PATCH 7/9] Make fade of ignored entries styleable --- crates/project_panel/src/project_panel.rs | 5 ++--- crates/theme/src/theme.rs | 1 + styles/src/styleTree/projectPanel.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9905c5e6dda07d801f46648b4c68467171b4b923..720ef249f566542fa0007612aee6cf07a911e20a 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -787,10 +787,9 @@ impl ProjectPanel { MouseEventHandler::new::(entry_id.to_usize(), cx, |state, _| { let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width; let mut style = theme.entry.style_for(state, details.is_selected).clone(); - // TODO: get style from theme. if details.is_ignored { - style.text.color.fade_out(0.6); - style.icon_color.fade_out(0.6); + style.text.color.fade_out(theme.ignored_entry_fade); + style.icon_color.fade_out(theme.ignored_entry_fade); } let row_container_style = if show_editor { theme.filename_editor.container diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index a307ee2d6f2bf8a6f17c99ec60aabf89df35ebeb..8de49be5dfeec999844fbe59b9e5c1dd7dd067bf 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -223,6 +223,7 @@ pub struct ProjectPanel { #[serde(flatten)] pub container: ContainerStyle, pub entry: Interactive, + pub ignored_entry_fade: f32, pub filename_editor: FieldEditor, pub indent_width: f32, } diff --git a/styles/src/styleTree/projectPanel.ts b/styles/src/styleTree/projectPanel.ts index 6892b666d9b37cab5c126ecfedb91feea9c335db..2f3e3eea72195e415a2a536bc959414f857b30b9 100644 --- a/styles/src/styleTree/projectPanel.ts +++ b/styles/src/styleTree/projectPanel.ts @@ -26,6 +26,7 @@ export default function projectPanel(theme: Theme) { text: text(theme, "mono", "active", { size: "sm" }), } }, + ignoredEntryFade: 0.6, filenameEditor: { background: backgroundColor(theme, 500, "active"), text: text(theme, "mono", "primary", { size: "sm" }), From ec88288d5e135c58f279ee3d5b31ec5e89fa9e20 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 24 May 2022 09:54:53 +0200 Subject: [PATCH 8/9] Bump chunk size to 256 --- crates/project/src/worktree.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 736ae83afc65dd8901d804b4c8033eaafd747f64..039ee0d838a630f17c639ea26b55a593a281c420 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -966,7 +966,7 @@ impl LocalWorktree { #[cfg(any(test, feature = "test-support"))] const CHUNK_SIZE: usize = 2; #[cfg(not(any(test, feature = "test-support")))] - const CHUNK_SIZE: usize = 128; + const CHUNK_SIZE: usize = 256; let entry = ignored_entries.next(); if ignored_entries_to_send.len() >= CHUNK_SIZE || entry.is_none() { From 99573ca27060c4011b6b0dd09eab87d4fe6a6b94 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 24 May 2022 10:50:27 +0200 Subject: [PATCH 9/9] Fix unit tests assuming ignored files were not displayed --- crates/project_panel/src/project_panel.rs | 32 ++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 720ef249f566542fa0007612aee6cf07a911e20a..7056eb9ceb61649c91282885b30b5372f5948031 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -972,6 +972,7 @@ mod tests { visible_entries_as_strings(&panel, 0..50, cx), &[ "v root1", + " > .git", " > a", " > b", " > C", @@ -987,6 +988,7 @@ mod tests { visible_entries_as_strings(&panel, 0..50, cx), &[ "v root1", + " > .git", " > a", " v b <== selected", " > 3", @@ -1000,7 +1002,7 @@ mod tests { ); assert_eq!( - visible_entries_as_strings(&panel, 5..8, cx), + visible_entries_as_strings(&panel, 6..9, cx), &[ // " > C", @@ -1064,6 +1066,7 @@ mod tests { visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1 <== selected", + " > .git", " > a", " > b", " > C", @@ -1082,6 +1085,7 @@ mod tests { visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " > b", " > C", @@ -1103,6 +1107,7 @@ mod tests { visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " > b", " > C", @@ -1119,6 +1124,7 @@ mod tests { visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " > b", " > C", @@ -1133,9 +1139,10 @@ mod tests { select_path(&panel, "root1/b", cx); panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx)); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > 3", @@ -1157,9 +1164,10 @@ mod tests { .await .unwrap(); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > 3", @@ -1174,9 +1182,10 @@ mod tests { select_path(&panel, "root1/b/another-filename", cx); panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > 3", @@ -1195,9 +1204,10 @@ mod tests { panel.confirm(&Confirm, cx).unwrap() }); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > 3", @@ -1211,9 +1221,10 @@ mod tests { confirm.await.unwrap(); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > 3", @@ -1227,9 +1238,10 @@ mod tests { panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx)); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > [EDITOR: ''] <== selected", @@ -1249,9 +1261,10 @@ mod tests { }); panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx)); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > [PROCESSING: 'new-dir']", @@ -1265,9 +1278,10 @@ mod tests { confirm.await.unwrap(); assert_eq!( - visible_entries_as_strings(&panel, 0..9, cx), + visible_entries_as_strings(&panel, 0..10, cx), &[ "v root1", + " > .git", " > a", " v b", " > 3 <== selected",