src/tui.rs 32.7 K raw
1
use arboard::Clipboard;
2
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
3
use ratatui::{
4
    DefaultTerminal,
5
    layout::{Alignment, Constraint, Layout},
6
    style::{Color, Modifier, Style},
7
    text::{Line, Span, Text},
8
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget},
9
};
10
use crate::backend::Backend;
11
use crate::config;
12
use crate::db::Snippet;
13
use std::io::Cursor;
14
use std::path::PathBuf;
15
use std::time::{Duration, Instant};
16
use syntect::easy::HighlightLines;
17
use syntect::highlighting::Theme;
18
use syntect::parsing::SyntaxSet;
19
use syntect::util::LinesWithEndings;
20
21
enum Focus {
22
    List,
23
    Content,
24
    CreateName,
25
    CreateContent,
26
}
27
28
struct App {
29
    snippets: Vec<Snippet>,
30
    list_state: ListState,
31
    should_quit: bool,
32
    status_message: Option<(String, Instant)>,
33
    focus: Focus,
34
    content_scroll: u16,
35
    show_help: bool,
36
    confirm_delete: bool,
37
    syntax_set: SyntaxSet,
38
    theme: Theme,
39
    create_name: String,
40
    create_content: String,
41
    is_remote: bool,
42
    remote_url: Option<String>,
43
}
44
45
impl App {
46
    fn new(snippets: Vec<Snippet>, is_remote: bool, remote_url: Option<String>) -> Self {
47
        let mut list_state = ListState::default();
48
        if !snippets.is_empty() {
49
            list_state.select(Some(0));
50
        }
51
        let syntax_set = SyntaxSet::load_defaults_newlines();
52
        let theme_data = include_bytes!("ansi.tmTheme");
53
        let theme =
54
            syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..]))
55
                .expect("failed to load base16 theme");
56
        Self {
57
            snippets,
58
            list_state,
59
            should_quit: false,
60
            status_message: None,
61
            focus: Focus::List,
62
            content_scroll: 0,
63
            show_help: false,
64
            confirm_delete: false,
65
            syntax_set,
66
            theme,
67
            create_name: String::new(),
68
            create_content: String::new(),
69
            is_remote,
70
            remote_url,
71
        }
72
    }
73
74
    fn selected_snippet(&self) -> Option<&Snippet> {
75
        self.list_state.selected().and_then(|i| self.snippets.get(i))
76
    }
77
78
    fn move_up(&mut self) {
79
        if self.snippets.is_empty() {
80
            return;
81
        }
82
        let i = match self.list_state.selected() {
83
            Some(i) if i > 0 => i - 1,
84
            Some(_) => self.snippets.len() - 1,
85
            None => 0,
86
        };
87
        self.list_state.select(Some(i));
88
        self.content_scroll = 0;
89
    }
90
91
    fn move_down(&mut self) {
92
        if self.snippets.is_empty() {
93
            return;
94
        }
95
        let i = match self.list_state.selected() {
96
            Some(i) if i < self.snippets.len() - 1 => i + 1,
97
            Some(_) => 0,
98
            None => 0,
99
        };
100
        self.list_state.select(Some(i));
101
        self.content_scroll = 0;
102
    }
103
104
    fn scroll_up(&mut self) {
105
        self.content_scroll = self.content_scroll.saturating_sub(1);
106
    }
107
108
    fn scroll_down(&mut self, max_lines: u16) {
109
        if self.content_scroll < max_lines {
110
            self.content_scroll += 1;
111
        }
112
    }
113
114
    fn copy_selected(&mut self) {
115
        if let Some(snippet) = self.selected_snippet() {
116
            if let Ok(mut clipboard) = Clipboard::new() {
117
                let _ = clipboard.set_text(&snippet.content);
118
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
119
            }
120
        }
121
    }
122
123
    fn copy_link(&mut self) {
124
        match &self.remote_url {
125
            Some(url) => {
126
                if let Some(snippet) = self.selected_snippet() {
127
                    let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id);
128
                    if let Ok(mut clipboard) = Clipboard::new() {
129
                        let _ = clipboard.set_text(&link);
130
                        self.status_message =
131
                            Some(("Link copied!".to_string(), Instant::now()));
132
                    }
133
                }
134
            }
135
            None => {
136
                self.status_message =
137
                    Some(("No remote URL configured".to_string(), Instant::now()));
138
            }
139
        }
