| 1 | //! Rendering: sidebar / URL bar / editor tabs / response / status / popups. |
| 2 | |
| 3 | use ratatui::Frame; |
| 4 | use ratatui::layout::{Constraint, Layout, Rect}; |
| 5 | use ratatui::style::{Color, Modifier, Style}; |
| 6 | use ratatui::text::{Line, Span}; |
| 7 | use ratatui::widgets::{ |
| 8 | Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState, |
| 9 | Tabs, Wrap, |
| 10 | }; |
| 11 | |
| 12 | use crate::app::{App, EditTarget, EditorTab, Focus, Mode, Popup, SidebarRow, TableId}; |
| 13 | use crate::highlight; |
| 14 | use crate::http::DYNAMIC_VARS; |
| 15 | use crate::model::Method; |
| 16 | |
| 17 | const SIDEBAR_WIDTH: u16 = 38; |
| 18 | |
| 19 | pub fn draw(f: &mut Frame, app: &mut App) { |
| 20 | let area = f.area(); |
| 21 | let [main_area, status_area] = |
| 22 | Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(area); |
| 23 | if app.zoom { |
| 24 | // Only the focused pane is drawn, filling everything above the status |
| 25 | // bar. The URL bar belongs to the editor pane (it renders the selected |
| 26 | // request and takes the editor's focus colour), so it comes along. |
| 27 | match app.focus { |
| 28 | Focus::Sidebar => draw_sidebar(f, app, main_area), |
| 29 | Focus::Editor => { |
| 30 | let [url_area, editor_area] = |
| 31 | Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).areas(main_area); |
| 32 | draw_url_bar(f, app, url_area); |
| 33 | draw_editor(f, app, editor_area); |
| 34 | } |
| 35 | Focus::Response => draw_response(f, app, main_area), |
| 36 | } |
| 37 | } else { |
| 38 | let [side_area, right_area] = |
| 39 | Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(40)]) |
| 40 | .areas(main_area); |
| 41 | let [url_area, editor_area, response_area] = Layout::vertical([ |
| 42 | Constraint::Length(3), |
| 43 | Constraint::Percentage(45), |
| 44 | Constraint::Min(5), |
| 45 | ]) |
| 46 | .areas(right_area); |
| 47 | |
| 48 | draw_sidebar(f, app, side_area); |
| 49 | draw_url_bar(f, app, url_area); |
| 50 | draw_editor(f, app, editor_area); |
| 51 | draw_response(f, app, response_area); |
| 52 | } |
| 53 | draw_status(f, app, status_area); |
| 54 | |
| 55 | match app.popup { |
| 56 | Popup::Help => draw_help(f, app, area), |
| 57 | Popup::Env => draw_env(f, app, area), |
| 58 | Popup::Auth => draw_auth(f, app, area), |
| 59 | Popup::None => {} |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // ----- shared styles ----- |
| 64 | |
| 65 | fn focused(focus: bool) -> Style { |
| 66 | if focus { |
| 67 | Style::default().fg(Color::Cyan) |
| 68 | } else { |
| 69 | Style::default().fg(Color::DarkGray) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn method_color(m: Method) -> Color { |
| 74 | match m { |
| 75 | Method::Get => Color::Green, |
| 76 | Method::Post => Color::Yellow, |
| 77 | Method::Put => Color::Blue, |
| 78 | Method::Patch => Color::Magenta, |
| 79 | Method::Delete => Color::Red, |
| 80 | Method::Head => Color::Cyan, |
| 81 | Method::Options => Color::Gray, |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | fn checkbox(enabled: bool) -> &'static str { |
| 86 | if enabled { "[x]" } else { "[ ]" } |
| 87 | } |
| 88 | |
| 89 | // ----- sidebar ----- |
| 90 | |
| 91 | fn draw_sidebar(f: &mut Frame, app: &mut App, area: Rect) { |
| 92 | let title = if app.filter.is_empty() { |
| 93 | format!(" {} ", app.collection.name) |
| 94 | } else { |
| 95 | format!(" {} — /{} ", app.collection.name, app.filter) |
| 96 | }; |
| 97 | let block = Block::default() |
| 98 | .title(title) |
| 99 | .borders(Borders::ALL) |
| 100 | .border_style(focused(app.focus == Focus::Sidebar)); |
| 101 | |
| 102 | let mut items: Vec<ListItem> = Vec::new(); |
| 103 | for row in &app.sidebar_rows { |
| 104 | match row { |
| 105 | SidebarRow::Group(tag) => { |
| 106 | let marker = if app.collapsed.contains(tag) { |
| 107 | "▸" |
| 108 | } else { |
| 109 | "▾" |
| 110 | }; |
| 111 | items.push(ListItem::new(Line::from(vec![Span::styled( |
| 112 | format!("{marker} {tag}"), |
| 113 | Style::default().add_modifier(Modifier::BOLD), |
| 114 | )]))); |
| 115 | } |
| 116 | SidebarRow::Request(i) => { |
| 117 | let req = &app.collection.requests[*i]; |
| 118 | items.push(ListItem::new(Line::from(vec![ |
| 119 | Span::styled( |
| 120 | format!(" {:<7}", req.method.to_string()), |
| 121 | Style::default().fg(method_color(req.method)), |
| 122 | ), |
| 123 | Span::raw(req.label(app.collection.label_mode).to_string()), |
| 124 | ]))); |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | let list = List::new(items) |
| 130 | .block(block) |
| 131 | .highlight_style( |
| 132 | Style::default() |
| 133 | .bg(Color::DarkGray) |
| 134 | .add_modifier(Modifier::BOLD), |
| 135 | ) |
| 136 | .highlight_symbol(">"); |
| 137 | // Keep the selection centered while scrolling; pin to the ends near top/bottom. |
| 138 | let viewport = area.height.saturating_sub(2) as usize; // minus borders |
| 139 | let offset = centered_offset(app.sidebar_sel, app.sidebar_rows.len(), viewport); |
| 140 | let mut state = ListState::default() |
| 141 | .with_selected(Some(app.sidebar_sel)) |
| 142 | .with_offset(offset); |
| 143 | f.render_stateful_widget(list, area, &mut state); |
| 144 | } |
| 145 | |
| 146 | /// Scroll offset that holds `sel` at the vertical middle of a `viewport`-tall |
| 147 | /// list, clamped so the first and last items never scroll past the edges. |
| 148 | fn centered_offset(sel: usize, len: usize, viewport: usize) -> usize { |
| 149 | if viewport == 0 || len <= viewport { |
| 150 | return 0; |
| 151 | } |
| 152 | let max_offset = len - viewport; |
| 153 | sel.saturating_sub(viewport / 2).min(max_offset) |
| 154 | } |
| 155 | |
| 156 | // ----- URL bar ----- |
| 157 | |
| 158 | fn draw_url_bar(f: &mut Frame, app: &App, area: Rect) { |
| 159 | let (title, line) = match app.selected_request() { |
| 160 | Some(req) => { |
| 161 | let url = format!( |
| 162 | "{}{}", |
| 163 | app.collection.base_url().unwrap_or("<no server — press E>"), |
| 164 | req.path |
| 165 | ); |
| 166 | ( |
| 167 | format!(" {} — p: edit url ", req.name), |
| 168 | Line::from(vec![ |
| 169 | Span::styled( |
| 170 | format!(" {:<7}", req.method.to_string()), |
| 171 | Style::default() |
| 172 | .fg(method_color(req.method)) |
| 173 | .add_modifier(Modifier::BOLD), |
| 174 | ), |
| 175 | Span::raw(url), |
| 176 | ]), |
| 177 | ) |
| 178 | } |
| 179 | None => ( |
| 180 | " cielago ".to_string(), |
| 181 | Line::from("No request selected — pick one from the sidebar"), |
| 182 | ), |
| 183 | }; |
| 184 | let block = Block::default() |
| 185 | .title(title) |
| 186 | .borders(Borders::ALL) |
| 187 | .border_style(focused(app.focus == Focus::Editor)); |
| 188 | f.render_widget(Paragraph::new(line).block(block), area); |
| 189 | } |
| 190 | |
| 191 | // ----- editor ----- |
| 192 | |
| 193 | fn draw_editor(f: &mut Frame, app: &mut App, area: Rect) { |
| 194 | let outer = Block::default() |
| 195 | .borders(Borders::ALL) |
| 196 | .border_style(focused(app.focus == Focus::Editor)); |
| 197 | let inner = outer.inner(area); |
| 198 | f.render_widget(outer, area); |
| 199 | |
| 200 | let [tabs_area, content_area] = |
| 201 | Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner); |
| 202 | |
| 203 | let tabs = Tabs::new(EditorTab::ALL.iter().map(|t| t.title()).collect::<Vec<_>>()) |
| 204 | .select(app.tab.index()) |
| 205 | .highlight_style( |
| 206 | Style::default() |
| 207 | .fg(Color::Cyan) |
| 208 | .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), |
| 209 | ) |
| 210 | .divider("│"); |
| 211 | f.render_widget(tabs, tabs_area); |
| 212 | |
| 213 | match app.tab { |
| 214 | EditorTab::Body => draw_body(f, app, content_area), |
| 215 | EditorTab::Docs => draw_docs(f, app, content_area), |
| 216 | tab => { |
| 217 | if let Some(table) = tab.table() { |
| 218 | draw_table(f, app, content_area, table) |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// The body is syntax-highlighted and read-only; edits go through `$EDITOR` |
| 225 | /// (`e`). `body_scroll` is a plain offset, clamped here against the content. |
| 226 | fn draw_body(f: &mut Frame, app: &mut App, area: Rect) { |
| 227 | let block = Block::default() |
| 228 | .title(" Body — e: $EDITOR · j/k: scroll ") |
| 229 | .borders(Borders::NONE); |
| 230 | let inner = block.inner(area); |
| 231 | f.render_widget(block, area); |
| 232 | |
| 233 | let lines = highlight::highlight(&app.body_text, true); |
| 234 | let max = lines.len().saturating_sub(1); |
| 235 | app.body_scroll = app.body_scroll.min(max); |
| 236 | f.render_widget( |
| 237 | Paragraph::new(lines).scroll((app.body_scroll as u16, 0)), |
| 238 | inner, |
| 239 | ); |
| 240 | } |
| 241 | |
| 242 | /// Read-only view of what the spec says about this request: the operation |
| 243 | /// description, then every parameter and body field with its type, accepted |
| 244 | /// values and default. |
| 245 | fn draw_docs(f: &mut Frame, app: &mut App, area: Rect) { |
| 246 | let block = Block::default() |
| 247 | .title(" Docs — j/k: scroll · read-only ") |
| 248 | .borders(Borders::NONE); |
| 249 | let inner = block.inner(area); |
| 250 | f.render_widget(block, area); |
| 251 | |
| 252 | let mut lines: Vec<Line> = Vec::new(); |
| 253 | match app.selected_request() { |
| 254 | None => lines.push(Line::raw("No request selected.")), |
| 255 | Some(req) => { |
| 256 | lines.push(Line::from(vec![ |
| 257 | Span::styled( |
| 258 | format!("{} {}", req.method, req.path), |
| 259 | Style::default() |
| 260 | .fg(method_color(req.method)) |
| 261 | .add_modifier(Modifier::BOLD), |
| 262 | ), |
| 263 | Span::raw(" "), |
| 264 | Span::styled( |
| 265 | req.summary.clone().unwrap_or_else(|| req.name.clone()), |
| 266 | Style::default().fg(Color::Gray), |
| 267 | ), |
| 268 | ])); |
| 269 | if let Some(desc) = &req.description { |
| 270 | lines.push(Line::raw("")); |
| 271 | lines.extend( |
| 272 | desc.lines() |
| 273 | .map(|l| Line::styled(l.to_string(), Style::default().fg(Color::Gray))), |
| 274 | ); |
| 275 | } |
| 276 | |
| 277 | if req.docs.is_empty() { |
| 278 | lines.push(Line::raw("")); |
| 279 | lines.push(Line::styled( |
| 280 | "No spec docs for this request. Hand-made requests have none;", |
| 281 | Style::default().fg(Color::DarkGray), |
| 282 | )); |
| 283 | lines.push(Line::styled( |
| 284 | "for imported ones, re-import the spec to fill this in.", |
| 285 | Style::default().fg(Color::DarkGray), |
| 286 | )); |
| 287 | } |
| 288 | for (location, heading) in [ |
| 289 | ("path", "Path params"), |
| 290 | ("query", "Query params"), |
| 291 | ("header", "Headers"), |
| 292 | ("body", "Body"), |
| 293 | ] { |
| 294 | let fields = req.docs.iter().filter(|d| d.location == location); |
| 295 | let mut first = true; |
| 296 | for d in fields { |
| 297 | if first { |
| 298 | lines.push(Line::raw("")); |
| 299 | lines.push(Line::styled( |
| 300 | heading.to_string(), |
| 301 | Style::default().add_modifier(Modifier::BOLD), |
| 302 | )); |
| 303 | first = false; |
| 304 | } |
| 305 | let mut head = vec![ |
| 306 | Span::styled(format!(" {}", d.name), Style::default().fg(Color::Cyan)), |
| 307 | // Required fields are starred, as in most API docs. |
| 308 | Span::styled( |
| 309 | if d.required { "*" } else { "" }, |
| 310 | Style::default().fg(Color::Red), |
| 311 | ), |
| 312 | Span::raw(" "), |
| 313 | Span::styled(d.ty.clone(), Style::default().fg(Color::Yellow)), |
| 314 | ]; |
| 315 | if let Some(default) = &d.default { |
| 316 | head.push(Span::styled( |
| 317 | format!(" = {default}"), |
| 318 | Style::default().fg(Color::DarkGray), |
| 319 | )); |
| 320 | } |
| 321 | lines.push(Line::from(head)); |
| 322 | if !d.options.is_empty() { |
| 323 | lines.push(Line::from(vec![ |
| 324 | Span::styled(" one of: ", Style::default().fg(Color::DarkGray)), |
| 325 | Span::styled( |
| 326 | d.options.join(" | "), |
| 327 | Style::default().fg(Color::Magenta), |
| 328 | ), |
| 329 | ])); |
| 330 | } |
| 331 | if let Some(desc) = &d.description { |
| 332 | lines.extend(desc.lines().map(|l| { |
| 333 | Line::styled(format!(" {l}"), Style::default().fg(Color::Gray)) |
| 334 | })); |
| 335 | } |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // Wrapping means this is a lower bound on the rendered height, so the last |
| 342 | // line always stays reachable. |
| 343 | let max_scroll = lines.len().saturating_sub(inner.height as usize); |
| 344 | app.docs_scroll = app.docs_scroll.min(max_scroll); |
| 345 | f.render_widget( |
| 346 | Paragraph::new(lines) |
| 347 | .wrap(Wrap { trim: false }) |
| 348 | .scroll((app.docs_scroll as u16, 0)), |
| 349 | inner, |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | fn draw_table(f: &mut Frame, app: &App, area: Rect, table: TableId) { |
| 354 | let rows_data: Vec<(bool, String, String, String)> = match table { |
| 355 | TableId::Params => { |
| 356 | let mut v: Vec<(bool, String, String, String)> = Vec::new(); |
| 357 | if let Some(req) = app.selected_request() { |
| 358 | v.extend( |
| 359 | req.path_params |
| 360 | .iter() |
| 361 | .map(|r| (r.enabled, "path".into(), r.key.clone(), r.value.clone())), |
| 362 | ); |
| 363 | v.extend( |
| 364 | req.query |
| 365 | .iter() |
| 366 | .map(|r| (r.enabled, "query".into(), r.key.clone(), r.value.clone())), |
| 367 | ); |
| 368 | } |
| 369 | v |
| 370 | } |
| 371 | TableId::Headers => app |
| 372 | .selected_request() |
| 373 | .map(|req| { |
| 374 | req.headers |
| 375 | .iter() |
| 376 | .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone())) |
| 377 | .collect() |
| 378 | }) |
| 379 | .unwrap_or_default(), |
| 380 | TableId::Vars => app |
| 381 | .collection |
| 382 | .variables |
| 383 | .iter() |
| 384 | .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone())) |
| 385 | .collect(), |
| 386 | }; |
| 387 | |
| 388 | let rows: Vec<Row> = rows_data |
| 389 | .iter() |
| 390 | .map(|(enabled, loc, key, value)| { |
| 391 | let style = if *enabled { |
| 392 | Style::default() |
| 393 | } else { |
| 394 | Style::default().fg(Color::DarkGray) |
| 395 | }; |
| 396 | let mut cells = vec![Cell::from(checkbox(*enabled))]; |
| 397 | if table == TableId::Params { |
| 398 | cells.push(Cell::from(loc.clone())); |
| 399 | } |
| 400 | cells.push(Cell::from(key.clone())); |
| 401 | cells.push(Cell::from(value.clone())); |
| 402 | Row::new(cells).style(style) |
| 403 | }) |
| 404 | .collect(); |
| 405 | |
| 406 | let widths: Vec<Constraint> = if table == TableId::Params { |
| 407 | vec![ |
| 408 | Constraint::Length(4), |
| 409 | Constraint::Length(6), |
| 410 | Constraint::Percentage(30), |
| 411 | Constraint::Min(10), |
| 412 | ] |
| 413 | } else { |
| 414 | vec![ |
| 415 | Constraint::Length(4), |
| 416 | Constraint::Percentage(30), |
| 417 | Constraint::Min(10), |
| 418 | ] |
| 419 | }; |
| 420 | |
| 421 | let hint = match table { |
| 422 | TableId::Vars => { |
| 423 | " Variables — {{name}} usable anywhere · space: toggle · a: add · i: edit · d: del " |
| 424 | } |
| 425 | _ => " space: toggle · a: add · i: edit · d: del · Enter: send ", |
| 426 | }; |
| 427 | |
| 428 | let t = Table::new(rows, widths) |
| 429 | .block(Block::default().title(hint).borders(Borders::NONE)) |
| 430 | .row_highlight_style( |
| 431 | Style::default() |
| 432 | .bg(Color::DarkGray) |
| 433 | .add_modifier(Modifier::BOLD), |
| 434 | ) |
| 435 | .highlight_symbol(">"); |
| 436 | let mut state = TableState::default().with_selected(if rows_data.is_empty() { |
| 437 | None |
| 438 | } else { |
| 439 | Some(app.table_row) |
| 440 | }); |
| 441 | f.render_stateful_widget(t, area, &mut state); |
| 442 | } |
| 443 | |
| 444 | // ----- response ----- |
| 445 | |
| 446 | fn draw_response(f: &mut Frame, app: &mut App, area: Rect) { |
| 447 | let title = match (&app.response, app.sending) { |
| 448 | (_, true) => " Response — sending… ".to_string(), |
| 449 | (Some(resp), false) => format!(" Response — {} ", resp.status_line()), |
| 450 | (None, false) => " Response ".to_string(), |
| 451 | }; |
| 452 | let block = Block::default() |
| 453 | .title(title) |
| 454 | .borders(Borders::ALL) |
| 455 | .border_style(focused(app.focus == Focus::Response)); |
| 456 | |
| 457 | let body = app |
| 458 | .response |
| 459 | .as_ref() |
| 460 | .map(|r| r.body.as_str()) |
| 461 | .unwrap_or("No response yet — press Enter on the editor to send."); |
| 462 | |
| 463 | // Clamp scroll to content length. |
| 464 | let max_scroll = body.lines().count().saturating_sub(1); |
| 465 | if app.response_scroll > max_scroll { |
| 466 | app.response_scroll = max_scroll; |
| 467 | } |
| 468 | |
| 469 | // `{{…}}` in a response is literal server output, not template syntax. |
| 470 | let p = Paragraph::new(highlight::highlight(body, false)) |
| 471 | .block(block) |
| 472 | .wrap(Wrap { trim: false }) |
| 473 | .scroll((app.response_scroll as u16, 0)); |
| 474 | f.render_widget(p, area); |
| 475 | } |
| 476 | |
| 477 | // ----- status bar ----- |
| 478 | |
| 479 | fn draw_status(f: &mut Frame, app: &App, area: Rect) { |
| 480 | match app.mode { |
| 481 | Mode::Command => { |
| 482 | f.render_widget(Paragraph::new(format!(":{}", app.command)), area); |
| 483 | f.set_cursor_position((area.x + 1 + app.command.len() as u16, area.y)); |
| 484 | } |
| 485 | Mode::Search => { |
| 486 | f.render_widget(Paragraph::new(format!("/{}", app.search.buf)), area); |
| 487 | let cursor_chars = app.search.buf[..app.search.cursor].chars().count() as u16; |
| 488 | f.set_cursor_position((area.x + 1 + cursor_chars, area.y)); |
| 489 | } |
| 490 | Mode::Insert if app.editing.is_some() => { |
| 491 | let label = match app.editing.unwrap() { |
| 492 | EditTarget::Cell { col, .. } => match col { |
| 493 | crate::app::CellCol::Key => "key", |
| 494 | crate::app::CellCol::Value => "value", |
| 495 | }, |
| 496 | EditTarget::Rename => "rename", |
| 497 | EditTarget::NewRequest => "new request", |
| 498 | EditTarget::Url => "url (verb path)", |
| 499 | EditTarget::EnvNew => "server url", |
| 500 | EditTarget::AuthField(_) => "auth", |
| 501 | }; |
| 502 | let prompt = format!("{label}> "); |
| 503 | f.render_widget(Paragraph::new(format!("{prompt}{}", app.input.buf)), area); |
| 504 | let cursor_chars = app.input.buf[..app.input.cursor].chars().count() as u16; |
| 505 | f.set_cursor_position((area.x + prompt.len() as u16 + cursor_chars, area.y)); |
| 506 | } |
| 507 | _ => { |
| 508 | let mode_badge = match app.mode { |
| 509 | Mode::Normal => Span::styled( |
| 510 | " NORMAL ", |
| 511 | Style::default().bg(Color::Green).fg(Color::Black), |
| 512 | ), |
| 513 | Mode::Insert => Span::styled( |
| 514 | " INSERT ", |
| 515 | Style::default().bg(Color::Yellow).fg(Color::Black), |
| 516 | ), |
| 517 | Mode::Command | Mode::Search => Span::raw(""), |
| 518 | }; |
| 519 | let dirty = if app.dirty { "*" } else { "" }; |
| 520 | let server = app.collection.base_url().unwrap_or("no server"); |
| 521 | let line = Line::from(vec![ |
| 522 | mode_badge, |
| 523 | Span::raw(format!( |
| 524 | " {}{} | {} | {} ", |
| 525 | app.collection.name, dirty, server, app.status |
| 526 | )), |
| 527 | ]); |
| 528 | f.render_widget(Paragraph::new(line), area); |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | // ----- popups ----- |
| 534 | |
| 535 | fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect { |
| 536 | let [_, v, _] = Layout::vertical([ |
| 537 | Constraint::Percentage((100 - pct_y) / 2), |
| 538 | Constraint::Percentage(pct_y), |
| 539 | Constraint::Percentage((100 - pct_y) / 2), |
| 540 | ]) |
| 541 | .areas(area); |
| 542 | let [_, h, _] = Layout::horizontal([ |
| 543 | Constraint::Percentage((100 - pct_x) / 2), |
| 544 | Constraint::Percentage(pct_x), |
| 545 | Constraint::Percentage((100 - pct_x) / 2), |
| 546 | ]) |
| 547 | .areas(v); |
| 548 | h |
| 549 | } |
| 550 | |
| 551 | fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { |
| 552 | let popup = centered(area, 64, 80); |
| 553 | f.render_widget(Clear, popup); |
| 554 | let mut lines = vec![ |
| 555 | Line::styled("Global", Style::default().add_modifier(Modifier::BOLD)), |
| 556 | Line::raw(" 1/2/3, Tab focus sidebar / editor / response"), |
| 557 | Line::raw(" z maximize the focused pane (z again to restore)"), |
| 558 | Line::raw(" ] or L next tab (Params/Headers/Body/Docs/Variables)"), |
| 559 | Line::raw(" [ or H previous editor tab"), |
| 560 | Line::raw(" / search / filter requests"), |
| 561 | Line::raw(" E servers / base URLs"), |
| 562 | Line::raw(" A auth config (bearer / API key / OAuth2)"), |
| 563 | Line::raw(" : command line (:w save, :q quit, :q! force, :wq)"), |
| 564 | Line::raw(" q quit (warns when unsaved)"), |
| 565 | Line::raw(""), |
| 566 | Line::styled("Sidebar", Style::default().add_modifier(Modifier::BOLD)), |
| 567 | Line::raw(" j/k, g/G navigate"), |
| 568 | Line::raw(" Enter/h/l open request · collapse/expand group"), |
| 569 | Line::raw(" n/r/d/y new / rename / delete / duplicate request"), |
| 570 | Line::raw(" / filter (Enter keeps it, Esc clears)"), |
| 571 | Line::raw(" t cycle labels: name → summary → path"), |
| 572 | Line::raw(""), |
| 573 | Line::styled( |
| 574 | "Editor (tables)", |
| 575 | Style::default().add_modifier(Modifier::BOLD), |
| 576 | ), |
| 577 | Line::raw(" Enter send request"), |
| 578 | Line::raw(" i edit value of selected row"), |
| 579 | Line::raw(" a add row (key then value)"), |
| 580 | Line::raw(" space enable/disable row"), |
| 581 | Line::raw(" d delete row · m cycle method · r rename"), |
| 582 | Line::raw(" p edit URL / path (paste a full URL to set"), |
| 583 | Line::raw(" the server; ?query fills the Params tab;"), |
| 584 | Line::raw(" a leading verb sets the method, e.g."), |
| 585 | Line::raw(" `post /pets`)"), |
| 586 | Line::raw(""), |
| 587 | Line::styled("Body tab", Style::default().add_modifier(Modifier::BOLD)), |
| 588 | Line::raw(" j/k, d/u scroll · g/G top/bottom"), |
| 589 | Line::raw(" e edit in $EDITOR"), |
| 590 | Line::raw(""), |
| 591 | Line::styled("Docs tab", Style::default().add_modifier(Modifier::BOLD)), |
| 592 | Line::raw(" types, enums and descriptions from the spec (* = required)"), |
| 593 | Line::raw(" j/k, d/u scroll · g/G top/bottom"), |
| 594 | Line::raw(""), |
| 595 | Line::styled("Response", Style::default().add_modifier(Modifier::BOLD)), |
| 596 | Line::raw(" j/k, d/u scroll · g/G top/bottom"), |
| 597 | Line::raw(" e open in $EDITOR (view only)"), |
| 598 | Line::raw(""), |
| 599 | Line::styled("Variables", Style::default().add_modifier(Modifier::BOLD)), |
| 600 | Line::raw(" {{name}} in paths, params, headers and bodies —"), |
| 601 | Line::raw(" substituted at send time. Dynamic ones, computed"), |
| 602 | Line::raw(" per send ({{$name}} to bypass a same-named variable):"), |
| 603 | ]; |
| 604 | lines.extend(DYNAMIC_VARS.iter().map(|(name, help)| { |
| 605 | Line::from(vec![ |
| 606 | Span::styled(format!(" {name:<15}"), Style::default().fg(Color::Magenta)), |
| 607 | Span::raw(*help), |
| 608 | ]) |
| 609 | })); |
| 610 | lines.extend([ |
| 611 | Line::raw(""), |
| 612 | Line::styled("Commands", Style::default().add_modifier(Modifier::BOLD)), |
| 613 | Line::raw(" :new <name> create a collection"), |
| 614 | Line::raw(" :open <name> switch collection"), |
| 615 | Line::raw(" :label name|summary|path sidebar label source"), |
| 616 | Line::raw(" :groups collapsed|expanded group state on open"), |
| 617 | Line::raw(" :rename-all summary|operation|path|method-path"), |
| 618 | ]); |
| 619 | |
| 620 | // The list outgrows short terminals, so the popup scrolls with j/k. |
| 621 | let viewport = popup.height.saturating_sub(2) as usize; |
| 622 | let max_scroll = lines.len().saturating_sub(viewport); |
| 623 | app.help_scroll = app.help_scroll.min(max_scroll); |
| 624 | let more = if app.help_scroll < max_scroll { |
| 625 | " Help — j/k scroll · Esc to close " |
| 626 | } else { |
| 627 | " Help — Esc to close " |
| 628 | }; |
| 629 | let block = Block::default() |
| 630 | .title(more) |
| 631 | .borders(Borders::ALL) |
| 632 | .border_style(Style::default().fg(Color::Cyan)); |
| 633 | f.render_widget( |
| 634 | Paragraph::new(lines) |
| 635 | .block(block) |
| 636 | .scroll((app.help_scroll as u16, 0)), |
| 637 | popup, |
| 638 | ); |
| 639 | } |
| 640 | |
| 641 | fn draw_env(f: &mut Frame, app: &App, area: Rect) { |
| 642 | let popup = centered(area, 60, 50); |
| 643 | f.render_widget(Clear, popup); |
| 644 | let items: Vec<ListItem> = app |
| 645 | .collection |
| 646 | .servers |
| 647 | .iter() |
| 648 | .enumerate() |
| 649 | .map(|(i, s)| { |
| 650 | let marker = if i == app.collection.active_server { |
| 651 | "● " |
| 652 | } else { |
| 653 | " " |
| 654 | }; |
| 655 | ListItem::new(format!("{marker}{s}")) |
| 656 | }) |
| 657 | .collect(); |
| 658 | let block = Block::default() |
| 659 | .title(" Servers — Enter: use · a: add · d: delete · Esc: close ") |
| 660 | .borders(Borders::ALL) |
| 661 | .border_style(Style::default().fg(Color::Cyan)); |
| 662 | let list = List::new(items) |
| 663 | .block(block) |
| 664 | .highlight_style(Style::default().bg(Color::DarkGray)) |
| 665 | .highlight_symbol(">"); |
| 666 | let mut state = ListState::default().with_selected(Some(app.env_sel)); |
| 667 | f.render_stateful_widget(list, popup, &mut state); |
| 668 | } |
| 669 | |
| 670 | fn draw_auth(f: &mut Frame, app: &App, area: Rect) { |
| 671 | use crate::app::AuthField; |
| 672 | use crate::model::{AuthKind, AuthStyle}; |
| 673 | |
| 674 | let popup = centered(area, 70, 45); |
| 675 | f.render_widget(Clear, popup); |
| 676 | let block = Block::default() |
| 677 | .title(" Auth — j/k: field · i/Enter: edit · space: toggle · Esc: save & close ") |
| 678 | .borders(Borders::ALL) |
| 679 | .border_style(Style::default().fg(Color::Cyan)); |
| 680 | let inner = block.inner(popup); |
| 681 | f.render_widget(block, popup); |
| 682 | |
| 683 | let fields = app.auth_fields(); |
| 684 | let mut lines = Vec::new(); |
| 685 | for (i, field) in fields.iter().enumerate() { |
| 686 | let value = match field { |
| 687 | AuthField::Kind => toggle_row(app.auth_form.kind, AuthKind::ALL, AuthKind::title), |
| 688 | AuthField::Style => toggle_row( |
| 689 | app.auth_form.auth_style, |
| 690 | [AuthStyle::Basic, AuthStyle::Post], |
| 691 | |s| match s { |
| 692 | AuthStyle::Basic => "basic", |
| 693 | AuthStyle::Post => "post", |
| 694 | }, |
| 695 | ), |
| 696 | f if f.is_secret() && !app.auth_field_value(i).is_empty() => "••••••••".to_string(), |
| 697 | _ => app.auth_field_value(i), |
| 698 | }; |
| 699 | let style = if i == app.auth_field { |
| 700 | Style::default() |
| 701 | .bg(Color::DarkGray) |
| 702 | .add_modifier(Modifier::BOLD) |
| 703 | } else { |
| 704 | Style::default() |
| 705 | }; |
| 706 | let label = app.auth_field_label(*field); |
| 707 | lines.push( |
| 708 | Line::from(vec![ |
| 709 | Span::styled(format!(" {label:<28}"), Style::default().fg(Color::Gray)), |
| 710 | Span::raw(value), |
| 711 | ]) |
| 712 | .style(style), |
| 713 | ); |
| 714 | } |
| 715 | |
| 716 | // A hint that secret fields understand `$(…)` command substitution. |
| 717 | if fields.iter().any(|f| f.is_secret()) { |
| 718 | lines.push(Line::raw("")); |
| 719 | lines.push(Line::styled( |
| 720 | " secret fields accept $(cmd), e.g. $(op read \"op://vault/item/field\")", |
| 721 | Style::default().fg(Color::DarkGray), |
| 722 | )); |
| 723 | } |
| 724 | |
| 725 | f.render_widget(Paragraph::new(lines), inner); |
| 726 | } |
| 727 | |
| 728 | /// Render a toggle field as its options with the active one bracketed, e.g. |
| 729 | /// `[bearer] apikey oauth2`. |
| 730 | fn toggle_row<T: PartialEq + Copy, const N: usize>( |
| 731 | current: T, |
| 732 | all: [T; N], |
| 733 | label: impl Fn(T) -> &'static str, |
| 734 | ) -> String { |
| 735 | all.iter() |
| 736 | .map(|opt| { |
| 737 | let name = label(*opt); |
| 738 | if *opt == current { |
| 739 | format!("[{name}]") |
| 740 | } else { |
| 741 | format!(" {name} ") |
| 742 | } |
| 743 | }) |
| 744 | .collect::<Vec<_>>() |
| 745 | .join(" ") |
| 746 | } |