src/app.rs 51.2 K raw
1
//! Application state and the TUI run loop.
2
3
use std::collections::HashSet;
4
use std::io;
5
use std::path::PathBuf;
6
use std::process::Command;
7
use std::time::Duration;
8
9
use anyhow::Result;
10
use crossterm::event::{self, Event, KeyEventKind};
11
use crossterm::execute;
12
use crossterm::terminal::{
13
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14
};
15
use ratatui::Terminal;
16
use ratatui::backend::CrosstermBackend;
17
use serde::{Deserialize, Serialize};
18
use tokio::sync::mpsc;
19
use tui_textarea::TextArea;
20
use uuid::Uuid;
21
22
use crate::http::{HttpResponse, OAuthToken, SendOutcome, send_with_auth, split_url_input};
23
use crate::model::{
24
    AuthKind, Collection, KeyValueRow, LabelMode, Method, OAuthConfig, SavedRequest, variables_map,
25
};
26
use crate::store::{self, AppConfig};
27
use crate::{input, ui};
28
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
pub enum Mode {
31
    Normal,
32
    Insert,
33
    Command,
34
    /// Incremental sidebar filter, opened with `/`.
35
    Search,
36
}
37
38
/// Which pane has the keyboard: the `1` / `2` / `3` panes. Persisted as part
39
/// of a collection's saved view, hence the serde derives.
40
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41
#[serde(rename_all = "lowercase")]
42
pub enum Focus {
43
    Sidebar,
44
    Editor,
45
    Response,
46
}
47
48
/// Persisted with the saved view alongside [`Focus`].
49
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50
#[serde(rename_all = "lowercase")]
51
pub enum EditorTab {
52
    Params,
53
    Headers,
54
    Body,
55
    /// Read-only view of the spec's types, enums and descriptions.
56
    Docs,
57
    Variables,
58
}
59
60
impl EditorTab {
61
    pub const ALL: [EditorTab; 5] = [
62
        EditorTab::Params,
63
        EditorTab::Headers,
64
        EditorTab::Body,
65
        EditorTab::Docs,
66
        EditorTab::Variables,
67
    ];
68
69
    pub fn title(self) -> &'static str {
70
        match self {
71
            EditorTab::Params => "Params",
72
            EditorTab::Headers => "Headers",
73
            EditorTab::Body => "Body",
74
            EditorTab::Docs => "Docs",
75
            EditorTab::Variables => "Variables",
76
        }
77
    }
78
79
    pub fn index(self) -> usize {
80
        EditorTab::ALL.iter().position(|t| *t == self).unwrap_or(0)
81
    }
82
83
    pub fn next(self) -> Self {
84
        EditorTab::ALL[(self.index() + 1) % EditorTab::ALL.len()]
85
    }
86
87
    pub fn prev(self) -> Self {
88
        EditorTab::ALL[(self.index() + EditorTab::ALL.len() - 1) % EditorTab::ALL.len()]
89
    }
90
91
    /// Tables map onto editable key/value rows; Body uses the textarea and
92
    /// Docs is rendered text.
93
    pub fn table(self) -> Option<TableId> {
94
        match self {
95
            EditorTab::Params => Some(TableId::Params),
96
            EditorTab::Headers => Some(TableId::Headers),
97
            EditorTab::Variables => Some(TableId::Vars),
98
            EditorTab::Body | EditorTab::Docs => None,
99
        }
100
    }
