src/tui.rs 43.8 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
    EditName,
27
    EditContent,
28
    Search,
29
}
30
31
struct App {
32
    snippets: Vec<Snippet>,
33
    list_state: ListState,
34
    should_quit: bool,
35
    status_message: Option<(String, Instant)>,
36
    focus: Focus,
37
    content_scroll: u16,
38
    show_help: bool,
39
    confirm_delete: bool,
40
    syntax_set: SyntaxSet,
41
    theme: Theme,
42
    create_name: String,
43
    create_content: String,
44
    edit_short_id: Option<String>,
45
    search_query: String,
46
    filtered_indices: Option<Vec<usize>>,
47
    is_remote: bool,
48
    remote_url: Option<String>,
49
}
50
51
impl App {
52
    fn new(snippets: Vec<Snippet>, is_remote: bool, remote_url: Option<String>) -> Self {
53
        let mut list_state = ListState::default();
54
        if !snippets.is_empty() {
55
            list_state.select(Some(0));
56
        }
57
        let syntax_set = SyntaxSet::load_defaults_newlines();
58
        let theme_data = include_bytes!("ansi.tmTheme");
59
        let theme =
60
            syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..]))
61
                .expect("failed to load base16 theme");
62
        Self {
63
            snippets,
64
            list_state,
65
            should_quit: false,
66
            status_message: None,
67
            focus: Focus::List,
68
            content_scroll: 0,
69
            show_help: false,
70
            confirm_delete: false,
71
            syntax_set,
72
            theme,
73
            create_name: String::new(),
74
            create_content: String::new(),
75
            edit_short_id: None,
76
            search_query: String::new(),
77
            filtered_indices: None,
78
            is_remote,
79
            remote_url,
80
        }
81
    }
82
83
    fn selected_snippet(&self) -> Option<&Snippet> {
84
        self.list_state.selected().and_then(|i| {
85
            if let Some(indices) = &self.filtered_indices {
86
                indices.get(i).and_then(|&real| self.snippets.get(real))
87
            } else {
88
                self.snippets.get(i)
89
            }
90
        })
91
    }
92
93
    fn visible_count(&self) -> usize {
94
        match &self.filtered_indices {
95
            Some(indices) => indices.len(),
96
            None => self.snippets.len(),
97
        }
98
    }
99
100
    fn move_up(&mut self) {
101
        let count = self.visible_count();
102
        if count == 0 {
103
            return;
104
        }
105
        let i = match self.list_state.selected() {
106
            Some(i) if i > 0 => i - 1,
107
            Some(_) => count - 1,
108
            None => 0,
109
        };
110
        self.list_state.select(Some(i));
111
        self.content_scroll = 0;
112
    }
113
114
    fn move_down(&mut self) {
115
        let count = self.visible_count();
116
        if count == 0 {
117
            return;
118
        }
119
        let i = match self.list_state.selected() {
120
            Some(i) if i < count - 1 => i + 1,
121
            Some(_) => 0,
122
            None => 0,
123
        };
124
        self.list_state.select(Some(i));
125
        self.content_scroll = 0;
126
    }
127
128
    fn scroll_up(&mut self) {
129
        self.content_scroll = self.content_scroll.saturating_sub(1);
130
    }
131
132
    fn scroll_down(&mut self, max_lines: u16) {
133
        if self.content_scroll < max_lines {
134
            self.content_scroll += 1;
135
        }
136
    }
137
138
    fn copy_selected(&mut self) {
139
        if let Some(snippet) = self.selected_snippet() {
140
            if let Ok(mut clipboard) = Clipboard::new() {
141
                let _ = clipboard.set_text(&snippet.content);
142
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
143
            }
144
        }
145
    }
146
147
    fn copy_link(&mut self) {
148
        match &self.remote_url {
149
            Some(url) => {
150
                if let Some(snippet) = self.selected_snippet() {
151
                    let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id);
152
                    if let Ok(mut clipboard) = Clipboard::new() {
153
                        let _ = clipboard.set_text(&link);
154
                        self.status_message =
155
                            Some(("Link copied!".to_string(), Instant::now()));
156
                    }
157
                }
158
            }
159
            None => {
160
                self.status_message =
161
                    Some(("No remote URL configured".to_string(), Instant::now()));
162
            }
163
        }
164
    }
165
166
    fn open_in_browser(&mut self) {
167
        match &self.remote_url {
168
            Some(url) => {
169
                if let Some(snippet) = self.selected_snippet() {
170
                    let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id);
171
                    if let Err(e) = open::that(&link) {
172
                        self.status_message =
173
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
174
                    } else {
175
                        self.status_message =
176
                            Some(("Opened in browser!".to_string(), Instant::now()));
177
                    }
178
                }
179
            }
180
            None => {
181
                self.status_message =
182
                    Some(("No remote URL configured".to_string(), Instant::now()));
183
            }
184
        }