140
    }
141
142
    fn open_in_browser(&mut self) {
143
        match &self.remote_url {
144
            Some(url) => {
145
                if let Some(snippet) = self.selected_snippet() {
146
                    let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id);
147
                    if let Err(e) = open::that(&link) {
148
                        self.status_message =
149
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
150
                    } else {
151
                        self.status_message =
152
                            Some(("Opened in browser!".to_string(), Instant::now()));
153
                    }
154
                }
155
            }
156
            None => {
157
                self.status_message =
158
                    Some(("No remote URL configured".to_string(), Instant::now()));
159
            }
160
        }
161
    }
162
163
    fn delete_selected(&mut self, backend: &Backend) {
164
        if let Some(selected_index) = self.list_state.selected() {
165
            if let Some(snippet) = self.snippets.get(selected_index) {
166
                let short_id = snippet.short_id.clone();
167
                match backend.delete_snippet(&short_id) {
168
                    Ok(true) => {
169
                        self.snippets.remove(selected_index);
170
                        if self.snippets.is_empty() {
171
                            self.list_state.select(None);
172
                        } else if selected_index >= self.snippets.len() {
173
                            self.list_state.select(Some(self.snippets.len() - 1));
174
                        } else {
175
                            self.list_state.select(Some(selected_index));
176
                        }
177
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
178
                    }
179
                    Ok(false) => {
180
                        self.status_message =
181
                            Some(("Snippet not found".to_string(), Instant::now()));
182
                    }
183
                    Err(e) => {
184
                        self.status_message = Some((e.to_string(), Instant::now()));
185
                    }
186
                }
187
            }
188
        }
189
    }
190
191
    fn refresh(&mut self, backend: &Backend) {
192
        match backend.list_snippets() {
193
            Ok(snippets) => {
194
                self.snippets = snippets;
195
                if self.snippets.is_empty() {
196
                    self.list_state.select(None);
197
                } else {
198
                    let idx = self.list_state.selected().unwrap_or(0);
199
                    if idx >= self.snippets.len() {
200
                        self.list_state.select(Some(self.snippets.len() - 1));
201
                    }
202
                }
203
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
204
            }
205
            Err(e) => {
206
                self.status_message = Some((e.to_string(), Instant::now()));
207
            }
208
        }
209
    }
210
211
    fn start_create(&mut self) {
212
        self.create_name.clear();
213
        self.create_content.clear();
214
        self.focus = Focus::CreateName;
215
    }
216
217
    fn save_create(&mut self, backend: &Backend) {
218
        if self.create_name.trim().is_empty() {
219
            self.status_message = Some(("Name cannot be empty".to_string(), Instant::now()));
220
            return;
221
        }
222
        match backend.create_snippet(&self.create_name, &self.create_content) {
223
            Ok(snippet) => {
224
                self.snippets.insert(0, snippet);
225
                self.list_state.select(Some(0));
226
                self.status_message = Some(("Created!".to_string(), Instant::now()));
227
                self.focus = Focus::List;
228
                self.create_name.clear();
229
                self.create_content.clear();
230
            }
231
            Err(e) => {
232
                self.status_message = Some((e.to_string(), Instant::now()));
233
            }
234
        }
235
    }
236
237
    fn cancel_create(&mut self) {
238
        self.create_name.clear();
239
        self.create_content.clear();
240
        self.focus = Focus::List;
241
    }
242
243
    fn clear_expired_status(&mut self) {
244
        if let Some((_, time)) = &self.status_message {
245
            if time.elapsed() > Duration::from_secs(2) {
246
                self.status_message = None;
247
            }
248
        }
249
    }
250
251
    fn highlight_content(&self, name: &str, content: &str) -> Text<'static> {
252
        let raw_ext = name.rsplit('.').next().unwrap_or("");
