Merge pull request #28 from stevedylandev/chore/refactor-sipp 758c4bd6
chore: updated tui and backend functions
Steve Simkins · 2026-04-16 23:13 8 file(s) · +1324 −1250
.github/FUNDING.yml (added) +1 −0
1 +
ko_fi: stevedylandev
apps/sipp/src/backend.rs +65 −50
1 -
use crate::db::{self, Db, Snippet};
1 +
use crate::db::{self, Db, Snippet, SnippetInput};
2 +
use reqwest::StatusCode;
3 +
use reqwest::blocking::{Client, RequestBuilder, Response};
2 4
use std::fmt;
3 5
4 6
#[derive(Debug)]
28 30
    }
29 31
}
30 32
33 +
fn net<E: fmt::Display>(e: E) -> BackendError {
34 +
    BackendError::Network(e.to_string())
35 +
}
36 +
37 +
fn with_key(req: RequestBuilder, key: &Option<String>) -> RequestBuilder {
38 +
    match key {
39 +
        Some(k) => req.header("x-api-key", k),
40 +
        None => req,
41 +
    }
42 +
}
43 +
44 +
fn send_request(req: RequestBuilder) -> Result<Response, BackendError> {
45 +
    let resp = req.send().map_err(net)?;
46 +
    match resp.status().as_u16() {
47 +
        401 => Err(BackendError::Unauthorized("Invalid API key".into())),
48 +
        403 => Err(BackendError::Unauthorized(
49 +
            "No API key configured on server".into(),
50 +
        )),
51 +
        _ => Ok(resp),
52 +
    }
53 +
}
54 +
55 +
fn unexpected(status: StatusCode) -> BackendError {
56 +
    BackendError::Network(format!("HTTP {}", status))
57 +
}
58 +
31 59
pub enum Backend {
32 60
    Local {
33 61
        db: Db,
35 63
    Remote {
36 64
        base_url: String,
37 65
        api_key: Option<String>,
38 -
        client: reqwest::blocking::Client,
66 +
        client: Client,
39 67
    },
40 68
}
41 69
48 76
        Backend::Remote {
49 77
            base_url,
50 78
            api_key,
51 -
            client: reqwest::blocking::Client::new(),
79 +
            client: Client::new(),
52 80
        }
53 81
    }
54 82
60 88
                api_key,
61 89
                client,
62 90
            } => {
63 -
                let mut req = client.get(format!("{}/api/snippets", base_url));
64 -
                if let Some(key) = api_key {
65 -
                    req = req.header("x-api-key", key);
66 -
                }
67 -
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
91 +
                let req = with_key(client.get(format!("{base_url}/api/snippets")), api_key);
92 +
                let resp = send_request(req)?;
68 93
                match resp.status().as_u16() {
69 -
                    200 => resp
70 -
                        .json::<Vec<Snippet>>()
71 -
                        .map_err(|e| BackendError::Network(e.to_string())),
72 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
73 -
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
74 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
94 +
                    200 => resp.json::<Vec<Snippet>>().map_err(net),
95 +
                    _ => Err(unexpected(resp.status())),
75 96
                }
76 97
            }
77 98
        }
85 106
                api_key,
86 107
                client,
87 108
            } => {
88 -
                let mut req = client
89 -
                    .post(format!("{}/api/snippets", base_url))
90 -
                    .json(&serde_json::json!({"name": name, "content": content}));
91 -
                if let Some(key) = api_key {
92 -
                    req = req.header("x-api-key", key);
93 -
                }
94 -
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
109 +
                let body = SnippetInput {
110 +
                    name: name.to_string(),
111 +
                    content: content.to_string(),
112 +
                };
113 +
                let req = with_key(
114 +
                    client.post(format!("{base_url}/api/snippets")).json(&body),
115 +
                    api_key,
116 +
                );
117 +
                let resp = send_request(req)?;
95 118
                match resp.status().as_u16() {
96 -
                    201 => resp
97 -
                        .json::<Snippet>()
98 -
                        .map_err(|e| BackendError::Network(e.to_string())),
99 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
100 -
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
101 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
119 +
                    201 => resp.json::<Snippet>().map_err(net),
120 +
                    _ => Err(unexpected(resp.status())),
102 121
                }
103 122
            }
104 123
        }
117 136
                api_key,
118 137
                client,
119 138
            } => {
120 -
                let mut req = client
121 -
                    .put(format!("{}/api/snippets/{}", base_url, short_id))
122 -
                    .json(&serde_json::json!({"name": name, "content": content}));
123 -
                if let Some(key) = api_key {
124 -
                    req = req.header("x-api-key", key);
125 -
                }
126 -
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
139 +
                let body = SnippetInput {
140 +
                    name: name.to_string(),
141 +
                    content: content.to_string(),
142 +
                };
143 +
                let req = with_key(
144 +
                    client
145 +
                        .put(format!("{base_url}/api/snippets/{short_id}"))
146 +
                        .json(&body),
147 +
                    api_key,
148 +
                );
149 +
                let resp = send_request(req)?;
127 150
                match resp.status().as_u16() {
128 -
                    200 => resp
129 -
                        .json::<Snippet>()
130 -
                        .map(Some)
131 -
                        .map_err(|e| BackendError::Network(e.to_string())),
132 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
133 -
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
151 +
                    200 => resp.json::<Snippet>().map(Some).map_err(net),
134 152
                    404 => Ok(None),
135 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
153 +
                    _ => Err(unexpected(resp.status())),
136 154
                }
137 155
            }
138 156
        }
146 164
                api_key,
147 165
                client,
148 166
            } => {
149 -
                let mut req =
150 -
                    client.delete(format!("{}/api/snippets/{}", base_url, short_id));
151 -
                if let Some(key) = api_key {
152 -
                    req = req.header("x-api-key", key);
153 -
                }
154 -
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
167 +
                let req = with_key(
168 +
                    client.delete(format!("{base_url}/api/snippets/{short_id}")),
169 +
                    api_key,
170 +
                );
171 +
                let resp = send_request(req)?;
155 172
                match resp.status().as_u16() {
156 173
                    200 => Ok(true),
157 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
158 -
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
159 174
                    404 => Ok(false),
160 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
175 +
                    _ => Err(unexpected(resp.status())),
161 176
                }
162 177
            }
163 178
        }
apps/sipp/src/db.rs +6 −0
13 13
    pub name: String,
