Skip to content

Commit 8566330

Browse files
committed
✨ Add mouse interaction and enhanced file navigation to Studio TUI
Implement comprehensive mouse support for the Studio TUI with double-click detection, drag selection in code views, and click-to-navigate in file trees across all modes (Explore, Commit, Review, PR). Key changes: - Add vim-style visual selection mode ('v') with multi-line copy support - Display all tracked repository files in Explore mode instead of just changed files - Add 'A' toggle in Commit mode to switch between changed/all files view - Improve file tree icons with Unicode symbols for better visual clarity - Fix mouse scroll handling across all Studio modes and panels - Add get_all_tracked_files() to git layer for full repo file listing
1 parent dd811c0 commit 8566330

File tree

10 files changed

+781
-73
lines changed

10 files changed

+781
-73
lines changed

src/git/files.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,52 @@ pub fn get_untracked_files(repo: &Repository) -> Result<Vec<String>> {
242242
Ok(untracked)
243243
}
244244

245+
/// Gets all tracked files in the repository (from HEAD tree + index)
246+
///
247+
/// This returns all files that are tracked by git, which includes:
248+
/// - Files committed in HEAD
249+
/// - Files staged in the index (including newly added files)
250+
///
251+
/// # Returns
252+
///
253+
/// A Result containing a Vec of file paths or an error.
254+
pub fn get_all_tracked_files(repo: &Repository) -> Result<Vec<String>> {
255+
log_debug!("Getting all tracked files");
256+
let mut files = std::collections::HashSet::new();
257+
258+
// Get files from HEAD tree
259+
if let Ok(head) = repo.head()
260+
&& let Ok(tree) = head.peel_to_tree()
261+
{
262+
tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
263+
if entry.kind() == Some(git2::ObjectType::Blob) {
264+
let path = if dir.is_empty() {
265+
entry.name().unwrap_or("").to_string()
266+
} else {
267+
format!("{}{}", dir, entry.name().unwrap_or(""))
268+
};
269+
if !path.is_empty() {
270+
files.insert(path);
271+
}
272+
}
273+
git2::TreeWalkResult::Ok
274+
})?;
275+
}
276+
277+
// Also include files from the index (staged files, including new files)
278+
let index = repo.index()?;
279+
for entry in index.iter() {
280+
let path = String::from_utf8_lossy(&entry.path).to_string();
281+
files.insert(path);
282+
}
283+
284+
let mut result: Vec<_> = files.into_iter().collect();
285+
result.sort();
286+
287+
log_debug!("Found {} tracked files", result.len());
288+
Ok(result)
289+
}
290+
245291
/// Gets the number of commits ahead and behind the upstream tracking branch
246292
///
247293
/// # Returns

src/git/repository.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use crate::config::Config;
22
use crate::context::{CommitContext, RecentCommit, StagedFile};
33
use crate::git::commit::{self, CommitResult};
44
use crate::git::files::{
5-
RepoFilesInfo, get_ahead_behind, get_file_statuses, get_unstaged_file_statuses,
6-
get_untracked_files,
5+
RepoFilesInfo, get_ahead_behind, get_all_tracked_files, get_file_statuses,
6+
get_unstaged_file_statuses, get_untracked_files,
77
};
88
use crate::git::utils::is_inside_work_tree;
99
use crate::log_debug;
@@ -856,6 +856,12 @@ impl GitRepo {
856856
get_untracked_files(&repo)
857857
}
858858

859+
/// Get all tracked files in the repository (from HEAD + index)
860+
pub fn get_all_tracked_files(&self) -> Result<Vec<String>> {
861+
let repo = self.open_repo()?;
862+
get_all_tracked_files(&repo)
863+
}
864+
859865
/// Get ahead/behind counts relative to upstream tracking branch
860866
///
861867
/// Returns (ahead, behind) tuple, or (0, 0) if no upstream is configured

src/studio/app.rs

Lines changed: 305 additions & 23 deletions
Large diffs are not rendered by default.