101
}
102
103
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104
pub enum TableId {
105
    /// Path params first, then query params (Postman-style Params tab).
106
    Params,
107
    Headers,
108
    Vars,
109
}
110
111
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112
pub enum Popup {
113
    None,
114
    Help,
115
    Env,
116
    Auth,
117
}
118
119
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120
pub enum CellCol {
121
    Key,
122
    Value,
123
}
124
125
/// What the single-line input currently edits (Insert mode).
126
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127
pub enum EditTarget {
128
    Cell {
129
        table: TableId,
130
        row: usize,
131
        col: CellCol,
132
    },
133
    Rename,
134
    NewRequest,
135
    /// The selected request's URL / path. Pasting an absolute URL here also
136
    /// sets the collection's server — see [`App::apply_url_input`].
137
    Url,
138
    EnvNew,
139
    /// Index into [`App::auth_fields`] for the current auth kind. An index (not
140
    /// the [`AuthField`] itself) so the enum stays `Copy`-cheap and the cursor
141
    /// and edit target share one notion of "which row".
142
    AuthField(usize),
143
}
144
145
/// One editable row in the auth popup. Which rows show depends on the selected
146
/// [`AuthKind`]; [`App::auth_fields`] builds the list.
147
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148
pub enum AuthField {
149
    /// The scheme selector (a toggle, not a text field).
150
    Kind,
151
    /// Bearer token, or API-key value (secret).
152
    Token,
153
    /// API-key header name.
154
    Header,
155
    TokenUrl,
156
    ClientId,
157
    ClientSecret,
158
    Scopes,
159
    /// OAuth client-auth placement (a toggle, not a text field).
160
    Style,
161
}
162
163
impl AuthField {
164
    /// Rows that carry a secret and should render masked.
165
    pub fn is_secret(self) -> bool {
166
        matches!(self, AuthField::Token | AuthField::ClientSecret)
167
    }
168
169
    /// Rows edited by toggling rather than typing.
170
    pub fn is_toggle(self) -> bool {
171
        matches!(self, AuthField::Kind | AuthField::Style)
172
    }
173
}
174
175
#[derive(Debug, Clone, PartialEq, Eq)]
176
pub enum SidebarRow {
177
    Group(String),
178
    Request(usize),
179
}
180
181
/// What a queued `$EDITOR` session opens.
182
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183
pub enum ExternalEdit {
184
    /// The request body; the file is read back on exit.
185
    Body,
186
    /// The response body, for paging/searching in a real editor. Saves are
187
    /// discarded — a response is a record of what the server returned.
188
    Response,
189
}
190
191
/// Sidebar group a request belongs to: its first spec tag, or `default`.
192
fn group_tag(req: &SavedRequest) -> String {
193
    req.tags
194
        .first()
195
        .cloned()
196
        .unwrap_or_else(|| "default".into())
197
}
198
199
/// `"list pets"` → `"list pets copy"`, then `"list pets copy 2"`, … Nothing in
200
/// the app keys on `name`, but three identically-labelled sidebar rows are
201
/// unusable. A ` copy`/` copy N` suffix on the source is stripped first, so
202
/// duplicating a duplicate gives `x copy 2` rather than `x copy copy`.
203
fn unique_request_name(requests: &[SavedRequest], base: &str) -> String {
204
    let stem = copy_stem(base);
205
    let taken = |name: &str| requests.iter().any(|r| r.name == name);
206
    let first = format!("{stem} copy");
207
    if !taken(&first) {
208
        return first;
209
    }
210
    (2..)
211
        .map(|n| format!("{stem} copy {n}"))
212
        .find(|candidate| !taken(candidate))
213
        .unwrap_or(first)
214
}
215
216
/// Strip a trailing ` copy` or ` copy <n>` from a request name.
217
fn copy_stem(name: &str) -> &str {
218
    if let Some(head) = name.strip_suffix(" copy") {
219
        return head;
220
    }
221
    let without_digits = name.trim_end_matches(|c: char| c.is_ascii_digit());
222
    if without_digits.len() < name.len()
223
        && let Some(head) = without_digits.strip_suffix(" copy ")
224
    {
225
        return head;
226
    }
227
    name
228
}
229
230
/// Minimal single-line editor with a cursor.
231
#[derive(Debug, Default, Clone)]
232
pub struct LineEdit {
233
    pub buf: String,
234
    pub cursor: usize,
235
}
236
237
impl LineEdit {
238
    pub fn set(&mut self, s: &str) {
239
        self.buf = s.to_string();
240
        self.cursor = self.buf.len();
241
    }
242
243
    pub fn insert(&mut self, c: char) {
244
        self.buf.insert(self.cursor, c);
245
        self.cursor += c.len_utf8();
246
    }
247
248
    pub fn backspace(&mut self) {
249
        if self.cursor > 0 {
250
            let prev = self.cursor - self.buf[..self.cursor].chars().last().unwrap().len_utf8();
251
            self.buf.replace_range(prev..self.cursor, "");
252
            self.cursor = prev;
253
        }
254
    }
255
256
    pub fn delete(&mut self) {
257
        if self.cursor < self.buf.len() {
258
            let next = self.cursor + self.buf[self.cursor..].chars().next().unwrap().len_utf8();
259
            self.buf.replace_range(self.cursor..next, "");
260
        }
261
    }
262
263
    pub fn left(&mut self) {
264
        if self.cursor > 0 {
265
            self.cursor -= self.buf[..self.cursor].chars().last().unwrap().len_utf8();
266
        }
267
    }
268
269
    pub fn right(&mut self) {
270
        if self.cursor < self.buf.len() {
271
            self.cursor += self.buf[self.cursor..].chars().next().unwrap().len_utf8();
272
        }
273
    }
274
275
    pub fn home(&mut self) {
276
        self.cursor = 0;
277
    }
278
279
    pub fn end(&mut self) {
280
        self.cursor = self.buf.len();
281
    }
282
}
283
284
pub struct App {
285
    pub collection: Collection,
286
    pub path: PathBuf,
287
    pub config: AppConfig,
288
    pub client: reqwest::Client,
289
    pub dirty: bool,
290
    pub should_quit: bool,
291
292
    pub mode: Mode,
293
    pub focus: Focus,
294
    pub tab: EditorTab,
295
    pub popup: Popup,
296
    /// `z`: the focused pane fills the frame and the others are not drawn.
297
    /// Purely a view flag — focus still moves normally underneath, so
298
    /// Tab/1/2/3 swap which pane is the zoomed one.
299
    pub zoom: bool,
300
301
    pub collapsed: HashSet<String>,
302
    pub sidebar_rows: Vec<SidebarRow>,
303
    pub sidebar_sel: usize,
304
    /// Active sidebar filter; empty means "show everything".
305
    pub filter: String,
306
    /// The `/` prompt buffer while [`Mode::Search`] is active.
307
    pub search: LineEdit,
308
309
    /// Index into `collection.requests` currently loaded in the editor.
310
    pub selected: Option<usize>,
311
    pub table_row: usize,
312
313
    pub editing: Option<EditTarget>,
314
    pub input: LineEdit,
315
    /// After committing a new row's key, continue to its value cell.
316
    pub chain_to_value: bool,
317
    pub textarea: TextArea<'static>,
318
319
    /// Scroll offset of the Docs tab, reset when another request is opened.
320
    pub docs_scroll: usize,
321
322
    pub response: Option<HttpResponse>,
323
    pub response_scroll: usize,
324
325
    pub sending: bool,
326
    pub tx: mpsc::UnboundedSender<SendOutcome>,
327
    pub rx: mpsc::UnboundedReceiver<SendOutcome>,
328
    pub token: Option<OAuthToken>,
329
330
    pub status: String,
331
    pub command: String,
332
    pub pending_external: Option<ExternalEdit>,
333
    /// Scroll offset of the help popup, which is taller than short terminals.
334
    pub help_scroll: usize,
335
336
    pub env_sel: usize,
337
    pub auth_form: OAuthConfig,
338
    pub auth_field: usize,
339
}
340
341
impl App {
342
    pub fn new(collection: Collection, path: PathBuf, config: AppConfig) -> Self {
343
        let (tx, rx) = mpsc::unbounded_channel();
344
        let client = reqwest::Client::builder()
345
            .timeout(Duration::from_secs(30))
346
            .build()
347
            .unwrap_or_default();
348
        let mut app = Self {
349
            collection,
350
            path,
351
            config,
352
            client,
353
            dirty: false,
354
            should_quit: false,
355
            mode: Mode::Normal,
356
            focus: Focus::Sidebar,
357
            tab: EditorTab::Params,
358
            popup: Popup::None,
359
            zoom: false,
360
            collapsed: HashSet::new(),
361
            sidebar_rows: Vec::new(),
362
            sidebar_sel: 0,
363
            filter: String::new(),
364
            search: LineEdit::default(),
365
            selected: None,
366
            table_row: 0,
367
            editing: None,
368
            input: LineEdit::default(),
369
            chain_to_value: false,
370
            textarea: TextArea::default(),
371
            docs_scroll: 0,
372
            response: None,
373
            response_scroll: 0,
374
            sending: false,
375
            tx,
376
            rx,
377
            token: None,
378
            status: "Press ? for help".to_string(),
379
            command: String::new(),
380
            pending_external: None,
381
            help_scroll: 0,
382
            env_sel: 0,
383
            auth_form: OAuthConfig::default(),
384
            auth_field: 0,
385
        };
386
        if app.collection.groups_collapsed {
387
            app.collapsed = app.group_tags();
388
        }
389
        app.rebuild_sidebar();
390
        if !app.collection.requests.is_empty() {
391
            // Restore the request that was open when the collection was last
392
            // saved; failing that, load the first one so the editor isn't
393
            // blank.
394
            match app.saved_view_index() {
395
                Some(idx) => app.restore_saved_view(idx),
396
                None => app.select_request(0),
397
            }
398
        }
399
        app.restore_saved_panes();
400
        if app.collection.requests.is_empty() {
401
            app.status = "Empty collection — press n to add a request".into();
402
        }
403
        app
404
    }
405
406
    // ----- saved view -----
407
408
    /// Index of `collection.last_request`, if that request still exists. Ids
409
    /// are matched rather than positions so a re-import that reorders or drops
410
    /// operations can't restore the wrong request.
411
    fn saved_view_index(&self) -> Option<usize> {
412
        let id = self.collection.last_request?;
413
        self.collection.requests.iter().position(|r| r.id == id)
414
    }
415
416
    /// Restore the focused pane and editor tab from the saved view. Without
417
    /// one, focus starts on the sidebar (`select_request` leaves it on the
418
    /// editor): opening a collection, the first move is picking which request
419
    /// to work on.
420
    fn restore_saved_panes(&mut self) {
421
        if let Some(tab) = self.collection.last_tab {
422
            self.tab = tab;
423
        }
424
        self.focus = match self.collection.last_focus {
425
            // Responses aren't persisted, so a saved Response pane is empty on
426
            // open — land on the editor instead of a pane with nothing in it.
427
            Some(Focus::Response) if self.response.is_none() => Focus::Editor,
428
            Some(focus) => focus,
429
            None => Focus::Sidebar,
430
        };
431
    }
432
433
    /// Open `idx` and park the sidebar cursor on it. Expands the containing
434
    /// group if needed: with `groups_collapsed` set, the restored request would
435
    /// otherwise be scrolled to but invisible.
436
    fn restore_saved_view(&mut self, idx: usize) {
437
        let tag = group_tag(&self.collection.requests[idx]);
438
        if self.collapsed.remove(&tag) {
439
            self.rebuild_sidebar();
440
        }
441
        if let Some(pos) = self
442
            .sidebar_rows
443
            .iter()
444
            .position(|r| *r == SidebarRow::Request(idx))
445
        {
446
            self.sidebar_sel = pos;
447
        }
448
        self.select_request(idx);
449
    }
450
451
    // ----- sidebar -----
452
453
    /// Every group tag in the collection, filter ignored.
454
    fn group_tags(&self) -> HashSet<String> {
455
        self.collection.requests.iter().map(group_tag).collect()
456
    }
457
458
    pub fn rebuild_sidebar(&mut self) {
459
        let filtering = !self.filter.is_empty();
460
        let mut rows = Vec::new();
461
        let mut groups: Vec<String> = Vec::new();
462
        for (i, req) in self.collection.requests.iter().enumerate() {
463
            if filtering && !req.matches(&self.filter) {
464
                continue;
465
            }
466
            let tag = group_tag(req);
467
            if !groups.contains(&tag) {
468
                groups.push(tag.clone());
469
                rows.push(SidebarRow::Group(tag.clone()));
470
            }
471
            // While filtering, matches are always shown — a collapsed group
472
            // would otherwise hide the thing being searched for.
473
            if filtering || !self.collapsed.contains(&tag) {
474
                rows.push(SidebarRow::Request(i));
475
            }
476
        }
477
        self.sidebar_rows = rows;
478
        if self.sidebar_sel >= self.sidebar_rows.len() {
479
            self.sidebar_sel = self.sidebar_rows.len().saturating_sub(1);
480
        }
481
    }
482
483
    // ----- sidebar search -----
484
485
    pub fn start_search(&mut self) {
486
        self.search.set(&self.filter);
487
        self.focus = Focus::Sidebar;
488
        self.mode = Mode::Search;
489
    }
490
491
    /// Re-apply the live `/` buffer as the filter and land the cursor on the
492
    /// first matching request.
493
    pub fn apply_search(&mut self) {
494
        self.filter = self.search.buf.clone();
495
        self.rebuild_sidebar();
496
        if let Some(pos) = self
497
            .sidebar_rows
498
            .iter()
499
            .position(|r| matches!(r, SidebarRow::Request(_)))
500
        {
501
            self.sidebar_sel = pos;
502
        }
503
    }
504
505
    pub fn finish_search(&mut self) {
506
        self.mode = Mode::Normal;
507
        self.status = if self.filter.is_empty() {
508
            "Filter cleared".into()
509
        } else {
510
            let n = self
511
                .sidebar_rows
512
                .iter()
513
                .filter(|r| matches!(r, SidebarRow::Request(_)))
514
                .count();
515
            format!("Filter \"{}\" — {n} request(s) · Esc clears", self.filter)
516
        };
517
    }
518
519
    pub fn clear_filter(&mut self) {
520
        if self.filter.is_empty() {
521
            return;
522
        }
523
        self.filter.clear();
524
        self.search.set("");
525
        self.rebuild_sidebar();
526
        self.status = "Filter cleared".into();
527
    }
528
529
    // ----- request labels -----
530
531
    pub fn cycle_label_mode(&mut self) {
532
        self.collection.label_mode = self.collection.label_mode.next();
533
        self.dirty = true;
534
        self.status = format!("Sidebar labels: {}", self.collection.label_mode.title());
535
    }
536
537
    pub fn activate_sidebar(&mut self) {
538
        match self.sidebar_rows.get(self.sidebar_sel).cloned() {
539
            Some(SidebarRow::Group(tag)) => {
540
                if !self.collapsed.remove(&tag) {
541
                    self.collapsed.insert(tag);
542
                }
543
                self.rebuild_sidebar();
544
            }
545
            Some(SidebarRow::Request(idx)) => self.select_request(idx),
546
            None => {}
547
        }
548
    }
549
550
    // ----- request selection -----
551
552
    pub fn selected_request(&self) -> Option<&SavedRequest> {
553
        self.selected.map(|i| &self.collection.requests[i])
554
    }
555
556
    pub fn select_request(&mut self, idx: usize) {
557
        self.commit_body();
558
        self.selected = Some(idx);
559
        self.table_row = 0;
560
        self.docs_scroll = 0;
561
        let body = self.collection.requests[idx]
562
            .body
563
            .clone()
564
            .unwrap_or_default();
565
        self.set_textarea_text(&body);
566
        self.focus = Focus::Editor;
567
    }
568
569
    pub fn set_textarea_text(&mut self, text: &str) {
570
        let lines: Vec<String> = if text.is_empty() {
571
            vec![String::new()]
572
        } else {
573
            text.lines().map(String::from).collect()
574
        };
575
        self.textarea = TextArea::from(lines);
576
        self.textarea
577
            .set_cursor_line_style(ratatui::style::Style::default());
578
    }
579
580
    /// Write the textarea contents back into the selected request body.
581
    pub fn commit_body(&mut self) {
582
        let Some(idx) = self.selected else { return };
583
        let text = self.textarea.lines().join("\n");
584
        let text = text.trim_end_matches('\n').to_string();
585
        let req = &mut self.collection.requests[idx];
586
        let new = if text.trim().is_empty() {
587
            None
588
        } else {
589
            Some(text)
590
        };
591
        if req.body != new {
592
            req.body = new;
593
            self.dirty = true;
594
        }
595
    }
596
597
    // ----- tables (params / headers / variables) -----
598
599
    pub fn table_len(&self, table: TableId) -> usize {
600
        let Some(req) = self.selected_request() else {
601
            return if table == TableId::Vars {
602
                self.collection.variables.len()
603
            } else {
604
                0
605
            };
606
        };
607
        match table {
608
            TableId::Params => req.path_params.len() + req.query.len(),
609
            TableId::Headers => req.headers.len(),
610
            TableId::Vars => self.collection.variables.len(),
611
        }
612
    }
613
614
    fn row_ref(&self, table: TableId, row: usize) -> Option<&KeyValueRow> {
615
        match table {
616
            TableId::Vars => self.collection.variables.get(row),
617
            TableId::Headers => self.selected_request()?.headers.get(row),
618
            TableId::Params => {
619
                let req = self.selected_request()?;
620
                let np = req.path_params.len();
621
                if row < np {
622
                    req.path_params.get(row)
623
                } else {
624
                    req.query.get(row - np)
625
                }
626
            }
627
        }
628
    }
629
630
    fn row_mut(&mut self, table: TableId, row: usize) -> Option<&mut KeyValueRow> {
631
        match table {
632
            TableId::Vars => self.collection.variables.get_mut(row),
633
            TableId::Headers => {
634
                let i = self.selected?;
635
                self.collection.requests.get_mut(i)?.headers.get_mut(row)
636
            }
637
            TableId::Params => {
638
                let i = self.selected?;
639
                let req = self.collection.requests.get_mut(i)?;
640
                let np = req.path_params.len();
641
                if row < np {
642
                    req.path_params.get_mut(row)
643
                } else {
644
                    req.query.get_mut(row - np)
645
                }
646
            }
647
        }
648
    }
649
650
    pub fn row_value(&self, table: TableId, row: usize, col: CellCol) -> Option<String> {
651
        let r = self.row_ref(table, row)?;
652
        Some(match col {
653
            CellCol::Key => r.key.clone(),
654
            CellCol::Value => r.value.clone(),
655
        })
656
    }
657
658
    pub fn toggle_row(&mut self, table: TableId, row: usize) {
659
        if let Some(r) = self.row_mut(table, row) {
660
            r.enabled = !r.enabled;
661
            self.dirty = true;
662
        }
663
    }
664
665
    pub fn delete_row(&mut self, table: TableId, row: usize) {
666
        let removed = match table {
667
            TableId::Vars => {
668
                if row < self.collection.variables.len() {
669
                    self.collection.variables.remove(row);
670
                    true
671
                } else {
672
                    false
673
                }
674
            }
675
            TableId::Headers => match self.selected {
676
                Some(i) if row < self.collection.requests[i].headers.len() => {
677
                    self.collection.requests[i].headers.remove(row);
678
                    true
679
                }
680
                _ => false,
681
            },
682
            TableId::Params => match self.selected {
683
                Some(i) => {
684
                    let req = &mut self.collection.requests[i];
685
                    let np = req.path_params.len();
686
                    if row < np {
687
                        req.path_params.remove(row);
688
                        true
689
                    } else if row - np < req.query.len() {
690
                        req.query.remove(row - np);
691
                        true
692
                    } else {
693
                        false
694
                    }
695
                }
696
                None => false,
697
            },
698
        };
699
        if removed {
700
            self.dirty = true;
701
            let len = self.table_len(table);
702
            if self.table_row >= len {
703
                self.table_row = len.saturating_sub(1);
704
            }
705
        }
706
    }
707
708
    /// Append an empty row and start editing its key (value edit chains after).
709
    pub fn add_row(&mut self, table: TableId) {
710
        let row = match table {
711
            TableId::Vars => {
712
                self.collection
713
                    .variables
714
                    .push(KeyValueRow::new("", "", true));
715
                self.collection.variables.len() - 1
716
            }
717
            TableId::Headers => {
718
                let Some(i) = self.selected else { return };
719
                self.collection.requests[i]
720
                    .headers
721
                    .push(KeyValueRow::new("", "", true));
722
                self.collection.requests[i].headers.len() - 1
723
            }
724
            TableId::Params => {
725
                let Some(i) = self.selected else { return };
726
                // New params are query params; path params come from the path.
727
                self.collection.requests[i]
728
                    .query
729
                    .push(KeyValueRow::new("", "", true));
730
                self.collection.requests[i].path_params.len()
731
                    + self.collection.requests[i].query.len()
732
                    - 1
733
            }
734
        };
735
        self.dirty = true;
736
        self.table_row = row;
737
        self.start_edit(EditTarget::Cell {
738
            table,
739
            row,
740
            col: CellCol::Key,
741
        });
742
        self.chain_to_value = true;
743
    }
744
745
    // ----- editing -----
746
747
    pub fn start_edit(&mut self, target: EditTarget) {
748
        let initial = match target {
749
            EditTarget::Cell { table, row, col } => {
750
                self.row_value(table, row, col).unwrap_or_default()
751
            }
752
            EditTarget::Rename => self
753
                .selected_request()
754
                .map(|r| r.name.clone())
755
                .unwrap_or_default(),
756
            // Prefill `method path`, not `base_url() + path`: re-serializing
757
            // the full URL would rebuild the query from the table and lose each
758
            // row's `enabled` flag. The origin is visible in the URL bar anyway.
759
            // The leading verb doubles as the method editor — `apply_url_input`
760
            // parses it back — and shows the current/default method up front. A
761
            // bare `/` (what `SavedRequest::blank` gives a new request) is
762
            // dropped, so a fresh request prefills as `GET ` awaiting a route.
763
            EditTarget::Url => self
764
                .selected_request()
765
                .map(|r| {
766
                    let path = if r.path == "/" { "" } else { &r.path };
767
                    format!("{} {path}", r.method)
768
                })
769
                .unwrap_or_default(),
770
            EditTarget::NewRequest | EditTarget::EnvNew => String::new(),
771
            EditTarget::AuthField(i) => self.auth_field_value(i),
772
        };
773
        self.input.set(&initial);
774
        self.editing = Some(target);
775
        self.mode = Mode::Insert;
776
    }
777
778
    pub fn cancel_edit(&mut self) {
779
        self.editing = None;
780
        self.chain_to_value = false;
781
        self.mode = Mode::Normal;
782
    }
783
784
    pub fn commit_edit(&mut self) {
785
        let Some(target) = self.editing.take() else {
786
            return;
787
        };
788
        let value = self.input.buf.trim().to_string();
789
        self.mode = Mode::Normal;
790
791
        match target {
792
            EditTarget::Cell { table, row, col } => {
793
                if let Some(r) = self.row_mut(table, row) {
794
                    match col {
795
                        CellCol::Key => r.key = value,
796
                        CellCol::Value => r.value = value,
797
                    }
798
                    self.dirty = true;
799
                }
800
                if col == CellCol::Key && self.chain_to_value {
801
                    self.chain_to_value = false;
802
                    self.start_edit(EditTarget::Cell {
803
                        table,
804
                        row,
805
                        col: CellCol::Value,
806
                    });
807
                }
808
            }
809
            EditTarget::Rename => {
810
                if !value.is_empty()
811
                    && let Some(i) = self.selected
812
                {
813
                    self.collection.requests[i].name = value;
814
                    self.dirty = true;
815
                }
816
            }
817
            EditTarget::NewRequest => {
818
                if !value.is_empty() {
819
                    let req = SavedRequest::blank(value);
820
                    self.collection.requests.push(req);
821
                    self.dirty = true;
822
                    self.rebuild_sidebar();
823
                    let idx = self.collection.requests.len() - 1;
824
                    // Move sidebar selection to the new request.
825
                    if let Some(pos) = self
826
                        .sidebar_rows
827
                        .iter()
828
                        .position(|r| *r == SidebarRow::Request(idx))
829
                    {
830
                        self.sidebar_sel = pos;
831
                    }
832
                    self.select_request(idx);
833
                    // `blank` gives you `GET /`, which is sendable but useless;
834
                    // chain straight into the URL so a new request is usable in
835
                    // one flow.
836
                    self.start_edit(EditTarget::Url);
837
                }
838
            }
839
            EditTarget::Url => self.apply_url_input(&value),
840
            EditTarget::EnvNew => {
841
                if !value.is_empty() {
842
                    self.collection.servers.push(value);
843
                    self.collection.active_server = self.collection.servers.len() - 1;
844
                    self.env_sel = self.collection.active_server;
845
                    self.dirty = true;
846
                }
847
            }
848
            EditTarget::AuthField(i) => {
849
                self.set_auth_field(i, &value);
850
            }
851
        }
852
    }
853
854
    // ----- url bar -----
855
856
    /// Apply a URL-bar entry to the selected request. An absolute URL
857
    /// contributes its origin to `collection.servers` — added if new, made
858
    /// active either way — the rest becomes `req.path`, and a query string (only
859
    /// if the input actually had one) replaces the query rows.
860
    pub fn apply_url_input(&mut self, input: &str) {
861
        let Some(i) = self.selected else {
862
            self.status = "No request selected".into();
863
            return;
864
        };
865
        // A leading HTTP verb sets the method and is stripped before URL
866
        // parsing, so `POST /pets` fixes method and path in one edit — the same
867
        // single field a new request chains into after naming. A bare path
868
        // leaves the current method untouched. The split is on the first space
869
        // only, and the token must parse as a method, so a pathless `delete`
870
        // typed alone stays a path, not a verb.
871
        let mut set_method: Option<Method> = None;
872
        let input = match input.trim().split_once(char::is_whitespace) {
873
            Some((head, rest)) if !rest.trim().is_empty() => match Method::parse(head) {
874
                Some(m) => {
875
                    set_method = Some(m);
876
                    rest.trim()
877
                }
878
                None => input,
879
            },
880
            _ => input,
881
        };
882
        // Something that names a scheme but isn't http(s) is a typo, not a
883
        // relative path — say so rather than filing it under `path`. The
884
        // scheme-shape check matters: it keeps a stray `/api/https://…` out of
885
        // this branch, where the error would be more confusing than the path.
886
        if let Some((scheme, _)) = input.trim().split_once("://")
887
            && !scheme.is_empty()
888
            && scheme
889
                .chars()
890
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
891
            && !matches!(scheme, "http" | "https")
892
        {
893
            self.status = format!("Only http(s) URLs are supported (got {scheme:?}://)");
894
            return;
895
        }
896
897
        let parts = split_url_input(input);
898
        let mut notes: Vec<String> = Vec::new();
899
        if let Some(origin) = parts.origin {
900
            // Compare trimmed: servers added with `E` may carry a trailing
901
            // slash, imported ones never do.
902
            match self
903
                .collection
904
                .servers
905
                .iter()
906
                .position(|s| s.trim_end_matches('/') == origin)
907
            {
908
                Some(idx) => {
909
                    if self.collection.active_server != idx {
910
                        self.collection.active_server = idx;
911
                        notes.push(format!("server → {origin}"));
912
                    }
913
                }
914
                None => {
915
                    self.collection.servers.push(origin.clone());
916
                    self.collection.active_server = self.collection.servers.len() - 1;
917
                    notes.push(format!("server + {origin}"));
918
                }
919
            }
920
        }
921
922
        let req = &mut self.collection.requests[i];
923
        if let Some(m) = set_method
924
            && req.method != m
925
        {
926
            req.method = m;
927
            notes.push(format!("method → {m}"));
928
        }
929
        req.path = parts.path;
930
        if let Some(query) = parts.query {
931
            notes.push(format!("{} query param(s)", query.len()));
932
            req.query = query;
933
        }
934
        req.sync_path_params();
935
        self.dirty = true;
936
        self.table_row = 0;
937
        let path = self.collection.requests[i].path.clone();
938
        self.status = if notes.is_empty() {
939
            format!("Path: {path}")
940
        } else {
941
            format!("Path: {path} · {}", notes.join(" · "))
942
        };
943
    }
944
945
    // ----- duplicating -----
946
947
    /// Clone the request at `idx` as a starting point: fresh id, name suffixed
948
    /// `copy` / `copy 2` / …, inserted directly after the original so it lands
949
    /// beside it in the same sidebar group. Selects the clone.
950
    pub fn duplicate_request(&mut self, idx: usize) {
951
        // Must precede the insert: `select_request` commits the textarea into
952
        // `requests[selected]`, and the indices shift underneath it.
953
        self.commit_body();
954
        let Some(src) = self.collection.requests.get(idx) else {
955
            return;
956
        };
957
        let mut clone = src.clone();
958
        // A fresh id is required, not cosmetic: `saved_view_index` resolves
959
        // `last_request` with `position(|r| r.id == id)`, so a duplicate id
960
        // would make reopening the collection ambiguous.
961
        clone.id = Uuid::new_v4();
962
        clone.name = unique_request_name(&self.collection.requests, &src.name);
963
        let at = idx + 1;
964
        self.collection.requests.insert(at, clone);
965
        if let Some(s) = self.selected.filter(|s| *s >= at) {
966
            self.selected = Some(s + 1);
967
        }
968
        self.dirty = true;
969
        self.rebuild_sidebar();
970
        // An active filter may hide the clone, in which case the cursor stays put.
971
        if let Some(pos) = self
972
            .sidebar_rows
973
            .iter()
974
            .position(|r| *r == SidebarRow::Request(at))
975
        {
976
            self.sidebar_sel = pos;
977
        }
978
        self.select_request(at);
979
        self.status = format!("Duplicated as \"{}\"", self.collection.requests[at].name);
980
    }
981
982
    // ----- auth popup -----
983
984
    pub fn open_auth_popup(&mut self) {
985
        // A brand-new config defaults to bearer — the simplest scheme, and the
986
        // one this popup mostly exists to make reachable. Existing configs open
987
        // on whatever `kind` they were saved with.
988
        self.auth_form = self.collection.auth.clone().unwrap_or(OAuthConfig {
989
            kind: AuthKind::Bearer,
990
            ..Default::default()
991
        });
992
        self.auth_field = 0;
993
        self.popup = Popup::Auth;
994
    }
995
996
    /// The rows shown for the form's current auth kind, in display order. Always
997
    /// leads with [`AuthField::Kind`] so the scheme is switchable from any state.
998
    pub fn auth_fields(&self) -> Vec<AuthField> {
999
        let mut fields = vec![AuthField::Kind];
1000
        match self.auth_form.kind {
1001
            AuthKind::Bearer => fields.push(AuthField::Token),
1002
            AuthKind::ApiKey => fields.extend([AuthField::Header, AuthField::Token]),
1003
            AuthKind::Oauth2 => fields.extend([
1004
                AuthField::TokenUrl,
1005
                AuthField::ClientId,
1006
                AuthField::ClientSecret,
1007
                AuthField::Scopes,
1008
                AuthField::Style,
1009
            ]),
1010
        }
1011
        fields
1012
    }
1013
1014
    /// The [`AuthField`] under the cursor, resolving `auth_field` against the
1015
    /// current kind's row list (clamped, so a stale index never panics).
1016
    pub fn auth_field_at(&self, i: usize) -> AuthField {
1017
        let fields = self.auth_fields();
1018
        fields[i.min(fields.len() - 1)]
1019
    }
1020
1021
    pub fn auth_field_label(&self, field: AuthField) -> &'static str {
1022
        match field {
1023
            AuthField::Kind => "Auth type",
1024
            AuthField::Token => match self.auth_form.kind {
1025
                AuthKind::ApiKey => "API key value",
1026
                _ => "Bearer token",
1027
            },
1028
            AuthField::Header => "Header name",
1029
            AuthField::TokenUrl => "Token URL",
1030
            AuthField::ClientId => "Client ID",
1031
            AuthField::ClientSecret => "Client Secret",
1032
            AuthField::Scopes => "Scopes (space separated)",
1033
            AuthField::Style => "Auth style",
1034
        }
1035
    }