14 14
}
15 15
16 +
#[derive(Debug, Serialize, Deserialize)]
17 +
pub struct SnippetInput {
18 +
    pub name: String,
19 +
    pub content: String,
20 +
}
21 +
16 22
const SNIPPET_COLS: &str = "id, short_id, content, name";
17 23
18 24
fn snippet_from_row(row: &rusqlite::Row) -> rusqlite::Result<Snippet> {
apps/sipp/src/tui.rs (deleted) +0 −1200
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 -
}
apps/sipp/src/tui/app.rs (added) +471 −0
1 +
use crate::backend::Backend;
2 +
use crate::db::Snippet;
3 +
use arboard::Clipboard;
4 +
use ratatui::style::{Color, Style};
5 +
use ratatui::text::{Line, Span, Text};
6 +
use ratatui::widgets::ListState;
7 +
use std::io::Cursor;
8 +
use std::time::{Duration, Instant};
9 +
use syntect::easy::HighlightLines;
10 +
use syntect::highlighting::Theme;
11 +
use syntect::parsing::SyntaxSet;
12 +
use syntect::util::LinesWithEndings;
13 +
14 +
pub(super) enum Focus {
15 +
    List,
16 +
    Content,
17 +
    CreateName,
18 +
    CreateContent,
19 +
    EditName,
20 +
    EditContent,
21 +
    Search,
22 +
}
23 +
24 +
pub(super) struct App {
25 +
    pub(super) snippets: Vec<Snippet>,
26 +
    pub(super) list_state: ListState,
27 +
    pub(super) should_quit: bool,
28 +
    pub(super) status_message: Option<(String, Instant)>,
29 +
    pub(super) focus: Focus,
30 +
    pub(super) content_scroll: u16,
31 +
    pub(super) show_help: bool,
32 +
    pub(super) confirm_delete: bool,
33 +
    syntax_set: SyntaxSet,
34 +
    theme: Theme,
35 +
    pub(super) create_name: String,
36 +
    pub(super) create_content: String,
37 +
    pub(super) edit_short_id: Option<String>,
38 +
    pub(super) search_query: String,
39 +
    pub(super) filtered_indices: Option<Vec<usize>>,
40 +
    pub(super) is_remote: bool,
41 +
    pub(super) remote_url: Option<String>,
42 +
    pub(super) wrap_content: bool,
43 +
    pub(super) edit_scroll: u16,
44 +
}
45 +
46 +
impl App {
47 +
    pub(super) fn new(
48 +
        snippets: Vec<Snippet>,
49 +
        is_remote: bool,
50 +
        remote_url: Option<String>,
51 +
    ) -> Self {
52 +
        let mut list_state = ListState::default();
53 +
        if !snippets.is_empty() {
54 +
            list_state.select(Some(0));
55 +
        }
56 +
        let syntax_set = SyntaxSet::load_defaults_newlines();
57 +
        let theme_data = include_bytes!("../ansi.tmTheme");
58 +
        let theme =
59 +
            syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..]))
60 +
                .expect("failed to load base16 theme");
61 +
        Self {
62 +
            snippets,
63 +
            list_state,
64 +
            should_quit: false,
65 +
            status_message: None,
66 +
            focus: Focus::List,
67 +
            content_scroll: 0,
68 +
            show_help: false,
69 +
            confirm_delete: false,
70 +
            syntax_set,
71 +
            theme,
72 +
            create_name: String::new(),
73 +
            create_content: String::new(),
74 +
            edit_short_id: None,
75 +
            search_query: String::new(),
76 +
            filtered_indices: None,
77 +
            is_remote,
78 +
            remote_url,
79 +
            wrap_content: true,
80 +
            edit_scroll: 0,
81 +
        }
82 +
    }
83 +
84 +
    pub(super) fn selected_snippet(&self) -> Option<&Snippet> {
85 +
        self.list_state.selected().and_then(|i| {
86 +
            if let Some(indices) = &self.filtered_indices {
87 +
                indices.get(i).and_then(|&real| self.snippets.get(real))
88 +
            } else {
89 +
                self.snippets.get(i)
90 +
            }
91 +
        })
92 +
    }
93 +
94 +
    pub(super) fn visible_count(&self) -> usize {
95 +
        match &self.filtered_indices {
96 +
            Some(indices) => indices.len(),
97 +
            None => self.snippets.len(),
98 +
        }
99 +
    }
100 +
101 +
    pub(super) fn move_up(&mut self) {
102 +
        let count = self.visible_count();
103 +
        if count == 0 {
104 +
            return;
105 +
        }
106 +
        let i = match self.list_state.selected() {
107 +
            Some(i) if i > 0 => i - 1,
108 +
            Some(_) => count - 1,
109 +
            None => 0,
110 +
        };
111 +
        self.list_state.select(Some(i));
112 +
        self.content_scroll = 0;
113 +
    }
114 +
115 +
    pub(super) fn move_down(&mut self) {
116 +
        let count = self.visible_count();
117 +
        if count == 0 {
118 +
            return;
119 +
        }
120 +
        let i = match self.list_state.selected() {
121 +
            Some(i) if i < count - 1 => i + 1,
122 +
            Some(_) => 0,
123 +
            None => 0,
124 +
        };
125 +
        self.list_state.select(Some(i));
126 +
        self.content_scroll = 0;
127 +
    }
128 +
129 +
    pub(super) fn scroll_up(&mut self) {
130 +
        self.content_scroll = self.content_scroll.saturating_sub(1);
131 +
    }
132 +
133 +
    pub(super) fn scroll_down(&mut self, max_lines: u16) {
134 +
        if self.content_scroll < max_lines {
135 +
            self.content_scroll += 1;
136 +
        }
137 +
    }
138 +
139 +
    pub(super) fn copy_selected(&mut self) {
140 +
        if let Some(snippet) = self.selected_snippet() {
141 +
            if let Ok(mut clipboard) = Clipboard::new() {
142 +
                let _ = clipboard.set_text(&snippet.content);
143 +
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
144 +
            }
145 +
        }
146 +
    }
147 +
148 +
    pub(super) fn copy_link(&mut self) {
149 +
        match &self.remote_url {
150 +
            Some(url) => {
151 +
                if let Some(snippet) = self.selected_snippet() {
152 +
                    let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id);
153 +
                    if let Ok(mut clipboard) = Clipboard::new() {
154 +
                        let _ = clipboard.set_text(&link);
155 +
                        self.status_message =
156 +
                            Some(("Link copied!".to_string(), Instant::now()));
157 +
                    }
158 +
                }
159 +
            }
160 +
            None => {
161 +
                self.status_message =
162 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
163 +
            }
164 +
        }
165 +
    }
166 +
167 +
    pub(super) fn open_in_browser(&mut self) {
168 +
        match &self.remote_url {
169 +
            Some(url) => {
170 +
                if let Some(snippet) = self.selected_snippet() {
171 +
                    let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id);
172 +
                    if let Err(e) = open::that(&link) {
173 +
                        self.status_message =
174 +
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
175 +
                    } else {
176 +
                        self.status_message =
177 +
                            Some(("Opened in browser!".to_string(), Instant::now()));
178 +
                    }
179 +
                }
180 +
            }
181 +
            None => {
182 +
                self.status_message =
183 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
184 +
            }
185 +
        }
186 +
    }
