feat: added tui 2deba90d
Steve · 2026-02-18 21:34 5 file(s) · +158 −4
Cargo.toml +11 −0
3 3
version = "0.1.0"
4 4
edition = "2024"
5 5
6 +
[[bin]]
7 +
name = "sipp-server"
8 +
path = "src/main.rs"
9 +
10 +
[[bin]]
11 +
name = "sipp-tui"
12 +
path = "src/bin/tui.rs"
13 +
6 14
[dependencies]
7 15
axum = "0.8.8"
8 16
tokio = { version = "1", features = ["full"] }
12 20
serde = { version = "1", features = ["derive"] }
13 21
tower-http = { version = "0.6.8", features = ["fs"] }
14 22
rand = "0.10"
23 +
ratatui = "0.30"
24 +
crossterm = "0.28"
25 +
arboard = "3"
src/bin/tui.rs (added) +127 −0
1 +
use arboard::Clipboard;
2 +
use crossterm::event::{self, Event, KeyCode};
3 +
use ratatui::{
4 +
    DefaultTerminal,
5 +
    layout::{Constraint, Layout},
6 +
    style::{Color, Modifier, Style},
7 +
    text::Text,
8 +
    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
9 +
};
10 +
use sipp_rust::db::{self, Snippet};
11 +
12 +
struct App {
13 +
    snippets: Vec<Snippet>,
14 +
    list_state: ListState,
15 +
    should_quit: bool,
16 +
}
17 +
18 +
impl App {
19 +
    fn new(snippets: Vec<Snippet>) -> Self {
20 +
        let mut list_state = ListState::default();
21 +
        if !snippets.is_empty() {
22 +
            list_state.select(Some(0));
23 +
        }
24 +
        Self {
25 +
            snippets,
26 +
            list_state,
27 +
            should_quit: false,
28 +
        }
29 +
    }
30 +
31 +
    fn selected_snippet(&self) -> Option<&Snippet> {
32 +
        self.list_state.selected().and_then(|i| self.snippets.get(i))
33 +
    }
34 +
35 +
    fn move_up(&mut self) {
36 +
        if self.snippets.is_empty() {
37 +
            return;
38 +
        }
39 +
        let i = match self.list_state.selected() {
40 +
            Some(i) if i > 0 => i - 1,
41 +
            Some(_) => self.snippets.len() - 1,
42 +
            None => 0,
43 +
        };
44 +
        self.list_state.select(Some(i));
45 +
    }
46 +
47 +
    fn move_down(&mut self) {
48 +
        if self.snippets.is_empty() {
49 +
            return;
50 +
        }
51 +
        let i = match self.list_state.selected() {
52 +
            Some(i) if i < self.snippets.len() - 1 => i + 1,
53 +
            Some(_) => 0,
54 +
            None => 0,
55 +
        };
56 +
        self.list_state.select(Some(i));
57 +
    }
58 +
59 +
    fn copy_selected(&self) {
60 +
        if let Some(snippet) = self.selected_snippet() {
61 +
            if let Ok(mut clipboard) = Clipboard::new() {
62 +
                let _ = clipboard.set_text(&snippet.content);
63 +
            }
64 +
        }
65 +
    }
66 +
}
67 +
68 +
fn main() -> Result<(), Box<dyn std::error::Error>> {
69 +
    let db = db::init_db();
70 +
    let snippets = db::get_all_snippets(&db);
71 +
72 +
    ratatui::run(|terminal| run_app(terminal, App::new(snippets)))
73 +
}
74 +
75 +
fn run_app(
76 +
    terminal: &mut DefaultTerminal,
77 +
    mut app: App,
78 +
) -> Result<(), Box<dyn std::error::Error>> {
79 +
    while !app.should_quit {
80 +
        terminal.draw(|frame| {
81 +
            let chunks = Layout::horizontal([
82 +
                Constraint::Percentage(30),
83 +
                Constraint::Percentage(70),
84 +
            ])
85 +
            .split(frame.area());
86 +
87 +
            let items: Vec<ListItem> = app
88 +
                .snippets
89 +
                .iter()
90 +
                .map(|s| ListItem::new(s.name.as_str()))
91 +
                .collect();
92 +
93 +
            let list = List::new(items)
94 +
                .block(Block::default().title(" Snippets ").borders(Borders::ALL))
95 +
                .highlight_style(
96 +
                    Style::default()
97 +
                        .fg(Color::Yellow)
98 +
                        .add_modifier(Modifier::BOLD),
99 +
                )
100 +
                .highlight_symbol("▶ ");
101 +
102 +
            frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
103 +
104 +
            let content = app
105 +
                .selected_snippet()
106 +
                .map(|s| s.content.as_str())
107 +
                .unwrap_or("");
108 +
109 +
            let paragraph = Paragraph::new(Text::raw(content))
110 +
                .block(Block::default().title(" Content ").borders(Borders::ALL));
111 +
112 +
            frame.render_widget(paragraph, chunks[1]);
113 +
        })?;
114 +
115 +
        if let Event::Key(key) = event::read()? {
116 +
            match key.code {
117 +
                KeyCode::Char('q') => app.should_quit = true,
118 +
                KeyCode::Char('j') | KeyCode::Down => app.move_down(),
119 +
                KeyCode::Char('k') | KeyCode::Up => app.move_up(),
120 +
                KeyCode::Char('y') => app.copy_selected(),
121 +
                _ => {}
122 +
            }
123 +
        }
124 +
    }
125 +
126 +
    Ok(())
127 +
}
src/db.rs +18 −0
69 69
    )
70 70
    .ok()
71 71
}
72 +
73 +
pub fn get_all_snippets(db: &Db) -> Vec<Snippet> {
74 +
    let conn = db.lock().unwrap();
75 +
    let mut stmt = conn
76 +
        .prepare("SELECT id, short_id, content, name FROM snippets ORDER BY id DESC")
77 +
        .expect("Failed to prepare statement");
78 +
    stmt.query_map([], |row| {
79 +
        Ok(Snippet {
80 +
            id: row.get(0)?,
81 +
            short_id: row.get(1)?,
82 +
            content: row.get(2)?,
83 +
            name: row.get(3)?,
84 +
        })
85 +
    })
86 +
    .expect("Failed to query snippets")
87 +
    .filter_map(|r| r.ok())
88 +
    .collect()
89 +
}
src/lib.rs (added) +1 −0
1 +
pub mod db;
src/main.rs +1 −4
1 -
mod db;
2 -
3 1
use askama::Template;
4 2
use askama_web::WebTemplate;
5 3
use axum::{
10 8
    routing::{get, post},
11 9
};
12 10
use serde::Deserialize;
11 +
use sipp_rust::db::{self, Db};
13 12
use tower_http::services::ServeDir;
14 -
15 -
use db::Db;
16 13
17 14
#[derive(Template)]
18 15
#[template(path = "index.html")]