1036
1037
    pub fn auth_field_value(&self, i: usize) -> String {
1038
        match self.auth_field_at(i) {
1039
            AuthField::Kind => self.auth_form.kind.title().to_string(),
1040
            AuthField::Token => self.auth_form.token.clone(),
1041
            AuthField::Header => self.auth_form.header.clone(),
1042
            AuthField::TokenUrl => self.auth_form.token_url.clone(),
1043
            AuthField::ClientId => self.auth_form.client_id.clone(),
1044
            AuthField::ClientSecret => self.auth_form.client_secret.clone(),
1045
            AuthField::Scopes => self.auth_form.scopes.join(" "),
1046
            AuthField::Style => match self.auth_form.auth_style {
1047
                crate::model::AuthStyle::Basic => "basic".into(),
1048
                crate::model::AuthStyle::Post => "post".into(),
1049
            },
1050
        }
1051
    }
1052
1053
    pub fn set_auth_field(&mut self, i: usize, value: &str) {
1054
        match self.auth_field_at(i) {
1055
            AuthField::Token => self.auth_form.token = value.to_string(),
1056
            AuthField::Header => self.auth_form.header = value.to_string(),
1057
            AuthField::TokenUrl => self.auth_form.token_url = value.to_string(),
1058
            AuthField::ClientId => self.auth_form.client_id = value.to_string(),
1059
            AuthField::ClientSecret => self.auth_form.client_secret = value.to_string(),
1060
            AuthField::Scopes => {
1061
                self.auth_form.scopes = value.split_whitespace().map(String::from).collect()
1062
            }
1063
            // Toggles carry no typed value.
1064
            AuthField::Kind | AuthField::Style => {}
1065
        }
1066
    }
