Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ A first-session cheatsheet. Press `?` inside tuicr for the full reference.
| `r` | Toggle file reviewed |
| `R` | Toggle hunk reviewed |
| `e` | Open focused file in `$EDITOR` |
| `Space` | Expand / collapse a generated file |
| `y` | Copy review to clipboard |
| `:edit` | Open focused file in `$EDITOR` |
| `:submit` | Push review to GitHub or GitLab |
Expand Down
43 changes: 43 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ pr_metadata = true
comments_header = "## Local tuicr Comments"
remote_comments_header = "## Existing GitHub Comments"
legend = true

[generated]
collapse = false
count = true
```

## Options
Expand Down Expand Up @@ -269,6 +273,45 @@ The example above produces an export that opens directly on the comment list:

The top-level `export_legend` key predates this section and still works. When both are set, `legend` wins. When `[export]` omits `legend`, `export_legend` stays in force, so adding an `[export]` block to trim the intro will not switch the legend back on.

## Generated files

Files marked as code-generated in `.gitattributes` can be collapsed so a review of hand-written code is not buried under regenerated protobufs, lockfiles, or API clients.

A file counts as generated when `.gitattributes` sets either `linguist-generated` (GitHub's Linguist) or `gitlab-generated`. Both documented spellings work:

```gitattributes
*.pb.go linguist-generated=true
schema.generated.ts gitlab-generated
vendor/** linguist-generated=true
```

Resolution is delegated to git itself, so nested `.gitattributes` files, `.git/info/attributes`, `core.attributesFile`, and `[attr]` macros all behave the way `git check-attr` does. An explicit opt-out — `-linguist-generated` or `linguist-generated=false` — wins over an opt-in on the other attribute, so a directory-wide rule can be excepted per file.

```toml
[generated]
collapse = true
count = false
```

| Key | Default | Description |
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `collapse` | `false` | Hide the diff body of generated files, leaving the header row. `Space` expands one file; `:set generated!` toggles all of them. |
| `count` | `true` | Count generated files toward the `Files · reviewed/total` progress indicator. When `false`, the file list also reports `N generated`. |

`count` is deliberately independent of `collapse`: expanding a generated file to look at it should not add it to the denominator, or review progress would drop as a side effect of looking. You can also exclude generated files from progress without collapsing them.

Runtime commands:

| Command | Effect |
| ------------------- | ------------------------------------------------- |
| `:set generated` | Collapse generated files. |
| `:set nogenerated` | Show generated files. |
| `:set generated!`, `:generated` | Toggle. |

`.gitattributes` is read from the local worktree, so this needs a git repository: mercurial and non-colocated jujutsu repos report nothing as generated, and in pull request mode the attributes come from your local checkout rather than the pull request's head. Attributes are re-read on reload (`:e`), so editing `.gitattributes` mid-session takes effect without a restart.

To hide files from a review entirely rather than collapse them, use `.tuicrignore` below.

## .tuicrignore

tuicr reads `.tuicrignore` from the repository root and excludes matching files from all review diffs. Rules follow gitignore-style pattern matching, including `!` negation.
Expand Down
4 changes: 4 additions & 0 deletions docs/KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Shown below the file tree when local comments or visible remote PR threads exist
|-----|--------|
| `r` | Toggle file reviewed |
| `R` | Toggle hunk reviewed |
| `Space` | Expand / collapse the file under the cursor, independent of reviewed/generated status (diff panel) |
| `c` | Add line comment (or file comment if not on a diff line) |
| `C` | Add file comment |
| `<leader>c` | Add review comment |
Expand Down Expand Up @@ -143,6 +144,9 @@ In command mode,
| `:set commits` | Show inline commit selector |
| `:set nocommits` | Hide inline commit selector |
| `:set commits!` | Toggle inline commit selector |
| `:set generated` | Collapse files marked generated in `.gitattributes` |
| `:set nogenerated` | Show generated files |
| `:set generated!`, `:generated` | Toggle generated file collapse |
| `:clear` | Clear all comments |
| `:clearc` | Clear comments without clearing reviewed marks |
| `:version` | Show tuicr version |
Expand Down
15 changes: 11 additions & 4 deletions src/app/annotations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ impl App {
if self.file_line_count_cache.is_empty() {
self.populate_file_line_count_cache();
}
// Which files are code-generated has to be known before the walk
// below decides whose body to emit. Detection is hooked here rather
// than at each of the fifteen `self.diff_files = ...` assignments
// because every one of them rebuilds annotations afterwards, and a
// missed call site would desynchronize the annotations from the
// render. Repeat calls over an unchanged file set do no work.
self.detect_generated_files();

self.line_annotations.clear();

Expand Down Expand Up @@ -163,10 +170,10 @@ impl App {
.push(AnnotatedLine::FileHeader { file_idx });
}

// If reviewed, skip all content for this file. Single-file
// view ignores the reviewed-collapse since the user
// explicitly focused this file.
if self.session.is_file_reviewed(path) && !self.is_single_file_view {
// If collapsed, skip all content for this file. Single-file
// view ignores the collapse since the user explicitly focused
// this file.
if self.is_file_collapsed(file) && !self.is_single_file_view {
continue;
}

Expand Down
8 changes: 8 additions & 0 deletions src/app/diff_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,14 @@ impl App {
self.diff_files = diff_files;
self.clear_expanded_gaps();

// Reload is the point where an edited `.gitattributes` should take
// effect, so discard the probe memo and re-detect eagerly — the height
// math below runs before `rebuild_annotations` would have done it.
// `collapse_override` survives: it is the user's override, not
// detected state.
self.invalidate_generated_detection();
self.detect_generated_files();

self.sort_files_by_directory(false);
self.populate_file_line_count_cache();
self.expand_all_dirs();
Expand Down
161 changes: 161 additions & 0 deletions src/app/generated.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use super::*;

impl App {
/// Apply resolved `[generated]` settings and detect matching files.
///
/// Called from startup after config load, because `App::build` runs before
/// the config is applied and would otherwise detect against the defaults.
pub fn apply_generated_config(&mut self, config: &GeneratedConfig) {
self.collapse_generated = config.collapse();
self.count_generated = config.count();
// Annotations were built during `App::build`, before detection could
// run, so any newly collapsed file is not reflected in them yet.
if self.detect_generated_files() {
self.rebuild_annotations();
}
}

/// Whether `.gitattributes` has to be consulted at all.
///
/// Both features off means the detected set stays empty, which is what
/// makes the default configuration free: no repository is opened, and the
/// render path's guards short-circuit before any lookup.
fn generated_detection_enabled(&self) -> bool {
self.collapse_generated || !self.count_generated
}

/// Repository root to resolve `.gitattributes` against, if there is one.
///
/// PR mode's `root_path` is the synthetic `forge:host/owner/repo` identity
/// rather than a directory, so it falls back to the local checkout the
/// forge backend resolved — which is only set when the checkout matches
/// the PR's target repository, so a foreign checkout can't mis-mark files.
fn generated_attributes_root(&self) -> Option<PathBuf> {
if self.vcs_info.root_path.is_absolute() {
return Some(self.vcs_info.root_path.clone());
}
self.forge_backend
.as_deref()
.and_then(|backend| backend.local_checkout_path())
}

/// Look up `.gitattributes` for every diff file not probed yet, returning
/// whether that grew the generated set.
///
/// Probed paths are remembered, so a repeat call over an unchanged file
/// set costs one hash lookup per file and no libgit2 work. That memo is
/// also the staleness test, which is what lets `rebuild_annotations` keep
/// detection current without each of the fifteen `diff_files` assignments
/// having to remember to trigger it.
pub(in crate::app) fn detect_generated_files(&mut self) -> bool {
if !self.generated_detection_enabled() {
return false;
}
if self
.diff_files
.iter()
.all(|file| self.generated_probed.contains(file.display_path()))
{
return false;
}
let Some(root) = self.generated_attributes_root() else {
return false;
};

let unprobed: Vec<PathBuf> = self
.diff_files
.iter()
.map(|file| file.display_path())
.filter(|path| !self.generated_probed.contains(*path))
.cloned()
.collect();

let detected = crate::profile::time("generated.detect", || {
crate::generated::detect_generated(&root, &unprobed)
});

self.generated_probed.extend(unprobed);
let changed = !detected.is_empty();
self.generated_files.extend(detected);
changed
}

/// Forget which paths were probed so the next detection re-reads
/// `.gitattributes`. Called on explicit reload, so editing the attributes
/// file takes effect without restarting tuicr.
pub(in crate::app) fn invalidate_generated_detection(&mut self) {
self.generated_probed.clear();
self.generated_files.clear();
}

/// Whether to surface this path as code-generated.
///
/// Gated on the same condition as detection rather than on the detected
/// set being non-empty, so the decoration is a function of the current
/// settings and not of what the session has detected in the past. The set
/// is deliberately *not* discarded when the feature is switched off — that
/// is what makes re-enabling free — so keying the decoration off it would
/// make `:set generated` / `:set nogenerated` asymmetric: the labels, the
/// dimmed tree rows, and the counter would all survive a toggle back off
/// and never return to how the session started.
#[inline]
pub fn is_generated_file(&self, path: &Path) -> bool {
self.generated_detection_enabled() && self.generated_files.contains(path)
}

/// Number of diff files surfaced as code-generated.
pub fn generated_file_count(&self) -> usize {
if !self.generated_detection_enabled() {
return 0;
}
self.diff_files
.iter()
.filter(|file| self.generated_files.contains(file.display_path()))
.count()
}

/// `(reviewed, total)` file counts for the review progress indicator.
///
/// Generated files drop out of both halves when `[generated] count` is
/// off — out of the numerator too, or marking one reviewed would push the
/// count past its own total.
pub fn review_progress(&self) -> (usize, usize) {
if self.count_generated || self.generated_files.is_empty() {
return (self.reviewed_count(), self.file_count());
}
let mut reviewed = 0;
let mut total = 0;
for file in &self.diff_files {
let path = file.display_path();
if self.generated_files.contains(path) {
continue;
}
total += 1;
if self.session.is_file_reviewed(path) {
reviewed += 1;
}
}
(reviewed, total)
}

/// Set the runtime collapse toggle, running detection if it was skipped
/// while both features were off.
pub fn set_collapse_generated(&mut self, collapse: bool) {
self.collapse_generated = collapse;
self.detect_generated_files();
self.rebuild_annotations();

let count = self.generated_file_count();
if !collapse {
self.set_message("Showing generated files");
} else if count == 0 {
self.set_warning("No files marked generated in .gitattributes");
} else {
self.set_message(format!("Collapsing {count} generated file(s)"));
}
}

pub fn toggle_collapse_generated(&mut self) {
self.set_collapse_generated(!self.collapse_generated);
}
}
5 changes: 5 additions & 0 deletions src/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,11 @@ impl App {
saved_inline_selection: None,
path_filter: path_filter.map(|s| s.to_string()),
export: ExportConfig::default(),
collapse_generated: false,
count_generated: true,
generated_files: HashSet::new(),
collapse_override: HashMap::new(),
generated_probed: HashSet::new(),
};
// Auto-hide file list when path filter matches exactly one file
if app.path_filter.is_some() && app.diff_files.len() == 1 {
Expand Down
33 changes: 32 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use chrono::Utc;
use ratatui::style::Color;

use crate::comment_vim::CommentVimEditor;
use crate::config::{CommentTypeConfig, ExportConfig};
use crate::config::{CommentTypeConfig, ExportConfig, GeneratedConfig};
use crate::editor::EditorTarget;
use crate::error::{Result, TuicrError};
use crate::forge::context::{ContextProvider, ForgeContextProvider, VcsContextProvider};
Expand Down Expand Up @@ -1289,6 +1289,36 @@ pub struct App {
pub path_filter: Option<String>,
/// Resolved `[export]` settings shaping the generated review markdown.
pub export: ExportConfig,
/// Whether files `.gitattributes` marks as code-generated have their diff
/// body hidden. Seeded from `[generated] collapse`, flipped at runtime by
/// `:set generated!`. Off by default, so tuicr ships behaving as before.
pub collapse_generated: bool,
/// Whether generated files count toward review progress. Seeded from
/// `[generated] count`, which defaults to true. Independent of
/// `collapse_generated`: expanding a generated file to peek at it must
/// not add it to the denominator.
pub count_generated: bool,
/// Diff files that `.gitattributes` marks as code-generated, keyed by
/// display path.
///
/// Path-keyed rather than index-keyed because reloads, watch ticks, and
/// commit-selection changes all replace `diff_files` wholesale and shift
/// every index. Empty whenever detection is disabled, which is what lets
/// the render path short-circuit for free.
pub generated_files: HashSet<PathBuf>,
/// Per-file collapse override set with `Space`, taking precedence over
/// the reviewed/generated defaults `is_file_collapsed` would otherwise
/// derive. Keyed by path rather than index so it survives the reloads
/// and commit-selection changes that replace `diff_files` wholesale.
pub collapse_override: HashMap<PathBuf, bool>,
/// Display paths already looked up in `.gitattributes`.
///
/// Doubles as the staleness test for detection: a diff file set with no
/// unprobed path needs no work, which is how `rebuild_annotations` can
/// keep detection current without every `diff_files` assignment having to
/// remember to trigger it. Cleared on explicit reload so an edited
/// `.gitattributes` takes effect.
pub generated_probed: HashSet<PathBuf>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -1503,6 +1533,7 @@ mod comments;
mod commits;
mod diff_load;
mod gaps;
mod generated;
mod init;
mod modes;
mod navigation;
Expand Down
15 changes: 7 additions & 8 deletions src/app/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,16 +841,15 @@ impl App {
continue;
}
let path = file.display_path();
let is_reviewed = self.session.is_file_reviewed(path);

// Mutually exclusive, so only one file-state lookup per file.
if !single {
cumulative += 1; // File header
}
if !single && is_reviewed {
// multi-file collapsed: no body, no trailing spacing
continue;
}
if single && is_reviewed {
if self.is_file_collapsed(file) {
// multi-file collapsed: no body, no trailing spacing
continue;
}
} else if self.session.is_file_reviewed(path) {
cumulative += 1; // banner
}
if let Some(review) = self.session.files.get(path) {
Expand Down Expand Up @@ -994,7 +993,7 @@ impl App {
}

pub(in crate::app) fn file_render_height(&self, file_idx: usize, file: &DiffFile) -> usize {
if self.session.is_file_reviewed(file.display_path()) {
if self.is_file_collapsed(file) {
return 1; // collapsed: header only
}
1 + self.file_render_body_height(file_idx, file) // header + body
Expand Down
Loading
Loading