185
    }
186
187
    fn delete_selected(&mut self, backend: &Backend) {
188
        if let Some(selected_index) = self.list_state.selected() {
189
            let real_index = if let Some(indices) = &self.filtered_indices {
190
                match indices.get(selected_index) {
191
                    Some(&ri) => ri,
192
                    None => return,
193
                }
194
            } else {
195
                selected_index
196
            };
197
            if let Some(snippet) = self.snippets.get(real_index) {
198
                let short_id = snippet.short_id.clone();
199
                match backend.delete_snippet(&short_id) {
200
                    Ok(true) => {
201
                        self.snippets.remove(real_index);
202
                        if self.filtered_indices.is_some() {
203
                            self.update_search_filter();
204
                        }
205
                        let count = self.visible_count();
206
                        if count == 0 {
207
                            self.list_state.select(None);
208
                        } else if selected_index >= count {
209
                            self.list_state.select(Some(count - 1));
210
                        } else {
211
                            self.list_state.select(Some(selected_index));
212
                        }
213
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
214
                    }
215
                    Ok(false) => {
216
                        self.status_message =
217
                            Some(("Snippet not found".to_string(), Instant::now()));
218
                    }
219
                    Err(e) => {
220
                        self.status_message = Some((e.to_string(), Instant::now()));
221
                    }
222
                }
223
            }
224
        }
225
    }
226
227
    fn refresh(&mut self, backend: &Backend) {
228
        match backend.list_snippets() {
229
            Ok(snippets) => {
230
                self.snippets = snippets;
231
                self.filtered_indices = None;
232
                self.search_query.clear();
233
                if self.snippets.is_empty() {
234
                    self.list_state.select(None);
235
                } else {
236
                    let idx = self.list_state.selected().unwrap_or(0);
237
                    if idx >= self.snippets.len() {
238
                        self.list_state.select(Some(self.snippets.len() - 1));
239
                    }
240
                }
241
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
242
            }
243
            Err(e) => {
244
                self.status_message = Some((e.to_string(), Instant::now()));
245
            }
246
        }
247
    }
248
249
    fn start_create(&mut self) {
250
        self.create_name.clear();
251
        self.create_content.clear();
252
        self.focus = Focus::CreateName;
253
    }
254
255
    fn save_create(&mut self, backend: &Backend) {
256
        if self.create_name.trim().is_empty() {
257
            self.status_message = Some(("Name cannot be empty".to_string(), Instant::now()));
258
            return;
259
        }
260
        match backend.create_snippet(&self.create_name, &self.create_content) {
261
            Ok(snippet) => {
262
                self.snippets.insert(0, snippet);
263
                self.list_state.select(Some(0));
264
                self.filtered_indices = None;
265
                self.search_query.clear();
266
                self.status_message = Some(("Created!".to_string(), Instant::now()));
267
                self.focus = Focus::List;
268
                self.create_name.clear();
269
                self.create_content.clear();
270
            }
271
            Err(e) => {
272
                self.status_message = Some((e.to_string(), Instant::now()));
273
            }
274
        }
275
    }
276
277
    fn cancel_create(&mut self) {
278
        self.create_name.clear();
279
        self.create_content.clear();
280
        self.focus = Focus::List;
281
    }
282
283
    fn start_edit(&mut self) {
284
        let data = self.selected_snippet().map(|s| {
285
            (s.name.clone(), s.content.clone(), s.short_id.clone())
286
        });
287
        if let Some((name, content, short_id)) = data {
288
            self.create_name = name;
289
            self.create_content = content;
290
            self.edit_short_id = Some(short_id);
291
            self.focus = Focus::EditName;
292
        }
293
    }
294
295
    fn save_edit(&mut self, backend: &Backend) {
296
        if self.create_name.trim().is_empty() {
297
            self.status_message = Some(("Name cannot be empty".to_string(), Instant::now()));
298
            return;
299
        }
300
        let short_id = match &self.edit_short_id {
301
            Some(id) => id.clone(),
302
            None => return,
303
        };
304
        match backend.update_snippet(&short_id, &self.create_name, &self.create_content) {
305
            Ok(Some(updated)) => {
306
                if let Some(pos) = self.snippets.iter().position(|s| s.short_id == short_id) {
307
                    self.snippets[pos] = updated;
308
                }
309
                self.status_message = Some(("Updated!".to_string(), Instant::now()));
310
                self.focus = Focus::List;
311
                self.create_name.clear();
312
                self.create_content.clear();
313
                self.edit_short_id = None;
314
            }
315
            Ok(None) => {
316
                self.status_message = Some(("Snippet not found".to_string(), Instant::now()));
317
            }
318
            Err(e) => {
319
                self.status_message = Some((e.to_string(), Instant::now()));
320
            }
321
        }
322
    }
