src/app.rs 49.9 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, 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 the path only, 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
            // A bare `/` (what `SavedRequest::blank` gives a new request) is
760
            // dropped, so pasting a URL into a fresh request isn't prefixed by it.
761
            EditTarget::Url => self
762
                .selected_request()
763
                .map(|r| r.path.clone())
764
                .filter(|p| p != "/")
765
                .unwrap_or_default(),
766
            EditTarget::NewRequest | EditTarget::EnvNew => String::new(),
767
            EditTarget::AuthField(i) => self.auth_field_value(i),
768
        };
769
        self.input.set(&initial);
770
        self.editing = Some(target);
771
        self.mode = Mode::Insert;
772
    }
773
774
    pub fn cancel_edit(&mut self) {
775
        self.editing = None;
776
        self.chain_to_value = false;
777
        self.mode = Mode::Normal;
778
    }
779
780
    pub fn commit_edit(&mut self) {
781
        let Some(target) = self.editing.take() else {
782
            return;
783
        };
784
        let value = self.input.buf.trim().to_string();
785
        self.mode = Mode::Normal;
786
787
        match target {
788
            EditTarget::Cell { table, row, col } => {
789
                if let Some(r) = self.row_mut(table, row) {
790
                    match col {
791
                        CellCol::Key => r.key = value,
792
                        CellCol::Value => r.value = value,
793
                    }
794
                    self.dirty = true;
795
                }
796
                if col == CellCol::Key && self.chain_to_value {
797
                    self.chain_to_value = false;
798
                    self.start_edit(EditTarget::Cell {
799
                        table,
800
                        row,
801
                        col: CellCol::Value,
802
                    });
803
                }
804
            }
805
            EditTarget::Rename => {
806
                if !value.is_empty()
807
                    && let Some(i) = self.selected
808
                {
809
                    self.collection.requests[i].name = value;
810
                    self.dirty = true;
811
                }
812
            }
813
            EditTarget::NewRequest => {
814
                if !value.is_empty() {
815
                    let req = SavedRequest::blank(value);
816
                    self.collection.requests.push(req);
817
                    self.dirty = true;
818
                    self.rebuild_sidebar();
819
                    let idx = self.collection.requests.len() - 1;
820
                    // Move sidebar selection to the new request.
821
                    if let Some(pos) = self
822
                        .sidebar_rows
823
                        .iter()
824
                        .position(|r| *r == SidebarRow::Request(idx))
825
                    {
826
                        self.sidebar_sel = pos;
827
                    }
828
                    self.select_request(idx);
829
                    // `blank` gives you `GET /`, which is sendable but useless;
830
                    // chain straight into the URL so a new request is usable in
831
                    // one flow.
832
                    self.start_edit(EditTarget::Url);
833
                }
834
            }
835
            EditTarget::Url => self.apply_url_input(&value),
836
            EditTarget::EnvNew => {
837
                if !value.is_empty() {
838
                    self.collection.servers.push(value);
839
                    self.collection.active_server = self.collection.servers.len() - 1;
840
                    self.env_sel = self.collection.active_server;
841
                    self.dirty = true;
842
                }
843
            }
844
            EditTarget::AuthField(i) => {
845
                self.set_auth_field(i, &value);
846
            }
847
        }
848
    }
849
850
    // ----- url bar -----
851
852
    /// Apply a URL-bar entry to the selected request. An absolute URL
853
    /// contributes its origin to `collection.servers` — added if new, made
854
    /// active either way — the rest becomes `req.path`, and a query string (only
855
    /// if the input actually had one) replaces the query rows.
