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