use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use ratatui::Terminal;
use std::io::stdout;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, watch};
use crate::{ssh, Config};
#[derive(Clone, PartialEq)]
enum Focus {
Settings,
Repos,
}
#[derive(Clone, PartialEq)]
enum Mode {
Normal,
Search,
Editing,
ConfirmRestart,
Commands,
RepoDetail,
Help,
}
#[derive(Clone, PartialEq)]
enum SortMode {
Name,
Date,
Size,
}
#[derive(Clone, PartialEq)]
enum SortDir {
Asc,
Desc,
}
impl SortDir {
fn toggle(&self) -> Self {
match self {
SortDir::Asc => SortDir::Desc,
SortDir::Desc => SortDir::Asc,
}
}
fn label(&self) -> &str {
match self {
SortDir::Asc => "↑",
SortDir::Desc => "↓",
}
}
}
#[derive(Clone, PartialEq)]
enum FileSortMode {
Name,
Size,
}
#[derive(Clone, PartialEq)]
enum DetailTab {
Commits,
Files,
Branches,
Contributors,
Languages,
}
struct ScanData {
activity: std::collections::HashMap<String, (u64, String)>, sizes: std::collections::HashMap<String, u64>,
total_size: u64,
}
struct DetailData {
repo: String,
commits: Vec<String>,
files: Vec<String>,
branches: Vec<String>,
contributors: Vec<String>,
languages: Vec<String>,
activity: Option<String>,
}
#[derive(Clone, PartialEq)]
enum ServerState {
Stopped,
Running,
Error(String),
}
struct App {
root: String,
host: String,
port: String,
ssh_port: String,
user: String,
pass: String,
noauth: bool,
enable_ssh: bool,
recursive: bool,
hooks_dir: String,
repos: Vec<String>,
repo_activity: std::collections::HashMap<String, String>,
repo_activity_ts: std::collections::HashMap<String, u64>,
repo_sizes: std::collections::HashMap<String, u64>,
total_size: u64,
sort_mode: SortMode,
sort_dir: SortDir,
filtered: Vec<String>,
search: String,
repo_state: ListState,
scan_rx: Option<mpsc::Receiver<ScanData>>,
focus: Focus,
mode: Mode,
setting_idx: usize,
server_state: ServerState,
status_msg: String,
shutdown_tx: Option<watch::Sender<bool>>,
listen_urls: Vec<String>,
cmd_repo: String,
cmd_items: Vec<(String, String)>, cmd_idx: usize,
cmd_copied: Option<std::time::Instant>,
detail_repo: String,
detail_tab: DetailTab,
detail_commits: Vec<String>,
detail_files: Vec<String>,
detail_branches: Vec<String>,
detail_contributors: Vec<String>,
detail_languages: Vec<String>,
file_sort_mode: FileSortMode,
file_sort_dir: SortDir,
detail_scroll: usize,
detail_activity: Option<String>,
detail_loading: bool,
detail_rx: Option<mpsc::Receiver<DetailData>>,
last_scan: std::time::Instant,
should_quit: bool,
}
const SETTING_NAMES: &[&str] = &[
"Root",
"Host",
"HTTP Port",
"SSH Port",
"Username",
"Password",
"Auth",
"SSH",
"Recursive",
"Hooks Dir",
];
impl App {
fn new(
root: String,
host: String,
port: u16,
ssh_port: u16,
user: Option<String>,
pass: Option<String>,
hooks_dir: Option<PathBuf>,
enable_ssh: bool,
recursive: bool,
) -> Self {
let noauth = user.is_none();
let mut app = App {
root,
host,
port: port.to_string(),
ssh_port: ssh_port.to_string(),
user: user.unwrap_or_default(),
pass: pass.unwrap_or_default(),
noauth,
enable_ssh,
recursive,
hooks_dir: hooks_dir
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
repos: Vec::new(),
repo_activity: std::collections::HashMap::new(),
repo_activity_ts: std::collections::HashMap::new(),
repo_sizes: std::collections::HashMap::new(),
total_size: 0,
sort_mode: SortMode::Name,
sort_dir: SortDir::Asc,
filtered: Vec::new(),
scan_rx: None,
search: String::new(),
repo_state: ListState::default(),
focus: Focus::Repos,
mode: Mode::Normal,
setting_idx: 0,
server_state: ServerState::Stopped,
status_msg: String::new(),
shutdown_tx: None,
listen_urls: Vec::new(),
cmd_repo: String::new(),
cmd_items: Vec::new(),
cmd_idx: 0,
cmd_copied: None,
detail_repo: String::new(),
detail_tab: DetailTab::Commits,
detail_commits: Vec::new(),
detail_files: Vec::new(),
detail_branches: Vec::new(),
detail_contributors: Vec::new(),
detail_languages: Vec::new(),
file_sort_mode: FileSortMode::Name,
file_sort_dir: SortDir::Asc,
detail_scroll: 0,
detail_activity: None,
detail_loading: false,
detail_rx: None,
last_scan: std::time::Instant::now(),
should_quit: false,
};
app.scan_repos();
app
}
fn scan_repos(&mut self) {
let prev_selected = self.repo_state.selected()
.and_then(|i| self.filtered.get(i).cloned());
self.repos.clear();
let root = PathBuf::from(&self.root);
if root.is_dir() {
find_repos(&root, &root, self.recursive, &mut self.repos);
}
self.sort_and_filter();
if let Some(prev) = prev_selected {
if let Some(pos) = self.filtered.iter().position(|r| *r == prev) {
self.repo_state.select(Some(pos));
}
}
self.last_scan = std::time::Instant::now();
let repos = self.repos.clone();
let root = root.clone();
let (tx, rx) = mpsc::channel(1);
self.scan_rx = Some(rx);
tokio::task::spawn_blocking(move || {
let mut activity = std::collections::HashMap::new();
let mut sizes = std::collections::HashMap::new();
let mut total_size = 0u64;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for repo in &repos {
let repo_path = root.join(repo);
if let Some((ts, kind)) = crate::git::last_activity(&repo_path) {
let ago = format_ago(now.saturating_sub(ts));
activity.insert(repo.clone(), (ts, format!("{} {}", kind, ago)));
}
let size = dir_size(&repo_path);
total_size += size;
sizes.insert(repo.clone(), size);
}
let _ = tx.blocking_send(ScanData { activity, sizes, total_size });
});
}
fn sort_and_filter(&mut self) {
let asc = self.sort_dir == SortDir::Asc;
match self.sort_mode {
SortMode::Name => {
self.repos.sort();
if !asc { self.repos.reverse(); }
}
SortMode::Date => {
let ts = &self.repo_activity_ts;
self.repos.sort_by(|a, b| {
let ta = ts.get(a).copied().unwrap_or(0);
let tb = ts.get(b).copied().unwrap_or(0);
if asc { ta.cmp(&tb) } else { tb.cmp(&ta) }
});
}
SortMode::Size => {
let sizes = &self.repo_sizes;
self.repos.sort_by(|a, b| {
let sa = sizes.get(a).copied().unwrap_or(0);
let sb = sizes.get(b).copied().unwrap_or(0);
if asc { sa.cmp(&sb) } else { sb.cmp(&sa) }
});
}
}
self.apply_filter();
}
fn sort_detail_files(&mut self) {
let asc = self.file_sort_dir == SortDir::Asc;
match self.file_sort_mode {
FileSortMode::Name => {
self.detail_files.sort_by(|a, b| {
let na = file_line_name(a);
let nb = file_line_name(b);
if asc { na.cmp(nb) } else { nb.cmp(na) }
});
}
FileSortMode::Size => {
self.detail_files.sort_by(|a, b| {
let sa = file_line_bytes(a);
let sb = file_line_bytes(b);
if asc { sa.cmp(&sb) } else { sb.cmp(&sa) }
});
}
}
}
fn apply_filter(&mut self) {
let q = self.search.to_lowercase();
self.filtered = if q.is_empty() {
self.repos.clone()
} else {
self.repos
.iter()
.filter(|r| r.to_lowercase().contains(&q))
.cloned()
.collect()
};
if self.filtered.is_empty() {
self.repo_state.select(None);
} else {
let idx = self.repo_state.selected().unwrap_or(0);
if idx >= self.filtered.len() {
self.repo_state.select(Some(self.filtered.len() - 1));
} else {
self.repo_state.select(Some(idx));
}
}
}
fn setting_value(&self, idx: usize) -> String {
match idx {
0 => self.root.clone(),
1 => self.host.clone(),
2 => self.port.clone(),
3 => self.ssh_port.clone(),
4 => self.user.clone(),
5 => {
if self.pass.is_empty() {
String::new()
} else {
"*".repeat(self.pass.len())
}
}
6 => {
if self.noauth {
"disabled".into()
} else {
"enabled".into()
}
}
7 => {
if self.enable_ssh {
"enabled".into()
} else {
"disabled".into()
}
}
8 => {
if self.recursive {
"enabled".into()
} else {
"disabled".into()
}
}
9 => {
if self.hooks_dir.is_empty() {
"(none)".into()
} else {
self.hooks_dir.clone()
}
}
_ => String::new(),
}
}
fn setting_value_raw(&self, idx: usize) -> String {
match idx {
0 => self.root.clone(),
1 => self.host.clone(),
2 => self.port.clone(),
3 => self.ssh_port.clone(),
4 => self.user.clone(),
5 => self.pass.clone(),
6 | 7 | 8 => String::new(), 9 => self.hooks_dir.clone(),
_ => String::new(),
}
}
fn set_setting_value(&mut self, idx: usize, val: String) {
match idx {
0 => {
self.root = val;
self.scan_repos();
}
1 => self.host = val,
2 => self.port = val,
3 => self.ssh_port = val,
4 => self.user = val,
5 => self.pass = val,
9 => self.hooks_dir = val,
_ => {}
}
}
fn open_commands(&mut self) {
let Some(idx) = self.repo_state.selected() else { return };
let Some(repo) = self.filtered.get(idx).cloned() else { return };
let port: u16 = self.port.parse().unwrap_or(3000);
let ssh_port: u16 = self.ssh_port.parse().unwrap_or(2222);
let http_base = if port == 80 {
format!("http://localhost/{}", repo)
} else {
format!("http://localhost:{}/{}", port, repo)
};
let http_auth = if !self.noauth && !self.user.is_empty() {
if port == 80 {
format!("http://{}:{}@localhost/{}", self.user, self.pass, repo)
} else {
format!("http://{}:{}@localhost:{}/{}", self.user, self.pass, port, repo)
}
} else {
http_base.clone()
};
let ssh_url = if ssh_port == 22 {
format!("ssh://git@localhost/{}", repo)
} else {
format!("ssh://git@localhost:{}/{}", ssh_port, repo)
};
let mut items: Vec<(String, String)> = Vec::new();
items.push(("── Clone ──".into(), String::new()));
items.push((" Clone (HTTP)".into(), format!("git clone {}", http_auth)));
if self.enable_ssh {
items.push((" Clone (SSH)".into(), format!("git clone {}", ssh_url)));
}
items.push((" Clone shallow".into(), format!("git clone --depth 1 {}", http_auth)));
items.push((" Clone blobless".into(), format!("git clone --filter=blob:none {}", http_auth)));
items.push(("── Remote ──".into(), String::new()));
items.push((" Set origin".into(), format!("git remote set-url origin {}", http_auth)));
items.push((" Add as secondary".into(), format!("git remote add local {}", http_auth)));
if self.enable_ssh {
items.push((" Add as secondary (SSH)".into(), format!("git remote add local {}", ssh_url)));
}
items.push((" Push to both (origin + local)".into(),
format!("git remote set-url --add --push origin {}", http_auth)));
items.push(("── Push ──".into(), String::new()));
items.push((" Push".into(), "git push origin main".into()));
items.push((" Push all branches".into(), "git push origin --all".into()));
items.push((" Push tags".into(), "git push origin --tags".into()));
items.push((" Push mirror".into(), format!("git push --mirror {}", http_auth)));
items.push((" Force push".into(), "git push --force origin main".into()));
items.push(("── Pull / Fetch ──".into(), String::new()));
items.push((" Pull".into(), "git pull origin main".into()));
items.push((" Fetch".into(), "git fetch origin".into()));
items.push((" Fetch all remotes".into(), "git fetch --all".into()));
items.push(("── Branch ──".into(), String::new()));
items.push((" Create & push branch".into(), "git checkout -b my-branch && git push -u origin my-branch".into()));
items.push((" List remote branches".into(), "git branch -r".into()));
items.push((" Delete remote branch".into(), "git push origin --delete my-branch".into()));
items.push(("── Tag ──".into(), String::new()));
items.push((" Create & push tag".into(), "git tag v1.0 && git push origin v1.0".into()));
items.push((" List tags".into(), "git tag -l".into()));
items.push((" Delete remote tag".into(), "git push origin --delete v1.0".into()));
items.push(("── Rebase / Merge ──".into(), String::new()));
items.push((" Rebase on main".into(), "git fetch origin && git rebase origin/main".into()));
items.push((" Merge branch".into(), "git merge my-branch".into()));
items.push((" Interactive rebase".into(), "git rebase -i origin/main".into()));
items.push(("── Archive ──".into(), String::new()));
items.push((" Download tar.gz".into(), format!("curl -LO {}/archive/main.tar.gz", http_base)));
items.push((" Download zip".into(), format!("curl -LO {}/archive/main.zip", http_base)));
items.push(("── LFS ──".into(), String::new()));
items.push((" Install LFS".into(), "git lfs install".into()));
items.push((" Track files".into(), "git lfs track '*.bin'".into()));
items.push((" LFS status".into(), "git lfs status".into()));
self.cmd_repo = repo;
self.cmd_items = items;
self.cmd_idx = 1; self.cmd_copied = None;
self.mode = Mode::Commands;
}
fn open_detail(&mut self) {
let Some(idx) = self.repo_state.selected() else { return };
let Some(repo) = self.filtered.get(idx).cloned() else { return };
self.detail_repo = repo.clone();
self.detail_commits = vec!["Loading...".into()];
self.detail_files = vec!["Loading...".into()];
self.detail_branches = vec!["Loading...".into()];
self.detail_contributors = vec!["Loading...".into()];
self.detail_languages = vec!["Loading...".into()];
self.detail_activity = None;
self.detail_scroll = 0;
self.detail_tab = DetailTab::Commits;
self.detail_loading = true;
self.mode = Mode::RepoDetail;
let (tx, rx) = mpsc::channel(1);
self.detail_rx = Some(rx);
let repo_path = PathBuf::from(&self.root).join(&repo);
tokio::task::spawn_blocking(move || {
let commits = crate::git::recent_commits(&repo_path, 50);
let files = crate::git::file_tree(&repo_path);
let branches = crate::git::branches(&repo_path);
let contributors = crate::git::contributors(&repo_path);
let languages = crate::git::languages(&repo_path);
let activity = crate::git::last_activity(&repo_path).map(|(ts, kind)| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ago = format_ago(now.saturating_sub(ts));
format!("Last {}: {}", kind, ago)
});
let _ = tx.blocking_send(DetailData {
repo,
commits,
files,
branches,
contributors,
languages,
activity,
});
});
}
fn refresh_detail(&mut self) {
let repo = self.detail_repo.clone();
self.detail_loading = true;
let (tx, rx) = mpsc::channel(1);
self.detail_rx = Some(rx);
let repo_path = PathBuf::from(&self.root).join(&repo);
tokio::task::spawn_blocking(move || {
let commits = crate::git::recent_commits(&repo_path, 50);
let files = crate::git::file_tree(&repo_path);
let branches = crate::git::branches(&repo_path);
let contributors = crate::git::contributors(&repo_path);
let languages = crate::git::languages(&repo_path);
let activity = crate::git::last_activity(&repo_path).map(|(ts, kind)| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ago = format_ago(now.saturating_sub(ts));
format!("Last {}: {}", kind, ago)
});
let _ = tx.blocking_send(DetailData {
repo,
commits,
files,
branches,
contributors,
languages,
activity,
});
});
}
fn is_toggle_field(&self, idx: usize) -> bool {
idx == 6 || idx == 7 || idx == 8
}
fn toggle_field(&mut self, idx: usize) {
match idx {
6 => self.noauth = !self.noauth,
7 => self.enable_ssh = !self.enable_ssh,
8 => {
self.recursive = !self.recursive;
self.scan_repos();
}
_ => {}
}
}
fn build_config(&self) -> Result<Config, String> {
let port: u16 = self
.port
.parse()
.map_err(|_| "Invalid HTTP port".to_string())?;
let ssh_port: u16 = self
.ssh_port
.parse()
.map_err(|_| "Invalid SSH port".to_string())?;
if !self.noauth && (self.user.is_empty() || self.pass.is_empty()) {
return Err("Username and password required when auth is enabled".into());
}
let root = PathBuf::from(&self.root);
if !root.is_dir() {
std::fs::create_dir_all(&root).map_err(|e| format!("Cannot create root: {}", e))?;
}
let root = root
.canonicalize()
.map_err(|e| format!("Cannot resolve root: {}", e))?;
let hooks_dir = if self.hooks_dir.is_empty() {
None
} else {
let p = PathBuf::from(&self.hooks_dir);
if !p.is_dir() {
return Err(format!("Hooks dir not found: {}", self.hooks_dir));
}
Some(p)
};
Ok(Config {
root,
host: self.host.clone(),
port,
ssh_port,
user: if self.noauth {
None
} else {
Some(self.user.clone())
},
pass: if self.noauth {
None
} else {
Some(self.pass.clone())
},
hooks_dir,
enable_ssh: self.enable_ssh,
recursive: self.recursive,
})
}
}
fn find_repos(base: &Path, dir: &Path, recursive: bool, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
continue;
}
if !path.is_dir() {
continue;
}
if crate::git::is_git_repo(&path) {
if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_string_lossy().into_owned());
}
} else if recursive {
find_repos(base, &path, recursive, out);
}
}
}
struct EditBuf {
text: String,
cursor: usize,
}
impl EditBuf {
fn new(text: String) -> Self {
let cursor = text.len();
EditBuf { text, cursor }
}
fn insert(&mut self, ch: char) {
self.text.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
}
fn backspace(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.text.remove(self.cursor);
}
}
fn delete(&mut self) {
if self.cursor < self.text.len() {
self.text.remove(self.cursor);
}
}
fn left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
fn right(&mut self) {
if self.cursor < self.text.len() {
self.cursor += 1;
}
}
fn home(&mut self) {
self.cursor = 0;
}
fn end(&mut self) {
self.cursor = self.text.len();
}
}
pub async fn run(
root: String,
host: String,
port: u16,
ssh_port: u16,
user: Option<String>,
pass: Option<String>,
hooks_dir: Option<PathBuf>,
enable_ssh: bool,
noauth: bool,
recursive: bool,
) -> Result<(), Box<dyn std::error::Error>> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = ratatui::backend::CrosstermBackend::new(stdout());
let mut term = Terminal::new(backend)?;
let mut app = App::new(root, host, port, ssh_port, user, pass, hooks_dir, enable_ssh, recursive);
if noauth {
app.noauth = true;
}
let mut edit_buf: Option<EditBuf> = None;
start_server(&mut app);
loop {
term.draw(|f| draw(f, &mut app, &edit_buf))?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
{
app.should_quit = true;
}
match app.mode {
Mode::Normal => {
handle_normal_input(&mut app, key.code, &mut edit_buf);
}
Mode::Search => {
handle_search_input(&mut app, key.code);
}
Mode::Editing => {
handle_edit_input(&mut app, key.code, &mut edit_buf);
}
Mode::ConfirmRestart => {
handle_confirm_restart(&mut app, key.code);
}
Mode::Commands => {
handle_commands_input(&mut app, key.code);
}
Mode::RepoDetail => {
handle_detail_input(&mut app, key.code);
}
Mode::Help => {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')) {
app.mode = Mode::Normal;
}
}
}
}
}
if let Some(rx) = app.detail_rx.as_mut() {
match rx.try_recv() {
Ok(data) => {
if data.repo == app.detail_repo {
app.detail_commits = data.commits;
app.detail_files = data.files;
app.detail_branches = data.branches;
app.detail_contributors = data.contributors;
app.detail_languages = data.languages;
app.detail_activity = data.activity;
app.detail_loading = false;
}
app.detail_rx = None;
}
Err(mpsc::error::TryRecvError::Disconnected) => {
app.detail_loading = false;
app.detail_rx = None;
}
Err(mpsc::error::TryRecvError::Empty) => {
}
}
}
if let Some(rx) = app.scan_rx.as_mut() {
match rx.try_recv() {
Ok(data) => {
app.repo_activity.clear();
app.repo_activity_ts.clear();
for (repo, (ts, display)) in &data.activity {
app.repo_activity.insert(repo.clone(), display.clone());
app.repo_activity_ts.insert(repo.clone(), *ts);
}
app.repo_sizes = data.sizes;
app.total_size = data.total_size;
if app.sort_mode != SortMode::Name {
let prev = app.repo_state.selected()
.and_then(|i| app.filtered.get(i).cloned());
app.sort_and_filter();
if let Some(prev) = prev {
if let Some(pos) = app.filtered.iter().position(|r| *r == prev) {
app.repo_state.select(Some(pos));
}
}
}
app.scan_rx = None;
}
Err(mpsc::error::TryRecvError::Disconnected) => {
app.scan_rx = None;
}
Err(mpsc::error::TryRecvError::Empty) => {}
}
}
if app.server_state == ServerState::Running
&& app.last_scan.elapsed().as_secs() >= 3
&& app.mode == Mode::Normal
{
app.scan_repos();
app.last_scan = std::time::Instant::now();
}
if app.should_quit {
break;
}
}
terminal::disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
if let Some(tx) = app.shutdown_tx.take() {
let _ = tx.send(true);
}
Ok(())
}
fn handle_normal_input(app: &mut App, key: KeyCode, edit_buf: &mut Option<EditBuf>) {
match key {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('?') => {
app.mode = Mode::Help;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.focus = Focus::Repos;
}
KeyCode::Tab | KeyCode::BackTab => {
app.focus = match app.focus {
Focus::Settings => Focus::Repos,
Focus::Repos => Focus::Settings,
};
}
KeyCode::Char('s') => {
toggle_server(app);
}
KeyCode::Char('r') => {
app.scan_repos();
app.status_msg = format!("Scanned: {} repos found", app.repos.len());
}
KeyCode::Char('o') => {
if app.focus == Focus::Repos {
app.sort_mode = match app.sort_mode {
SortMode::Name => SortMode::Date,
SortMode::Date => SortMode::Size,
SortMode::Size => SortMode::Name,
};
let prev = app.repo_state.selected()
.and_then(|i| app.filtered.get(i).cloned());
app.sort_and_filter();
if let Some(prev) = prev {
if let Some(pos) = app.filtered.iter().position(|r| *r == prev) {
app.repo_state.select(Some(pos));
}
}
let label = match app.sort_mode {
SortMode::Name => "name",
SortMode::Date => "date",
SortMode::Size => "size",
};
app.status_msg = format!("Sort: {} {}", label, app.sort_dir.label());
}
}
KeyCode::Char('O') => {
if app.focus == Focus::Repos {
app.sort_dir = app.sort_dir.toggle();
let prev = app.repo_state.selected()
.and_then(|i| app.filtered.get(i).cloned());
app.sort_and_filter();
if let Some(prev) = prev {
if let Some(pos) = app.filtered.iter().position(|r| *r == prev) {
app.repo_state.select(Some(pos));
}
}
let label = match app.sort_mode {
SortMode::Name => "name",
SortMode::Date => "date",
SortMode::Size => "size",
};
app.status_msg = format!("Sort: {} {}", label, app.sort_dir.label());
}
}
KeyCode::Up | KeyCode::Char('k') => {
match app.focus {
Focus::Settings => {
if app.setting_idx > 0 {
app.setting_idx -= 1;
}
}
Focus::Repos => {
let i = app.repo_state.selected().unwrap_or(0);
if i > 0 {
app.repo_state.select(Some(i - 1));
}
}
}
}
KeyCode::Down | KeyCode::Char('j') => {
match app.focus {
Focus::Settings => {
if app.setting_idx < SETTING_NAMES.len() - 1 {
app.setting_idx += 1;
}
}
Focus::Repos => {
let i = app.repo_state.selected().unwrap_or(0);
if i + 1 < app.filtered.len() {
app.repo_state.select(Some(i + 1));
}
}
}
}
KeyCode::Char('c') => {
if app.focus == Focus::Repos {
app.open_commands();
}
}
KeyCode::Enter => {
match app.focus {
Focus::Settings => {
if app.is_toggle_field(app.setting_idx) {
app.toggle_field(app.setting_idx);
if app.server_state == ServerState::Running {
app.mode = Mode::ConfirmRestart;
app.status_msg = "Restart server with new settings? [y/n]".into();
}
} else {
app.mode = Mode::Editing;
*edit_buf = Some(EditBuf::new(app.setting_value_raw(app.setting_idx)));
}
}
Focus::Repos => {
app.open_detail();
}
}
}
KeyCode::PageUp => {
if app.focus == Focus::Repos {
let i = app.repo_state.selected().unwrap_or(0);
app.repo_state.select(Some(i.saturating_sub(10)));
}
}
KeyCode::PageDown => {
if app.focus == Focus::Repos && !app.filtered.is_empty() {
let i = app.repo_state.selected().unwrap_or(0);
let max = app.filtered.len() - 1;
app.repo_state.select(Some((i + 10).min(max)));
}
}
KeyCode::Home => {
if app.focus == Focus::Repos && !app.filtered.is_empty() {
app.repo_state.select(Some(0));
}
}
KeyCode::End => {
if app.focus == Focus::Repos && !app.filtered.is_empty() {
app.repo_state.select(Some(app.filtered.len() - 1));
}
}
_ => {}
}
}
fn handle_search_input(app: &mut App, key: KeyCode) {
match key {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.search.clear();
app.apply_filter();
}
KeyCode::Enter => {
app.mode = Mode::Normal;
}
KeyCode::Backspace => {
app.search.pop();
app.apply_filter();
}
KeyCode::Char(c) => {
app.search.push(c);
app.apply_filter();
}
_ => {}
}
}
fn handle_edit_input(app: &mut App, key: KeyCode, edit_buf: &mut Option<EditBuf>) {
let Some(buf) = edit_buf.as_mut() else {
app.mode = Mode::Normal;
return;
};
match key {
KeyCode::Esc => {
*edit_buf = None;
app.mode = Mode::Normal;
}
KeyCode::Enter => {
app.set_setting_value(app.setting_idx, buf.text.clone());
*edit_buf = None;
if app.server_state == ServerState::Running {
app.mode = Mode::ConfirmRestart;
app.status_msg = "Restart server with new settings? [y/n]".into();
} else {
app.mode = Mode::Normal;
app.status_msg = format!("{} updated", SETTING_NAMES[app.setting_idx]);
}
}
KeyCode::Backspace => buf.backspace(),
KeyCode::Delete => buf.delete(),
KeyCode::Left => buf.left(),
KeyCode::Right => buf.right(),
KeyCode::Home => buf.home(),
KeyCode::End => buf.end(),
KeyCode::Char(c) => buf.insert(c),
_ => {}
}
}
fn handle_confirm_restart(app: &mut App, key: KeyCode) {
match key {
KeyCode::Char('y') | KeyCode::Char('Y') => {
stop_server(app);
start_server(app);
app.mode = Mode::Normal;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.mode = Mode::Normal;
app.status_msg = "Settings changed (server not restarted)".into();
}
_ => {}
}
}
fn handle_commands_input(app: &mut App, key: KeyCode) {
match key {
KeyCode::Esc | KeyCode::Char('q') => {
app.mode = Mode::Normal;
app.status_msg.clear();
}
KeyCode::Up | KeyCode::Char('k') => {
if app.cmd_idx > 0 {
let mut i = app.cmd_idx - 1;
while i > 0 && app.cmd_items[i].1.is_empty() {
i -= 1;
}
if !app.cmd_items[i].1.is_empty() {
app.cmd_idx = i;
}
}
app.cmd_copied = None;
}
KeyCode::Down | KeyCode::Char('j') => {
if app.cmd_idx + 1 < app.cmd_items.len() {
let mut i = app.cmd_idx + 1;
while i < app.cmd_items.len() && app.cmd_items[i].1.is_empty() {
i += 1;
}
if i < app.cmd_items.len() {
app.cmd_idx = i;
}
}
app.cmd_copied = None;
}
KeyCode::Enter | KeyCode::Char('y') => {
if let Some((label, cmd)) = app.cmd_items.get(app.cmd_idx) {
if !cmd.is_empty() {
copy_to_clipboard(cmd);
app.cmd_copied = Some(std::time::Instant::now());
app.status_msg = format!("Copied: {}", label.trim());
}
}
}
KeyCode::PageUp => {
let mut i = app.cmd_idx.saturating_sub(1);
while i > 0 && !app.cmd_items[i].1.is_empty() {
i -= 1;
}
if i + 1 < app.cmd_items.len() && !app.cmd_items[i + 1].1.is_empty() {
app.cmd_idx = i + 1;
}
app.cmd_copied = None;
}
KeyCode::PageDown => {
let mut i = app.cmd_idx + 1;
while i < app.cmd_items.len() && !app.cmd_items[i].1.is_empty() {
i += 1;
}
if i + 1 < app.cmd_items.len() {
app.cmd_idx = i + 1;
}
app.cmd_copied = None;
}
_ => {}
}
}
fn handle_detail_input(app: &mut App, key: KeyCode) {
match key {
KeyCode::Char('?') => {
app.mode = Mode::Help;
}
KeyCode::Esc | KeyCode::Char('q') => {
app.mode = Mode::Normal;
app.status_msg.clear();
}
KeyCode::Tab | KeyCode::Right => {
app.detail_tab = match app.detail_tab {
DetailTab::Commits => DetailTab::Files,
DetailTab::Files => DetailTab::Branches,
DetailTab::Branches => DetailTab::Contributors,
DetailTab::Contributors => DetailTab::Languages,
DetailTab::Languages => DetailTab::Commits,
};
app.detail_scroll = 0;
}
KeyCode::BackTab | KeyCode::Left => {
app.detail_tab = match app.detail_tab {
DetailTab::Commits => DetailTab::Languages,
DetailTab::Files => DetailTab::Commits,
DetailTab::Branches => DetailTab::Files,
DetailTab::Contributors => DetailTab::Branches,
DetailTab::Languages => DetailTab::Contributors,
};
app.detail_scroll = 0;
}
KeyCode::Up | KeyCode::Char('k') => {
if app.detail_scroll > 0 {
app.detail_scroll -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
let max = detail_item_count(app);
if app.detail_scroll + 1 < max {
app.detail_scroll += 1;
}
}
KeyCode::PageUp => {
app.detail_scroll = app.detail_scroll.saturating_sub(20);
}
KeyCode::PageDown => {
let max = detail_item_count(app);
app.detail_scroll = (app.detail_scroll + 20).min(max.saturating_sub(1));
}
KeyCode::Home => {
app.detail_scroll = 0;
}
KeyCode::End => {
let max = detail_item_count(app);
app.detail_scroll = max.saturating_sub(1);
}
KeyCode::Char('c') => {
app.mode = Mode::Normal;
app.open_commands();
}
KeyCode::Char('r') => {
app.refresh_detail();
app.status_msg = "Refreshing...".into();
}
KeyCode::Char('o') => {
if app.detail_tab == DetailTab::Files {
app.file_sort_mode = match app.file_sort_mode {
FileSortMode::Name => FileSortMode::Size,
FileSortMode::Size => FileSortMode::Name,
};
app.sort_detail_files();
app.detail_scroll = 0;
let label = match app.file_sort_mode {
FileSortMode::Name => "name",
FileSortMode::Size => "size",
};
app.status_msg = format!("Files sort: {} {}", label, app.file_sort_dir.label());
}
}
KeyCode::Char('O') => {
if app.detail_tab == DetailTab::Files {
app.file_sort_dir = app.file_sort_dir.toggle();
app.sort_detail_files();
app.detail_scroll = 0;
let label = match app.file_sort_mode {
FileSortMode::Name => "name",
FileSortMode::Size => "size",
};
app.status_msg = format!("Files sort: {} {}", label, app.file_sort_dir.label());
}
}
_ => {}
}
}
fn copy_to_clipboard(text: &str) {
let encoded = base64_enc(text.as_bytes());
print!("\x1b]52;c;{}\x07", encoded);
use std::io::Write;
let _ = std::io::stdout().flush();
}
fn base64_enc(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[(n >> 18 & 63) as usize] as char);
out.push(CHARS[(n >> 12 & 63) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[(n >> 6 & 63) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(CHARS[(n & 63) as usize] as char);
} else {
out.push('=');
}
}
out
}
fn toggle_server(app: &mut App) {
match &app.server_state {
ServerState::Stopped | ServerState::Error(_) => {
start_server(app);
}
ServerState::Running => {
stop_server(app);
}
}
}
fn start_server(app: &mut App) {
let config = match app.build_config() {
Ok(c) => c,
Err(e) => {
app.server_state = ServerState::Error(e.clone());
app.status_msg = format!("Error: {}", e);
return;
}
};
let config = Arc::new(config);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let http_config = config.clone();
let ssh_config = config.clone();
let http_rx = shutdown_rx.clone();
let port = config.port;
let host = config.host.clone();
let host_for_spawn = host.clone();
let enable_ssh = config.enable_ssh;
tokio::spawn(async move {
let listener = match TcpListener::bind((&*host_for_spawn, port)).await {
Ok(l) => l,
Err(e) => {
eprintln!("HTTP bind error: {}", e);
return;
}
};
let mut rx = http_rx;
loop {
tokio::select! {
result = listener.accept() => {
if let Ok((stream, _)) = result {
let config = http_config.clone();
tokio::spawn(async move {
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
let svc = service_fn(|req| {
let config = config.clone();
async move { crate::http::handle_request(req, &config).await }
});
http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await
.ok();
});
}
}
_ = rx.changed() => {
break;
}
}
}
});
if enable_ssh {
let ssh_rx = shutdown_rx;
tokio::spawn(async move {
if let Err(e) = ssh::serve(ssh_config, ssh_rx).await {
eprintln!("SSH error: {}", e);
}
});
}
app.shutdown_tx = Some(shutdown_tx);
app.server_state = ServerState::Running;
let ips = crate::local_ips();
let bind_all = host == "0.0.0.0" || host == "::";
app.listen_urls.clear();
if bind_all {
for ip in &ips {
let mut line = format!("http://{}:{}", ip, port);
if enable_ssh {
line.push_str(&format!(" ssh://{}:{}", ip, app.ssh_port));
}
app.listen_urls.push(line);
}
} else {
let mut line = format!("http://{}:{}", host, port);
if enable_ssh {
line.push_str(&format!(" ssh://{}:{}", host, app.ssh_port));
}
app.listen_urls.push(line);
}
app.status_msg = format!(
"Server started on port {}{}",
app.port,
if enable_ssh {
format!(" (SSH: {})", app.ssh_port)
} else {
String::new()
}
);
}
fn stop_server(app: &mut App) {
if let Some(tx) = app.shutdown_tx.take() {
let _ = tx.send(true);
}
app.server_state = ServerState::Stopped;
app.listen_urls.clear();
app.status_msg = "Server stopped".into();
}
fn draw(
f: &mut ratatui::Frame,
app: &mut App,
edit_buf: &Option<EditBuf>,
) {
let area = f.area();
let listen_lines = if app.server_state == ServerState::Running {
app.listen_urls.len() as u16
} else {
0
};
let header_h = 3 + listen_lines;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_h),
Constraint::Length(SETTING_NAMES.len() as u16 + 2), Constraint::Min(5), Constraint::Length(1), Constraint::Length(3), ])
.split(area);
draw_header(f, app, chunks[0]);
draw_settings(f, app, edit_buf, chunks[1]);
draw_repos(f, app, chunks[2]);
draw_status_bar(f, app, chunks[3]);
draw_footer(f, app, chunks[4]);
if app.mode == Mode::Commands {
draw_commands(f, app, area);
}
if app.mode == Mode::RepoDetail {
draw_repo_detail(f, app, area);
}
if app.mode == Mode::Help {
draw_help(f, area);
}
}
fn draw_header(f: &mut ratatui::Frame, app: &App, area: Rect) {
let (indicator, color) = match &app.server_state {
ServerState::Stopped => ("■ Stopped", Color::Red),
ServerState::Running => ("● Running", Color::Green),
ServerState::Error(_) => ("✖ Error", Color::Yellow),
};
let mut lines = vec![Line::from(vec![
Span::styled(" gitrub ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw("│ "),
Span::styled(indicator, Style::default().fg(color).add_modifier(Modifier::BOLD)),
])];
for url in &app.listen_urls {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(url.as_str(), Style::default().fg(Color::Gray)),
]));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let para = Paragraph::new(lines).block(block);
f.render_widget(para, area);
}
fn draw_settings(
f: &mut ratatui::Frame,
app: &App,
edit_buf: &Option<EditBuf>,
area: Rect,
) {
let focused = app.focus == Focus::Settings;
let border_color = if focused { Color::Cyan } else { Color::DarkGray };
let block = Block::default()
.title(" Settings ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
f.render_widget(block, area);
for (i, name) in SETTING_NAMES.iter().enumerate() {
if i as u16 >= inner.height {
break;
}
let row_area = Rect {
x: inner.x,
y: inner.y + i as u16,
width: inner.width,
height: 1,
};
let is_selected = focused && app.setting_idx == i;
let is_editing = is_selected && app.mode == Mode::Editing;
let is_locked = app.server_state == ServerState::Running;
let label_style = if is_selected {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let value_str = if is_editing {
if let Some(buf) = edit_buf {
let before = &buf.text[..buf.cursor];
let after = &buf.text[buf.cursor..];
format!("{}│{}", before, after)
} else {
app.setting_value(i)
}
} else {
app.setting_value(i)
};
let value_style = if is_editing {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else if is_locked {
Style::default().fg(Color::DarkGray)
} else if is_selected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::Gray)
};
let marker = if is_selected { "▸ " } else { " " };
let toggle_hint = if is_selected && app.is_toggle_field(i) && !is_locked {
" [Enter to toggle]"
} else if is_selected && !app.is_toggle_field(i) && !is_locked && !is_editing {
" [Enter to edit]"
} else {
""
};
let line = Line::from(vec![
Span::styled(marker, label_style),
Span::styled(format!("{:<12}", name), label_style),
Span::styled(value_str, value_style),
Span::styled(toggle_hint, Style::default().fg(Color::DarkGray)),
]);
f.render_widget(Paragraph::new(line), row_area);
}
}
fn draw_repos(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
let focused = app.focus == Focus::Repos;
let border_color = if focused { Color::Cyan } else { Color::DarkGray };
let search_display = if app.mode == Mode::Search {
format!(" Search: {}█", app.search)
} else if !app.search.is_empty() {
format!(" Filter: {}", app.search)
} else {
String::new()
};
let sort_label = if app.sort_mode == SortMode::Name && app.sort_dir == SortDir::Asc {
String::new()
} else {
let field = match app.sort_mode {
SortMode::Name => "name",
SortMode::Date => "date",
SortMode::Size => "size",
};
format!(" {}{}", app.sort_dir.label(), field)
};
let title = format!(
" Repos ({}/{}){}{}",
app.filtered.len(),
app.repos.len(),
sort_label,
search_display
);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
if app.filtered.is_empty() {
let msg = if app.repos.is_empty() {
"No repos found. Push to create one."
} else {
"No repos match the filter."
};
let para = Paragraph::new(Span::styled(msg, Style::default().fg(Color::DarkGray)))
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(para, area);
return;
}
let items: Vec<ListItem> = app
.filtered
.iter()
.enumerate()
.map(|(i, repo)| {
let selected = app.repo_state.selected() == Some(i);
let style = if selected && focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let prefix = if selected && focused { "▸ " } else { " " };
let activity = app.repo_activity.get(repo).cloned().unwrap_or_default();
let size = app.repo_sizes.get(repo).copied().unwrap_or(0);
let size_str = format_size(size);
let meta = if activity.is_empty() {
format!(" {}", size_str)
} else {
format!(" {} │ {}", activity, size_str)
};
let inner_w = area.width.saturating_sub(2) as usize; let name_budget = inner_w.saturating_sub(prefix.len() + 4 + meta.chars().count()); let repo_display = truncate_str(repo, name_budget);
ListItem::new(Line::from(vec![
Span::styled(prefix, style),
Span::styled(repo_display, style),
Span::styled(".git", Style::default().fg(Color::DarkGray)),
Span::styled(meta, Style::default().fg(Color::DarkGray)),
]))
})
.collect();
if let Some(idx) = app.repo_state.selected() {
if idx >= app.filtered.len() {
app.repo_state.select(Some(app.filtered.len().saturating_sub(1)));
}
}
let list = List::new(items).block(block);
f.render_stateful_widget(list, area, &mut app.repo_state);
}
fn draw_status_bar(f: &mut ratatui::Frame, app: &App, area: Rect) {
let size_info = format!(
" {} repos │ {} total",
app.repos.len(),
format_size(app.total_size),
);
let status = if app.status_msg.is_empty() {
size_info
} else {
format!(" {} │{}", app.status_msg, size_info)
};
let line = Line::from(vec![
Span::styled(status, Style::default().fg(Color::DarkGray)),
]);
f.render_widget(Paragraph::new(line), area);
}
fn draw_help(f: &mut ratatui::Frame, area: Rect) {
let help_lines = vec![
("── Main View ──", ""),
("↑/↓ k/j", "Navigate repos or settings"),
("Tab", "Switch focus (settings ↔ repos)"),
("Enter", "Open repo detail / edit setting"),
("c", "Open command palette"),
("/", "Search / filter repos"),
("o", "Cycle sort: name → date → size"),
("O", "Toggle sort direction (asc/desc)"),
("s", "Start / stop server"),
("r", "Refresh repo list"),
("q", "Quit"),
("", ""),
("── Repo Detail ──", ""),
("Tab / ←→", "Switch tab"),
("↑/↓ k/j", "Scroll"),
("PgUp/PgDn", "Scroll by page"),
("Home/End", "Jump to top / bottom"),
("o", "Cycle file sort (name/size)"),
("O", "Toggle file sort direction"),
("c", "Open command palette"),
("r", "Refresh data"),
("Esc/q", "Close detail view"),
("", ""),
("── Command Palette ──", ""),
("↑/↓", "Navigate commands"),
("Enter/y", "Copy command to clipboard"),
("PgUp/PgDn", "Jump between sections"),
("Esc", "Close"),
("", ""),
("── Search ──", ""),
("type", "Filter repos"),
("Enter", "Confirm filter"),
("Esc", "Cancel and clear"),
];
let popup_w = 52u16.min(area.width.saturating_sub(4));
let popup_h = (help_lines.len() as u16 + 2).min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_w)) / 2;
let popup_y = (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
f.render_widget(Clear, popup);
let block = Block::default()
.title(" Keybindings ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(popup);
f.render_widget(block, popup);
for (i, (key, desc)) in help_lines.iter().enumerate() {
if i as u16 >= inner.height {
break;
}
let row = Rect {
x: inner.x,
y: inner.y + i as u16,
width: inner.width,
height: 1,
};
if key.starts_with("──") {
f.render_widget(
Paragraph::new(Span::styled(
*key,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)),
row,
);
} else if key.is_empty() {
} else {
let line = Line::from(vec![
Span::styled(
format!(" {:>12} ", key),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(*desc, Style::default().fg(Color::Gray)),
]);
f.render_widget(Paragraph::new(line), row);
}
}
}
fn draw_footer(f: &mut ratatui::Frame, app: &App, area: Rect) {
let keys = match app.mode {
Mode::Search => vec![
("Esc", "cancel"),
("Enter", "confirm"),
],
Mode::Editing => vec![
("Esc", "cancel"),
("Enter", "save"),
],
Mode::ConfirmRestart => vec![
("y", "restart"),
("n", "cancel"),
],
Mode::Commands => vec![
("↑↓", "navigate"),
("Enter", "copy"),
("Esc", "close"),
],
Mode::Help => vec![
("Esc", "close"),
],
Mode::RepoDetail => vec![
("Tab", "tab"),
("↑↓", "scroll"),
("Esc", "close"),
("?", "help"),
],
Mode::Normal => vec![
("↑↓", "navigate"),
("Enter", "open"),
("/", "search"),
("s", "server"),
("q", "quit"),
("?", "help"),
],
};
let spans: Vec<Span> = keys
.iter()
.flat_map(|(k, d)| {
vec![
Span::styled(
format!(" {} ", k),
Style::default()
.fg(Color::Black)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {} ", d), Style::default().fg(Color::Gray)),
Span::raw(" "),
]
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let para = Paragraph::new(Line::from(spans)).block(block);
f.render_widget(para, area);
}
fn draw_commands(f: &mut ratatui::Frame, app: &App, area: Rect) {
let popup_w = (area.width as f32 * 0.8).max(60.0).min(area.width as f32) as u16;
let popup_h = (area.height as f32 * 0.8).max(20.0).min(area.height as f32) as u16;
let popup_x = (area.width.saturating_sub(popup_w)) / 2;
let popup_y = (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
f.render_widget(Clear, popup);
let copied_indicator = if let Some(t) = app.cmd_copied {
if t.elapsed().as_millis() < 2000 { " ✓ Copied!" } else { "" }
} else {
""
};
let title = format!(" {} {}", app.cmd_repo, copied_indicator);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(popup);
f.render_widget(block, popup);
let visible_h = inner.height as usize;
let scroll = if app.cmd_idx >= visible_h {
app.cmd_idx - visible_h + 1
} else {
0
};
for (draw_i, item_i) in (scroll..app.cmd_items.len()).enumerate() {
if draw_i >= visible_h {
break;
}
let (label, cmd) = &app.cmd_items[item_i];
let is_header = cmd.is_empty();
let is_selected = item_i == app.cmd_idx;
let row_area = Rect {
x: inner.x,
y: inner.y + draw_i as u16,
width: inner.width,
height: 1,
};
if is_header {
let line = Line::from(vec![
Span::styled(
label.as_str(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
]);
f.render_widget(Paragraph::new(line), row_area);
} else if is_selected {
let line = Line::from(vec![
Span::styled(
"▸ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
label.as_str(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(
truncate_str(cmd, inner.width as usize - label.len() - 4),
Style::default().fg(Color::White),
),
]);
f.render_widget(Paragraph::new(line), row_area);
} else {
let line = Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(label.as_str(), Style::default().fg(Color::Gray)),
]);
f.render_widget(Paragraph::new(line), row_area);
}
}
}
fn detail_item_count(app: &App) -> usize {
match app.detail_tab {
DetailTab::Commits => app.detail_commits.len(),
DetailTab::Files => app.detail_files.len(),
DetailTab::Branches => app.detail_branches.len(),
DetailTab::Contributors => app.detail_contributors.len(),
DetailTab::Languages => app.detail_languages.len(),
}
}
fn detail_items(app: &App) -> &[String] {
match app.detail_tab {
DetailTab::Commits => &app.detail_commits,
DetailTab::Files => &app.detail_files,
DetailTab::Branches => &app.detail_branches,
DetailTab::Contributors => &app.detail_contributors,
DetailTab::Languages => &app.detail_languages,
}
}
fn draw_repo_detail(f: &mut ratatui::Frame, app: &App, area: Rect) {
let popup_w = (area.width as f32 * 0.9).max(60.0).min(area.width as f32) as u16;
let popup_h = (area.height as f32 * 0.9).max(20.0).min(area.height as f32) as u16;
let popup_x = (area.width.saturating_sub(popup_w)) / 2;
let popup_y = (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
f.render_widget(Clear, popup);
let activity = app.detail_activity.as_deref().unwrap_or("");
let loading = if app.detail_loading { " ⟳ Loading..." } else { "" };
let title = format!(" {} {}{} ", app.detail_repo, activity, loading);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(popup);
f.render_widget(block, popup);
if inner.height < 4 {
return;
}
let tab_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
let tabs = vec![
("Commits", DetailTab::Commits),
("Files", DetailTab::Files),
("Branches", DetailTab::Branches),
("Contributors", DetailTab::Contributors),
("Languages", DetailTab::Languages),
];
let tab_spans: Vec<Span> = tabs
.iter()
.flat_map(|(label, tab)| {
let style = if *tab == app.detail_tab {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(Color::Gray)
};
vec![
Span::styled(format!(" {} ", label), style),
Span::raw("│"),
]
})
.collect();
f.render_widget(Paragraph::new(Line::from(tab_spans)), tab_area);
let content_area = Rect {
x: inner.x,
y: inner.y + 2,
width: inner.width,
height: inner.height.saturating_sub(3),
};
let items = detail_items(app);
let visible = content_area.height as usize;
let total = items.len();
let scroll = app.detail_scroll.min(total.saturating_sub(1));
for (draw_i, item_i) in (scroll..total).enumerate() {
if draw_i >= visible {
break;
}
let row_area = Rect {
x: content_area.x,
y: content_area.y + draw_i as u16,
width: content_area.width,
height: 1,
};
let line_text = &items[item_i];
let w = content_area.width as usize;
match app.detail_tab {
DetailTab::Commits => {
let display = truncate_str(line_text, w);
let trimmed = line_text.trim_start_matches(|c: char| {
c == '*' || c == '|' || c == '/' || c == '\\' || c == ' ' || c == '_'
});
if let Some(hash_end) = trimmed.find(' ') {
let hash = &trimmed[..hash_end];
if let Some(hash_pos) = line_text.find(hash) {
let graph_part = &line_text[..hash_pos];
let after_hash = &trimmed[hash_end..];
let (author, rest) = if let Some(sep) = after_hash.find('│') {
(&after_hash[..sep], &after_hash[sep..])
} else {
(after_hash, "")
};
let line = Line::from(vec![
Span::styled(graph_part, Style::default().fg(Color::DarkGray)),
Span::styled(hash, Style::default().fg(Color::Yellow)),
Span::styled(author, Style::default().fg(Color::Green)),
Span::styled(
truncate_str(rest, w.saturating_sub(graph_part.len() + hash.len() + author.len())),
Style::default().fg(Color::Gray),
),
]);
f.render_widget(Paragraph::new(line), row_area);
} else {
f.render_widget(
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray))),
row_area,
);
}
} else {
f.render_widget(
Paragraph::new(Span::styled(display, Style::default().fg(Color::DarkGray))),
row_area,
);
}
}
DetailTab::Branches => {
let style = if line_text.starts_with('*') {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let display = truncate_str(line_text, w);
f.render_widget(Paragraph::new(Span::styled(display, style)), row_area);
}
DetailTab::Contributors => {
let display = truncate_str(line_text, w);
let trimmed = line_text.trim();
if let Some(tab_pos) = trimmed.find('\t') {
let count = &trimmed[..tab_pos];
let name = &trimmed[tab_pos + 1..];
let line = Line::from(vec![
Span::styled(
format!("{:>6} ", count),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
Span::styled(name, Style::default().fg(Color::Gray)),
]);
f.render_widget(Paragraph::new(line), row_area);
} else {
f.render_widget(
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray))),
row_area,
);
}
}
DetailTab::Languages => {
let display = truncate_str(line_text, w);
if item_i == 0 {
f.render_widget(
Paragraph::new(Span::styled(
display,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)),
row_area,
);
} else if line_text.starts_with('─') {
f.render_widget(
Paragraph::new(Span::styled(display, Style::default().fg(Color::DarkGray))),
row_area,
);
} else if line_text.starts_with("Total") {
f.render_widget(
Paragraph::new(Span::styled(
display,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)),
row_area,
);
} else {
if line_text.len() > 16 {
let lang_part = &line_text[..16];
let nums_part = &line_text[16..];
let line = Line::from(vec![
Span::styled(
lang_part.to_string(),
Style::default().fg(Color::Green),
),
Span::styled(
truncate_str(nums_part, w.saturating_sub(16)),
Style::default().fg(Color::Gray),
),
]);
f.render_widget(Paragraph::new(line), row_area);
} else {
f.render_widget(
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray))),
row_area,
);
}
}
}
_ => {
let display = truncate_str(line_text, w);
f.render_widget(
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray))),
row_area,
);
}
}
}
let footer_area = Rect {
x: inner.x,
y: inner.y + inner.height - 1,
width: inner.width,
height: 1,
};
let scroll_info = format!(" {}/{} ", scroll + 1, total.max(1));
let hints = Line::from(vec![
Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::DarkGray).add_modifier(Modifier::BOLD)),
Span::styled(" switch tab ", Style::default().fg(Color::Gray)),
Span::styled(" ↑↓ ", Style::default().fg(Color::Black).bg(Color::DarkGray).add_modifier(Modifier::BOLD)),
Span::styled(" scroll ", Style::default().fg(Color::Gray)),
Span::styled(" c ", Style::default().fg(Color::Black).bg(Color::DarkGray).add_modifier(Modifier::BOLD)),
Span::styled(" commands ", Style::default().fg(Color::Gray)),
Span::styled(" r ", Style::default().fg(Color::Black).bg(Color::DarkGray).add_modifier(Modifier::BOLD)),
Span::styled(" refresh ", Style::default().fg(Color::Gray)),
Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::DarkGray).add_modifier(Modifier::BOLD)),
Span::styled(" close ", Style::default().fg(Color::Gray)),
Span::styled(scroll_info, Style::default().fg(Color::DarkGray)),
]);
f.render_widget(Paragraph::new(hints), footer_area);
}
fn file_line_name(line: &str) -> &str {
let trimmed = line.trim_start();
if let Some(pos) = trimmed.find(" ") {
trimmed[pos..].trim_start()
} else {
trimmed
}
}
fn file_line_bytes(line: &str) -> u64 {
let trimmed = line.trim_start();
let size_str = trimmed.split_whitespace().next().unwrap_or("0");
let size_str = size_str.trim();
if size_str.ends_with('G') {
(size_str.trim_end_matches('G').parse::<f64>().unwrap_or(0.0) * 1024.0 * 1024.0 * 1024.0) as u64
} else if size_str.ends_with('M') {
(size_str.trim_end_matches('M').parse::<f64>().unwrap_or(0.0) * 1024.0 * 1024.0) as u64
} else if size_str.ends_with('K') {
(size_str.trim_end_matches('K').parse::<f64>().unwrap_or(0.0) * 1024.0) as u64
} else if size_str.ends_with('B') {
size_str.trim_end_matches('B').parse::<u64>().unwrap_or(0)
} else {
size_str.parse::<u64>().unwrap_or(0)
}
}
fn dir_size(path: &Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_dir() {
total += dir_size(&p);
} else if let Ok(meta) = p.metadata() {
total += meta.len();
}
}
}
total
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{}B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1}K", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
fn format_ago(secs: u64) -> String {
if secs < 60 {
format!("{}s ago", secs)
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
fn truncate_str(s: &str, max: usize) -> String {
let char_count = s.chars().count();
if char_count <= max {
s.to_string()
} else if max > 3 {
let truncated: String = s.chars().take(max - 3).collect();
format!("{}...", truncated)
} else {
s.chars().take(max).collect()
}
}