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