Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ ignore_whitespace = false
commit_order = "descending"
initial_commit_selection = "all"
show_file_list = true
compact_folders = false
show_pr_checks = false
show_pr_comments = true
show_commits = true
Expand Down Expand Up @@ -87,6 +88,7 @@ session_header = true
| `initial_commit_selection` | `all` | Which commits are selected when a multi-commit review first opens: `all`, or `oldest` to start on just the oldest commit and walk forward with `(` / `)`. |
| `ignore_whitespace` | `false` | Ignore all whitespace in local Git, jj, and hg diffs. PR diffs are unchanged. |
| `show_file_list` | `true` | Whether the file list panel is visible on startup. Toggle with `<leader>e`. |
| `compact_folders` | `false` | Join single-child directory chains into one file-tree row. Restart tuicr after changing this setting. |
| `show_pr_checks` | `false` | Whether PR CI checks are fetched and shown. Set to `true` to include GitHub check rollups. |
| `show_pr_comments` | `true` | Whether PR conversation comments are fetched and shown. Set to `false` to skip PR comments. |
| `show_commits` | `true` | Whether the inline commit selector pane is visible on startup for multi-commit reviews. Toggle with `<leader>s` or `:set commits!`. |
Expand Down Expand Up @@ -318,3 +320,16 @@ dist/
*.lock
!Cargo.lock
```

## Compact folders

Set `compact_folders = true` to reduce nesting in the file tree. For example,
`app/src/main/kotlin/Editor.kt` appears as one `app/src/main/kotlin/` directory
row with `Editor.kt` one level below it.

A chain stops at a directory containing a review file directly or more than one
child directory. Chains follow the active file filters, so hiding a sibling can
join more directories. Opening or closing a joined row affects the whole chain;
expand-all, collapse-all, and full-path search continue to work. Long labels use
the existing horizontal scrolling. The default is `false`; there is no runtime
toggle.
1 change: 1 addition & 0 deletions src/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ impl App {
pending_confirm: None,
supports_keyboard_enhancement: false,
show_file_list: true,
compact_folders: false,
is_pristine_mode: false,
is_single_file_view: false,
revealed_reviewed_file: None,
Expand Down
2 changes: 2 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ fn profile_unit_result(result: &Result<()>) -> String {
pub enum FileTreeItem {
Directory {
path: String,
label: String,
depth: usize,
expanded: bool,
},
Expand Down Expand Up @@ -1311,6 +1312,7 @@ pub struct App {
pub pending_confirm: Option<ConfirmAction>,
pub supports_keyboard_enhancement: bool,
pub show_file_list: bool,
pub compact_folders: bool,
/// `true` when the session was opened via `--all-files`. Drives the
/// `PRISTINE · N files` chip in the status bar and prevents that chip
/// from showing in the regular `--file <dir>` directory mode.
Expand Down
106 changes: 106 additions & 0 deletions src/app/tests/tree_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ impl TreeTestHarness {
let expanded = self.expanded_dirs.contains(dir);
items.push(FileTreeItem::Directory {
path: dir.clone(),
label: Path::new(dir)
.file_name()
.unwrap()
.to_string_lossy()
.into_owned(),
depth,
expanded,
});
Expand Down Expand Up @@ -294,3 +299,104 @@ fn test_interleaved_paths_stay_under_own_directory() {
]
);
}

#[test]
fn compact_folders_join_deep_chains_and_toggle_as_one_row() {
let mut app = app_with(&["app/src/main/kotlin/Editor.kt"]);
app.compact_folders = true;
app.expand_all_dirs();
assert_eq!(
rendered_tree(&app),
vec![
("app/src/main/kotlin/".into(), 0),
("app/src/main/kotlin/Editor.kt".into(), 1),
]
);
app.collapse_all_dirs();
assert_eq!(app.build_visible_items().len(), 1);
app.toggle_directory("app/src/main/kotlin");
assert_eq!(app.build_visible_items().len(), 2);
app.toggle_directory("app/src/main/kotlin");
assert_eq!(app.build_visible_items().len(), 1);
assert_eq!(app.file_list_state.selected(), 0);
}

#[test]
fn compact_folders_stop_at_files_and_branches() {
let mut app = app_with(&[
"README.md",
"a/b/direct.kt",
"a/b/c/d/one.kt",
"a/b/e/two.kt",
]);
app.compact_folders = true;
assert_eq!(
rendered_tree(&app),
vec![
("README.md".into(), 0),
("a/b/".into(), 0),
("a/b/direct.kt".into(), 1),
("a/b/c/d/".into(), 1),
("a/b/c/d/one.kt".into(), 2),
("a/b/e/".into(), 1),
("a/b/e/two.kt".into(), 2),
]
);
app.collapse_all_dirs();
assert_eq!(
rendered_tree(&app),
vec![("README.md".into(), 0), ("a/b/".into(), 0)]
);
app.expand_all_dirs();
assert_eq!(app.build_visible_items().len(), 7);
}

#[test]
fn compact_folders_recompute_after_filter_and_search_reveals_full_path() {
let mut app = app_with(&["a/b/c/one.kt", "a/b/d/two.kt"]);
app.compact_folders = true;
assert_eq!(app.build_visible_items().len(), 5);
app.begin_file_tree_prompt(FileTreePrompt::Include);
for ch in "one".chars() {
app.file_tree_prompt_insert_char(ch);
}
app.commit_file_tree_prompt();
assert_eq!(
rendered_tree(&app),
vec![("a/b/c/".into(), 0), ("a/b/c/one.kt".into(), 1)]
);
app.collapse_all_dirs();
app.begin_file_tree_prompt(FileTreePrompt::Search);
for ch in "a/b/c/one".chars() {
app.file_tree_prompt_insert_char(ch);
}
app.commit_file_tree_prompt();
assert!(matches!(
app.get_selected_tree_item(),
Some(FileTreeItem::File {
file_idx: 0,
depth: 1
})
));
app.clear_include_filter();
assert_eq!(app.build_visible_items().len(), 4);
app.expand_all_dirs();
assert_eq!(app.build_visible_items().len(), 5);
}

#[test]
fn compact_folders_are_disabled_by_default() {
let app = app_with(&["a/b/c/one.kt"]);
assert!(!app.compact_folders);
assert_eq!(app.build_visible_items().len(), 4);
}

#[test]
fn compact_folders_startup_keeps_selection_on_current_file() {
let mut app = app_with(&["a/b/c/one.kt", "z/two.kt"]);
let current_file_idx = app.diff_state.current_file_idx;
app.set_compact_folders(true);
assert!(
matches!(app.get_selected_tree_item(), Some(FileTreeItem::File { file_idx, .. }) if file_idx == current_file_idx)
);
}
111 changes: 97 additions & 14 deletions src/app/tree.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
use super::*;

impl App {
pub fn set_compact_folders(&mut self, enabled: bool) {
self.compact_folders = enabled;
self.ensure_valid_tree_selection();
}

pub fn file_list_down(&mut self, n: usize) {
let visible_items = self.build_visible_items();
let max_idx = visible_items.len().saturating_sub(1);
Expand Down Expand Up @@ -219,7 +224,17 @@ impl App {
}

pub fn toggle_directory(&mut self, dir_path: &str) {
if self.expanded_dirs.contains(dir_path) {
let expanded = self
.build_visible_items()
.iter()
.find_map(|item| match item {
FileTreeItem::Directory { path, expanded, .. } if path == dir_path => {
Some(*expanded)
}
_ => None,
})
.unwrap_or_else(|| self.expanded_dirs.contains(dir_path));
if expanded {
self.expanded_dirs.remove(dir_path);
if let Some(tree_idx) = self
.build_visible_items()
Expand All @@ -234,7 +249,18 @@ impl App {
self.ensure_valid_tree_selection();
}
} else {
self.expanded_dirs.insert(dir_path.to_string());
if self.compact_folders {
// A compact row can contain closed intermediate directories,
// notably after collapse-all. Open the whole represented chain.
for ancestor in std::path::Path::new(dir_path).ancestors() {
if !ancestor.as_os_str().is_empty() {
self.expanded_dirs
.insert(ancestor.to_string_lossy().into_owned());
}
}
} else {
self.expanded_dirs.insert(dir_path.to_string());
}
}
}

Expand Down Expand Up @@ -282,6 +308,11 @@ impl App {
pub fn build_visible_items(&self) -> Vec<FileTreeItem> {
use std::path::Path;

let single_children = if self.compact_folders {
self.single_child_directories()
} else {
HashMap::new()
};
let mut items = Vec::new();
let mut seen_dirs: HashSet<String> = HashSet::new();

Expand All @@ -305,33 +336,85 @@ impl App {
ancestors.reverse();

let mut visible = true;
for (depth, dir) in ancestors.iter().enumerate() {
if !seen_dirs.contains(dir) && visible {
let expanded = self.expanded_dirs.contains(dir);
let mut start = 0;
let mut depth = 0;
while start < ancestors.len() && visible {
let mut end = start;
while end + 1 < ancestors.len()
&& single_children
.get(&ancestors[end])
.and_then(Option::as_ref)
== Some(&ancestors[end + 1])
{
end += 1;
}
let dir = &ancestors[end];
let expanded = ancestors[start..=end]
.iter()
.all(|path| self.expanded_dirs.contains(path));
if seen_dirs.insert(dir.clone()) {
let parent = Path::new(&ancestors[start])
.parent()
.unwrap_or(Path::new(""));
let label = Path::new(dir)
.strip_prefix(parent)
.unwrap_or(Path::new(dir))
.to_string_lossy()
.into_owned();
items.push(FileTreeItem::Directory {
path: dir.clone(),
label,
depth,
expanded,
});
seen_dirs.insert(dir.clone());
}

if !self.expanded_dirs.contains(dir) {
visible = false;
}
visible = expanded;
depth += 1;
start = end + 1;
}

if visible {
items.push(FileTreeItem::File {
file_idx,
depth: ancestors.len(),
});
items.push(FileTreeItem::File { file_idx, depth });
}
}

items
}

/// `Some(child)` identifies a mergeable directory; `None` marks a
/// branch point or a directory containing a direct file. Expansion state
/// does not affect chain shape, so collapsed rows keep their full label.
fn single_child_directories(&self) -> HashMap<String, Option<String>> {
let mut children: HashMap<String, Option<String>> = HashMap::new();
for file in self
.diff_files
.iter()
.filter(|file| self.file_passes_filter(file))
{
let Some(directory) = file.display_path().parent() else {
continue;
};
children.insert(directory.to_string_lossy().into_owned(), None);
let mut child = directory;
while let Some(parent) = child.parent() {
if parent.as_os_str().is_empty() {
break;
}
let child_path = child.to_string_lossy().into_owned();
children
.entry(parent.to_string_lossy().into_owned())
.and_modify(|existing| {
if existing.as_ref() != Some(&child_path) {
*existing = None;
}
})
.or_insert(Some(child_path));
child = parent;
}
}
children
}

pub fn get_selected_tree_item(&self) -> Option<FileTreeItem> {
let visible_items = self.build_visible_items();
let selected_idx = self.file_list_state.selected();
Expand Down
16 changes: 16 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ pub struct AppConfig {
pub backend: Option<String>,
pub comment_types: Option<Vec<CommentTypeConfig>>,
pub show_file_list: Option<bool>,
/// Join single-child directory chains in the file tree. Defaults to false.
pub compact_folders: Option<bool>,
/// Whether pull-request CI checks are fetched and shown.
/// Defaults to false.
pub show_pr_checks: Option<bool>,
Expand Down Expand Up @@ -198,6 +200,7 @@ const KNOWN_KEYS: &[&str] = &[
"backend",
"comment_types",
"show_file_list",
"compact_folders",
"show_pr_checks",
"show_pr_comments",
"show_commits",
Expand Down Expand Up @@ -426,6 +429,7 @@ fn load_config_from_path(path: &Path) -> Result<ConfigLoadOutcome> {
.get("comment_types")
.and_then(|v| parse_comment_types(v, &mut warnings)),
show_file_list: read_bool(table, "show_file_list", &mut warnings),
compact_folders: read_bool(table, "compact_folders", &mut warnings),
show_pr_checks: read_bool(table, "show_pr_checks", &mut warnings),
show_pr_comments: read_bool(table, "show_pr_comments", &mut warnings),
show_commits: read_bool(table, "show_commits", &mut warnings),
Expand Down Expand Up @@ -915,6 +919,18 @@ mod tests {
);
}

#[test]
fn should_parse_compact_folders_and_reject_invalid_types() {
for value in [true, false] {
let outcome = parse_config(&format!("compact_folders = {value}\n"));
assert_eq!(outcome.config.unwrap().compact_folders, Some(value));
assert!(outcome.warnings.is_empty());
}
let outcome = parse_config("compact_folders = \"yes\"\n");
assert_eq!(outcome.config.unwrap().compact_folders, None);
assert_eq!(outcome.warnings.len(), 1);
}

// show_file_list

#[test]
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ fn main() -> anyhow::Result<()> {
if let Some(ref cfg) = config_outcome.config {
app.show_pr_checks = cfg.show_pr_checks.unwrap_or(false);
app.show_pr_comments = cfg.show_pr_comments.unwrap_or(true);
app.set_compact_folders(cfg.compact_folders.unwrap_or(false));
if cfg.show_file_list == Some(false) {
app.show_file_list = false;
app.focused_panel = FocusedPanel::Diff;
Expand Down
Loading
Loading