856
    pub fn apply_url_input(&mut self, input: &str) {
857
        let Some(i) = self.selected else {
858
            self.status = "No request selected".into();
859
            return;
860
        };
861
        // Something that names a scheme but isn't http(s) is a typo, not a
862
        // relative path — say so rather than filing it under `path`. The
863
        // scheme-shape check matters: it keeps a stray `/api/https://…` out of
864
        // this branch, where the error would be more confusing than the path.
865
        if let Some((scheme, _)) = input.trim().split_once("://")
866
            && !scheme.is_empty()
867
            && scheme
868
                .chars()
869
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
870
            && !matches!(scheme, "http" | "https")
871
        {
872
            self.status = format!("Only http(s) URLs are supported (got {scheme:?}://)");
873
            return;
874
        }
875
876
        let parts = split_url_input(input);
877
        let mut notes: Vec<String> = Vec::new();
878
        if let Some(origin) = parts.origin {
879
            // Compare trimmed: servers added with `E` may carry a trailing
880
            // slash, imported ones never do.
881
            match self
882
                .collection
883
                .servers
884
                .iter()
885
                .position(|s| s.trim_end_matches('/') == origin)
886
            {
887
                Some(idx) => {
888
                    if self.collection.active_server != idx {
889
                        self.collection.active_server = idx;
890
                        notes.push(format!("server → {origin}"));
891
                    }
892
                }
893
                None => {
894
                    self.collection.servers.push(origin.clone());
895
                    self.collection.active_server = self.collection.servers.len() - 1;
896
                    notes.push(format!("server + {origin}"));
897
                }
898
            }
899
        }
900
901
        let req = &mut self.collection.requests[i];
902
        req.path = parts.path;
903
        if let Some(query) = parts.query {
904
            notes.push(format!("{} query param(s)", query.len()));
905
            req.query = query;
906
        }
907
        req.sync_path_params();
908
        self.dirty = true;
909
        self.table_row = 0;
910
        let path = self.collection.requests[i].path.clone();
911
        self.status = if notes.is_empty() {
912
            format!("Path: {path}")
913
        } else {
914
            format!("Path: {path} · {}", notes.join(" · "))
915
        };
916
    }
917
918
    // ----- duplicating -----
919
920
    /// Clone the request at `idx` as a starting point: fresh id, name suffixed
921
    /// `copy` / `copy 2` / …, inserted directly after the original so it lands
922
    /// beside it in the same sidebar group. Selects the clone.
923
    pub fn duplicate_request(&mut self, idx: usize) {
924
        // Must precede the insert: `select_request` commits the textarea into
925
        // `requests[selected]`, and the indices shift underneath it.
926
        self.commit_body();
927
        let Some(src) = self.collection.requests.get(idx) else {
928
            return;
929
        };
930
        let mut clone = src.clone();
931
        // A fresh id is required, not cosmetic: `saved_view_index` resolves
932
        // `last_request` with `position(|r| r.id == id)`, so a duplicate id
933
        // would make reopening the collection ambiguous.
934
        clone.id = Uuid::new_v4();
935
        clone.name = unique_request_name(&self.collection.requests, &src.name);
936
        let at = idx + 1;
937
        self.collection.requests.insert(at, clone);
938
        if let Some(s) = self.selected.filter(|s| *s >= at) {
939
            self.selected = Some(s + 1);
940
        }
941
        self.dirty = true;
942
        self.rebuild_sidebar();
943
        // An active filter may hide the clone, in which case the cursor stays put.
944
        if let Some(pos) = self
945
            .sidebar_rows
946
            .iter()
947
            .position(|r| *r == SidebarRow::Request(at))
948
        {
949
            self.sidebar_sel = pos;
950
        }
951
        self.select_request(at);
952
        self.status = format!("Duplicated as \"{}\"", self.collection.requests[at].name);
953
    }
954
955
    // ----- auth popup -----
956
957
    pub fn open_auth_popup(&mut self) {
958
        // A brand-new config defaults to bearer — the simplest scheme, and the
959
        // one this popup mostly exists to make reachable. Existing configs open
960
        // on whatever `kind` they were saved with.
961
        self.auth_form = self.collection.auth.clone().unwrap_or(OAuthConfig {
962
            kind: AuthKind::Bearer,
963
            ..Default::default()
964
        });
965
        self.auth_field = 0;
966
        self.popup = Popup::Auth;
967
    }
968
969
    /// The rows shown for the form's current auth kind, in display order. Always