src/studio/components/code_view.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,18 @@ impl CodeViewState {
160160
pub fn is_loaded(&self) -> bool {
161161
self.current_file.is_some()
162162
}
163+
164+
/// Select a line by visible row (for mouse clicks)
165+
/// Returns true if selection changed
166+
pub fn select_by_row(&mut self, row: usize) -> bool {
167+
let target_line = self.scroll_offset + row + 1; // Convert to 1-indexed
168+
if target_line <= self.lines.len() && target_line != self.selected_line {
169+
self.selected_line = target_line;
170+
true
171+
} else {
172+
false
173+
}
174+
}
163175
}
164176

165177
// ═══════════════════════════════════════════════════════════════════════════════

src/studio/components/file_tree.rs

Lines changed: 125 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,47 @@ impl FileTreeState {
452452
pub fn selected_index(&self) -> usize {
453453
self.selected
454454
}
455+
456+
/// Select an item by visible row (for mouse clicks)
457+
/// Returns true if selection changed, false otherwise
458+
pub fn select_by_row(&mut self, row: usize) -> bool {
459+
let flat_len = self.flat_view().len();
460+
let target_index = self.scroll_offset + row;
461+
462+
if target_index < flat_len && target_index != self.selected {
463+
self.selected = target_index;
464+
true
465+
} else {
466+
false
467+
}
468+
}
469+
470+
/// Check if the clicked row matches the currently selected item
471+
/// Used for double-click detection
472+
pub fn is_row_selected(&self, row: usize) -> bool {
473+
let target_index = self.scroll_offset + row;
474+
target_index == self.selected
475+
}
476+
477+
/// Handle mouse click at a specific row within the visible area.
478+
/// Returns a tuple of (`selection_changed`, `is_directory`) for the caller to handle.
479+
pub fn handle_click(&mut self, row: usize) -> (bool, bool) {
480+
let flat_len = self.flat_view().len();
481+
let target_index = self.scroll_offset + row;
482+
483+
if target_index >= flat_len {
484+
return (false, false);
485+
}
486+
487+
let was_selected = target_index == self.selected;
488+
let is_dir = self.flat_cache.get(target_index).is_some_and(|e| e.is_dir);
489+
490+
if !was_selected {
491+
self.selected = target_index;
492+
}
493+
494+
(!was_selected, is_dir)
495+
}
455496
}
456497