323
324
    fn cancel_edit(&mut self) {
325
        self.create_name.clear();
326
        self.create_content.clear();
327
        self.edit_short_id = None;
328
        self.focus = Focus::List;
329
    }
330
331
    fn start_search(&mut self) {
332
        self.search_query.clear();
333
        self.filtered_indices = Some((0..self.snippets.len()).collect());
334
        self.focus = Focus::Search;
335
        self.list_state.select(if self.snippets.is_empty() { None } else { Some(0) });
336
    }
337
338
    fn update_search_filter(&mut self) {
339
        let query = self.search_query.to_lowercase();
340
        let indices: Vec<usize> = self
341
            .snippets
342
            .iter()
343
            .enumerate()
344
            .filter(|(_, s)| s.name.to_lowercase().contains(&query))
345
            .map(|(i, _)| i)
346
            .collect();
347
        self.filtered_indices = Some(indices);
348
        if self.visible_count() == 0 {
349
            self.list_state.select(None);
350
        } else {
351
            self.list_state.select(Some(0));
352
        }
353
    }
354
355
    fn cancel_search(&mut self) {
356
        self.filtered_indices = None;
357
        self.search_query.clear();
358
        self.focus = Focus::List;
359
    }
360
361
    fn confirm_search(&mut self) {
362
        let real_index = self.list_state.selected().and_then(|i| {
363
            self.filtered_indices.as_ref().and_then(|indices| indices.get(i).copied())
364
        });
365
        self.filtered_indices = None;
366
        self.search_query.clear();
367
        self.focus = Focus::List;
368
        if let Some(ri) = real_index {
369
            self.list_state.select(Some(ri));
370
        }
371
    }
372
373
    fn clear_expired_status(&mut self) {
374
        if let Some((_, time)) = &self.status_message {
375
            if time.elapsed() > Duration::from_secs(2) {
376
                self.status_message = None;
377
            }
378
        }
379
    }
380
381
    fn highlight_content(&self, name: &str, content: &str) -> Text<'static> {
382
        let raw_ext = name.rsplit('.').next().unwrap_or("");
383
        let ext = match raw_ext {
384
            "ts" | "tsx" | "jsx" => "js",
385
            other => other,
386
        };
387
        let syntax = self
388
            .syntax_set
389
            .find_syntax_by_extension(ext)
390
            .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
391
        let mut highlighter = HighlightLines::new(syntax, &self.theme);
392
393
        let lines: Vec<Line<'static>> = LinesWithEndings::from(content)
394
            .map(|line| {
395
                let ranges = highlighter
396
                    .highlight_line(line, &self.syntax_set)
397
                    .unwrap_or_default();
398
                let spans: Vec<Span<'static>> = ranges
399
                    .into_iter()
400
                    .map(|(style, text)| {
401
                        let color = to_ratatui_color(style.foreground);
402
                        Span::styled(text.to_owned(), Style::default().fg(color))
403
                    })
404
                    .collect();
405
                Line::from(spans)
406
            })
407
            .collect();
408
409
        Text::from(lines)
410
    }
411
}
412
413
fn to_ratatui_color(color: syntect::highlighting::Color) -> Color {
414
    if color.a == 0 {
415
        Color::Indexed(color.r)
416
    } else {
417
        Color::Reset
418
    }
419
}
420
421
fn resolve_backend(remote: Option<String>, api_key: Option<String>) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> {
422
    if let Some(url) = remote {
423
        return Ok((
424
            Backend::remote(url.clone(), api_key),
425
            true,
426
            Some(url),
427
        ));
428
    }
429
430
    if !std::path::Path::new("sipp.sqlite").exists() {
431
        let cfg = config::load_config();
432
        let url = cfg.remote_url.unwrap_or_else(|| "http://localhost:3000".to_string());
433
        let api_key = api_key.or(cfg.api_key);
434
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
435
    }
436
437
    Ok((Backend::local()?, false, Some("http://localhost:3000".to_string())))
438
}
439
440
pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
441
    use std::io::{self, Write};
442
443
    print!("Remote URL: ");
444
    io::stdout().flush()?;
445
    let mut remote_url = String::new();
446
    io::stdin().read_line(&mut remote_url)?;
447
    let remote_url = remote_url.trim().to_string();
448
449
    print!("API Key: ");
450
    io::stdout().flush()?;
451
    let api_key = rpassword::read_password()?;
452
    let api_key = api_key.trim().to_string();