187 +
188 +
    pub(super) fn delete_selected(&mut self, backend: &Backend) {
189 +
        if let Some(selected_index) = self.list_state.selected() {
190 +
            let real_index = if let Some(indices) = &self.filtered_indices {
191 +
                match indices.get(selected_index) {
192 +
                    Some(&ri) => ri,
193 +
                    None => return,
194 +
                }
195 +
            } else {
196 +
                selected_index
197 +
            };
198 +
            if let Some(snippet) = self.snippets.get(real_index) {
199 +
                let short_id = snippet.short_id.clone();
200 +
                match backend.delete_snippet(&short_id) {
201 +
                    Ok(true) => {
202 +
                        self.snippets.remove(real_index);
203 +
                        if self.filtered_indices.is_some() {
204 +
                            self.update_search_filter();
205 +
                        }
206 +
                        let count = self.visible_count();
207 +
                        if count == 0 {
208 +
                            self.list_state.select(None);
209 +
                        } else if selected_index >= count {
210 +
                            self.list_state.select(Some(count - 1));
211 +
                        } else {
212 +
                            self.list_state.select(Some(selected_index));
213 +
                        }
214 +
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
215 +
                    }
216 +
                    Ok(false) => {
217 +
                        self.status_message =
218 +
                            Some(("Snippet not found".to_string(), Instant::now()));
219 +
                    }
220 +
                    Err(e) => {
221 +
                        self.status_message = Some((e.to_string(), Instant::now()));
222 +
                    }
223 +
                }
224 +
            }
225 +
        }
226 +
    }
227 +
228 +
    pub(super) fn refresh(&mut self, backend: &Backend) {
229 +
        match backend.list_snippets() {
230 +
            Ok(snippets) => {
231 +
                self.snippets = snippets;
232 +
                self.filtered_indices = None;
233 +
                self.search_query.clear();
234 +
                if self.snippets.is_empty() {
235 +
                    self.list_state.select(None);
236 +
                } else {
237 +
                    let idx = self.list_state.selected().unwrap_or(0);
238 +
                    if idx >= self.snippets.len() {
239 +
                        self.list_state.select(Some(self.snippets.len() - 1));
240 +
                    }
241 +
                }
242 +
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
243 +
            }
244 +
            Err(e) => {
245 +
                self.status_message = Some((e.to_string(), Instant::now()));
246 +
            }
247 +
        }
248 +
    }
249 +
250 +
    pub(super) fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) {
251 +
        let w = width as usize;
252 +
        if w == 0 {
253 +
            return (0, 0);
254 +
        }
255 +
        let text = &self.create_content;
256 +
        let mut visual_row: usize = 0;
257 +
        let lines: Vec<&str> = if text.is_empty() {
258 +
            vec![""]
259 +
        } else if text.ends_with('\n') {
260 +
            text.split('\n').collect()
261 +
        } else {
262 +
            text.split('\n').collect()
263 +
        };
264 +
        let last_idx = lines.len() - 1;
265 +
        for (i, line) in lines.iter().enumerate() {
266 +
            let line_len = line.len();
267 +
            let wrapped_lines = if line_len == 0 {
268 +
                1
269 +
            } else {
270 +
                (line_len + w - 1) / w
271 +
            };
272 +
            if i < last_idx {
273 +
                visual_row += wrapped_lines;
274 +
            } else {
275 +
                let cursor_col = if text.ends_with('\n') { 0 } else { line_len };
276 +
                let extra_rows = cursor_col / w;
277 +
                let col = cursor_col % w;
278 +
                visual_row += extra_rows;
279 +
                return (col as u16, visual_row as u16);
280 +
            }
281 +
        }
282 +
        (0, visual_row as u16)
283 +
    }
284 +
285 +
    pub(super) fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) {
286 +
        if visible_height == 0 {
287 +
            return;
288 +
        }
289 +
        if cursor_visual_row < self.edit_scroll {
290 +
            self.edit_scroll = cursor_visual_row;
291 +
        } else if cursor_visual_row >= self.edit_scroll + visible_height {
292 +
            self.edit_scroll = cursor_visual_row - visible_height + 1;
293 +
        }
294 +
    }
295 +
296 +
    pub(super) fn start_create(&mut self) {
297 +
        self.create_name.clear();
298 +
        self.create_content.clear();
299 +
        self.edit_scroll = 0;
300 +
        self.focus = Focus::CreateName;
301 +
    }
302 +
303 +
    pub(super) fn save_create(&mut self, backend: &Backend) {
304 +
        if self.create_name.trim().is_empty() {
305 +
            self.status_message = Some(("Name cannot be empty".to_string(), Instant::now()));
306 +
            return;
307 +
        }
308 +
        match backend.create_snippet(&self.create_name, &self.create_content) {
309 +
            Ok(snippet) => {
310 +
                self.snippets.insert(0, snippet);
311 +
                self.list_state.select(Some(0));
312 +
                self.filtered_indices = None;
313 +
                self.search_query.clear();
314 +
                self.status_message = Some(("Created!".to_string(), Instant::now()));
315 +
                self.focus = Focus::List;
316 +
                self.create_name.clear();
317 +
                self.create_content.clear();
318 +
            }
319 +
            Err(e) => {
320 +
                self.status_message = Some((e.to_string(), Instant::now()));
321 +
            }
322 +
        }
323 +
    }
324 +
325 +
    pub(super) fn cancel_create(&mut self) {
326 +
        self.create_name.clear();
327 +
        self.create_content.clear();
328 +
        self.focus = Focus::List;
329 +
    }
330 +
331 +
    pub(super) fn start_edit(&mut self) {
332 +
        let data = self
333 +
            .selected_snippet()
334 +
            .map(|s| (s.name.clone(), s.content.clone(), s.short_id.clone()));
335 +
        if let Some((name, content, short_id)) = data {
336 +
            self.create_name = name;
337 +
            self.create_content = content;
338 +
            self.edit_short_id = Some(short_id);
339 +
            self.edit_scroll = 0;
340 +
            self.focus = Focus::EditName;
341 +
        }
342 +
    }
343 +
344 +
    pub(super) fn save_edit(&mut self, backend: &Backend) {
345 +
        if self.create_name.trim().is_empty() {
346 +
            self.status_message = Some(("Name cannot be empty".to_string(), Instant::now()));
347 +
            return;
348 +
        }
349 +
        let short_id = match &self.edit_short_id {
350 +
            Some(id) => id.clone(),
351 +
            None => return,
352 +
        };
353 +
        match backend.update_snippet(&short_id, &self.create_name, &self.create_content) {
354 +
            Ok(Some(updated)) => {
355 +
                if let Some(pos) = self.snippets.iter().position(|s| s.short_id == short_id) {
356 +
                    self.snippets[pos] = updated;
357 +
                }
358 +
                self.status_message = Some(("Updated!".to_string(), Instant::now()));
359 +
                self.focus = Focus::List;
360 +
                self.create_name.clear();
361 +
                self.create_content.clear();
362 +
                self.edit_short_id = None;
363 +
            }
364 +
            Ok(None) => {
365 +
                self.status_message = Some(("Snippet not found".to_string(), Instant::now()));
366 +
            }
367 +
            Err(e) => {
368 +
                self.status_message = Some((e.to_string(), Instant::now()));
369 +
            }
370 +
        }
371 +
    }