253
        let ext = match raw_ext {
254
            "ts" | "tsx" | "jsx" => "js",
255
            other => other,
256
        };
257
        let syntax = self
258
            .syntax_set
259
            .find_syntax_by_extension(ext)
260
            .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
261
        let mut highlighter = HighlightLines::new(syntax, &self.theme);
262
263
        let lines: Vec<Line<'static>> = LinesWithEndings::from(content)
264
            .map(|line| {
265
                let ranges = highlighter
266
                    .highlight_line(line, &self.syntax_set)
267
                    .unwrap_or_default();
268
                let spans: Vec<Span<'static>> = ranges
269
                    .into_iter()
270
                    .map(|(style, text)| {
271
                        let color = to_ratatui_color(style.foreground);
272
                        Span::styled(text.to_owned(), Style::default().fg(color))
273
                    })
274
                    .collect();
275
                Line::from(spans)
276
            })
277
            .collect();
278
279
        Text::from(lines)
280
    }
281
}
282
283
fn to_ratatui_color(color: syntect::highlighting::Color) -> Color {
284
    if color.a == 0 {
285
        Color::Indexed(color.r)
286
    } else {
287
        Color::Reset
288
    }
289
}
290
291
fn resolve_backend(remote: Option<String>, api_key: Option<String>) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> {
292
    if let Some(url) = remote {
293
        return Ok((
294
            Backend::remote(url.clone(), api_key),
295
            true,
296
            Some(url),
297
        ));
298
    }
299
300
    if !std::path::Path::new("sipp.sqlite").exists() {
301
        let cfg = config::load_config();
302
        let url = cfg.remote_url.unwrap_or_else(|| "http://localhost:3000".to_string());
303
        let api_key = api_key.or(cfg.api_key);
304
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
305
    }
306
307
    Ok((Backend::local()?, false, Some("http://localhost:3000".to_string())))
308
}
309
310
pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
311
    use std::io::{self, Write};
312
313
    print!("Remote URL: ");
314
    io::stdout().flush()?;
315
    let mut remote_url = String::new();
316
    io::stdin().read_line(&mut remote_url)?;
317
    let remote_url = remote_url.trim().to_string();
318
319
    print!("API Key: ");
320
    io::stdout().flush()?;
321
    let api_key = rpassword::read_password()?;
322
    let api_key = api_key.trim().to_string();
323
324
    let cfg = config::Config {
325
        remote_url: if remote_url.is_empty() {
326
            None
327
        } else {
328
            Some(remote_url)
329
        },
330
        api_key: if api_key.is_empty() {
331
            None
332
        } else {
333
            Some(api_key)
334
        },
335
    };
336
337
    config::save_config(&cfg)?;
338
    println!("Config saved to {}", config::config_path().display());
339
    Ok(())
340
}
341
342
pub fn run_interactive(remote: Option<String>, api_key: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
343
    let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?;
344
345
    let snippets = match backend.list_snippets() {
346
        Ok(s) => s,
347
        Err(e) => {
348
            eprintln!("Failed to load snippets: {}", e);
349
            Vec::new()
350
        }
351
    };
352
353
    ratatui::run(|terminal| run_app(terminal, App::new(snippets, is_remote, remote_url), &backend))
354
}
355
356
pub fn run_file_upload(remote: Option<String>, api_key: Option<String>, file: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
357
    let (backend, _, remote_url) = resolve_backend(remote, api_key)?;
358
359
    let name = file
360
        .file_name()
361
        .ok_or("Invalid file path")?
362
        .to_string_lossy()
363
        .to_string();
364
    let content = std::fs::read_to_string(&file)
365
        .map_err(|e| format!("Failed to read file: {}", e))?;
366
    let snippet = backend
367
        .create_snippet(&name, &content)
368
        .map_err(|e| format!("{}", e))?;
369
    let link = match &remote_url {
370
        Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id),
371
        None => snippet.short_id.clone(),
372
    };
373
    println!("{}", link);
374
    if let Ok(mut clipboard) = Clipboard::new() {
375
        let _ = clipboard.set_text(&link);
376
        println!("\u{2714} Copied to clipboard!");
377
    }
378
    Ok(())