970
    /// leads with [`AuthField::Kind`] so the scheme is switchable from any state.
971
    pub fn auth_fields(&self) -> Vec<AuthField> {
972
        let mut fields = vec![AuthField::Kind];
973
        match self.auth_form.kind {
974
            AuthKind::Bearer => fields.push(AuthField::Token),
975
            AuthKind::ApiKey => fields.extend([AuthField::Header, AuthField::Token]),
976
            AuthKind::Oauth2 => fields.extend([
977
                AuthField::TokenUrl,
978
                AuthField::ClientId,
979
                AuthField::ClientSecret,
980
                AuthField::Scopes,
981
                AuthField::Style,
982
            ]),
983
        }
984
        fields
985
    }
986
987
    /// The [`AuthField`] under the cursor, resolving `auth_field` against the
988
    /// current kind's row list (clamped, so a stale index never panics).
989
    pub fn auth_field_at(&self, i: usize) -> AuthField {
990
        let fields = self.auth_fields();
991
        fields[i.min(fields.len() - 1)]
992
    }
993
994
    pub fn auth_field_label(&self, field: AuthField) -> &'static str {
995
        match field {
996
            AuthField::Kind => "Auth type",
997
            AuthField::Token => match self.auth_form.kind {
998
                AuthKind::ApiKey => "API key value",
999
                _ => "Bearer token",
1000
            },
1001
            AuthField::Header => "Header name",
1002
            AuthField::TokenUrl => "Token URL",
1003
            AuthField::ClientId => "Client ID",
1004
            AuthField::ClientSecret => "Client Secret",
1005
            AuthField::Scopes => "Scopes (space separated)",
1006
            AuthField::Style => "Auth style",
1007
        }
1008
    }
1009
1010
    pub fn auth_field_value(&self, i: usize) -> String {
1011
        match self.auth_field_at(i) {
1012
            AuthField::Kind => self.auth_form.kind.title().to_string(),
1013
            AuthField::Token => self.auth_form.token.clone(),
1014
            AuthField::Header => self.auth_form.header.clone(),
1015
            AuthField::TokenUrl => self.auth_form.token_url.clone(),
1016
            AuthField::ClientId => self.auth_form.client_id.clone(),
1017
            AuthField::ClientSecret => self.auth_form.client_secret.clone(),
1018
            AuthField::Scopes => self.auth_form.scopes.join(" "),
1019
            AuthField::Style => match self.auth_form.auth_style {
1020
                crate::model::AuthStyle::Basic => "basic".into(),
1021
                crate::model::AuthStyle::Post => "post".into(),
1022
            },
1023
        }
1024
    }
1025
1026
    pub fn set_auth_field(&mut self, i: usize, value: &str) {
1027
        match self.auth_field_at(i) {
1028
            AuthField::Token => self.auth_form.token = value.to_string(),
1029
            AuthField::Header => self.auth_form.header = value.to_string(),
1030
            AuthField::TokenUrl => self.auth_form.token_url = value.to_string(),
1031
            AuthField::ClientId => self.auth_form.client_id = value.to_string(),
1032
            AuthField::ClientSecret => self.auth_form.client_secret = value.to_string(),
1033
            AuthField::Scopes => {
1034
                self.auth_form.scopes = value.split_whitespace().map(String::from).collect()
1035
            }
1036
            // Toggles carry no typed value.
1037
            AuthField::Kind | AuthField::Style => {}
1038
        }
1039
    }
1040
1041
    /// Advance the toggle under the cursor. `Kind` cycles the scheme (which
1042
    /// changes the row list — the cursor stays put on `Kind` at index 0), and
1043
    /// `Style` flips the OAuth client-auth placement. No-op on text fields.
1044
    pub fn toggle_auth_field(&mut self, i: usize) {
1045
        match self.auth_field_at(i) {
1046
            AuthField::Kind => self.auth_form.kind = self.auth_form.kind.next(),
1047
            AuthField::Style => {
1048
                self.auth_form.auth_style = match self.auth_form.auth_style {
1049
                    crate::model::AuthStyle::Basic => crate::model::AuthStyle::Post,
1050
                    crate::model::AuthStyle::Post => crate::model::AuthStyle::Basic,
1051
                }
1052
            }
1053
            _ => {}
1054
        }
1055
    }