1067
1068
    /// Advance the toggle under the cursor. `Kind` cycles the scheme (which
1069
    /// changes the row list — the cursor stays put on `Kind` at index 0), and
1070
    /// `Style` flips the OAuth client-auth placement. No-op on text fields.
1071
    pub fn toggle_auth_field(&mut self, i: usize) {
1072
        match self.auth_field_at(i) {
1073
            AuthField::Kind => self.auth_form.kind = self.auth_form.kind.next(),
1074
            AuthField::Style => {
1075
                self.auth_form.auth_style = match self.auth_form.auth_style {
1076
                    crate::model::AuthStyle::Basic => crate::model::AuthStyle::Post,
1077
                    crate::model::AuthStyle::Post => crate::model::AuthStyle::Basic,
1078
                }
1079
            }
1080
            _ => {}
1081
        }
1082
    }
1083
1084
    /// Apply the auth form to the collection (called when the popup closes). A
1085
    /// form with no meaningful field set clears auth entirely, so cycling to a
1086
    /// scheme and leaving it blank doesn't attach an unusable config.
1087
    pub fn apply_auth_form(&mut self) {
1088
        let f = &self.auth_form;
1089
        let empty = f.token.is_empty()
1090
            && f.header.is_empty()
1091
            && f.token_url.is_empty()
1092
            && f.client_id.is_empty()
1093
            && f.client_secret.is_empty()
1094
            && f.scopes.is_empty();
1095
        let new = if empty {
1096
            None
1097
        } else {
1098
            Some(self.auth_form.clone())
1099
        };
1100
        if self.collection.auth != new {
1101
            self.collection.auth = new;
1102
            self.dirty = true;
1103
        }
1104
    }