372 +
373 +
    pub(super) fn cancel_edit(&mut self) {
374 +
        self.create_name.clear();
375 +
        self.create_content.clear();
376 +
        self.edit_short_id = None;
377 +
        self.focus = Focus::List;
378 +
    }
379 +
380 +
    pub(super) fn start_search(&mut self) {
381 +
        self.search_query.clear();
382 +
        self.filtered_indices = Some((0..self.snippets.len()).collect());
383 +
        self.focus = Focus::Search;
384 +
        self.list_state
385 +
            .select(if self.snippets.is_empty() { None } else { Some(0) });
386 +
    }
387 +
388 +
    pub(super) fn update_search_filter(&mut self) {
389 +
        let query = self.search_query.to_lowercase();
390 +
        let indices: Vec<usize> = self
391 +
            .snippets
392 +
            .iter()
393 +
            .enumerate()
394 +
            .filter(|(_, s)| s.name.to_lowercase().contains(&query))
395 +
            .map(|(i, _)| i)
396 +
            .collect();
397 +
        self.filtered_indices = Some(indices);
398 +
        if self.visible_count() == 0 {
399 +
            self.list_state.select(None);
400 +
        } else {
401 +
            self.list_state.select(Some(0));
402 +
        }
403 +
    }
404 +
405 +
    pub(super) fn cancel_search(&mut self) {
406 +
        self.filtered_indices = None;
407 +
        self.search_query.clear();
408 +
        self.focus = Focus::List;
409 +
    }
410 +
411 +
    pub(super) fn confirm_search(&mut self) {
412 +
        let real_index = self.list_state.selected().and_then(|i| {
413 +
            self.filtered_indices
414 +
                .as_ref()
415 +
                .and_then(|indices| indices.get(i).copied())
416 +
        });
417 +
        self.filtered_indices = None;
418 +
        self.search_query.clear();
419 +
        self.focus = Focus::List;
420 +
        if let Some(ri) = real_index {
421 +
            self.list_state.select(Some(ri));
422 +
        }
423 +
    }
424 +
425 +
    pub(super) fn clear_expired_status(&mut self) {
426 +
        if let Some((_, time)) = &self.status_message {
427 +
            if time.elapsed() > Duration::from_secs(2) {
428 +
                self.status_message = None;
429 +
            }
430 +
        }
431 +
    }
432 +
433 +
    pub(super) fn highlight_content(&self, name: &str, content: &str) -> Text<'static> {
434 +
        let raw_ext = name.rsplit('.').next().unwrap_or("");
435 +
        let ext = match raw_ext {
436 +
            "ts" | "tsx" | "jsx" => "js",
437 +
            other => other,
438 +
        };
439 +
        let syntax = self
440 +
            .syntax_set
441 +
            .find_syntax_by_extension(ext)
442 +
            .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
443 +
        let mut highlighter = HighlightLines::new(syntax, &self.theme);
444 +
445 +
        let lines: Vec<Line<'static>> = LinesWithEndings::from(content)
446 +
            .map(|line| {
447 +
                let ranges = highlighter
448 +
                    .highlight_line(line, &self.syntax_set)
449 +
                    .unwrap_or_default();
450 +
                let spans: Vec<Span<'static>> = ranges
451 +
                    .into_iter()
452 +
                    .map(|(style, text)| {
453 +
                        let color = to_ratatui_color(style.foreground);
454 +
                        Span::styled(text.to_owned(), Style::default().fg(color))
455 +
                    })
456 +
                    .collect();
457 +
                Line::from(spans)
458 +
            })
459 +
            .collect();
460 +
461 +
        Text::from(lines)
462 +
    }
