use ratatui::{
layout::Rect,
text::{Line, Span},
};
use super::{
overlay_panel::{
classify_panel_key, overlay_panel_inner_width, overlay_panel_layout, render_overlay_panel,
OverlayPanelFrame, PanelKey, PanelScroll, PanelScrollTarget,
},
panel_text::{heading_with_status, indented_wrapped_lines, truncate_to},
render::{display_width, wrap_line_at_whitespace},
theme::Theme,
App, ComposerMode,
};
use crate::hooks::{HookContractView, HookReport};
const TITLE: &str = "Hooks";
const FOOTER: &str = "Enter/Esc close";
const NO_FILES: &str = "no hooks files found";
const NO_HOOKS: &str = "no hooks are configured";
const ROW_INDENT: usize = 2;
const FIELD_INDENT: usize = 4;
const FIELD_GAP: usize = 2;
const FIELD_LABELS: &[&str] = &["tools", "argv", "directory", "timeout", "environment"];
#[derive(Clone, Debug, PartialEq)]
pub(super) struct HooksOverlay {
report: HookReport,
scroll: PanelScroll,
}
impl App {
pub(super) fn show_hooks_report(&mut self, report: HookReport) {
self.input_ui
.set_composer(ComposerMode::Hooks(HooksOverlay {
report,
scroll: PanelScroll::default(),
}));
self.set_status_quiet("hooks");
}
pub(super) fn hooks_overlay_frame(&self, area: Rect) -> Option<OverlayPanelFrame> {
let ComposerMode::Hooks(overlay) = self.input_ui.composer() else {
return None;
};
let lines = hooks_body_lines(&overlay.report, hooks_body_width(area));
Some(render_overlay_panel(
TITLE,
FOOTER,
&lines,
overlay.scroll.offset(),
area,
))
}
pub(super) fn scroll_hooks_overlay(&mut self, area: Rect, target: PanelScrollTarget) -> bool {
if !matches!(self.input_ui.composer(), ComposerMode::Hooks(_)) {
return false;
}
let body_len = self.hooks_body_len(area);
let body_rows = overlay_panel_layout(area, body_len).body_rows;
if let ComposerMode::Hooks(overlay) = self.input_ui.composer_mut() {
overlay.scroll.apply(target, body_len, body_rows);
}
true
}
pub(super) fn clamp_hooks_overlay_scroll(&mut self, terminal: &ratatui::DefaultTerminal) {
if let (ComposerMode::Hooks(overlay), Ok(size)) =
(self.input_ui.composer(), terminal.size())
{
let target = PanelScrollTarget::Absolute(overlay.scroll.offset());
self.scroll_hooks_overlay(Rect::new(0, 0, size.width, size.height), target);
}
}
pub(super) fn handle_hooks_overlay_key(
&mut self,
key: crossterm::event::KeyEvent,
terminal: &ratatui::DefaultTerminal,
) -> bool {
if !matches!(self.input_ui.composer(), ComposerMode::Hooks(_)) {
return false;
}
match classify_panel_key(key) {
PanelKey::Close => {
self.input_ui.set_composer(ComposerMode::Input);
true
}
PanelKey::Scroll(target) => {
if let Ok(size) = terminal.size() {
self.scroll_hooks_overlay(Rect::new(0, 0, size.width, size.height), target);
}
true
}
PanelKey::Passthrough => false,
PanelKey::Swallow => true,
}
}
fn hooks_body_len(&self, area: Rect) -> usize {
let ComposerMode::Hooks(overlay) = self.input_ui.composer() else {
return 0;
};
hooks_body_lines(&overlay.report, hooks_body_width(area)).len()
}
}
fn hooks_body_width(area: Rect) -> usize {
overlay_panel_inner_width(area).saturating_sub(1)
}
fn hooks_body_lines(report: &HookReport, width: usize) -> Vec<Line<'static>> {
let mut lines = vec![heading_with_status(
"Files",
&count_status(report.files.len()),
width,
)];
if report.files.is_empty() {
lines.extend(indented_wrapped_lines(
NO_FILES,
ROW_INDENT,
width,
Theme::dim(),
));
} else {
for file in &report.files {
lines.extend(indented_wrapped_lines(
file,
ROW_INDENT,
width,
Theme::text(),
));
}
}
if report.skipped_untrusted.is_some() || report.skipped_untrusted_error.is_some() {
lines.push(Line::default());
}
if let Some(skipped) = &report.skipped_untrusted {
lines.extend(indented_wrapped_lines(
&format!("ignoring {skipped}"),
ROW_INDENT,
width,
Theme::warning(),
));
lines.extend(indented_wrapped_lines(
&format!(
"workspace is not trusted; set {}=1 to load it",
crate::hooks::TRUST_PROJECT_HOOKS_ENV
),
ROW_INDENT,
width,
Theme::warning(),
));
}
if let Some(error) = &report.skipped_untrusted_error {
lines.extend(indented_wrapped_lines(
&format!("could not inspect untrusted hooks: {error}"),
ROW_INDENT,
width,
Theme::error(),
));
}
lines.push(Line::default());
lines.push(heading_with_status(
"Hooks",
&count_status(report.hooks.len()),
width,
));
if report.hooks.is_empty() {
lines.extend(indented_wrapped_lines(
NO_HOOKS,
ROW_INDENT,
width,
Theme::dim(),
));
return lines;
}
for (index, hook) in report.hooks.iter().enumerate() {
if index > 0 {
lines.push(Line::default());
}
lines.extend(hook_lines(hook, width));
}
lines
}
fn hook_lines(hook: &HookContractView, width: usize) -> Vec<Line<'static>> {
let indent = " ".repeat(ROW_INDENT.min(width));
let mut lines = vec![heading_with_status(
&format!("{indent}{}", hook.id),
&hook.event,
width,
)];
if !hook.active {
lines.extend(indented_wrapped_lines(
"inactive",
FIELD_INDENT,
width,
Theme::warning(),
));
}
let label_width = FIELD_LABELS
.iter()
.copied()
.map(display_width)
.max()
.unwrap_or(0);
let layout = field_layout(width, label_width);
let values = [
hard_wrap(&hook.tools, layout.value_width),
argv_rows(&hook.command, layout.value_width),
hard_wrap(&hook.working_directory, layout.value_width),
hard_wrap(&hook.timeout, layout.value_width),
pack_names(&hook.environment, layout.value_width),
];
debug_assert_eq!(FIELD_LABELS.len(), values.len());
for (label, rows) in FIELD_LABELS.iter().copied().zip(values) {
lines.extend(labeled_rows(label, rows, &layout));
}
lines
}
struct FieldLayout {
indent: usize,
label_width: usize,
value_at: usize,
value_width: usize,
}
fn field_layout(width: usize, label_width: usize) -> FieldLayout {
let width = width.max(1);
let gap = FIELD_GAP.min(width.saturating_sub(2));
let indent = FIELD_INDENT.min(width.saturating_sub(gap + 1));
let label_width = label_width.min(width.saturating_sub(indent + gap + 1));
let value_at = indent + label_width + gap;
FieldLayout {
indent,
label_width,
value_at,
value_width: width.saturating_sub(value_at).max(1),
}
}
impl FieldLayout {
fn prefix(&self, label: &str) -> String {
let indent = " ".repeat(self.indent.min(self.value_at));
let shown = if self.label_width == 0 {
String::new()
} else {
truncate_to(label, self.label_width)
};
let mut prefix = format!("{indent}{shown}");
let used = display_width(&prefix);
if used < self.value_at {
prefix.push_str(&" ".repeat(self.value_at - used));
}
prefix
}
}
fn labeled_rows(label: &str, mut rows: Vec<String>, layout: &FieldLayout) -> Vec<Line<'static>> {
if rows.is_empty() {
rows.push(String::new());
}
rows.into_iter()
.enumerate()
.map(|(index, row)| {
let value = truncate_to(&row, layout.value_width);
if index == 0 {
Line::from(vec![
Span::styled(layout.prefix(label), Theme::dim()),
Span::styled(value, Theme::text()),
])
} else {
let indent = " ".repeat(layout.value_at);
Line::from(Span::styled(format!("{indent}{value}"), Theme::text()))
}
})
.collect()
}
fn argv_rows(command: &[String], value_width: usize) -> Vec<String> {
let mut rows = Vec::new();
for arg in command {
rows.extend(hard_wrap(&display_argv(arg), value_width));
}
rows
}
fn display_argv(arg: &str) -> String {
let literal = |ch: char| ch.is_ascii_graphic() && ch != '\'';
if !arg.is_empty() && arg.chars().all(literal) {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
}
fn pack_names(names: &[String], value_width: usize) -> Vec<String> {
let mut rows = Vec::new();
let mut current = String::new();
for name in names {
append_token(&mut rows, &mut current, name, value_width);
}
if !current.is_empty() {
rows.push(current);
}
rows
}
fn append_token(rows: &mut Vec<String>, current: &mut String, token: &str, value_width: usize) {
let token_width = display_width(token);
if current.is_empty() {
if token_width <= value_width {
current.push_str(token);
} else {
rows.extend(hard_wrap(token, value_width));
}
return;
}
if display_width(current) + 1 + token_width <= value_width {
current.push(' ');
current.push_str(token);
return;
}
rows.push(std::mem::take(current));
if token_width <= value_width {
current.push_str(token);
} else {
rows.extend(hard_wrap(token, value_width));
}
}
fn hard_wrap(value: &str, value_width: usize) -> Vec<String> {
wrap_line_at_whitespace(value, value_width.max(1))
.into_iter()
.map(|part| part.trim_start().to_string())
.filter(|part| !part.is_empty())
.collect()
}
fn count_status(count: usize) -> String {
if count == 0 {
"none".into()
} else {
count.to_string()
}
}
#[cfg(test)]
#[path = "hooks_overlay_tests.rs"]
mod tests;