1105
1106
    // ----- sending -----
1107
1108
    pub fn send_selected(&mut self) {
1109
        self.commit_body();
1110
        let Some(idx) = self.selected else {
1111
            self.status = "No request selected".into();
1112
            return;
1113
        };
1114
        let Some(base) = self.collection.base_url().map(String::from) else {
1115
            self.status = "No server configured — press E to add a base URL".into();
1116
            return;
1117
        };
1118
        let req = self.collection.requests[idx].clone();
1119
        let vars = variables_map(&self.collection.variables);
1120
        let auth = self.collection.auth.clone();
1121
        let token = self.token.take();
1122
        let client = self.client.clone();
1123
        let tx = self.tx.clone();
1124
1125
        self.sending = true;
1126
        self.status = format!("Sending {} {} …", req.method, req.path);
1127
        tokio::spawn(async move {
1128
            let outcome = send_with_auth(&client, &base, &req, &vars, auth.as_ref(), token).await;
1129
            let _ = tx.send(outcome);
1130
        });
1131
    }
1132
1133
    pub fn handle_outcome(&mut self, outcome: SendOutcome) {
1134
        self.sending = false;
1135
        self.token = outcome.token;
1136
        match outcome.result {
1137
            Ok(resp) => {
1138
                self.status = resp.status_line();
1139
                self.response = Some(resp);
1140
                self.response_scroll = 0;
1141
            }
1142
            Err(e) => {
1143
                self.status = e;
1144
            }
1145
        }
1146
    }