463 +
}
464 +
465 +
fn to_ratatui_color(color: syntect::highlighting::Color) -> Color {
466 +
    if color.a == 0 {
467 +
        Color::Indexed(color.r)
468 +
    } else {
469 +
        Color::Reset
470 +
    }
471 +
}
apps/sipp/src/tui/events.rs (added) +152 −0
1 +
use super::app::{App, Focus};
2 +
use crate::backend::Backend;
3 +
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4 +
5 +
pub(super) fn handle_key(
6 +
    app: &mut App,
7 +
    backend: &Backend,
8 +
    key: KeyEvent,
9 +
    content_line_count: u16,
10 +
) {
11 +
    if app.show_help {
12 +
        app.show_help = false;
13 +
    } else if app.status_message.is_some() {
14 +
        app.status_message = None;
15 +
    } else if app.confirm_delete {
16 +
        if key.code == KeyCode::Char('y') {
17 +
            app.delete_selected(backend);
18 +
        }
19 +
        app.confirm_delete = false;
20 +
    } else {
21 +
        match app.focus {
22 +
            Focus::List => match key.code {
23 +
                KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
24 +
                KeyCode::Char('j') | KeyCode::Down => app.move_down(),
25 +
                KeyCode::Char('k') | KeyCode::Up => app.move_up(),
26 +
                KeyCode::Char('y') => app.copy_selected(),
27 +
                KeyCode::Char('Y') => app.copy_link(),
28 +
                KeyCode::Char('d') => app.confirm_delete = true,
29 +
                KeyCode::Char('c') => app.start_create(),
30 +
                KeyCode::Char('e') => app.start_edit(),
31 +
                KeyCode::Char('/') => app.start_search(),
32 +
                KeyCode::Char('o') => app.open_in_browser(),
33 +
                KeyCode::Char('r') if app.is_remote => app.refresh(backend),
34 +
                KeyCode::Char('?') => app.show_help = true,
35 +
                KeyCode::Enter | KeyCode::Char('l') => {
36 +
                    if app.selected_snippet().is_some() {
37 +
                        app.focus = Focus::Content;
38 +
                    }
39 +
                }
40 +
                _ => {}
41 +
            },
42 +
            Focus::Content => match key.code {
43 +
                KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => {
44 +
                    app.focus = Focus::List;
45 +
                }
46 +
                KeyCode::Char('j') | KeyCode::Down => {
47 +
                    app.scroll_down(content_line_count);
48 +
                }
49 +
                KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
50 +
                KeyCode::Char('y') => app.copy_selected(),
51 +
                KeyCode::Char('Y') => app.copy_link(),
52 +
                KeyCode::Char('e') => app.start_edit(),
53 +
                KeyCode::Char('o') => app.open_in_browser(),
54 +
                KeyCode::Char('?') => app.show_help = true,
55 +
                _ => {}
56 +
            },
57 +
            Focus::CreateName => {
58 +
                if key.modifiers.contains(KeyModifiers::CONTROL)
59 +
                    && key.code == KeyCode::Char('s')
60 +
                {
61 +
                    app.save_create(backend);
62 +
                } else {
63 +
                    match key.code {
64 +
                        KeyCode::Esc => app.cancel_create(),
65 +
                        KeyCode::Enter | KeyCode::Tab => app.focus = Focus::CreateContent,
66 +
                        KeyCode::Backspace => {
67 +
                            app.create_name.pop();
68 +
                        }
69 +
                        KeyCode::Char(c) => app.create_name.push(c),
70 +
                        _ => {}
71 +
                    }
72 +
                }
73 +
            }
74 +
            Focus::CreateContent => {
75 +
                if key.modifiers.contains(KeyModifiers::CONTROL) {
76 +
                    match key.code {
77 +
                        KeyCode::Char('s') => app.save_create(backend),
78 +
                        KeyCode::Char('w') => {
79 +
                            app.wrap_content = !app.wrap_content;
80 +
                            app.edit_scroll = 0;
81 +
                        }
82 +
                        _ => {}
83 +
                    }
84 +
                } else {
85 +
                    match key.code {
86 +
                        KeyCode::Esc => app.cancel_create(),
87 +
                        KeyCode::Tab => app.focus = Focus::CreateName,
88 +
                        KeyCode::Enter => app.create_content.push('\n'),
89 +
                        KeyCode::Backspace => {
90 +
                            app.create_content.pop();
91 +
                        }
92 +
                        KeyCode::Char(c) => app.create_content.push(c),
93 +
                        _ => {}
94 +
                    }
95 +
                }
96 +
            }
97 +
            Focus::EditName => {
98 +
                if key.modifiers.contains(KeyModifiers::CONTROL)
99 +
                    && key.code == KeyCode::Char('s')
100 +
                {
101 +
                    app.save_edit(backend);
102 +
                } else {
103 +
                    match key.code {
104 +
                        KeyCode::Esc => app.cancel_edit(),
105 +
                        KeyCode::Enter | KeyCode::Tab => app.focus = Focus::EditContent,
106 +
                        KeyCode::Backspace => {
107 +
                            app.create_name.pop();
108 +
                        }
109 +
                        KeyCode::Char(c) => app.create_name.push(c),
110 +
                        _ => {}
111 +
                    }
112 +
                }
113 +
            }
114 +
            Focus::EditContent => {
115 +
                if key.modifiers.contains(KeyModifiers::CONTROL) {
116 +
                    match key.code {
117 +
                        KeyCode::Char('s') => app.save_edit(backend),
118 +
                        KeyCode::Char('w') => {
119 +
                            app.wrap_content = !app.wrap_content;
120 +
                            app.edit_scroll = 0;
121 +
                        }
122 +
                        _ => {}
123 +
                    }
124 +
                } else {
125 +
                    match key.code {
126 +
                        KeyCode::Esc => app.cancel_edit(),
127 +
                        KeyCode::Tab => app.focus = Focus::EditName,
128 +
                        KeyCode::Enter => app.create_content.push('\n'),
129 +
                        KeyCode::Backspace => {
130 +
                            app.create_content.pop();
131 +
                        }
132 +
                        KeyCode::Char(c) => app.create_content.push(c),
133 +
                        _ => {}
134 +
                    }
135 +
                }
136 +
            }
137 +
            Focus::Search => match key.code {
138 +
                KeyCode::Esc => app.cancel_search(),
139 +
                KeyCode::Enter => app.confirm_search(),
140 +
                KeyCode::Backspace => {
141 +
                    app.search_query.pop();
142 +
                    app.update_search_filter();
143 +
                }
144 +
                KeyCode::Char(c) => {
145 +
                    app.search_query.push(c);
146 +
                    app.update_search_filter();
147 +
                }
148 +
                _ => {}
149 +
            },
150 +
        }
151 +
    }
152 +
}
apps/sipp/src/tui/mod.rs (added) +145 −0
1 +
mod app;
2 +
mod events;
3 +
mod render;
4 +
5 +
use crate::backend::Backend;
6 +
use crate::config;
7 +
use app::App;
8 +
use arboard::Clipboard;
9 +
use crossterm::event::{self, Event};
10 +
use ratatui::DefaultTerminal;
11 +
use std::path::PathBuf;
12 +
use std::time::Duration;
13 +
14 +
fn resolve_backend(
15 +
    remote: Option<String>,
16 +
    api_key: Option<String>,
17 +
) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> {
18 +
    if let Some(url) = remote {
19 +
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
20 +
    }
21 +
22 +
    if !std::path::Path::new(&crate::db::db_path()).exists() {
23 +
        let cfg = config::load_config();
24 +
        let url = cfg
25 +
            .remote_url
26 +
            .unwrap_or_else(|| "http://localhost:3000".to_string());
27 +
        let api_key = api_key.or(cfg.api_key);
28 +
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
29 +
    }
30 +
31 +
    Ok((
32 +
        Backend::local()?,
33 +
        false,
34 +
        Some("http://localhost:3000".to_string()),
35 +
    ))
36 +
}
37 +
38 +
pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
39 +
    use std::io::{self, Write};
40 +
41 +
    print!("Remote URL: ");
42 +
    io::stdout().flush()?;
43 +
    let mut remote_url = String::new();
44 +
    io::stdin().read_line(&mut remote_url)?;
45 +
    let remote_url = remote_url.trim().to_string();
46 +
47 +
    print!("API Key: ");
48 +
    io::stdout().flush()?;
49 +
    let api_key = rpassword::read_password()?;
50 +
    let api_key = api_key.trim().to_string();
51 +
52 +
    let cfg = config::Config {
53 +
        remote_url: if remote_url.is_empty() {
54 +
            None
55 +
        } else {
56 +
            Some(remote_url)
57 +
        },
58 +
        api_key: if api_key.is_empty() {
59 +
            None
60 +
        } else {
61 +
            Some(api_key)
62 +
        },
63 +
    };
64 +
65 +
    config::save_config(&cfg)?;
66 +
    println!("Config saved to {}", config::config_path().display());
67 +
    Ok(())
68 +
}
69 +
70 +
pub fn run_interactive(
71 +
    remote: Option<String>,
72 +
    api_key: Option<String>,
73 +
) -> Result<(), Box<dyn std::error::Error>> {
74 +
    let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?;
75 +
76 +
    let snippets = match backend.list_snippets() {
77 +
        Ok(s) => s,
78 +
        Err(e) => {
79 +
            eprintln!("Failed to load snippets: {}", e);
80 +
            Vec::new()
81 +
        }
82 +
    };
83 +
84 +
    ratatui::run(|terminal| {
85 +
        run_app(
86 +
            terminal,
87 +
            App::new(snippets, is_remote, remote_url),
88 +
            &backend,
89 +
        )
90 +
    })
91 +
}
92 +
93 +
pub fn run_file_upload(
94 +
    remote: Option<String>,
95 +
    api_key: Option<String>,
96 +
    file: PathBuf,
97 +
) -> Result<(), Box<dyn std::error::Error>> {
98 +
    let (backend, _, remote_url) = resolve_backend(remote, api_key)?;
99 +
100 +
    let name = file
101 +
        .file_name()
102 +
        .ok_or("Invalid file path")?
103 +
        .to_string_lossy()
104 +
        .to_string();
105 +
    let content =
106 +
        std::fs::read_to_string(&file).map_err(|e| format!("Failed to read file: {}", e))?;
107 +
    let snippet = backend
108 +
        .create_snippet(&name, &content)
109 +
        .map_err(|e| format!("{}", e))?;
110 +
    let link = match &remote_url {
111 +
        Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id),
112 +
        None => snippet.short_id.clone(),
113 +
    };