1056
1057
    /// Apply the auth form to the collection (called when the popup closes). A
1058
    /// form with no meaningful field set clears auth entirely, so cycling to a
1059
    /// scheme and leaving it blank doesn't attach an unusable config.
1060
    pub fn apply_auth_form(&mut self) {
1061
        let f = &self.auth_form;
1062
        let empty = f.token.is_empty()
1063
            && f.header.is_empty()
1064
            && f.token_url.is_empty()
1065
            && f.client_id.is_empty()
1066
            && f.client_secret.is_empty()
1067
            && f.scopes.is_empty();
1068
        let new = if empty {
1069
            None
1070
        } else {
1071
            Some(self.auth_form.clone())
1072
        };
1073
        if self.collection.auth != new {
1074
            self.collection.auth = new;
1075
            self.dirty = true;
1076
        }
1077
    }
1078
1079
    // ----- sending -----
1080
1081
    pub fn send_selected(&mut self) {
1082
        self.commit_body();
1083
        let Some(idx) = self.selected else {
1084
            self.status = "No request selected".into();
1085
            return;
1086
        };
1087
        let Some(base) = self.collection.base_url().map(String::from) else {
1088
            self.status = "No server configured — press E to add a base URL".into();
1089
            return;
1090
        };
1091
        let req = self.collection.requests[idx].clone();
1092
        let vars = variables_map(&self.collection.variables);
1093
        let auth = self.collection.auth.clone();
1094
        let token = self.token.take();
1095
        let client = self.client.clone();
1096
        let tx = self.tx.clone();
1097
1098
        self.sending = true;
1099
        self.status = format!("Sending {} {} …", req.method, req.path);
1100
        tokio::spawn(async move {
1101
            let outcome = send_with_auth(&client, &base, &req, &vars, auth.as_ref(), token).await;
1102
            let _ = tx.send(outcome);
1103
        });
1104
    }
1105
1106
    pub fn handle_outcome(&mut self, outcome: SendOutcome) {
1107
        self.sending = false;
1108
        self.token = outcome.token;
1109
        match outcome.result {
1110
            Ok(resp) => {
1111
                self.status = resp.status_line();
1112
                self.response = Some(resp);
1113
                self.response_scroll = 0;
1114
            }
1115
            Err(e) => {
1116
                self.status = e;
1117
            }
1118
        }
1119
    }
1120
1121
    // ----- persistence / quit -----
1122
1123
    /// Copy the current view (open request, pane, editor tab) onto the
1124
    /// collection. Called from [`App::save`] rather than from the navigation
1125
    /// handlers: marking the collection dirty every time the cursor moves
1126
    /// would make `:q` complain about unsaved changes after a read-only browse.
1127
    pub fn record_view(&mut self) {
1128
        self.collection.last_request = self.selected_request().map(|r| r.id);
1129
        self.collection.last_focus = Some(self.focus);
1130
        self.collection.last_tab = Some(self.tab);
1131
    }
1132
1133
    pub fn save(&mut self) {
1134
        self.commit_body();
1135
        self.record_view();
1136
        match store::save_collection(&self.collection) {
1137
            Ok(path) => {
1138
                self.dirty = false;
1139
                self.status = format!("Saved to {}", path.display());
1140
            }
1141
            Err(e) => self.status = format!("Save failed: {e:#}"),
1142
        }
1143
    }
1144
1145
    // ----- switching collections -----
1146
1147
    /// Replace the whole app state with a different collection, keeping the
1148
    /// process and terminal alive. Everything view-related is derived from the
1149
    /// collection by [`App::new`], so a wholesale reassign is both the smallest
1150
    /// and the safest option: it also drops the send channel (so a response
1151
    /// still in flight for the old collection can't land in the new one) and the
