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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,16 @@ A first-session cheatsheet. Press `?` inside tuicr for the full reference.
| `v` / `V` | Visual mode (range comment) |
| `r` | Toggle file reviewed |
| `R` | Toggle hunk reviewed |
| `t` (file list) | Toggle tree / flat file-list mode (remembered next launch) |
| `e` | Open focused file in `$EDITOR` |
| `y` | Copy review to clipboard |
| `:edit` | Open focused file in `$EDITOR` |
| `:submit` | Push review to GitHub or GitLab |
| `Tab` in `:` prompt | Complete or cycle commands |
| `?` | Toggle full help |

Drag the separator between the file list and diff to resize the left panel; the width is remembered for the next launch.

Full reference in [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).

## Sponsors
Expand Down
3 changes: 3 additions & 0 deletions docs/KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ Press `?` to open help.
|-----|--------|
| `Space` | Toggle expand directory |
| `Enter` | Expand directory / jump to file in diff |
| `t` | Toggle tree / flat file-list mode (remembered next launch) |
| `o` | Expand all directories |
| `O` | Collapse all directories |

Drag the vertical separator between the file list and diff to resize the left panel. The width is saved for the next launch.

## Panel focus

| Key | Action |
Expand Down
3 changes: 3 additions & 0 deletions src/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,9 @@ impl App {
pending_confirm: None,
supports_keyboard_enhancement: false,
show_file_list: true,
file_list_width: 20,
file_list_resize_active: false,
file_list_flat: false,
is_pristine_mode: false,
is_single_file_view: false,
primed_walk_next: false,
Expand Down
7 changes: 7 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,13 @@ pub struct App {
pub pending_confirm: Option<ConfirmAction>,
pub supports_keyboard_enhancement: bool,
pub show_file_list: bool,
/// Horizontal file-list width as a percentage of the main content area.
pub file_list_width: u16,
/// True while the mouse is dragging the separator between file list/diff.
pub file_list_resize_active: bool,
/// When true, the file list shows every file as a single flat row rather
/// than rendering directory nodes and indentation.
pub file_list_flat: 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
26 changes: 26 additions & 0 deletions src/app/tests/tree_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ fn make_file(path: &str) -> DiffFile {
struct TreeTestHarness {
diff_files: Vec<DiffFile>,
expanded_dirs: HashSet<String>,
flat: bool,
}

impl TreeTestHarness {
fn new(paths: &[&str]) -> Self {
Self {
diff_files: paths.iter().map(|p| make_file(p)).collect(),
expanded_dirs: HashSet::new(),
flat: false,
}
}

Expand Down Expand Up @@ -56,6 +58,14 @@ impl TreeTestHarness {

fn build_visible_items(&self) -> Vec<FileTreeItem> {
use std::path::Path;
if self.flat {
return self
.diff_files
.iter()
.enumerate()
.map(|(file_idx, _)| FileTreeItem::File { file_idx, depth: 0 })
.collect();
}
let mut items = Vec::new();
let mut seen_dirs: HashSet<String> = HashSet::new();

Expand Down Expand Up @@ -186,3 +196,19 @@ fn test_sibling_dirs_independent() {

assert_eq!(h.visible_file_count(), 1); // only tests/test.rs
}

#[test]
fn test_flat_mode_shows_every_file_without_directories() {
let mut h = TreeTestHarness::new(&["src/ui/app.rs", "tests/test.rs", "README.md"]);
h.flat = true;

let items = h.build_visible_items();
assert_eq!(items.len(), 3);
assert_eq!(h.visible_file_count(), 3);
assert_eq!(h.visible_dir_count(), 0);
assert!(
items
.iter()
.all(|item| matches!(item, FileTreeItem::File { depth: 0, .. }))
);
}
83 changes: 83 additions & 0 deletions src/app/tree.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,62 @@
use super::*;

impl App {
/// Whether a mouse position is on the separator between the file list and
/// diff panels. The one-column tolerance makes the handle usable even
/// when the terminal reports a border cell inconsistently.
pub fn is_file_list_resize_handle(&self, x: u16, y: u16) -> bool {
let Some(file_list) = self.file_list_area else {
return false;
};
let Some(diff) = self.diff_area else {
return false;
};
y >= diff.y
&& y < diff.y.saturating_add(diff.height)
&& x.abs_diff(diff.x) <= 1
&& diff.x > file_list.x
}

/// Update the file-list width from a mouse x-coordinate. Width is bounded
/// so neither panel can become unusably narrow.
pub fn resize_file_list_to(&mut self, x: u16) {
let Some(file_list) = self.file_list_area else {
return;
};
let Some(diff) = self.diff_area else {
return;
};
let total = u32::from(
diff.x
.saturating_add(diff.width)
.saturating_sub(file_list.x),
);
if total == 0 {
return;
}
let relative = u32::from(x.saturating_sub(file_list.x));
let percent = ((relative * 100 + total / 2) / total).clamp(15, 50) as u16;
if percent != self.file_list_width {
self.file_list_width = percent;
self.file_list_resize_active = true;
}
}

/// Finish a resize gesture and persist the selected width for the next
/// launch. Errors are non-fatal because the current session keeps the
/// in-memory width.
pub fn finish_file_list_resize(&mut self) {
if !self.file_list_resize_active {
return;
}
self.file_list_resize_active = false;
if let Err(error) = crate::config::save_file_list_width(self.file_list_width) {
self.set_warning(format!("Failed to save file-list width: {error}"));
} else {
self.set_message(format!("File list width: {}%", self.file_list_width));
}
}

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 @@ -107,6 +163,24 @@ impl App {
self.set_message(format!("File list: {status}"));
}

/// Toggle between the hierarchical file tree and a flat list of all
/// changed files. Keep the currently focused file selected when possible.
pub fn toggle_file_list_mode(&mut self) {
let current_file_idx = self.diff_state.current_file_idx;
self.file_list_flat = !self.file_list_flat;
self.ensure_valid_tree_selection();
if let Some(tree_idx) = self.file_idx_to_tree_idx(current_file_idx) {
self.file_list_state.select(tree_idx);
}
*self.file_list_state.list_state.offset_mut() = 0;
let mode = if self.file_list_flat { "flat" } else { "tree" };
if let Err(error) = crate::config::save_file_list_flat(self.file_list_flat) {
self.set_warning(format!("Failed to save file-list mode: {error}"));
} else {
self.set_message(format!("File list mode: {mode}"));
}
}

/// Toggle single-file view. When on, the diff panel renders only the
/// currently focused file instead of the full continuous-scroll
/// concatenation. Annotations, navigation, and export work the same
Expand Down Expand Up @@ -271,6 +345,15 @@ impl App {
pub fn build_visible_items(&self) -> Vec<FileTreeItem> {
use std::path::Path;

if self.file_list_flat {
return self
.diff_files
.iter()
.enumerate()
.map(|(file_idx, _)| FileTreeItem::File { file_idx, depth: 0 })
.collect();
}

let mut items = Vec::new();
let mut seen_dirs: HashSet<String> = HashSet::new();

Expand Down
64 changes: 64 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,58 @@ pub fn themes_dir() -> Result<PathBuf> {
Ok(config_dir()?.join("themes"))
}

const UI_STATE_FILE: &str = "ui_state.toml";

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct UiState {
pub file_list_width: Option<u16>,
pub file_list_flat: Option<bool>,
}

/// Load UI-only state that is changed interactively rather than authored in
/// `config.toml`.
pub fn load_ui_state() -> UiState {
let Ok(dir) = config_dir() else {
return UiState::default();
};
let Ok(content) = fs::read_to_string(dir.join(UI_STATE_FILE)) else {
return UiState::default();
};
toml::from_str(&content).unwrap_or_default()
}

/// Persist UI state. This sidecar keeps the user's
/// hand-written `config.toml` comments and formatting untouched.
fn save_ui_state(state: &UiState) -> Result<()> {
let dir = config_dir()?;
fs::create_dir_all(&dir)?;
fs::write(dir.join(UI_STATE_FILE), toml::to_string(state)?)?;
Ok(())
}

pub fn load_file_list_width() -> Option<u16> {
load_ui_state()
.file_list_width
.filter(|width| (15..=50).contains(width))
}

pub fn load_file_list_flat() -> Option<bool> {
load_ui_state().file_list_flat
}

pub fn save_file_list_width(width: u16) -> Result<()> {
let mut state = load_ui_state();
state.file_list_width = Some(width);
save_ui_state(&state)
}

pub fn save_file_list_flat(flat: bool) -> Result<()> {
let mut state = load_ui_state();
state.file_list_flat = Some(flat);
save_ui_state(&state)
}

fn config_path_env_parts() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
(
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
Expand Down Expand Up @@ -748,6 +800,18 @@ mod tests {
assert!(outcome.warnings.is_empty());
}

#[test]
fn should_round_trip_file_list_ui_state() {
let state = UiState {
file_list_width: Some(35),
file_list_flat: Some(true),
};

let serialized = toml::to_string(&state).expect("state should serialize");
let restored: UiState = toml::from_str(&serialized).expect("state should deserialize");
assert_eq!(restored, state);
}

#[test]
fn should_load_theme_from_valid_toml() {
let outcome = parse_config("theme = \"light\"\n");
Expand Down
19 changes: 19 additions & 0 deletions src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) {
}
}
}
MouseEventKind::Down(MouseButton::Left)
if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect)
&& app.is_file_list_resize_handle(pos.x, pos.y) =>
{
app.focused_panel = FocusedPanel::FileList;
if app.input_mode == InputMode::VisualSelect {
app.exit_visual_mode();
}
app.visual_selection = None;
app.file_list_resize_active = true;
app.resize_file_list_to(pos.x);
}
MouseEventKind::Down(MouseButton::Left)
if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) =>
{
Expand Down Expand Up @@ -229,6 +241,9 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) {
handle_commit_select_action(app, Action::ToggleCommitSelect);
}
}
MouseEventKind::Drag(MouseButton::Left) if app.file_list_resize_active => {
app.resize_file_list_to(pos.x);
}
MouseEventKind::Drag(MouseButton::Left)
if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) =>
{
Expand Down Expand Up @@ -261,6 +276,9 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) {
app.move_cursor_to_annotation(head.annotation_idx);
}
}
MouseEventKind::Up(MouseButton::Left) if app.file_list_resize_active => {
app.finish_file_list_resize();
}
MouseEventKind::Up(MouseButton::Left)
if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) =>
{
Expand Down Expand Up @@ -1352,6 +1370,7 @@ pub fn handle_file_list_action(app: &mut App, action: Action) {
}
}
}
Action::ToggleFileListMode => app.toggle_file_list_mode(),
Action::ToggleReviewed => {
if let Some(FileTreeItem::File { file_idx, .. }) = app.get_selected_tree_item() {
app.toggle_reviewed_for_file_idx(file_idx, false);
Expand Down
11 changes: 11 additions & 0 deletions src/input/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ pub enum Action {
SubmitPickerConfirm,

ToggleExpand,
/// Toggle the left file list between tree and flat modes (`t`).
ToggleFileListMode,
ExpandAll,
CollapseAll,
SelectFileFull,
Expand Down Expand Up @@ -221,6 +223,7 @@ fn map_normal_mode(key: KeyEvent, leader_key: char) -> Action {
(KeyCode::Char('q'), KeyModifiers::NONE) => Action::Quit,

(KeyCode::Char(' '), KeyModifiers::NONE) => Action::ToggleExpand,
(KeyCode::Char('t'), KeyModifiers::NONE) => Action::ToggleFileListMode,
(KeyCode::Char('o'), KeyModifiers::NONE) => Action::ExpandAll,
(KeyCode::Char('O'), _) => Action::CollapseAll,

Expand Down Expand Up @@ -480,6 +483,14 @@ mod tests {
);
}

#[test]
fn should_map_t_to_toggle_file_list_mode() {
assert_eq!(
map_normal_mode(key(KeyCode::Char('t')), DEFAULT_LEADER_KEY),
Action::ToggleFileListMode
);
}

#[test]
fn should_map_uppercase_g_to_go_to_bottom_in_normal_mode() {
let action = map_normal_mode(key_shift('G'), DEFAULT_LEADER_KEY);
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ fn main() -> anyhow::Result<()> {
}
}

if let Some(width) = config::load_file_list_width() {
app.file_list_width = width;
}
if let Some(flat) = config::load_file_list_flat() {
app.file_list_flat = flat;
}

// On narrow terminals, start with only the diff panel visible.
if let Ok((width, _)) = crossterm::terminal::size()
&& width < MIN_WIDTH_FOR_FILE_LIST
Expand Down
4 changes: 2 additions & 2 deletions src/ui/app_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ fn render_main_content(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20), // File list
Constraint::Percentage(80), // Diff view
Constraint::Percentage(app.file_list_width), // File list
Constraint::Percentage(100u16.saturating_sub(app.file_list_width)), // Diff view
])
.split(content_area);

Expand Down
Loading
Loading