114 +
    println!("{}", link);
115 +
    if let Ok(mut clipboard) = Clipboard::new() {
116 +
        let _ = clipboard.set_text(&link);
117 +
        println!("\u{2714} Copied to clipboard!");
118 +
    }
119 +
    Ok(())
120 +
}
121 +
122 +
fn run_app(
123 +
    terminal: &mut DefaultTerminal,
124 +
    mut app: App,
125 +
    backend: &Backend,
126 +
) -> Result<(), Box<dyn std::error::Error>> {
127 +
    while !app.should_quit {
128 +
        app.clear_expired_status();
129 +
130 +
        let content_line_count = app
131 +
            .selected_snippet()
132 +
            .map(|s| s.content.lines().count() as u16)
133 +
            .unwrap_or(0);
134 +
135 +
        terminal.draw(|frame| render::draw(frame, &mut app))?;
136 +
137 +
        if event::poll(Duration::from_millis(100))?
138 +
            && let Event::Key(key) = event::read()?
139 +
        {
140 +
            events::handle_key(&mut app, backend, key, content_line_count);
141 +
        }
142 +
    }
143 +
144 +
    Ok(())
145 +
}
apps/sipp/src/tui/render.rs (added) +484 −0
1 +
use super::app::{App, Focus};
2 +
use ratatui::{
3 +
    Frame,
4 +
    layout::{Alignment, Constraint, Layout},
5 +
    style::{Color, Modifier, Style},
6 +
    text::{Line, Span, Text},
7 +
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget, Wrap},
8 +
};
9 +
10 +
pub(super) fn draw(frame: &mut Frame, app: &mut App) {
11 +
    let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area());
12 +
13 +
    let chunks = Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)])
14 +
        .split(outer[0]);
15 +
16 +
    let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
17 +
        indices
18 +
            .iter()
19 +
            .filter_map(|&i| app.snippets.get(i))
20 +
            .map(|s| ListItem::new(s.name.as_str()))
21 +
            .collect()
22 +
    } else {
23 +
        app.snippets
24 +
            .iter()
25 +
            .map(|s| ListItem::new(s.name.as_str()))
26 +
            .collect()
27 +
    };
28 +
29 +
    let list_border_style = match app.focus {
30 +
        Focus::List | Focus::Search => Style::default().fg(Color::Yellow),
31 +
        _ => Style::default().fg(Color::DarkGray),
32 +
    };
33 +
    let content_border_style = match app.focus {
34 +
        Focus::Content => Style::default().fg(Color::Yellow),
35 +
        _ => Style::default().fg(Color::DarkGray),
36 +
    };
37 +
38 +
    let list = List::new(items)
39 +
        .block(
40 +
            Block::default()
41 +
                .title(" Snippets ")
42 +
                .borders(Borders::ALL)
43 +
                .border_style(list_border_style),
44 +
        )
45 +
        .highlight_style(
46 +
            Style::default()
47 +
                .fg(Color::Yellow)
48 +
                .add_modifier(Modifier::BOLD),
49 +
        )
50 +
        .highlight_symbol("▶ ");
51 +
52 +
    if matches!(app.focus, Focus::Search) {
53 +
        let search_split =
54 +
            Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]);
55 +
56 +
        let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
57 +
            indices
58 +
                .iter()
59 +
                .filter_map(|&i| app.snippets.get(i))
60 +
                .map(|s| ListItem::new(s.name.as_str()))
61 +
                .collect()
62 +
        } else {
63 +
            app.snippets
64 +
                .iter()
65 +
                .map(|s| ListItem::new(s.name.as_str()))
66 +
                .collect()
67 +
        };
68 +
        let search_list = List::new(search_items)
69 +
            .block(
70 +
                Block::default()
71 +
                    .title(" Snippets ")
72 +
                    .borders(Borders::ALL)
73 +
                    .border_style(list_border_style),
74 +
            )
75 +
            .highlight_style(
76 +
                Style::default()
77 +
                    .fg(Color::Yellow)
78 +
                    .add_modifier(Modifier::BOLD),
79 +
            )
80 +
            .highlight_symbol("▶ ");
81 +
        frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state);
82 +
83 +
        let search_input = Paragraph::new(app.search_query.as_str()).block(
84 +
            Block::default()
85 +
                .title(" Search ")
86 +
                .borders(Borders::ALL)
87 +
                .border_style(Style::default().fg(Color::Yellow)),
88 +
        );
89 +
        frame.render_widget(search_input, search_split[1]);
90 +
91 +
        let x = search_split[1].x + 1 + app.search_query.len() as u16;
92 +
        let y = search_split[1].y + 1;
93 +
        frame.set_cursor_position((x, y));
94 +
    } else {
95 +
        frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
96 +
    }
97 +
98 +
    match app.focus {
99 +
        Focus::CreateName | Focus::CreateContent | Focus::EditName | Focus::EditContent => {
100 +
            let form_title = match app.focus {
101 +
                Focus::EditName | Focus::EditContent => " Edit Snippet ",
102 +
                _ => " New Snippet ",
103 +
            };
104 +
            let create_block = Block::default()
105 +
                .title(form_title)
106 +
                .borders(Borders::ALL)
107 +
                .border_style(Style::default().fg(Color::Yellow));
108 +
109 +
            let inner = create_block.inner(chunks[1]);
110 +
            frame.render_widget(create_block, chunks[1]);
111 +
112 +
            let form_layout =
113 +
                Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner);
114 +
115 +
            let name_style = match app.focus {
116 +
                Focus::CreateName | Focus::EditName => Style::default().fg(Color::Yellow),
117 +
                _ => Style::default().fg(Color::DarkGray),
118 +
            };
119 +
            let name_input = Paragraph::new(app.create_name.as_str()).block(
120 +
                Block::default()
121 +
                    .title(" Name ")
122 +
                    .borders(Borders::ALL)
123 +
                    .border_style(name_style),
124 +
            );
125 +
            frame.render_widget(name_input, form_layout[0]);
126 +
127 +
            let content_style = match app.focus {
128 +
                Focus::CreateContent | Focus::EditContent => Style::default().fg(Color::Yellow),
129 +
                _ => Style::default().fg(Color::DarkGray),
130 +
            };
131 +
            let mut content_input = Paragraph::new(app.create_content.as_str()).block(
132 +
                Block::default()
133 +
                    .title(" Content ")
134 +
                    .borders(Borders::ALL)
135 +
                    .border_style(content_style),
136 +
            );
137 +
            if app.wrap_content {
138 +
                content_input = content_input.wrap(Wrap { trim: false });
139 +
            }
140 +
            content_input = content_input.scroll((app.edit_scroll, 0));
141 +
            frame.render_widget(content_input, form_layout[1]);
142 +
143 +
            let content_inner = Block::default().borders(Borders::ALL).inner(form_layout[1]);
144 +
            let inner_width = content_inner.width;
145 +
            let inner_height = content_inner.height;
146 +
147 +
            match app.focus {
148 +
                Focus::CreateName | Focus::EditName => {
149 +
                    let x = form_layout[0].x + 1 + app.create_name.len() as u16;
150 +
                    let y = form_layout[0].y + 1;
151 +
                    frame.set_cursor_position((x, y));
152 +
                }
153 +
                Focus::CreateContent | Focus::EditContent => {
154 +
                    let (cx, cy) = if app.wrap_content {
155 +
                        app.cursor_position_wrapped(inner_width)
156 +
                    } else {
157 +
                        let last_line = app.create_content.lines().last().unwrap_or("");
158 +
                        let line_count = app.create_content.lines().count()
159 +
                            + if app.create_content.ends_with('\n') {
160 +
                                1
161 +
                            } else {
162 +
                                0
163 +
                            };
164 +
                        let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
165 +
                        let col = if app.create_content.ends_with('\n') {
166 +
                            0
167 +
                        } else {
168 +
                            last_line.len() as u16
169 +
                        };
170 +
                        (col, y_offset as u16)
171 +
                    };
172 +
                    app.auto_scroll_edit(cy, inner_height);
173 +
                    let screen_y = cy.saturating_sub(app.edit_scroll);
174 +
                    let x = content_inner.x + cx;
175 +
                    let y = content_inner.y + screen_y;
176 +
                    frame.set_cursor_position((x, y));
177 +
                }
178 +
                _ => {}
179 +
            }
180 +
        }