379
}
380
381
fn run_app(
382
    terminal: &mut DefaultTerminal,
383
    mut app: App,
384
    backend: &Backend,
385
) -> Result<(), Box<dyn std::error::Error>> {
386
    while !app.should_quit {
387
        app.clear_expired_status();
388
389
        let content_line_count = app
390
            .selected_snippet()
391
            .map(|s| s.content.lines().count() as u16)
392
            .unwrap_or(0);
393
394
        terminal.draw(|frame| {
395
            let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
396
                .split(frame.area());
397
398
            let chunks = Layout::horizontal([
399
                Constraint::Percentage(30),
400
                Constraint::Percentage(70),
401
            ])
402
            .split(outer[0]);
403
404
            let items: Vec<ListItem> = app
405
                .snippets
406
                .iter()
407
                .map(|s| ListItem::new(s.name.as_str()))
408
                .collect();
409
410
            let list_border_style = match app.focus {
411
                Focus::List => Style::default().fg(Color::Yellow),
412
                _ => Style::default().fg(Color::DarkGray),
413
            };
414
            let content_border_style = match app.focus {
415
                Focus::Content => Style::default().fg(Color::Yellow),
416
                _ => Style::default().fg(Color::DarkGray),
417
            };
418
419
            let list = List::new(items)
420
                .block(
421
                    Block::default()
422
                        .title(" Snippets ")
423
                        .borders(Borders::ALL)
424
                        .border_style(list_border_style),
425
                )
426
                .highlight_style(
427
                    Style::default()
428
                        .fg(Color::Yellow)
429
                        .add_modifier(Modifier::BOLD),
430
                )
431
                .highlight_symbol("▶ ");
432
433
            frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
434
435
            match app.focus {
436
                Focus::CreateName | Focus::CreateContent => {
437
                    let create_block = Block::default()
438
                        .title(" New Snippet ")
439
                        .borders(Borders::ALL)
440
                        .border_style(Style::default().fg(Color::Yellow));
441
442
                    let inner = create_block.inner(chunks[1]);
443
                    frame.render_widget(create_block, chunks[1]);
444
445
                    let form_layout = Layout::vertical([
446
                        Constraint::Length(3),
447
                        Constraint::Min(1),
448
                    ])
449
                    .split(inner);
450
451
                    let name_style = match app.focus {
452
                        Focus::CreateName => Style::default().fg(Color::Yellow),
453
                        _ => Style::default().fg(Color::DarkGray),
454
                    };
455
                    let name_input = Paragraph::new(app.create_name.as_str()).block(
456
                        Block::default()
457
                            .title(" Name ")
458
                            .borders(Borders::ALL)
459
                            .border_style(name_style),
460
                    );
461
                    frame.render_widget(name_input, form_layout[0]);
462
463
                    let content_style = match app.focus {
464
                        Focus::CreateContent => Style::default().fg(Color::Yellow),
465
                        _ => Style::default().fg(Color::DarkGray),
466
                    };
467
                    let content_input = Paragraph::new(app.create_content.as_str()).block(
468
                        Block::default()
469
                            .title(" Content ")
470
                            .borders(Borders::ALL)
471
                            .border_style(content_style),
472
                    );
473
                    frame.render_widget(content_input, form_layout[1]);
474
475
                    match app.focus {
476
                        Focus::CreateName => {
477
                            let x = form_layout[0].x + 1 + app.create_name.len() as u16;
478
                            let y = form_layout[0].y + 1;
479
                            frame.set_cursor_position((x, y));
480
                        }
481
                        Focus::CreateContent => {
482
                            let last_line = app.create_content.lines().last().unwrap_or("");
483
                            let line_count = app.create_content.lines().count()
484
                                + if app.create_content.ends_with('\n') {
485
                                    1
486
                                } else {
487
                                    0
488
                                };
489
                            let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
490
                            let x = form_layout[1].x
491
                                + 1
492
                                + if app.create_content.ends_with('\n') {
493
                                    0
494
                                } else {
495
                                    last_line.len() as u16
496
                                };
497
                            let y = form_layout[1].y + 1 + y_offset as u16;
498
                            frame.set_cursor_position((x, y));
499
                        }
500
                        _ => {}
501
                    }
502
503
                }
504
                _ => {
505
                    let highlighted = match app.selected_snippet() {
506
                        Some(s) => app.highlight_content(&s.name, &s.content),
507
                        None => Text::raw(""),
508
                    };
509
510
                    let paragraph = Paragraph::new(highlighted)
511
                        .block(
512
                            Block::default()
513
                                .title(" Content ")
514
                                .borders(Borders::ALL)
515
                                .border_style(content_border_style),
516
                        )
517
                        .scroll((app.content_scroll, 0));
518
519
                    frame.render_widget(paragraph, chunks[1]);
520
                }
521
            }
522
523
            let hints = match app.focus {
524
                Focus::List => Line::from(vec![
525
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
526
                    Span::raw(": Navigate  "),
527
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
528
                    Span::raw(": View  "),
529
                    Span::styled("y", Style::default().fg(Color::Yellow)),
530
                    Span::raw(": Copy  "),
531
                    Span::styled("d", Style::default().fg(Color::Yellow)),
532
                    Span::raw(": Delete  "),
533
                    Span::styled("c", Style::default().fg(Color::Yellow)),
534
                    Span::raw(": Create  "),
535
                    Span::styled("?", Style::default().fg(Color::Yellow)),
536
                    Span::raw(": Help  "),
537
                    Span::styled("q", Style::default().fg(Color::Yellow)),
538
                    Span::raw(": Quit"),
539
                ]),
540
                Focus::Content => Line::from(vec![
541
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
542
                    Span::raw(": Scroll  "),
543
                    Span::styled("y", Style::default().fg(Color::Yellow)),
544
                    Span::raw(": Copy  "),
545
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
546
                    Span::raw(": Back  "),
547
                    Span::styled("?", Style::default().fg(Color::Yellow)),
548
                    Span::raw(": Help"),
549
                ]),
550
                Focus::CreateName | Focus::CreateContent => Line::from(vec![
551
                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
552
                    Span::raw(": Switch field  "),
553
                    Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
554
                    Span::raw(": Save  "),
555
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
556
                    Span::raw(": Cancel"),
557
                ]),
558
            };
559
            frame.render_widget(Paragraph::new(hints), outer[1]);
560
561
            if let Some((msg, _)) = &app.status_message {
562
                let area = frame.area();
563
                let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
564
                let popup_area = ratatui::layout::Rect {
565
                    x: (area.width.saturating_sub(msg_width)) / 2,
566
                    y: (area.height.saturating_sub(3)) / 2,
567
                    width: msg_width,
568
                    height: 3,
569
                };
570
                Clear.render(popup_area, frame.buffer_mut());
571
                let status_popup = Paragraph::new(Line::from(msg.as_str()))
572
                    .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
573
                    .alignment(Alignment::Center)
574
                    .block(
575
                        Block::default()
576
                            .borders(Borders::ALL)
577
                            .border_style(Style::default().fg(Color::Green)),
578
                    );
579
                frame.render_widget(status_popup, popup_area);
580
            }
581
582
            if app.confirm_delete {
583
                let delete_msg = match app.selected_snippet() {
584
                    Some(s) => format!("Delete {}? (y/n)", s.name),
585
                    None => "Delete snippet? (y/n)".to_string(),
586
                };
587
                let area = frame.area();
588
                let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4));
589
                let popup_area = ratatui::layout::Rect {
590
                    x: (area.width.saturating_sub(msg_width)) / 2,
591
                    y: (area.height.saturating_sub(3)) / 2,
592
                    width: msg_width,
593
                    height: 3,
594
                };
595
                Clear.render(popup_area, frame.buffer_mut());
596
                let confirm_popup = Paragraph::new(Line::from(delete_msg))
597
                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
598
                    .alignment(Alignment::Center)
599
                    .block(
600
                        Block::default()
601
                            .borders(Borders::ALL)
602
                            .border_style(Style::default().fg(Color::Red)),
603
                    );
604
                frame.render_widget(confirm_popup, popup_area);
605
            }
606
607
            if app.show_help {
608
                let area = frame.area();
609
                let popup_width = 34u16.min(area.width.saturating_sub(4));
610
                let popup_height = 17u16.min(area.height.saturating_sub(4));
611
                let popup_area = ratatui::layout::Rect {
612
                    x: (area.width.saturating_sub(popup_width)) / 2,
613
                    y: (area.height.saturating_sub(popup_height)) / 2,
614
                    width: popup_width,
615
                    height: popup_height,
616
                };
617
618
                let mut help_lines = vec![
619
                    Line::from(""),
620
                    Line::from(vec![
621
                        Span::styled(
622
                            "  j/↓  ",
623
                            Style::default()
624
                                .fg(Color::Yellow)
625
                                .add_modifier(Modifier::BOLD),
626
                        ),
627
                        Span::raw("Move down / Scroll down"),
628
                    ]),
629
                    Line::from(vec![
630
                        Span::styled(
631
                            "  k/↑  ",
632
                            Style::default()
633
                                .fg(Color::Yellow)
634
                                .add_modifier(Modifier::BOLD),
635
                        ),
636
                        Span::raw("Move up / Scroll up"),
637
                    ]),
638
                    Line::from(vec![
639
                        Span::styled(
640
                            "  Enter",
641
                            Style::default()
642
                                .fg(Color::Yellow)
643
                                .add_modifier(Modifier::BOLD),
644
                        ),
645
                        Span::raw("  Focus content pane"),
646
                    ]),
647
                    Line::from(vec![
648
                        Span::styled(
649
                            "  Esc  ",
650
                            Style::default()
651
                                .fg(Color::Yellow)
652
                                .add_modifier(Modifier::BOLD),
653
                        ),
654
                        Span::raw("Back / Quit"),
655
                    ]),
656
                    Line::from(vec![
657
                        Span::styled(
658
                            "  y    ",
659
                            Style::default()
660
                                .fg(Color::Yellow)
661
                                .add_modifier(Modifier::BOLD),
662
                        ),
663
                        Span::raw("Copy snippet"),
664
                    ]),
665
                    Line::from(vec![
666
                        Span::styled(
667
                            "  Y    ",
668
                            Style::default()
669
                                .fg(Color::Yellow)
670
                                .add_modifier(Modifier::BOLD),
671
                        ),
672
                        Span::raw("Copy link"),
673
                    ]),
674
                    Line::from(vec![
675
                        Span::styled(
676
                            "  o    ",
677
                            Style::default()
678
                                .fg(Color::Yellow)
679
                                .add_modifier(Modifier::BOLD),
680
                        ),
681
                        Span::raw("Open in browser"),
682
                    ]),
683
                    Line::from(vec![
684
                        Span::styled(
685
                            "  d    ",
686
                            Style::default()
687
                                .fg(Color::Yellow)
688
                                .add_modifier(Modifier::BOLD),
689
                        ),
690
                        Span::raw("Delete snippet"),
691
                    ]),
692
                    Line::from(vec![
693
                        Span::styled(
694
                            "  c    ",
695
                            Style::default()
696
                                .fg(Color::Yellow)
697
                                .add_modifier(Modifier::BOLD),
698
                        ),
699
                        Span::raw("Create snippet"),
700
                    ]),
701
                ];
702
703
                if app.is_remote {
704
                    help_lines.push(Line::from(vec![
705
                        Span::styled(
706
                            "  r    ",
707
                            Style::default()
708
                                .fg(Color::Yellow)
709
                                .add_modifier(Modifier::BOLD),
710
                        ),
711
                        Span::raw("Refresh snippets"),
712
                    ]));
713
                }
714
715
                help_lines.extend([
716
                    Line::from(vec![
717
                        Span::styled(
718
                            "  q    ",
719
                            Style::default()
720
                                .fg(Color::Yellow)
721
                                .add_modifier(Modifier::BOLD),
722
                        ),
723
                        Span::raw("Quit"),
724
                    ]),
725
                    Line::from(vec![
726
                        Span::styled(
727
                            "  ?    ",
728
                            Style::default()
729
                                .fg(Color::Yellow)
730
                                .add_modifier(Modifier::BOLD),
731
                        ),
732
                        Span::raw("Toggle this help"),
733
                    ]),
734
                    Line::from(""),
735
                    Line::from(Span::styled(
736
                        "  Press any key to close",
737
                        Style::default().fg(Color::DarkGray),
738
                    )),
739
                ]);
740
741
                let help_text = Text::from(help_lines);
742
743
                Clear.render(popup_area, frame.buffer_mut());
744
                let help = Paragraph::new(help_text).block(
745
                    Block::default()
746
                        .title(" Keybindings ")
747
                        .borders(Borders::ALL)
748
                        .border_style(Style::default().fg(Color::Yellow)),
749
                );
750
                frame.render_widget(help, popup_area);
751
            }
752
        })?;