1152
    /// cached OAuth token, which belonged to the old collection's auth config.
1153
    ///
1154
    /// Deliberately does not persist `AppConfig`: staying filesystem-free keeps
1155
    /// this callable from tests (`store::config_dir` is hard-wired to the real
1156
    /// home directory). The two callers below write it once they've committed.
1157
    pub fn switch_collection(&mut self, collection: Collection, path: PathBuf) {
1158
        let name = collection.name.clone();
1159
        let mut config = std::mem::take(&mut self.config);
1160
        config.last_collection = Some(name.clone());
1161
        *self = App::new(collection, path, config);
1162
        // `App::new` sets its own status; ours is the more useful one here.
1163
        self.status = format!("Switched to \"{name}\"");
1164
    }
1165
1166
    /// `:new <name>` — create an empty collection on disk and switch to it.
1167
    fn new_collection(&mut self, name: &str, force: bool) {
1168
        if name.is_empty() {
1169
            self.status = "Usage: :new <collection name>".into();
1170
            return;
1171
        }
1172
        if self.dirty && !force {
1173
            self.status = "Unsaved changes — :w first, or :new! to discard".into();
1174
            return;
1175
        }
1176
        let path = match store::collection_path(name) {
1177
            Ok(p) => p,
1178
            Err(e) => {
1179
                self.status = format!("{e:#}");
1180
                return;
1181
            }
1182
        };
1183
        // Checks the slug path, so a name that collides after slugify is caught.
1184
        if path.exists() {
1185
            self.status = format!("A collection already exists at {}", path.display());
1186
            return;
1187
        }
1188
        let collection = Collection::new(name);
1189
        match store::save_collection(&collection) {
1190
            Ok(path) => {
1191
                self.switch_collection(collection, path);
1192
                let _ = self.config.save();
1193
            }
1194
            Err(e) => self.status = format!("Could not create {name:?}: {e:#}"),
1195
        }
1196
    }
1197
1198
    /// `:open <name>` — switch to another saved collection.
1199
    fn open_collection(&mut self, name: &str, force: bool) {
1200
        if name.is_empty() {
1201
            self.status = "Usage: :open <collection name>".into();
1202
            return;
1203
        }
1204
        if self.dirty && !force {
1205
            self.status = "Unsaved changes — :w first, or :open! to discard".into();
1206
            return;
1207
        }
1208
        let loaded = store::resolve_collection(name)
1209
            .and_then(|n| Ok((store::load_collection(&n)?, store::collection_path(&n)?)));
1210
        match loaded {
1211
            Ok((collection, path)) => {
1212
                self.switch_collection(collection, path);
1213
                let _ = self.config.save();
1214
            }
1215
            // `resolve_collection`'s error already lists what is available.
1216
            Err(e) => self.status = format!("{e:#}").replace('\n', " "),
1217
        }
1218
    }
1219
1220
    pub fn try_quit(&mut self) {
1221
        if self.dirty {
1222
            self.status = "Unsaved changes — use :q! to discard, :w to save".into();
1223
        } else {
1224
            self.should_quit = true;
1225
        }
1226
    }
1227
1228
    pub fn exec_command(&mut self) {
1229
        let cmd = self.command.trim().to_string();
1230
        self.command.clear();
1231
        self.mode = Mode::Normal;
1232
        match cmd.as_str() {
1233
            "w" => self.save(),
1234
            "q" => self.try_quit(),
1235
            "q!" => self.should_quit = true,
1236
            "wq" => {
1237
                self.save();
1238
                if !self.dirty {
1239
                    self.should_quit = true;
1240
                }
1241
            }
1242
            // Argument-less forms; the command is already trimmed, so these
1243
            // never reach the `split_once` arms below.
1244
            "new" | "new!" => self.new_collection("", false),
1245
            "open" | "open!" => self.open_collection("", false),
1246
            "" => {}
1247
            other => match other.split_once(char::is_whitespace) {
1248
                Some(("new", arg)) => self.new_collection(arg.trim(), false),
1249
                Some(("new!", arg)) => self.new_collection(arg.trim(), true),
1250
                Some(("open", arg)) => self.open_collection(arg.trim(), false),
1251
                Some(("open!", arg)) => self.open_collection(arg.trim(), true),
1252
                Some(("label", arg)) => self.set_label_mode(arg.trim()),
1253
                Some(("groups", arg)) => self.set_group_default(arg.trim()),
1254
                Some(("rename-all", arg)) => self.rename_all(arg.trim()),
1255
                _ => self.status = format!("Unknown command: {other}"),
1256
            },
1257
        }
1258
    }
