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