1147
1148
    // ----- persistence / quit -----
1149
1150
    /// Copy the current view (open request, pane, editor tab) onto the
1151
    /// collection. Called from [`App::save`] rather than from the navigation
1152
    /// handlers: marking the collection dirty every time the cursor moves
1153
    /// would make `:q` complain about unsaved changes after a read-only browse.
1154
    pub fn record_view(&mut self) {
1155
        self.collection.last_request = self.selected_request().map(|r| r.id);
1156
        self.collection.last_focus = Some(self.focus);
1157
        self.collection.last_tab = Some(self.tab);
1158
    }
1159
1160
    pub fn save(&mut self) {
1161
        self.commit_body();
1162
        self.record_view();
1163
        match store::save_collection(&self.collection) {
1164
            Ok(path) => {
1165
                self.dirty = false;
1166
                self.status = format!("Saved to {}", path.display());
1167
            }
1168
            Err(e) => self.status = format!("Save failed: {e:#}"),
1169
        }
1170
    }
1171
1172
    // ----- switching collections -----
1173
1174
    /// Replace the whole app state with a different collection, keeping the
1175
    /// process and terminal alive. Everything view-related is derived from the
1176
    /// collection by [`App::new`], so a wholesale reassign is both the smallest
1177
    /// and the safest option: it also drops the send channel (so a response