457498
/// Helper to insert a path into the tree structure
@@ -576,21 +617,21 @@ pub fn render_file_tree(
576617
fn render_entry(entry: &FlatEntry, is_selected: bool, width: usize) -> Line<'static> {
577618
let indent = " ".repeat(entry.depth);
578619

579-
// Icon (use ASCII for consistent width)
620+
// Icon with nice Unicode symbols
580621
let icon = if entry.is_dir {
581-
if entry.is_expanded { "v" } else { ">" }
622+
if entry.is_expanded { "" } else { "" }
582623
} else {
583624
get_file_icon(&entry.name)
584625
};
585626

586-
// Git status indicator (use ASCII for consistent width)
627+
// Git status indicator with Unicode symbols
587628
let status_indicator = match entry.git_status {
588-
FileGitStatus::Staged => "+",
589-
FileGitStatus::Modified => "*",
590-
FileGitStatus::Untracked => "?",
591-
FileGitStatus::Deleted => "x",
592-
FileGitStatus::Renamed => "r",
593-
FileGitStatus::Conflict => "!",
629+
FileGitStatus::Staged => "",
630+
FileGitStatus::Modified => "",
631+
FileGitStatus::Untracked => "",
632+
FileGitStatus::Deleted => "",
633+
FileGitStatus::Renamed => "",
634+
FileGitStatus::Conflict => "",
594635
FileGitStatus::Normal => " ",
595636
};
596637
let status_style = entry.git_status.style();
@@ -673,23 +714,83 @@ fn truncate_to_width(s: &str, max_width: usize) -> String {
673714
result
674715
}
675716

676-
/// Get icon for file based on extension (ASCII-only for consistent width)
717+
/// Get icon for file based on extension (Unicode symbols, no emoji)
677718
fn get_file_icon(name: &str) -> &'static str {
719+
// Check for special filenames first
720+
let lower_name = name.to_lowercase();
721+
if lower_name == "cargo.toml" || lower_name == "cargo.lock" {
722+
return "◫";
723+
}
724+
if lower_name.starts_with("readme") {
725+
return "◈";
726+
}
727+
if lower_name.starts_with("license") {
728+
return "§";
729+
}
730+
if lower_name.starts_with(".git") {
731+
return "⊙";
732+
}
733+
if lower_name == "dockerfile" || lower_name.starts_with("docker-compose") {
734+
return "◲";
735+
}
736+
if lower_name == "makefile" {
737+
return "⚙";
738+
}
739+
678740
let ext = name.rsplit('.').next().unwrap_or("");
679741
match ext.to_lowercase().as_str() {
680-
"rs" => "#",
681-
"toml" => "=",
682-
"md" => "m",
683-
"json" => "j",
684-
"yaml" | "yml" => "y",
685-
"js" | "jsx" => "J",
686-
"ts" | "tsx" => "T",
687-
"py" => "p",
688-
"go" => "g",
689-
"sh" | "bash" | "zsh" => "$",
690-
"lock" => "L",
691-
"gitignore" => ".",
692-
"dockerfile" => "D",
693-
_ => "-",
742+
// Rust
743+
"rs" => "●",
744+
// Config files
745+
"toml" => "⚙",
746+
"yaml" | "yml" => "⚙",
747+
"json" => "◇",
748+
"xml" => "◇",
749+
"ini" | "cfg" | "conf" => "⚙",
750+
// Documentation
751+
"md" | "mdx" => "◈",
752+
"txt" => "≡",
753+
"pdf" => "▤",
754+
// Web
755+
"html" | "htm" => "◊",
756+
"css" | "scss" | "sass" | "less" => "◊",
757+
"js" | "mjs" | "cjs" => "◆",
758+
"jsx" => "◆",
759+
"ts" | "mts" | "cts" => "◇",
760+
"tsx" => "◇",
761+
"vue" => "◊",
762+
"svelte" => "◊",
763+
// Programming languages
764+
"py" | "pyi" => "◈",
765+
"go" => "◈",
766+
"rb" => "◈",
767+
"java" | "class" | "jar" => "◈",
768+
"kt" | "kts" => "◈",
769+
"swift" => "◈",
770+
"c" | "h" => "○",
771+
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "○",
772+
"cs" => "◈",
773+
"php" => "◈",
774+
"lua" => "◈",
775+
"r" => "◈",
776+
"sql" => "◫",
777+
// Shell
778+
"sh" | "bash" | "zsh" | "fish" => "▷",
779+
"ps1" | "psm1" => "▷",
780+
// Data
781+
"csv" => "◫",
782+
"db" | "sqlite" | "sqlite3" => "◫",
783+
// Images
784+
"png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => "◧",
785+
// Archives
786+
"zip" | "tar" | "gz" | "rar" | "7z" => "▣",
787+
// Lock files
788+
"lock" => "◉",
789+
// Git
790+
"gitignore" | "gitattributes" | "gitmodules" => "⊙",
791+
// Env
792+
"env" | "env.local" | "env.development" | "env.production" => "◉",
793+
// Default
794+
_ => "◦",
694795
}
695796
}

src/studio/handlers/commit.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,22 @@ fn handle_files_key(state: &mut StudioState, key: KeyEvent) -> Vec<SideEffect> {
127127
// Unstage all files
128128
KeyCode::Char('U') => vec![SideEffect::GitUnstageAll],
129129

130+
// Toggle between changed files and all tracked files
131+
KeyCode::Char('A') => {
132+
state.modes.commit.show_all_files = !state.modes.commit.show_all_files;
133+
let mode = if state.modes.commit.show_all_files {
134+
"all files"
135+
} else {
136+
"changed files"
137+
};
138+
state.notify(crate::studio::state::Notification::info(format!(
139+
"Showing: {}",
140+
mode
141+
)));
142+
state.mark_dirty();
143+
vec![SideEffect::RefreshGitStatus]
144+
}
145+
130146
_ => vec![],
131147
}
132148
}

0 commit comments

Comments
 (0)