1259
1260
    fn set_label_mode(&mut self, arg: &str) {
1261
        let mode = match arg {
1262
            "name" => LabelMode::Name,
1263
            "summary" => LabelMode::Summary,
1264
            "path" => LabelMode::Path,
1265
            other => {
1266
                self.status = format!("Usage: :label name|summary|path (got {other:?})");
1267
                return;
1268
            }
1269
        };
1270
        self.collection.label_mode = mode;
1271
        self.dirty = true;
1272
        self.status = format!("Sidebar labels: {}", mode.title());
1273
    }
1274
1275
    /// Set whether groups start collapsed, and apply it to the current view so
1276
    /// the effect is visible without reopening the collection.
1277
    fn set_group_default(&mut self, arg: &str) {
1278
        let collapsed = match arg {
1279
            "collapsed" => true,
1280
            "expanded" => false,
1281
            other => {
1282
                self.status = format!("Usage: :groups collapsed|expanded (got {other:?})");
1283
                return;
1284
            }
1285
        };
1286
        self.collection.groups_collapsed = collapsed;
1287
        self.collapsed = if collapsed {
1288
            self.group_tags()
1289
        } else {
1290
            HashSet::new()
1291
        };
1292
        self.rebuild_sidebar();
1293
        self.dirty = true;
1294
        self.status = format!("Groups default: {arg}");
1295
    }
1296
1297
    /// Rewrite every request's `name` from a spec-derived field. Unlike
1298
    /// `:label`, this is destructive — it replaces the stored names.
1299
    fn rename_all(&mut self, arg: &str) {
1300
        let mut renamed = 0usize;
1301
        for req in &mut self.collection.requests {
1302
            let new = match arg {
1303
                "summary" => req.summary.clone(),
1304
                "operation" => req.operation_id.clone(),
1305
                "path" => Some(req.path.clone()),
1306
                "method-path" => Some(format!("{} {}", req.method, req.path)),
1307
                other => {
1308
                    self.status = format!(
1309
                        "Usage: :rename-all summary|operation|path|method-path (got {other:?})"
1310
                    );
1311
                    return;
1312
                }
1313
            };
1314
            if let Some(new) = new.filter(|s| !s.is_empty())
1315
                && req.name != new
1316
            {
1317
                req.name = new;
1318
                renamed += 1;
1319
            }
1320
        }
1321
        if renamed > 0 {
1322
            self.dirty = true;
1323
        }
1324
        self.status = format!("Renamed {renamed} request(s) from {arg}");
1325
    }
