1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::widgets::Block;
use ratatui::Frame;
use crate::app::{App, AppScreen};
use crate::features;
use crate::widgets;
/// Main render entry point — called once per frame from the event loop.
pub fn render(app: &mut App, frame: &mut Frame) {
// Paint the entire terminal area with the theme's background colour.
// Without this, light themes appear broken because ratatui defaults
// unstyled cells to `Color::Reset` (the terminal's own background).
let bg = app.theme().bg;
frame.render_widget(
Block::default().style(Style::default().bg(bg)),
frame.area(),
);
match app.screen {
AppScreen::Welcome => {
features::repo::view::render(&*app, frame, frame.area());
}
AppScreen::DirBrowser => {
features::repo::view::render_browser(app, frame, frame.area());
}
AppScreen::Main => {
render_main(app, frame);
}
}
}
/// Render the full Main screen layout with header, content columns, staging
/// area, and status bar.
fn render_main(app: &mut App, frame: &mut Frame) {
// Show the shimmering skeleton while the initial repo data is loading.
// Once commits arrive the real content takes over; background refreshes
// (watcher-triggered) use real content + the status-bar spinner instead.
let initial_load = app.tab().is_loading && app.tab().commits.is_empty();
if initial_load {
features::skeleton::render(app, frame);
return;
}
// -- Outer vertical split --
// [0] Header bar — 3 rows
// [1] Main content area — flexible
// [2] Staging area — 12 rows
// [3] Status bar — 1 row
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Percentage(60),
Constraint::Percentage(40),
Constraint::Length(1),
])
.split(frame.area());
// Header
widgets::header::render(app, frame, outer[0]);
// -- Main content: three columns --
// [0] Sidebar (branches + stashes + remotes) — dynamic width
// [1] Commit log — 40 %
// [2] Diff view — remainder
// Compute sidebar width from the longest branch name + padding for
// the indicator icon, highlight symbol, and borders (~6 chars overhead).
let longest_branch = app
.tab()
.branches
.iter()
.map(|b| b.name.chars().count())
.max()
.unwrap_or(10);
// +6 for: 2 (border) + 2 (highlight "▶ ") + 2 (prefix "* " or "⇄ ")
let ideal_sidebar = (longest_branch + 6) as u16;
let term_width = outer[1].width;
// Sidebar gets up to 30% of terminal width, clamped to [22, 50]
let max_sidebar = (term_width * 30 / 100).clamp(22, 50);
let sidebar_width = ideal_sidebar.min(max_sidebar).max(22);
let main_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(sidebar_width),
Constraint::Percentage(40),
Constraint::Min(20),
])
.split(outer[1]);
// The sidebar is itself split vertically: branches get the lion's share,
// with stashes and remotes at the bottom.
let sidebar = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(6), // branches
Constraint::Length(5), // stashes
Constraint::Length(5), // remotes
])
.split(main_cols[0]);
features::branches::view::render(app, frame, sidebar[0]);
features::stash::view::render(app, frame, sidebar[1]);
features::remotes::view::render(app, frame, sidebar[2]);
// Commit log
features::commits::view::render(app, frame, main_cols[1]);
// Compute a full-height overlay rect spanning main_cols[2] down through
// the staging area (outer[2]) for theme/options panels.
let overlay_rect = Rect {
x: main_cols[2].x,
y: main_cols[2].y,
width: main_cols[2].width,
height: main_cols[2].height + outer[2].height,
};
// Diff view OR theme panel OR options panel (full-height overlay)
if app.show_theme_panel {
features::theme::view::render(app, frame, overlay_rect);
} else if app.show_options_panel {
features::options::view::render(app, frame, overlay_rect);
} else if app.show_editor_panel {
features::editor::view::render(app, frame, main_cols[2]);
} else if app.tab().file_history_path.is_some() {
features::diff::view::render_file_history(app, frame, main_cols[2]);
} else if app.tab().blame_path.is_some() {
features::diff::view::render_blame(app, frame, main_cols[2]);
} else {
features::diff::view::render(app, frame, main_cols[2]);
}
// Staging area (only when no overlay is active)
if !app.show_theme_panel && !app.show_options_panel {
features::staging::view::render(app, frame, outer[2]);
} else {
// Render staging only for the left 2/3 (unstaged + staged columns)
let staging_partial = Rect {
x: outer[2].x,
y: outer[2].y,
width: main_cols[2].x.saturating_sub(outer[2].x),
height: outer[2].height,
};
features::staging::view::render(app, frame, staging_partial);
}
// Status bar
widgets::status_bar::render(app, frame, outer[3]);
}