753
754
        if event::poll(Duration::from_millis(100))? {
755
            if let Event::Key(key) = event::read()? {
756
                if app.show_help {
757
                    app.show_help = false;
758
                } else if app.status_message.is_some() {
759
                    app.status_message = None;
760
                } else if app.confirm_delete {
761
                    if key.code == KeyCode::Char('y') {
762
                        app.delete_selected(backend);
763
                    }
764
                    app.confirm_delete = false;
765
                } else {
766
                    match app.focus {
767
                        Focus::List => match key.code {
768
                            KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
769
                            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
770
                            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
771
                            KeyCode::Char('y') => app.copy_selected(),
772
                            KeyCode::Char('Y') => app.copy_link(),
773
                            KeyCode::Char('d') => app.confirm_delete = true,
774
                            KeyCode::Char('c') => app.start_create(),
775
                            KeyCode::Char('o') => app.open_in_browser(),
776
                            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
777
                            KeyCode::Char('?') => app.show_help = true,
778
                            KeyCode::Enter | KeyCode::Char('l') => {
779
                                if app.selected_snippet().is_some() {
780
                                    app.focus = Focus::Content;
781
                                }
782
                            }
783
                            _ => {}
784
                        },
785
                        Focus::Content => match key.code {
786
                          KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => {
787
                                app.focus = Focus::List;
788
                            }
789
                            KeyCode::Char('j') | KeyCode::Down => {
790
                                app.scroll_down(content_line_count);
791
                            }
792
                            KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
793
                            KeyCode::Char('y') => app.copy_selected(),
794
                            KeyCode::Char('Y') => app.copy_link(),
795
                            KeyCode::Char('o') => app.open_in_browser(),
796
                            KeyCode::Char('?') => app.show_help = true,
797
                            _ => {}
798
                        },
799
                        Focus::CreateName => {
800
                            if key.modifiers.contains(KeyModifiers::CONTROL)
801
                                && key.code == KeyCode::Char('s')
802
                            {
803
                                app.save_create(backend);
804
                            } else {
805
                                match key.code {
806
                                    KeyCode::Esc => app.cancel_create(),
807
                                    KeyCode::Enter | KeyCode::Tab => {
808
                                        app.focus = Focus::CreateContent
809
                                    }
810
                                    KeyCode::Backspace => {
811
                                        app.create_name.pop();
812
                                    }
813
                                    KeyCode::Char(c) => app.create_name.push(c),
814
                                    _ => {}
815
                                }
816
                            }
817
                        }
818
                        Focus::CreateContent => {
819
                            if key.modifiers.contains(KeyModifiers::CONTROL)
820
                                && key.code == KeyCode::Char('s')
821
                            {
822
                                app.save_create(backend);
823
                            } else {
824
                                match key.code {
825
                                    KeyCode::Esc => app.cancel_create(),
826
                                    KeyCode::Tab => app.focus = Focus::CreateName,
827
                                    KeyCode::Enter => app.create_content.push('\n'),
828
                                    KeyCode::Backspace => {
829
                                        app.create_content.pop();
830
                                    }
831
                                    KeyCode::Char(c) => app.create_content.push(c),
832
                                    _ => {}
833
                                }
834
                            }
835
                        }
836
                    }
837
                }
838
            }
839
        }
840
    }
841
842
    Ok(())
843
}