453
454
    let cfg = config::Config {
455
        remote_url: if remote_url.is_empty() {
456
            None
457
        } else {
458
            Some(remote_url)
459
        },
460
        api_key: if api_key.is_empty() {
461
            None
462
        } else {
463
            Some(api_key)
464
        },
465
    };
466
467
    config::save_config(&cfg)?;
468
    println!("Config saved to {}", config::config_path().display());
469
    Ok(())
470
}
471
472
pub fn run_interactive(remote: Option<String>, api_key: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
473
    let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?;
474
475
    let snippets = match backend.list_snippets() {
476
        Ok(s) => s,
477
        Err(e) => {
478
            eprintln!("Failed to load snippets: {}", e);
479
            Vec::new()
480
        }
481
    };
482
483
    ratatui::run(|terminal| run_app(terminal, App::new(snippets, is_remote, remote_url), &backend))
484
}
485
486
pub fn run_file_upload(remote: Option<String>, api_key: Option<String>, file: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
487
    let (backend, _, remote_url) = resolve_backend(remote, api_key)?;
488
489
    let name = file
490
        .file_name()
491
        .ok_or("Invalid file path")?
492
        .to_string_lossy()
493
        .to_string();
494
    let content = std::fs::read_to_string(&file)
495
        .map_err(|e| format!("Failed to read file: {}", e))?;
496
    let snippet = backend
497
        .create_snippet(&name, &content)
498
        .map_err(|e| format!("{}", e))?;
499
    let link = match &remote_url {
500
        Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id),
501
        None => snippet.short_id.clone(),
502
    };
503
    println!("{}", link);
504
    if let Ok(mut clipboard) = Clipboard::new() {
505
        let _ = clipboard.set_text(&link);
506
        println!("\u{2714} Copied to clipboard!");
507
    }
508
    Ok(())