1326
}
1327
1328
// ----- run loop -----
1329
1330
pub async fn run(collection: Collection, path: PathBuf, config: AppConfig) -> Result<()> {
1331
    let mut app = App::new(collection, path, config);
1332
1333
    // Restore the terminal even if the TUI panics.
1334
    let original_hook = std::panic::take_hook();
1335
    std::panic::set_hook(Box::new(move |info| {
1336
        let _ = disable_raw_mode();
1337
        let _ = execute!(io::stdout(), LeaveAlternateScreen);
1338
        original_hook(info);
1339
    }));
1340
1341
    enable_raw_mode()?;
1342
    let mut stdout = io::stdout();
1343
    execute!(stdout, EnterAlternateScreen)?;
1344
    let backend = CrosstermBackend::new(stdout);
1345
    let mut terminal = Terminal::new(backend)?;
1346
1347
    let result = run_loop(&mut app, &mut terminal).await;
1348
1349
    disable_raw_mode()?;
1350
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1351
    terminal.show_cursor()?;
1352
    result
1353
}
1354
1355
async fn run_loop(
1356
    app: &mut App,
1357
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1358
) -> Result<()> {
1359
    loop {
1360
        terminal.draw(|f| ui::draw(f, app))?;
1361
1362
        while let Ok(outcome) = app.rx.try_recv() {
1363
            app.handle_outcome(outcome);
1364
        }
1365
1366
        if let Some(target) = app.pending_external {
1367
            run_external_edit(app, terminal, target)?;
1368
        }
1369
1370
        if event::poll(Duration::from_millis(60))?
1371
            && let Event::Key(key) = event::read()?
1372
            && key.kind == KeyEventKind::Press
1373
        {
1374
            input::handle_key(app, key);
1375
        }
1376
1377
        if app.should_quit {
1378
            return Ok(());
1379
        }
1380
    }
1381
}
1382
1383
/// Open a request or response body in `$EDITOR`: suspend the TUI, edit a temp
1384
/// file, resume. Request bodies are read back; responses are view-only.
1385
fn run_external_edit(
1386
    app: &mut App,
1387
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1388
    target: ExternalEdit,
1389
) -> Result<()> {
1390
    app.pending_external = None;
1391
1392
    let content = match target {
1393
        ExternalEdit::Body => {
1394
            app.commit_body();
1395
            app.selected_request()
1396
                .and_then(|r| r.body.clone())
1397
                .unwrap_or_default()
1398
        }
1399
        ExternalEdit::Response => match app.response.as_ref() {
1400
            Some(resp) => resp.body.clone(),
1401
            None => {
1402
                app.status = "No response to open".into();
1403
                return Ok(());
1404
            }
1405
        },
1406
    };
1407
1408
    let stem = match target {
1409
        ExternalEdit::Body => "body",
1410
        ExternalEdit::Response => "response",
1411
    };
1412
    let mut tmp = std::env::temp_dir();
1413
    tmp.push(format!(
1414
        "cielago-{stem}-{}.{}",
1415
        std::process::id(),
1416
        guess_extension(&content)
1417
    ));
1418
    std::fs::write(&tmp, &content)?;
1419
1420
    disable_raw_mode()?;
1421
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1422
1423
    let editor = app.config.editor_cmd();
1424
    let mut parts = editor.split_whitespace();
1425
    let program = parts.next().unwrap_or("vi");
1426
    let status = Command::new(program).args(parts).arg(&tmp).status();
1427
1428
    enable_raw_mode()?;
1429
    execute!(terminal.backend_mut(), EnterAlternateScreen)?;
1430
    terminal.clear()?;
1431
1432
    match (target, status) {
1433
        (ExternalEdit::Body, Ok(s)) if s.success() => {
1434
            let content = std::fs::read_to_string(&tmp)?;
1435
            if let Some(i) = app.selected {
1436
                app.collection.requests[i].body = Some(content.clone());
1437
                app.dirty = true;
1438
            }
1439
            app.set_textarea_text(&content);
1440
            app.status = "Body updated from editor".into();
1441
        }
1442
        (ExternalEdit::Body, Ok(s)) => {
1443
            app.status = format!("Editor exited with {s}; body unchanged")
1444
        }
1445
        // Nothing is read back: the response stays exactly as received.
1446
        (ExternalEdit::Response, Ok(_)) => app.status = "Response closed — unchanged".into(),
1447
        (_, Err(e)) => app.status = format!("Could not launch editor: {e}"),
1448
    }
1449
    let _ = std::fs::remove_file(&tmp);
1450
    Ok(())
1451
}
1452
1453
/// Extension for the temp file, so the editor picks sane syntax highlighting.
1454
fn guess_extension(content: &str) -> &'static str {
1455
    match content.trim_start().chars().next() {
1456
        Some('{') | Some('[') => "json",
1457
        Some('<') => "xml",
1458
        _ => "txt",
1459
    }
1460
}