181 +
        _ => {
182 +
            let highlighted = match app.selected_snippet() {
183 +
                Some(s) => app.highlight_content(&s.name, &s.content),
184 +
                None => Text::raw(""),
185 +
            };
186 +
187 +
            let paragraph = Paragraph::new(highlighted)
188 +
                .block(
189 +
                    Block::default()
190 +
                        .title(" Content ")
191 +
                        .borders(Borders::ALL)
192 +
                        .border_style(content_border_style),
193 +
                )
194 +
                .scroll((app.content_scroll, 0));
195 +
196 +
            frame.render_widget(paragraph, chunks[1]);
197 +
        }
198 +
    }
199 +
200 +
    let hints = match app.focus {
201 +
        Focus::List => Line::from(vec![
202 +
            Span::styled("j/k", Style::default().fg(Color::Yellow)),
203 +
            Span::raw(": Navigate  "),
204 +
            Span::styled("Enter", Style::default().fg(Color::Yellow)),
205 +
            Span::raw(": View  "),
206 +
            Span::styled("y", Style::default().fg(Color::Yellow)),
207 +
            Span::raw(": Copy  "),
208 +
            Span::styled("e", Style::default().fg(Color::Yellow)),
209 +
            Span::raw(": Edit  "),
210 +
            Span::styled("d", Style::default().fg(Color::Yellow)),
211 +
            Span::raw(": Delete  "),
212 +
            Span::styled("c", Style::default().fg(Color::Yellow)),
213 +
            Span::raw(": Create  "),
214 +
            Span::styled("/", Style::default().fg(Color::Yellow)),
215 +
            Span::raw(": Search  "),
216 +
            Span::styled("?", Style::default().fg(Color::Yellow)),
217 +
            Span::raw(": Help  "),
218 +
            Span::styled("q", Style::default().fg(Color::Yellow)),
219 +
            Span::raw(": Quit"),
220 +
        ]),
221 +
        Focus::Content => Line::from(vec![
222 +
            Span::styled("j/k", Style::default().fg(Color::Yellow)),
223 +
            Span::raw(": Scroll  "),
224 +
            Span::styled("y", Style::default().fg(Color::Yellow)),
225 +
            Span::raw(": Copy  "),
226 +
            Span::styled("e", Style::default().fg(Color::Yellow)),
227 +
            Span::raw(": Edit  "),
228 +
            Span::styled("Esc", Style::default().fg(Color::Yellow)),
229 +
            Span::raw(": Back  "),
230 +
            Span::styled("?", Style::default().fg(Color::Yellow)),
231 +
            Span::raw(": Help"),
232 +
        ]),
233 +
        Focus::CreateName | Focus::CreateContent | Focus::EditName | Focus::EditContent => {
234 +
            Line::from(vec![
235 +
                Span::styled("Tab", Style::default().fg(Color::Yellow)),
236 +
                Span::raw(": Switch field  "),
237 +
                Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
238 +
                Span::raw(": Save  "),
239 +
                Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)),
240 +
                Span::raw(": Wrap  "),
241 +
                Span::styled("Esc", Style::default().fg(Color::Yellow)),
242 +
                Span::raw(": Cancel"),
243 +
            ])
244 +
        }
245 +
        Focus::Search => Line::from(vec![
246 +
            Span::styled("Type", Style::default().fg(Color::Yellow)),
247 +
            Span::raw(": Filter  "),
248 +
            Span::styled("Enter", Style::default().fg(Color::Yellow)),
249 +
            Span::raw(": Select  "),
250 +
            Span::styled("Esc", Style::default().fg(Color::Yellow)),
251 +
            Span::raw(": Cancel"),
252 +
        ]),
253 +
    };
254 +
    frame.render_widget(Paragraph::new(hints), outer[1]);
255 +
256 +
    if let Some((msg, _)) = &app.status_message {
257 +
        let area = frame.area();
258 +
        let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
259 +
        let popup_area = ratatui::layout::Rect {
260 +
            x: (area.width.saturating_sub(msg_width)) / 2,
261 +
            y: (area.height.saturating_sub(3)) / 2,
262 +
            width: msg_width,
263 +
            height: 3,
264 +
        };
265 +
        Clear.render(popup_area, frame.buffer_mut());
266 +
        let status_popup = Paragraph::new(Line::from(msg.as_str()))
267 +
            .style(
268 +
                Style::default()
269 +
                    .fg(Color::Green)
270 +
                    .add_modifier(Modifier::BOLD),
271 +
            )
272 +
            .alignment(Alignment::Center)
273 +
            .block(
274 +
                Block::default()
275 +
                    .borders(Borders::ALL)
276 +
                    .border_style(Style::default().fg(Color::Green)),
277 +
            );
278 +
        frame.render_widget(status_popup, popup_area);
279 +
    }
280 +
281 +
    if app.confirm_delete {
282 +
        let delete_msg = match app.selected_snippet() {
283 +
            Some(s) => format!("Delete {}? (y/n)", s.name),
284 +
            None => "Delete snippet? (y/n)".to_string(),
285 +
        };
286 +
        let area = frame.area();
287 +
        let msg_width = (delete_msg.len() as u16 + 4)
288 +
            .max(24)
289 +
            .min(area.width.saturating_sub(4));
290 +
        let popup_area = ratatui::layout::Rect {
291 +
            x: (area.width.saturating_sub(msg_width)) / 2,
292 +
            y: (area.height.saturating_sub(3)) / 2,
293 +
            width: msg_width,
294 +
            height: 3,
295 +
        };
296 +
        Clear.render(popup_area, frame.buffer_mut());
297 +
        let confirm_popup = Paragraph::new(Line::from(delete_msg))
298 +
            .style(
299 +
                Style::default()
300 +
                    .fg(Color::Red)
301 +
                    .add_modifier(Modifier::BOLD),
302 +
            )
303 +
            .alignment(Alignment::Center)
304 +
            .block(
305 +
                Block::default()
306 +
                    .borders(Borders::ALL)
307 +
                    .border_style(Style::default().fg(Color::Red)),
308 +
            );
309 +
        frame.render_widget(confirm_popup, popup_area);
310 +
    }