509
}
510
511
fn run_app(
512
    terminal: &mut DefaultTerminal,
513
    mut app: App,
514
    backend: &Backend,
515
) -> Result<(), Box<dyn std::error::Error>> {
516
    while !app.should_quit {
517
        app.clear_expired_status();
518
519
        let content_line_count = app
520
            .selected_snippet()
521
            .map(|s| s.content.lines().count() as u16)
522
            .unwrap_or(0);
523
524
        terminal.draw(|frame| {
525
            let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
526
                .split(frame.area());
527
528
            let chunks = Layout::horizontal([
529
                Constraint::Percentage(30),
530
                Constraint::Percentage(70),
531
            ])
532
            .split(outer[0]);
533
534
            let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
535
                indices
536
                    .iter()
537
                    .filter_map(|&i| app.snippets.get(i))
538
                    .map(|s| ListItem::new(s.name.as_str()))
539
                    .collect()
540
            } else {
541
                app.snippets
542
                    .iter()
543
                    .map(|s| ListItem::new(s.name.as_str()))
544
                    .collect()
545
            };
546
547
            let list_border_style = match app.focus {
548
                Focus::List | Focus::Search => Style::default().fg(Color::Yellow),
549
                _ => Style::default().fg(Color::DarkGray),
550
            };
551
            let content_border_style = match app.focus {
552
                Focus::Content => Style::default().fg(Color::Yellow),
553
                _ => Style::default().fg(Color::DarkGray),
554
            };
555
556
            let list = List::new(items)
557
                .block(
558
                    Block::default()
559
                        .title(" Snippets ")
560
                        .borders(Borders::ALL)
561
                        .border_style(list_border_style),
562
                )
563
                .highlight_style(
564
                    Style::default()
565
                        .fg(Color::Yellow)
566
                        .add_modifier(Modifier::BOLD),
567
                )
568
                .highlight_symbol("▶ ");
569
570
            if matches!(app.focus, Focus::Search) {
571
                let search_split = Layout::vertical([
572
                    Constraint::Min(1),
573
                    Constraint::Length(3),
574
                ])
575
                .split(chunks[0]);
576
577
                let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
578
                    indices
579
                        .iter()
580
                        .filter_map(|&i| app.snippets.get(i))
581
                        .map(|s| ListItem::new(s.name.as_str()))
582
                        .collect()
583
                } else {
584
                    app.snippets.iter().map(|s| ListItem::new(s.name.as_str())).collect()
585
                };
586
                let search_list = List::new(search_items)
587
                .block(
588
                    Block::default()
589
                        .title(" Snippets ")
590
                        .borders(Borders::ALL)
591
                        .border_style(list_border_style),
592
                )
593
                .highlight_style(
594
                    Style::default()
595
                        .fg(Color::Yellow)
596
                        .add_modifier(Modifier::BOLD),
597
                )
598
                .highlight_symbol("▶ ");
599
                frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state);
600
601
                let search_input = Paragraph::new(app.search_query.as_str()).block(
602
                    Block::default()
603
                        .title(" Search ")
604
                        .borders(Borders::ALL)
605
                        .border_style(Style::default().fg(Color::Yellow)),
606
                );
607
                frame.render_widget(search_input, search_split[1]);
608
609
                let x = search_split[1].x + 1 + app.search_query.len() as u16;
610
                let y = search_split[1].y + 1;
611
                frame.set_cursor_position((x, y));
612
            } else {
613
                frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
614
            }
615
616
            match app.focus {
617
                Focus::CreateName | Focus::CreateContent | Focus::EditName | Focus::EditContent => {
618
                    let form_title = match app.focus {
619
                        Focus::EditName | Focus::EditContent => " Edit Snippet ",
620
                        _ => " New Snippet ",
621
                    };
622
                    let create_block = Block::default()
623
                        .title(form_title)
624
                        .borders(Borders::ALL)
625
                        .border_style(Style::default().fg(Color::Yellow));
626
627
                    let inner = create_block.inner(chunks[1]);
628
                    frame.render_widget(create_block, chunks[1]);
629
630
                    let form_layout = Layout::vertical([
631
                        Constraint::Length(3),
632
                        Constraint::Min(1),
633
                    ])
634
                    .split(inner);
635
636
                    let name_style = match app.focus {
637
                        Focus::CreateName | Focus::EditName => Style::default().fg(Color::Yellow),
638
                        _ => Style::default().fg(Color::DarkGray),
639
                    };
640
                    let name_input = Paragraph::new(app.create_name.as_str()).block(
641
                        Block::default()
642
                            .title(" Name ")
643
                            .borders(Borders::ALL)
644
                            .border_style(name_style),
645
                    );
646
                    frame.render_widget(name_input, form_layout[0]);
647
648
                    let content_style = match app.focus {
649
                        Focus::CreateContent | Focus::EditContent => Style::default().fg(Color::Yellow),
650
                        _ => Style::default().fg(Color::DarkGray),
651
                    };
652
                    let content_input = Paragraph::new(app.create_content.as_str()).block(
653
                        Block::default()
654
                            .title(" Content ")
655
                            .borders(Borders::ALL)
656
                            .border_style(content_style),
657
                    );
658
                    frame.render_widget(content_input, form_layout[1]);
659
660
                    match app.focus {
661
                        Focus::CreateName | Focus::EditName => {
662
                            let x = form_layout[0].x + 1 + app.create_name.len() as u16;
663
                            let y = form_layout[0].y + 1;
664
                            frame.set_cursor_position((x, y));
665
                        }
666
                        Focus::CreateContent | Focus::EditContent => {
667
                            let last_line = app.create_content.lines().last().unwrap_or("");
668
                            let line_count = app.create_content.lines().count()
669
                                + if app.create_content.ends_with('\n') {
670
                                    1
671
                                } else {
672
                                    0
673
                                };
674
                            let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
675
                            let x = form_layout[1].x
676
                                + 1
677
                                + if app.create_content.ends_with('\n') {
678
                                    0
679
                                } else {
680
                                    last_line.len() as u16
681
                                };
682
                            let y = form_layout[1].y + 1 + y_offset as u16;
683
                            frame.set_cursor_position((x, y));
684
                        }
685
                        _ => {}
686
                    }
687
688
                }
689
                _ => {
690
                    let highlighted = match app.selected_snippet() {
691
                        Some(s) => app.highlight_content(&s.name, &s.content),
692
                        None => Text::raw(""),
693
                    };
694
695
                    let paragraph = Paragraph::new(highlighted)
696
                        .block(
697
                            Block::default()
698
                                .title(" Content ")
699
                                .borders(Borders::ALL)
700
                                .border_style(content_border_style),
701
                        )
702
                        .scroll((app.content_scroll, 0));
703
704
                    frame.render_widget(paragraph, chunks[1]);
705
                }
706
            }
707
708
            let hints = match app.focus {
709
                Focus::List => Line::from(vec![
710
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
711
                    Span::raw(": Navigate  "),
712
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
713
                    Span::raw(": View  "),
714
                    Span::styled("y", Style::default().fg(Color::Yellow)),
715
                    Span::raw(": Copy  "),
716
                    Span::styled("e", Style::default().fg(Color::Yellow)),
717
                    Span::raw(": Edit  "),
718
                    Span::styled("d", Style::default().fg(Color::Yellow)),
719
                    Span::raw(": Delete  "),
720
                    Span::styled("c", Style::default().fg(Color::Yellow)),
721
                    Span::raw(": Create  "),
722
                    Span::styled("/", Style::default().fg(Color::Yellow)),
723
                    Span::raw(": Search  "),
724
                    Span::styled("?", Style::default().fg(Color::Yellow)),
725
                    Span::raw(": Help  "),
726
                    Span::styled("q", Style::default().fg(Color::Yellow)),
727
                    Span::raw(": Quit"),
728
                ]),
729
                Focus::Content => Line::from(vec![
730
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
731
                    Span::raw(": Scroll  "),
732
                    Span::styled("y", Style::default().fg(Color::Yellow)),
733
                    Span::raw(": Copy  "),
734
                    Span::styled("e", Style::default().fg(Color::Yellow)),
735
                    Span::raw(": Edit  "),
736
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
737
                    Span::raw(": Back  "),
738
                    Span::styled("?", Style::default().fg(Color::Yellow)),
739
                    Span::raw(": Help"),
740
                ]),
741
                Focus::CreateName | Focus::CreateContent
742
                | Focus::EditName | Focus::EditContent => Line::from(vec![
743
                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
744
                    Span::raw(": Switch field  "),
745
                    Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
746
                    Span::raw(": Save  "),
747
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
748
                    Span::raw(": Cancel"),
749
                ]),
750
                Focus::Search => Line::from(vec![
751
                    Span::styled("Type", Style::default().fg(Color::Yellow)),
752
                    Span::raw(": Filter  "),
753
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
754
                    Span::raw(": Select  "),
755
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
756
                    Span::raw(": Cancel"),
757
                ]),
758
            };
759
            frame.render_widget(Paragraph::new(hints), outer[1]);
760
761
            if let Some((msg, _)) = &app.status_message {
762
                let area = frame.area();
763
                let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
764
                let popup_area = ratatui::layout::Rect {
765
                    x: (area.width.saturating_sub(msg_width)) / 2,
766
                    y: (area.height.saturating_sub(3)) / 2,
767
                    width: msg_width,
768
                    height: 3,
769
                };
770
                Clear.render(popup_area, frame.buffer_mut());
771
                let status_popup = Paragraph::new(Line::from(msg.as_str()))
772
                    .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
773
                    .alignment(Alignment::Center)
774
                    .block(
775
                        Block::default()
776
                            .borders(Borders::ALL)
777
                            .border_style(Style::default().fg(Color::Green)),
778
                    );
779
                frame.render_widget(status_popup, popup_area);
780
            }
781
782
            if app.confirm_delete {
783
                let delete_msg = match app.selected_snippet() {
784
                    Some(s) => format!("Delete {}? (y/n)", s.name),
785
                    None => "Delete snippet? (y/n)".to_string(),
786
                };
787
                let area = frame.area();
788
                let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4));