1178
    /// still in flight for the old collection can't land in the new one) and the
1179
    /// cached OAuth token, which belonged to the old collection's auth config.
1180
    ///
1181
    /// Deliberately does not persist `AppConfig`: staying filesystem-free keeps
1182
    /// this callable from tests (`store::config_dir` is hard-wired to the real
1183
    /// home directory). The two callers below write it once they've committed.
1184
    pub fn switch_collection(&mut self, collection: Collection, path: PathBuf) {
1185
        let name = collection.name.clone();
1186
        let mut config = std::mem::take(&mut self.config);
1187
        config.last_collection = Some(name.clone());
1188
        *self = App::new(collection, path, config);
1189
        // `App::new` sets its own status; ours is the more useful one here.
1190
        self.status = format!("Switched to \"{name}\"");
1191
    }
1192
1193
    /// `:new <name>` — create an empty collection on disk and switch to it.
1194
    fn new_collection(&mut self, name: &str, force: bool) {
1195
        if name.is_empty() {
1196
            self.status = "Usage: :new <collection name>".into();
1197
            return;
1198
        }
1199
        if self.dirty && !force {
1200
            self.status = "Unsaved changes — :w first, or :new! to discard".into();
1201
            return;
1202
        }
1203
        let path = match store::collection_path(name) {
1204
            Ok(p) => p,
1205
            Err(e) => {
1206
                self.status = format!("{e:#}");
1207
                return;
1208
            }
1209
        };
1210
        // Checks the slug path, so a name that collides after slugify is caught.
1211
        if path.exists() {
1212
            self.status = format!("A collection already exists at {}", path.display());
1213
            return;
1214
        }
1215
        let collection = Collection::new(name);
1216
        match store::save_collection(&collection) {
1217
            Ok(path) => {
1218
                self.switch_collection(collection, path);
1219
                let _ = self.config.save();
1220
            }
1221
            Err(e) => self.status = format!("Could not create {name:?}: {e:#}"),
1222
        }
1223
    }
1224
1225
    /// `:open <name>` — switch to another saved collection.
1226
    fn open_collection(&mut self, name: &str, force: bool) {
1227
        if name.is_empty() {
1228
            self.status = "Usage: :open <collection name>".into();
1229
            return;
1230
        }
1231
        if self.dirty && !force {
1232
            self.status = "Unsaved changes — :w first, or :open! to discard".into();
1233
            return;
1234
        }
1235
        let loaded = store::resolve_collection(name)
1236
            .and_then(|n| Ok((store::load_collection(&n)?, store::collection_path(&n)?)));
1237
        match loaded {
1238
            Ok((collection, path)) => {
1239
                self.switch_collection(collection, path);
1240
                let _ = self.config.save();
1241
            }
1242
            // `resolve_collection`'s error already lists what is available.
1243
            Err(e) => self.status = format!("{e:#}").replace('\n', " "),
1244
        }
1245
    }
1246
1247
    pub fn try_quit(&mut self) {
1248
        if self.dirty {
1249
            self.status = "Unsaved changes — use :q! to discard, :w to save".into();
1250
        } else {
1251
            self.should_quit = true;
1252
        }
1253
    }
1254
1255
    pub fn exec_command(&mut self) {
1256
        let cmd = self.command.trim().to_string();
1257
        self.command.clear();
1258
        self.mode = Mode::Normal;
1259
        match cmd.as_str() {
1260
            "w" => self.save(),
1261
            "q" => self.try_quit(),
1262
            "q!" => self.should_quit = true,
1263
            "wq" => {
1264
                self.save();
1265
                if !self.dirty {
1266
                    self.should_quit = true;
1267
                }
1268
            }
1269
            // Argument-less forms; the command is already trimmed, so these
1270
            // never reach the `split_once` arms below.
1271
            "new" | "new!" => self.new_collection("", false),
1272
            "open" | "open!" => self.open_collection("", false),
1273
            "" => {}
1274
            other => match other.split_once(char::is_whitespace) {
1275
                Some(("new", arg)) => self.new_collection(arg.trim(), false),
1276
                Some(("new!", arg)) => self.new_collection(arg.trim(), true),
1277
                Some(("open", arg)) => self.open_collection(arg.trim(), false),
1278
                Some(("open!", arg)) => self.open_collection(arg.trim(), true),
1279
                Some(("label", arg)) => self.set_label_mode(arg.trim()),
1280
                Some(("groups", arg)) => self.set_group_default(arg.trim()),
1281
                Some(("rename-all", arg)) => self.rename_all(arg.trim()),
1282
                _ => self.status = format!("Unknown command: {other}"),
1283
            },
1284
        }
1285
    }
1286
1287
    fn set_label_mode(&mut self, arg: &str) {
1288
        let mode = match arg {
1289
            "name" => LabelMode::Name,
1290
            "summary" => LabelMode::Summary,
1291
            "path" => LabelMode::Path,
1292
            other => {
1293
                self.status = format!("Usage: :label name|summary|path (got {other:?})");
1294
                return;
1295
            }
1296
        };
1297
        self.collection.label_mode = mode;
1298
        self.dirty = true;
1299
        self.status = format!("Sidebar labels: {}", mode.title());
1300
    }