311 +
312 +
    if app.show_help {
313 +
        let area = frame.area();
314 +
        let popup_width = 34u16.min(area.width.saturating_sub(4));
315 +
        let popup_height = 21u16.min(area.height.saturating_sub(4));
316 +
        let popup_area = ratatui::layout::Rect {
317 +
            x: (area.width.saturating_sub(popup_width)) / 2,
318 +
            y: (area.height.saturating_sub(popup_height)) / 2,
319 +
            width: popup_width,
320 +
            height: popup_height,
321 +
        };
322 +
323 +
        let mut help_lines = vec![
324 +
            Line::from(""),
325 +
            Line::from(vec![
326 +
                Span::styled(
327 +
                    "  j/↓  ",
328 +
                    Style::default()
329 +
                        .fg(Color::Yellow)
330 +
                        .add_modifier(Modifier::BOLD),
331 +
                ),
332 +
                Span::raw("Move down / Scroll down"),
333 +
            ]),
334 +
            Line::from(vec![
335 +
                Span::styled(
336 +
                    "  k/↑  ",
337 +
                    Style::default()
338 +
                        .fg(Color::Yellow)
339 +
                        .add_modifier(Modifier::BOLD),
340 +
                ),
341 +
                Span::raw("Move up / Scroll up"),
342 +
            ]),
343 +
            Line::from(vec![
344 +
                Span::styled(
345 +
                    "  Enter",
346 +
                    Style::default()
347 +
                        .fg(Color::Yellow)
348 +
                        .add_modifier(Modifier::BOLD),
349 +
                ),
350 +
                Span::raw("  Focus content pane"),
351 +
            ]),
352 +
            Line::from(vec![
353 +
                Span::styled(
354 +
                    "  Esc  ",
355 +
                    Style::default()
356 +
                        .fg(Color::Yellow)
357 +
                        .add_modifier(Modifier::BOLD),
358 +
                ),
359 +
                Span::raw("Back / Quit"),
360 +
            ]),
361 +
            Line::from(vec![
362 +
                Span::styled(
363 +
                    "  y    ",
364 +
                    Style::default()
365 +
                        .fg(Color::Yellow)
366 +
                        .add_modifier(Modifier::BOLD),
367 +
                ),
368 +
                Span::raw("Copy snippet"),
369 +
            ]),
370 +
            Line::from(vec![
371 +
                Span::styled(
372 +
                    "  Y    ",
373 +
                    Style::default()
374 +
                        .fg(Color::Yellow)
375 +
                        .add_modifier(Modifier::BOLD),
376 +
                ),
377 +
                Span::raw("Copy link"),
378 +
            ]),
379 +
            Line::from(vec![
380 +
                Span::styled(
381 +
                    "  o    ",
382 +
                    Style::default()
383 +
                        .fg(Color::Yellow)
384 +
                        .add_modifier(Modifier::BOLD),
385 +
                ),
386 +
                Span::raw("Open in browser"),
387 +
            ]),
388 +
            Line::from(vec![
389 +
                Span::styled(
390 +
                    "  d    ",
391 +
                    Style::default()
392 +
                        .fg(Color::Yellow)
393 +
                        .add_modifier(Modifier::BOLD),
394 +
                ),
395 +
                Span::raw("Delete snippet"),
396 +
            ]),
397 +
            Line::from(vec![
398 +
                Span::styled(
399 +
                    "  c    ",
400 +
                    Style::default()
401 +
                        .fg(Color::Yellow)
402 +
                        .add_modifier(Modifier::BOLD),
403 +
                ),
404 +
                Span::raw("Create snippet"),
405 +
            ]),
406 +
            Line::from(vec![
407 +
                Span::styled(
408 +
                    "  e    ",
409 +
                    Style::default()
410 +
                        .fg(Color::Yellow)
411 +
                        .add_modifier(Modifier::BOLD),
412 +
                ),
413 +
                Span::raw("Edit snippet"),
414 +
            ]),
415 +
            Line::from(vec![
416 +
                Span::styled(
417 +
                    "  /    ",
418 +
                    Style::default()
419 +
                        .fg(Color::Yellow)
420 +
                        .add_modifier(Modifier::BOLD),
421 +
                ),
422 +
                Span::raw("Search snippets"),
423 +
            ]),
424 +
            Line::from(vec![
425 +
                Span::styled(
426 +
                    "  ^W   ",
427 +
                    Style::default()
428 +
                        .fg(Color::Yellow)
429 +
                        .add_modifier(Modifier::BOLD),
430 +
                ),
431 +
                Span::raw("Toggle word wrap (edit)"),
432 +
            ]),
433 +
        ];
434 +
435 +
        if app.is_remote {
436 +
            help_lines.push(Line::from(vec![
437 +
                Span::styled(
438 +
                    "  r    ",
439 +
                    Style::default()
440 +
                        .fg(Color::Yellow)
441 +
                        .add_modifier(Modifier::BOLD),
442 +
                ),
443 +
                Span::raw("Refresh snippets"),
444 +
            ]));
445 +
        }
446 +
447 +
        help_lines.extend([
448 +
            Line::from(vec![
449 +
                Span::styled(
450 +
                    "  q    ",
451 +
                    Style::default()
452 +
                        .fg(Color::Yellow)
453 +
                        .add_modifier(Modifier::BOLD),
454 +
                ),
455 +
                Span::raw("Quit"),
456 +
            ]),
457 +
            Line::from(vec![
458 +
                Span::styled(
459 +
                    "  ?    ",
460 +
                    Style::default()
461 +
                        .fg(Color::Yellow)
462 +
                        .add_modifier(Modifier::BOLD),
463 +
                ),
464 +
                Span::raw("Toggle this help"),
465 +
            ]),
466 +
            Line::from(""),
467 +
            Line::from(Span::styled(
468 +
                "  Press any key to close",
469 +
                Style::default().fg(Color::DarkGray),
470 +
            )),
471 +
        ]);
472 +
473 +
        let help_text = Text::from(help_lines);
474 +
475 +
        Clear.render(popup_area, frame.buffer_mut());
476 +
        let help = Paragraph::new(help_text).block(
477 +
            Block::default()
478 +
                .title(" Keybindings ")
479 +
                .borders(Borders::ALL)
480 +
                .border_style(Style::default().fg(Color::Yellow)),
481 +
        );
482 +
        frame.render_widget(help, popup_area);
483 +
    }
484 +
}