789
                let popup_area = ratatui::layout::Rect {
790
                    x: (area.width.saturating_sub(msg_width)) / 2,
791
                    y: (area.height.saturating_sub(3)) / 2,
792
                    width: msg_width,
793
                    height: 3,
794
                };
795
                Clear.render(popup_area, frame.buffer_mut());
796
                let confirm_popup = Paragraph::new(Line::from(delete_msg))
797
                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
798
                    .alignment(Alignment::Center)
799
                    .block(
800
                        Block::default()
801
                            .borders(Borders::ALL)
802
                            .border_style(Style::default().fg(Color::Red)),
803
                    );
804
                frame.render_widget(confirm_popup, popup_area);
805
            }
806
807
            if app.show_help {
808
                let area = frame.area();
809
                let popup_width = 34u16.min(area.width.saturating_sub(4));
810
                let popup_height = 20u16.min(area.height.saturating_sub(4));
811
                let popup_area = ratatui::layout::Rect {
812
                    x: (area.width.saturating_sub(popup_width)) / 2,
813
                    y: (area.height.saturating_sub(popup_height)) / 2,
814
                    width: popup_width,
815
                    height: popup_height,
816
                };
817
818
                let mut help_lines = vec![
819
                    Line::from(""),
820
                    Line::from(vec![
821
                        Span::styled(
822
                            "  j/↓  ",
823
                            Style::default()
824
                                .fg(Color::Yellow)
825
                                .add_modifier(Modifier::BOLD),
826
                        ),
827
                        Span::raw("Move down / Scroll down"),
828
                    ]),
829
                    Line::from(vec![
830
                        Span::styled(
831
                            "  k/↑  ",
832
                            Style::default()
833
                                .fg(Color::Yellow)
834
                                .add_modifier(Modifier::BOLD),
835
                        ),
836
                        Span::raw("Move up / Scroll up"),
837
                    ]),
838
                    Line::from(vec![
839
                        Span::styled(
840
                            "  Enter",
841
                            Style::default()
842
                                .fg(Color::Yellow)
843
                                .add_modifier(Modifier::BOLD),
844
                        ),
845
                        Span::raw("  Focus content pane"),
846
                    ]),
847
                    Line::from(vec![
848
                        Span::styled(
849
                            "  Esc  ",
850
                            Style::default()
851
                                .fg(Color::Yellow)
852
                                .add_modifier(Modifier::BOLD),
853
                        ),
854
                        Span::raw("Back / Quit"),
855
                    ]),
856
                    Line::from(vec![
857
                        Span::styled(
858
                            "  y    ",
859
                            Style::default()
860
                                .fg(Color::Yellow)
861
                                .add_modifier(Modifier::BOLD),
862
                        ),
863
                        Span::raw("Copy snippet"),
864
                    ]),
865
                    Line::from(vec![
866
                        Span::styled(
867
                            "  Y    ",
868
                            Style::default()
869
                                .fg(Color::Yellow)
870
                                .add_modifier(Modifier::BOLD),
871
                        ),
872
                        Span::raw("Copy link"),
873
                    ]),
874
                    Line::from(vec![
875
                        Span::styled(
876
                            "  o    ",
877
                            Style::default()
878
                                .fg(Color::Yellow)
879
                                .add_modifier(Modifier::BOLD),
880
                        ),
881
                        Span::raw("Open in browser"),
882
                    ]),
883
                    Line::from(vec![
884
                        Span::styled(
885
                            "  d    ",
886
                            Style::default()
887
                                .fg(Color::Yellow)
888
                                .add_modifier(Modifier::BOLD),
889
                        ),
890
                        Span::raw("Delete snippet"),
891
                    ]),
892
                    Line::from(vec![
893
                        Span::styled(
894
                            "  c    ",
895
                            Style::default()
896
                                .fg(Color::Yellow)
897
                                .add_modifier(Modifier::BOLD),
898
                        ),
899
                        Span::raw("Create snippet"),
900
                    ]),
901
                    Line::from(vec![
902
                        Span::styled(
903
                            "  e    ",
904
                            Style::default()
905
                                .fg(Color::Yellow)
906
                                .add_modifier(Modifier::BOLD),
907
                        ),
908
                        Span::raw("Edit snippet"),
909
                    ]),
910
                    Line::from(vec![
911
                        Span::styled(
912
                            "  /    ",
913
                            Style::default()
914
                                .fg(Color::Yellow)
915
                                .add_modifier(Modifier::BOLD),
916
                        ),
917
                        Span::raw("Search snippets"),
918
                    ]),
919
                ];
920
921
                if app.is_remote {
922
                    help_lines.push(Line::from(vec![
923
                        Span::styled(
924
                            "  r    ",
925
                            Style::default()
926
                                .fg(Color::Yellow)
927
                                .add_modifier(Modifier::BOLD),
928
                        ),
929
                        Span::raw("Refresh snippets"),
930
                    ]));
931
                }
932
933
                help_lines.extend([
934
                    Line::from(vec![
935
                        Span::styled(
936
                            "  q    ",
937
                            Style::default()
938
                                .fg(Color::Yellow)
939
                                .add_modifier(Modifier::BOLD),
940
                        ),
941
                        Span::raw("Quit"),
942
                    ]),
943
                    Line::from(vec![
944
                        Span::styled(
945
                            "  ?    ",
946
                            Style::default()
947
                                .fg(Color::Yellow)
948
                                .add_modifier(Modifier::BOLD),
949
                        ),
950
                        Span::raw("Toggle this help"),
951
                    ]),
952
                    Line::from(""),
953
                    Line::from(Span::styled(
954
                        "  Press any key to close",
955
                        Style::default().fg(Color::DarkGray),
956
                    )),
957
                ]);
958
959
                let help_text = Text::from(help_lines);
960
961
                Clear.render(popup_area, frame.buffer_mut());
962
                let help = Paragraph::new(help_text).block(
963
                    Block::default()
964
                        .title(" Keybindings ")
965
                        .borders(Borders::ALL)
966
                        .border_style(Style::default().fg(Color::Yellow)),
967
                );
968
                frame.render_widget(help, popup_area);
969
            }