1301
1302
    /// Set whether groups start collapsed, and apply it to the current view so
1303
    /// the effect is visible without reopening the collection.
1304
    fn set_group_default(&mut self, arg: &str) {
1305
        let collapsed = match arg {
1306
            "collapsed" => true,
1307
            "expanded" => false,
1308
            other => {
1309
                self.status = format!("Usage: :groups collapsed|expanded (got {other:?})");
1310
                return;
1311
            }
1312
        };
1313
        self.collection.groups_collapsed = collapsed;
1314
        self.collapsed = if collapsed {
1315
            self.group_tags()
1316
        } else {
1317
            HashSet::new()
1318
        };
1319
        self.rebuild_sidebar();
1320
        self.dirty = true;
1321
        self.status = format!("Groups default: {arg}");
1322
    }
1323
1324
    /// Rewrite every request's `name` from a spec-derived field. Unlike
1325
    /// `:label`, this is destructive — it replaces the stored names.
1326
    fn rename_all(&mut self, arg: &str) {
1327
        let mut renamed = 0usize;
1328
        for req in &mut self.collection.requests {
1329
            let new = match arg {
1330
                "summary" => req.summary.clone(),
1331
                "operation" => req.operation_id.clone(),
1332
                "path" => Some(req.path.clone()),
1333
                "method-path" => Some(format!("{} {}", req.method, req.path)),
1334
                other => {
1335
                    self.status = format!(
1336
                        "Usage: :rename-all summary|operation|path|method-path (got {other:?})"
1337
                    );
1338
                    return;
1339
                }
1340
            };
1341
            if let Some(new) = new.filter(|s| !s.is_empty())
1342
                && req.name != new
1343
            {
1344
                req.name = new;
1345
                renamed += 1;
1346
            }
1347
        }
1348
        if renamed > 0 {
1349
            self.dirty = true;
1350
        }
1351
        self.status = format!("Renamed {renamed} request(s) from {arg}");
1352
    }
1353
}
1354
1355
// ----- run loop -----
1356
1357
pub async fn run(collection: Collection, path: PathBuf, config: AppConfig) -> Result<()> {
1358
    let mut app = App::new(collection, path, config);
1359
1360
    // Restore the terminal even if the TUI panics.
1361
    let original_hook = std::panic::take_hook();
1362
    std::panic::set_hook(Box::new(move |info| {
1363
        let _ = disable_raw_mode();
1364
        let _ = execute!(io::stdout(), LeaveAlternateScreen);
1365
        original_hook(info);
1366
    }));
1367
1368
    enable_raw_mode()?;
1369
    let mut stdout = io::stdout();
1370
    execute!(stdout, EnterAlternateScreen)?;
1371
    let backend = CrosstermBackend::new(stdout);
1372
    let mut terminal = Terminal::new(backend)?;
1373
1374
    let result = run_loop(&mut app, &mut terminal).await;
1375
1376
    disable_raw_mode()?;
1377
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1378
    terminal.show_cursor()?;
1379
    result
1380
}
1381
1382
async fn run_loop(
1383
    app: &mut App,
1384
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1385
) -> Result<()> {
1386
    loop {
1387
        terminal.draw(|f| ui::draw(f, app))?;
1388
1389
        while let Ok(outcome) = app.rx.try_recv() {
1390
            app.handle_outcome(outcome);
1391
        }
1392
1393
        if let Some(target) = app.pending_external {
1394
            run_external_edit(app, terminal, target)?;
1395
        }
1396
1397
        if event::poll(Duration::from_millis(60))?
1398
            && let Event::Key(key) = event::read()?
1399
            && key.kind == KeyEventKind::Press
1400
        {
1401
            input::handle_key(app, key);
1402
        }
1403
1404
        if app.should_quit {
1405
            return Ok(());
1406
        }
1407
    }
1408
}
1409
1410
/// Open a request or response body in `$EDITOR`: suspend the TUI, edit a temp
1411
/// file, resume. Request bodies are read back; responses are view-only.
1412
fn run_external_edit(
1413
    app: &mut App,
1414
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1415
    target: ExternalEdit,
1416
) -> Result<()> {
1417
    app.pending_external = None;
1418
1419
    let content = match target {
1420
        ExternalEdit::Body => {
1421
            app.commit_body();
1422
            app.selected_request()
1423
                .and_then(|r| r.body.clone())
1424
                .unwrap_or_default()
1425
        }
1426
        ExternalEdit::Response => match app.response.as_ref() {
1427
            Some(resp) => resp.body.clone(),
1428
            None => {
1429
                app.status = "No response to open".into();
1430
                return Ok(());
1431
            }
1432
        },
1433
    };
1434
1435
    let stem = match target {
1436
        ExternalEdit::Body => "body",
1437
        ExternalEdit::Response => "response",
1438
    };
1439
    let mut tmp = std::env::temp_dir();
1440
    tmp.push(format!(
1441
        "cielago-{stem}-{}.{}",
1442
        std::process::id(),
1443
        guess_extension(&content)
1444
    ));
1445
    std::fs::write(&tmp, &content)?;
1446
1447
    disable_raw_mode()?;
1448
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1449
1450
    let editor = app.config.editor_cmd();
1451
    let mut parts = editor.split_whitespace();
1452
    let program = parts.next().unwrap_or("vi");
1453
    let status = Command::new(program).args(parts).arg(&tmp).status();
1454
1455
    enable_raw_mode()?;
1456
    execute!(terminal.backend_mut(), EnterAlternateScreen)?;
1457
    terminal.clear()?;
1458
1459
    match (target, status) {
1460
        (ExternalEdit::Body, Ok(s)) if s.success() => {
1461
            let content = std::fs::read_to_string(&tmp)?;
1462
            if let Some(i) = app.selected {
1463
                app.collection.requests[i].body = Some(content.clone());
1464
                app.dirty = true;
1465
            }
1466
            app.set_textarea_text(&content);
1467
            app.status = "Body updated from editor".into();
1468
        }
1469
        (ExternalEdit::Body, Ok(s)) => {
1470
            app.status = format!("Editor exited with {s}; body unchanged")
1471
        }
1472
        // Nothing is read back: the response stays exactly as received.
1473
        (ExternalEdit::Response, Ok(_)) => app.status = "Response closed — unchanged".into(),
1474
        (_, Err(e)) => app.status = format!("Could not launch editor: {e}"),
1475
    }
1476
    let _ = std::fs::remove_file(&tmp);
1477
    Ok(())
1478
}
1479
1480
/// Extension for the temp file, so the editor picks sane syntax highlighting.
1481
fn guess_extension(content: &str) -> &'static str {
1482
    match content.trim_start().chars().next() {
1483
        Some('{') | Some('[') => "json",
1484
        Some('<') => "xml",
1485
        _ => "txt",
1486
    }
1487
}