970
        })?;
971
972
        if event::poll(Duration::from_millis(100))? {
973
            if let Event::Key(key) = event::read()? {
974
                if app.show_help {
975
                    app.show_help = false;
976
                } else if app.status_message.is_some() {
977
                    app.status_message = None;
978
                } else if app.confirm_delete {
979
                    if key.code == KeyCode::Char('y') {
980
                        app.delete_selected(backend);
981
                    }
982
                    app.confirm_delete = false;
983
                } else {
984
                    match app.focus {
985
                        Focus::List => match key.code {
986
                            KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
987
                            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
988
                            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
989
                            KeyCode::Char('y') => app.copy_selected(),
990
                            KeyCode::Char('Y') => app.copy_link(),
991
                            KeyCode::Char('d') => app.confirm_delete = true,
992
                            KeyCode::Char('c') => app.start_create(),
993
                            KeyCode::Char('e') => app.start_edit(),
994
                            KeyCode::Char('/') => app.start_search(),
995
                            KeyCode::Char('o') => app.open_in_browser(),
996
                            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
997
                            KeyCode::Char('?') => app.show_help = true,
998
                            KeyCode::Enter | KeyCode::Char('l') => {
999
                                if app.selected_snippet().is_some() {
1000
                                    app.focus = Focus::Content;
1001
                                }
1002
                            }
1003
                            _ => {}
1004
                        },
1005
                        Focus::Content => match key.code {
1006
                          KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => {
1007
                                app.focus = Focus::List;
1008
                            }
1009
                            KeyCode::Char('j') | KeyCode::Down => {
1010
                                app.scroll_down(content_line_count);
1011
                            }
1012
                            KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
1013
                            KeyCode::Char('y') => app.copy_selected(),
1014
                            KeyCode::Char('Y') => app.copy_link(),
1015
                            KeyCode::Char('e') => app.start_edit(),
1016
                            KeyCode::Char('o') => app.open_in_browser(),
1017
                            KeyCode::Char('?') => app.show_help = true,
1018
                            _ => {}
1019
                        },
1020
                        Focus::CreateName => {
1021
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1022
                                && key.code == KeyCode::Char('s')
1023
                            {
1024
                                app.save_create(backend);
1025
                            } else {
1026
                                match key.code {
1027
                                    KeyCode::Esc => app.cancel_create(),
1028
                                    KeyCode::Enter | KeyCode::Tab => {
1029
                                        app.focus = Focus::CreateContent
1030
                                    }
1031
                                    KeyCode::Backspace => {
1032
                                        app.create_name.pop();
1033
                                    }
1034
                                    KeyCode::Char(c) => app.create_name.push(c),
1035
                                    _ => {}
1036
                                }
1037
                            }
1038
                        }
1039
                        Focus::CreateContent => {
1040
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1041
                                && key.code == KeyCode::Char('s')
1042
                            {
1043
                                app.save_create(backend);
1044
                            } else {
1045
                                match key.code {
1046
                                    KeyCode::Esc => app.cancel_create(),
1047
                                    KeyCode::Tab => app.focus = Focus::CreateName,
1048
                                    KeyCode::Enter => app.create_content.push('\n'),
1049
                                    KeyCode::Backspace => {
1050
                                        app.create_content.pop();
1051
                                    }
1052
                                    KeyCode::Char(c) => app.create_content.push(c),
1053
                                    _ => {}
1054
                                }
1055
                            }
1056
                        }
1057
                        Focus::EditName => {
1058
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1059
                                && key.code == KeyCode::Char('s')
1060
                            {
1061
                                app.save_edit(backend);
1062
                            } else {
1063
                                match key.code {
1064
                                    KeyCode::Esc => app.cancel_edit(),
1065
                                    KeyCode::Enter | KeyCode::Tab => {
1066
                                        app.focus = Focus::EditContent
1067
                                    }
1068
                                    KeyCode::Backspace => {
1069
                                        app.create_name.pop();
1070
                                    }
1071
                                    KeyCode::Char(c) => app.create_name.push(c),
1072
                                    _ => {}
1073
                                }
1074
                            }
1075
                        }
1076
                        Focus::EditContent => {
1077
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1078
                                && key.code == KeyCode::Char('s')
1079
                            {
1080
                                app.save_edit(backend);
1081
                            } else {
1082
                                match key.code {
1083
                                    KeyCode::Esc => app.cancel_edit(),
1084
                                    KeyCode::Tab => app.focus = Focus::EditName,
1085
                                    KeyCode::Enter => app.create_content.push('\n'),
1086
                                    KeyCode::Backspace => {
1087
                                        app.create_content.pop();
1088
                                    }
1089
                                    KeyCode::Char(c) => app.create_content.push(c),
1090
                                    _ => {}
1091
                                }
1092
                            }
1093
                        }
1094
                        Focus::Search => match key.code {
1095
                            KeyCode::Esc => app.cancel_search(),
1096
                            KeyCode::Enter => app.confirm_search(),
1097
                            KeyCode::Backspace => {
1098
                                app.search_query.pop();
1099
                                app.update_search_filter();
1100
                            }
1101
                            KeyCode::Char(c) => {
1102
                                app.search_query.push(c);
1103
                                app.update_search_filter();
1104
                            }
1105
                            _ => {}
1106
                        },
1107
                    }
1108
                }
1109
            }
1110
        }
1111
    }
1112
1113
    Ok(())
1114
}