feat: inital jotts tui feature 51cb06ab
Steve · 2026-04-13 22:01 13 file(s) · +2065 −13
Cargo.lock +9 −0
2194 2194
version = "0.1.2"
2195 2195
dependencies = [
2196 2196
 "andromeda-auth",
2197 +
 "arboard",
2197 2198
 "askama 0.15.6",
2198 2199
 "askama_web",
2199 2200
 "axum",
2201 +
 "clap",
2202 +
 "crossterm",
2200 2203
 "dotenvy",
2201 2204
 "nanoid",
2205 +
 "open",
2202 2206
 "pulldown-cmark",
2203 2207
 "rand 0.8.5",
2208 +
 "ratatui",
2209 +
 "reqwest 0.13.2",
2210 +
 "rpassword",
2204 2211
 "rusqlite",
2205 2212
 "rust-embed",
2206 2213
 "serde",
2207 2214
 "serde_json",
2208 2215
 "subtle",
2216 +
 "syntect",
2209 2217
 "tokio",
2218 +
 "toml",
2210 2219
 "tracing",
2211 2220
 "tracing-subscriber",
2212 2221
]
apps/jotts/.env.example +3 −0
3 3
COOKIE_SECURE=false
4 4
HOST=127.0.0.1
5 5
PORT=3000
6 +
# Optional. When set, enables the JSON API at /api/notes gated by x-api-key header.
7 +
# Leave unset to disable the API (returns 403).
8 +
JOTTS_API_KEY=
apps/jotts/Cargo.toml +13 −0
24 24
askama = "0.15"
25 25
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 26
pulldown-cmark = "0.12"
27 +
ratatui = "0.30"
28 +
crossterm = "0.29"
29 +
arboard = "3"
30 +
syntect = "5"
31 +
reqwest = { version = "0.13", features = ["json", "blocking"] }
32 +
clap = { version = "4", features = ["derive", "env"] }
33 +
toml = "1.0"
34 +
rpassword = "7"
35 +
open = "5.3.3"
36 +
37 +
[[bin]]
38 +
name = "jotts"
39 +
path = "src/main.rs"
apps/jotts/README.md +16 −0
24 24
| `HOST` | Server bind address | `127.0.0.1` |
25 25
| `PORT` | Server port | `3000` |
26 26
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
27 +
| `JOTTS_API_KEY` | API key for `/api/notes` JSON endpoints (unset = API disabled) | _(unset)_ |
27 28
28 29
## Overview
29 30
56 57
├── Dockerfile         # Multi-stage build (Rust + Debian slim)
57 58
└── docker-compose.yml
58 59
```
60 +
61 +
## CLI / TUI
62 +
63 +
The `jotts` binary also ships an interactive terminal UI and a minimal CLI.
64 +
65 +
```bash
66 +
jotts               # Launch TUI (local SQLite by default, remote if configured)
67 +
jotts server        # Run the web server
68 +
jotts tui           # Launch TUI explicitly
69 +
jotts auth          # Prompt for remote URL + API key, save to ~/.config/jotts/config.toml
70 +
```
71 +
72 +
Flags: `--remote <URL>` (env `JOTTS_REMOTE_URL`), `--api-key <KEY>` (env `JOTTS_API_KEY`).
73 +
74 +
Against a remote server, set `JOTTS_API_KEY` on the server and pass the same key from the CLI. Requests go to `/api/notes` with the `x-api-key` header. The TUI renders note content with syntect Markdown syntax highlighting.
59 75
60 76
## Deployment
61 77
apps/jotts/src/ansi.tmTheme (added) +430 −0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +
<plist version="1.0">
4 +
    <dict>
5 +
        <!--
6 +
        The colors in this theme are encoded as #RRGGBBAA where:
7 +
        * If AA is 00, then RR is an ANSI palette number from 00 to 07.
8 +
        * If AA is 01, the terminal's default fg/bg color is used.
9 +
        -->
10 +
        <key>author</key>
11 +
        <string>Template: Chris Kempson, Scheme: Mitchell Kember</string>
12 +
        <key>name</key>
13 +
        <string>ANSI</string>
14 +
        <key>colorSpaceName</key>
15 +
        <string>sRGB</string>
16 +
        <key>settings</key>
17 +
        <array>
18 +
            <dict>
19 +
                <key>settings</key>
20 +
                <dict>
21 +
                    <key>background</key>
22 +
                    <string>#00000001</string>
23 +
                    <key>foreground</key>
24 +
                    <string>#00000001</string>
25 +
                    <!--
26 +
                    Explicitly set the gutter color since bat falls back to a
27 +
                    hardcoded DEFAULT_GUTTER_COLOR otherwise.
28 +
                    -->
29 +
                    <key>gutter</key>
30 +
                    <string>#00000001</string>
31 +
                    <key>gutterForeground</key>
32 +
                    <string>#00000001</string>
33 +
                </dict>
34 +
            </dict>
35 +
            <dict>
36 +
                <key>name</key>
37 +
                <string>Comments</string>
38 +
                <key>scope</key>
39 +
                <string>comment, punctuation.definition.comment</string>
40 +
                <key>settings</key>
41 +
                <dict>
42 +
                    <key>foreground</key>
43 +
                    <string>#02000000</string>
44 +
                </dict>
45 +
            </dict>
46 +
            <dict>
47 +
                <key>name</key>
48 +
                <string>Keywords</string>
49 +
                <key>scope</key>
50 +
                <string>keyword</string>
51 +
                <key>settings</key>
52 +
                <dict>
53 +
                    <key>foreground</key>
54 +
                    <string>#05000000</string>
55 +
                </dict>
56 +
            </dict>
57 +
            <dict>
58 +
                <key>name</key>
59 +
                <string>Functions</string>
60 +
                <key>scope</key>
61 +
                <string>entity.name.function, meta.require, support.function.any-method</string>
62 +
                <key>settings</key>
63 +
                <dict>
64 +
                    <key>foreground</key>
65 +
                    <string>#04000000</string>
66 +
                </dict>
67 +
            </dict>
68 +
            <dict>
69 +
                <key>name</key>
70 +
                <string>Labels</string>
71 +
                <key>scope</key>
72 +
                <string>entity.name.label, variable.parameter</string>
73 +
                <key>settings</key>
74 +
                <dict>
75 +
                    <key>foreground</key>
76 +
                    <string>#06000000</string>
77 +
                </dict>
78 +
            </dict>
79 +
            <dict>
80 +
                <key>name</key>
81 +
                <string>Classes</string>
82 +
                <key>scope</key>
83 +
                <string>support.class, entity.name.class, entity.name.type.class, entity.name</string>
84 +
                <key>settings</key>
85 +
                <dict>
86 +
                    <key>foreground</key>
87 +
                    <string>#03000000</string>
88 +
                </dict>
89 +
            </dict>
90 +
            <dict>
91 +
                <key>name</key>
92 +
                <string>Methods</string>
93 +
                <key>scope</key>
94 +
                <string>keyword.other.special-method</string>
95 +
                <key>settings</key>
96 +
                <dict>
97 +
                    <key>foreground</key>
98 +
                    <string>#04000000</string>
99 +
                </dict>
100 +
            </dict>
101 +
            <dict>
102 +
                <key>name</key>
103 +
                <string>Storage</string>
104 +
                <key>scope</key>
105 +
                <string>storage</string>
106 +
                <key>settings</key>
107 +
                <dict>
108 +
                    <key>foreground</key>
109 +
                    <string>#05000000</string>
110 +
                </dict>
111 +
            </dict>
112 +
            <dict>
113 +
                <key>name</key>
114 +
                <string>Support</string>
115 +
                <key>scope</key>
116 +
                <string>support.function</string>
117 +
                <key>settings</key>
118 +
                <dict>
119 +
                    <key>foreground</key>
120 +
                    <string>#06000000</string>
121 +
                </dict>
122 +
            </dict>
123 +
            <dict>
124 +
                <key>name</key>
125 +
                <string>Strings, Inherited Class</string>
126 +
                <key>scope</key>
127 +
                <string>string, constant.other.symbol, entity.other.inherited-class</string>
128 +
                <key>settings</key>
129 +
                <dict>
130 +
                    <key>foreground</key>
131 +
                    <string>#02000000</string>
132 +
                </dict>
133 +
            </dict>
134 +
            <dict>
135 +
                <key>name</key>
136 +
                <string>Integers</string>
137 +
                <key>scope</key>
138 +
                <string>constant.numeric</string>
139 +
                <key>settings</key>
140 +
                <dict>
141 +
                    <key>foreground</key>
142 +
                    <string>#03000000</string>
143 +
                </dict>
144 +
            </dict>
145 +
            <dict>
146 +
                <key>name</key>
147 +
                <string>Floats</string>
148 +
                <key>scope</key>
149 +
                <string>none</string>
150 +
                <key>settings</key>
151 +
                <dict>
152 +
                    <key>foreground</key>
153 +
                    <string>#03000000</string>
154 +
                </dict>
155 +
            </dict>
156 +
            <dict>
157 +
                <key>name</key>
158 +
                <string>Boolean</string>
159 +
                <key>scope</key>
160 +
                <string>none</string>
161 +
                <key>settings</key>
162 +
                <dict>
163 +
                    <key>foreground</key>
164 +
                    <string>#03000000</string>
165 +
                </dict>
166 +
            </dict>
167 +
            <dict>
168 +
                <key>name</key>
169 +
                <string>Constants</string>
170 +
                <key>scope</key>
171 +
                <string>constant</string>
172 +
                <key>settings</key>
173 +
                <dict>
174 +
                    <key>foreground</key>
175 +
                    <string>#03000000</string>
176 +
                </dict>
177 +
            </dict>
178 +
            <dict>
179 +
                <key>name</key>
180 +
                <string>Tags</string>
181 +
                <key>scope</key>
182 +
                <string>entity.name.tag</string>
183 +
                <key>settings</key>
184 +
                <dict>
185 +
                    <key>foreground</key>
186 +
                    <string>#01000000</string>
187 +
                </dict>
188 +
            </dict>
189 +
            <dict>
190 +
                <key>name</key>
191 +
                <string>Attributes</string>
192 +
                <key>scope</key>
193 +
                <string>entity.other.attribute-name</string>
194 +
                <key>settings</key>
195 +
                <dict>
196 +
                    <key>foreground</key>
197 +
                    <string>#03000000</string>
198 +
                </dict>
199 +
            </dict>
200 +
            <dict>
201 +
                <key>name</key>
202 +
                <string>Attribute IDs</string>
203 +
                <key>scope</key>
204 +
                <string>entity.other.attribute-name.id, punctuation.definition.entity</string>
205 +
                <key>settings</key>
206 +
                <dict>
207 +
                    <key>foreground</key>
208 +
                    <string>#04000000</string>
209 +
                </dict>
210 +
            </dict>
211 +
            <dict>
212 +
                <key>name</key>
213 +
                <string>Selector</string>
214 +
                <key>scope</key>
215 +
                <string>meta.selector</string>
216 +
                <key>settings</key>
217 +
                <dict>
218 +
                    <key>foreground</key>
219 +
                    <string>#05000000</string>
220 +
                </dict>
221 +
            </dict>
222 +
            <dict>
223 +
                <key>name</key>
224 +
                <string>Values</string>
225 +
                <key>scope</key>
226 +
                <string>none</string>
227 +
                <key>settings</key>
228 +
                <dict>
229 +
                    <key>foreground</key>
230 +
                    <string>#03000000</string>
231 +
                </dict>
232 +
            </dict>
233 +
            <dict>
234 +
                <key>name</key>
235 +
                <string>Headings</string>
236 +
                <key>scope</key>
237 +
                <string>markup.heading punctuation.definition.heading, entity.name.section, markup.heading - text.html.markdown, meta.mapping.key string.quoted.double</string>
238 +
                <key>settings</key>
239 +
                <dict>
240 +
                    <key>fontStyle</key>
241 +
                    <string></string>
242 +
                    <key>foreground</key>
243 +
                    <string>#04000000</string>
244 +
                </dict>
245 +
            </dict>
246 +
            <dict>
247 +
                <key>name</key>
248 +
                <string>Units</string>
249 +
                <key>scope</key>
250 +
                <string>keyword.other.unit</string>
251 +
                <key>settings</key>
252 +
                <dict>
253 +
                    <key>foreground</key>
254 +
                    <string>#03000000</string>
255 +
                </dict>
256 +
            </dict>
257 +
            <dict>
258 +
                <key>name</key>
259 +
                <string>Bold</string>
260 +
                <key>scope</key>
261 +
                <string>markup.bold, punctuation.definition.bold</string>
262 +
                <key>settings</key>
263 +
                <dict>
264 +
                    <key>fontStyle</key>
265 +
                    <string>bold</string>
266 +
                    <key>foreground</key>
267 +
                    <string>#03000000</string>
268 +
                </dict>
269 +
            </dict>
270 +
            <dict>
271 +
                <key>name</key>
272 +
                <string>Italic</string>
273 +
                <key>scope</key>
274 +
                <string>markup.italic, punctuation.definition.italic</string>
275 +
                <key>settings</key>
276 +
                <dict>
277 +
                    <key>fontStyle</key>
278 +
                    <string>italic</string>
279 +
                    <key>foreground</key>
280 +
                    <string>#05000000</string>
281 +
                </dict>
282 +
            </dict>
283 +
            <dict>
284 +
                <key>name</key>
285 +
                <string>Code</string>
286 +
                <key>scope</key>
287 +
                <string>markup.raw.inline</string>
288 +
                <key>settings</key>
289 +
                <dict>
290 +
                    <key>foreground</key>
291 +
                    <string>#02000000</string>
292 +
                </dict>
293 +
            </dict>
294 +
            <dict>
295 +
                <key>name</key>
296 +
                <string>Link Text</string>
297 +
                <key>scope</key>
298 +
                <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string>
299 +
                <key>settings</key>
300 +
                <dict>
301 +
                    <key>foreground</key>
302 +
                    <string>#01000000</string>
303 +
                </dict>
304 +
            </dict>
305 +
            <dict>
306 +
                <key>name</key>
307 +
                <string>Link Url</string>
308 +
                <key>scope</key>
309 +
                <string>meta.link</string>
310 +
                <key>settings</key>
311 +
                <dict>
312 +
                    <key>foreground</key>
313 +
                    <string>#03000000</string>
314 +
                </dict>
315 +
            </dict>
316 +
            <dict>
317 +
                <key>name</key>
318 +
                <string>Quotes</string>
319 +
                <key>scope</key>
320 +
                <string>markup.quote</string>
321 +
                <key>settings</key>
322 +
                <dict>
323 +
                    <key>foreground</key>
324 +
                    <string>#03000000</string>
325 +
                </dict>
326 +
            </dict>
327 +
            <dict>
328 +
                <key>name</key>
329 +
                <string>Inserted</string>
330 +
                <key>scope</key>
331 +
                <string>markup.inserted</string>
332 +
                <key>settings</key>
333 +
                <dict>
334 +
                    <key>foreground</key>
335 +
                    <string>#02000000</string>
336 +
                </dict>
337 +
            </dict>
338 +
            <dict>
339 +
                <key>name</key>
340 +
                <string>Deleted</string>
341 +
                <key>scope</key>
342 +
                <string>markup.deleted</string>
343 +
                <key>settings</key>
344 +
                <dict>
345 +
                    <key>foreground</key>
346 +
                    <string>#01000000</string>
347 +
                </dict>
348 +
            </dict>
349 +
            <dict>
350 +
                <key>name</key>
351 +
                <string>Changed</string>
352 +
                <key>scope</key>
353 +
                <string>markup.changed</string>
354 +
                <key>settings</key>
355 +
                <dict>
356 +
                    <key>foreground</key>
357 +
                    <string>#05000000</string>
358 +
                </dict>
359 +
            </dict>
360 +
            <dict>
361 +
                <key>name</key>
362 +
                <string>Colors</string>
363 +
                <key>scope</key>
364 +
                <string>constant.other.color</string>
365 +
                <key>settings</key>
366 +
                <dict>
367 +
                    <key>foreground</key>
368 +
                    <string>#06000000</string>
369 +
                </dict>
370 +
            </dict>
371 +
            <dict>
372 +
                <key>name</key>
373 +
                <string>Regular Expressions</string>
374 +
                <key>scope</key>
375 +
                <string>string.regexp</string>
376 +
                <key>settings</key>
377 +
                <dict>
378 +
                    <key>foreground</key>
379 +
                    <string>#06000000</string>
380 +
                </dict>
381 +
            </dict>
382 +
            <dict>
383 +
                <key>name</key>
384 +
                <string>Escape Characters</string>
385 +
                <key>scope</key>
386 +
                <string>constant.character.escape</string>
387 +
                <key>settings</key>
388 +
                <dict>
389 +
                    <key>foreground</key>
390 +
                    <string>#06000000</string>
391 +
                </dict>
392 +
            </dict>
393 +
            <dict>
394 +
                <key>name</key>
395 +
                <string>Embedded</string>
396 +
                <key>scope</key>
397 +
                <string>punctuation.section.embedded, variable.interpolation</string>
398 +
                <key>settings</key>
399 +
                <dict>
400 +
                    <key>foreground</key>
401 +
                    <string>#05000000</string>
402 +
                </dict>
403 +
            </dict>
404 +
            <dict>
405 +
                <key>name</key>
406 +
                <string>Illegal</string>
407 +
                <key>scope</key>
408 +
                <string>invalid.illegal</string>
409 +
                <key>settings</key>
410 +
                <dict>
411 +
                    <key>background</key>
412 +
                    <string>#01000000</string>
413 +
                </dict>
414 +
            </dict>
415 +
            <dict>
416 +
                <key>name</key>
417 +
                <string>Broken</string>
418 +
                <key>scope</key>
419 +
                <string>invalid.broken</string>
420 +
                <key>settings</key>
421 +
                <dict>
422 +
                    <key>background</key>
423 +
                    <string>#03000000</string>
424 +
                </dict>
425 +
            </dict>
426 +
        </array>
427 +
        <key>uuid</key>
428 +
        <string>uuid</string>
429 +
    </dict>
430 +
</plist>
apps/jotts/src/backend.rs (added) +182 −0
1 +
use crate::db::{self, Db, Note};
2 +
use std::fmt;
3 +
4 +
#[derive(Debug)]
5 +
pub enum BackendError {
6 +
    #[allow(dead_code)]
7 +
    NotFound,
8 +
    Unauthorized(String),
9 +
    Network(String),
10 +
    Database(String),
11 +
}
12 +
13 +
impl fmt::Display for BackendError {
14 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 +
        match self {
16 +
            BackendError::NotFound => write!(f, "Not found"),
17 +
            BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
18 +
            BackendError::Network(msg) => write!(f, "Network error: {}", msg),
19 +
            BackendError::Database(msg) => write!(f, "Database error: {}", msg),
20 +
        }
21 +
    }
22 +
}
23 +
24 +
impl std::error::Error for BackendError {}
25 +
26 +
impl From<db::DbError> for BackendError {
27 +
    fn from(e: db::DbError) -> Self {
28 +
        BackendError::Database(e.to_string())
29 +
    }
30 +
}
31 +
32 +
pub enum Backend {
33 +
    Local {
34 +
        db: Db,
35 +
    },
36 +
    Remote {
37 +
        base_url: String,
38 +
        api_key: Option<String>,
39 +
        client: reqwest::blocking::Client,
40 +
    },
41 +
}
42 +
43 +
impl Backend {
44 +
    pub fn local() -> Self {
45 +
        Backend::Local { db: db::init_db() }
46 +
    }
47 +
48 +
    pub fn remote(base_url: String, api_key: Option<String>) -> Self {
49 +
        Backend::Remote {
50 +
            base_url,
51 +
            api_key,
52 +
            client: reqwest::blocking::Client::new(),
53 +
        }
54 +
    }
55 +
56 +
    pub fn list_notes(&self) -> Result<Vec<Note>, BackendError> {
57 +
        match self {
58 +
            Backend::Local { db } => Ok(db::get_all_notes(db)?),
59 +
            Backend::Remote {
60 +
                base_url,
61 +
                api_key,
62 +
                client,
63 +
            } => {
64 +
                let mut req = client.get(format!("{}/api/notes", base_url));
65 +
                if let Some(key) = api_key {
66 +
                    req = req.header("x-api-key", key);
67 +
                }
68 +
                let resp = req
69 +
                    .send()
70 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
71 +
                match resp.status().as_u16() {
72 +
                    200 => resp
73 +
                        .json::<Vec<Note>>()
74 +
                        .map_err(|e| BackendError::Network(e.to_string())),
75 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
76 +
                    403 => Err(BackendError::Unauthorized(
77 +
                        "No API key configured on server".into(),
78 +
                    )),
79 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
80 +
                }
81 +
            }
82 +
        }
83 +
    }
84 +
85 +
    pub fn create_note(&self, title: &str, content: &str) -> Result<Note, BackendError> {
86 +
        match self {
87 +
            Backend::Local { db } => Ok(db::create_note(db, title, content)?),
88 +
            Backend::Remote {
89 +
                base_url,
90 +
                api_key,
91 +
                client,
92 +
            } => {
93 +
                let mut req = client
94 +
                    .post(format!("{}/api/notes", base_url))
95 +
                    .json(&serde_json::json!({"title": title, "content": content}));
96 +
                if let Some(key) = api_key {
97 +
                    req = req.header("x-api-key", key);
98 +
                }
99 +
                let resp = req
100 +
                    .send()
101 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
102 +
                match resp.status().as_u16() {
103 +
                    201 => resp
104 +
                        .json::<Note>()
105 +
                        .map_err(|e| BackendError::Network(e.to_string())),
106 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
107 +
                    403 => Err(BackendError::Unauthorized(
108 +
                        "No API key configured on server".into(),
109 +
                    )),
110 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
111 +
                }
112 +
            }
113 +
        }
114 +
    }
115 +
116 +
    pub fn update_note(
117 +
        &self,
118 +
        short_id: &str,
119 +
        title: &str,
120 +
        content: &str,
121 +
    ) -> Result<Option<Note>, BackendError> {
122 +
        match self {
123 +
            Backend::Local { db } => Ok(db::update_note_by_short_id(db, short_id, title, content)?),
124 +
            Backend::Remote {
125 +
                base_url,
126 +
                api_key,
127 +
                client,
128 +
            } => {
129 +
                let mut req = client
130 +
                    .put(format!("{}/api/notes/{}", base_url, short_id))
131 +
                    .json(&serde_json::json!({"title": title, "content": content}));
132 +
                if let Some(key) = api_key {
133 +
                    req = req.header("x-api-key", key);
134 +
                }
135 +
                let resp = req
136 +
                    .send()
137 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
138 +
                match resp.status().as_u16() {
139 +
                    200 => resp
140 +
                        .json::<Note>()
141 +
                        .map(Some)
142 +
                        .map_err(|e| BackendError::Network(e.to_string())),
143 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
144 +
                    403 => Err(BackendError::Unauthorized(
145 +
                        "No API key configured on server".into(),
146 +
                    )),
147 +
                    404 => Ok(None),
148 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
149 +
                }
150 +
            }
151 +
        }
152 +
    }
153 +
154 +
    pub fn delete_note(&self, short_id: &str) -> Result<bool, BackendError> {
155 +
        match self {
156 +
            Backend::Local { db } => Ok(db::delete_note_by_short_id(db, short_id)?),
157 +
            Backend::Remote {
158 +
                base_url,
159 +
                api_key,
160 +
                client,
161 +
            } => {
162 +
                let mut req = client.delete(format!("{}/api/notes/{}", base_url, short_id));
163 +
                if let Some(key) = api_key {
164 +
                    req = req.header("x-api-key", key);
165 +
                }
166 +
                let resp = req
167 +
                    .send()
168 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
169 +
                match resp.status().as_u16() {
170 +
                    200 | 204 => Ok(true),
171 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
172 +
                    403 => Err(BackendError::Unauthorized(
173 +
                        "No API key configured on server".into(),
174 +
                    )),
175 +
                    404 => Ok(false),
176 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
177 +
                }
178 +
            }
179 +
        }
180 +
    }
181 +
}
182 +
apps/jotts/src/config.rs (added) +31 −0
1 +
use serde::{Deserialize, Serialize};
2 +
use std::path::PathBuf;
3 +
4 +
#[derive(Debug, Default, Serialize, Deserialize)]
5 +
pub struct Config {
6 +
    pub remote_url: Option<String>,
7 +
    pub api_key: Option<String>,
8 +
}
9 +
10 +
pub fn config_path() -> PathBuf {
11 +
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
12 +
    PathBuf::from(home).join(".config/jotts/config.toml")
13 +
}
14 +
15 +
pub fn load_config() -> Config {
16 +
    let path = config_path();
17 +
    match std::fs::read_to_string(&path) {
18 +
        Ok(contents) => toml::from_str(&contents).unwrap_or_default(),
19 +
        Err(_) => Config::default(),
20 +
    }
21 +
}
22 +
23 +
pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
24 +
    let path = config_path();
25 +
    if let Some(parent) = path.parent() {
26 +
        std::fs::create_dir_all(parent)?;
27 +
    }
28 +
    let contents = toml::to_string_pretty(config)?;
29 +
    std::fs::write(&path, contents)?;
30 +
    Ok(())
31 +
}
apps/jotts/src/highlight.rs (added) +60 −0
1 +
use ratatui::style::{Color, Style};
2 +
use ratatui::text::{Line, Span, Text};
3 +
use std::io::Cursor;
4 +
use syntect::easy::HighlightLines;
5 +
use syntect::highlighting::Theme;
6 +
use syntect::parsing::SyntaxSet;
7 +
use syntect::util::LinesWithEndings;
8 +
9 +
pub struct Highlighter {
10 +
    syntax_set: SyntaxSet,
11 +
    theme: Theme,
12 +
}
13 +
14 +
impl Highlighter {
15 +
    pub fn new() -> Self {
16 +
        let theme_data = include_bytes!("ansi.tmTheme");
17 +
        let theme =
18 +
            syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..]))
19 +
                .expect("failed to load ansi theme");
20 +
        Self {
21 +
            syntax_set: SyntaxSet::load_defaults_newlines(),
22 +
            theme,
23 +
        }
24 +
    }
25 +
26 +
    pub fn highlight_markdown(&self, content: &str) -> Text<'static> {
27 +
        let syntax = self
28 +
            .syntax_set
29 +
            .find_syntax_by_extension("md")
30 +
            .or_else(|| self.syntax_set.find_syntax_by_name("Markdown"))
31 +
            .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
32 +
        let mut h = HighlightLines::new(syntax, &self.theme);
33 +
34 +
        let lines: Vec<Line<'static>> = LinesWithEndings::from(content)
35 +
            .map(|line| {
36 +
                let ranges = h
37 +
                    .highlight_line(line, &self.syntax_set)
38 +
                    .unwrap_or_default();
39 +
                let spans: Vec<Span<'static>> = ranges
40 +
                    .into_iter()
41 +
                    .map(|(style, text)| {
42 +
                        let color = to_ratatui_color(style.foreground);
43 +
                        Span::styled(text.to_owned(), Style::default().fg(color))
44 +
                    })
45 +
                    .collect();
46 +
                Line::from(spans)
47 +
            })
48 +
            .collect();
49 +
50 +
        Text::from(lines)
51 +
    }
52 +
}
53 +
54 +
fn to_ratatui_color(color: syntect::highlighting::Color) -> Color {
55 +
    if color.a == 0 {
56 +
        Color::Indexed(color.r)
57 +
    } else {
58 +
        Color::Reset
59 +
    }
60 +
}
apps/jotts/src/lib.rs (added) +7 −0
1 +
pub mod auth;
2 +
pub mod backend;
3 +
pub mod config;
4 +
pub mod db;
5 +
pub mod highlight;
6 +
pub mod server;
7 +
pub mod tui;
apps/jotts/src/main.rs +62 −11
1 -
mod auth;
2 -
mod db;
3 -
mod server;
1 +
use clap::{Parser, Subcommand};
2 +
3 +
#[derive(Parser)]
4 +
#[command(name = "jotts", about = "Markdown notes — TUI, server, and CLI")]
5 +
struct Cli {
6 +
    /// Remote server URL (e.g. http://localhost:3000)
7 +
    #[arg(short, long, env = "JOTTS_REMOTE_URL")]
8 +
    remote: Option<String>,
9 +
10 +
    /// API key for authenticated operations
11 +
    #[arg(short = 'k', long, env = "JOTTS_API_KEY")]
12 +
    api_key: Option<String>,
13 +
14 +
    #[command(subcommand)]
15 +
    command: Option<Commands>,
16 +
}
17 +
18 +
#[derive(Subcommand)]
19 +
enum Commands {
20 +
    /// Start the web server
21 +
    Server {
22 +
        /// Port to listen on
23 +
        #[arg(short, long, default_value_t = 3000)]
24 +
        port: u16,
25 +
26 +
        /// Host to bind to
27 +
        #[arg(long, default_value = "127.0.0.1")]
28 +
        host: String,
29 +
    },
30 +
    /// Launch the interactive TUI
31 +
    Tui {
32 +
        #[arg(short, long, env = "JOTTS_REMOTE_URL")]
33 +
        remote: Option<String>,
34 +
35 +
        #[arg(short = 'k', long, env = "JOTTS_API_KEY")]
36 +
        api_key: Option<String>,
37 +
    },
38 +
    /// Save remote URL and API key to config file
39 +
    Auth,
40 +
}
4 41
5 -
#[tokio::main]
6 -
async fn main() {
42 +
fn main() -> Result<(), Box<dyn std::error::Error>> {
43 +
    dotenvy::dotenv().ok();
7 44
    tracing_subscriber::fmt::init();
8 -
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
9 -
    let port: u16 = std::env::var("PORT")
10 -
        .ok()
11 -
        .and_then(|v| v.parse().ok())
12 -
        .unwrap_or(3000);
13 -
    server::run(host, port).await;
45 +
46 +
    let cli = Cli::parse();
47 +
48 +
    match cli.command {
49 +
        Some(Commands::Server { port, host }) => {
50 +
            let rt = tokio::runtime::Runtime::new()?;
51 +
            rt.block_on(jotts::server::run(host, port));
52 +
        }
53 +
        Some(Commands::Tui { remote, api_key }) => {
54 +
            jotts::tui::run_interactive(remote, api_key)?;
55 +
        }
56 +
        Some(Commands::Auth) => {
57 +
            jotts::tui::run_auth()?;
58 +
        }
59 +
        None => {
60 +
            jotts::tui::run_interactive(cli.remote, cli.api_key)?;
61 +
        }
62 +
    }
63 +
64 +
    Ok(())
14 65
}
apps/jotts/src/server.rs +135 −2
1 1
use askama::Template;
2 2
use askama_web::WebTemplate;
3 3
use axum::{
4 -
    extract::{Form, Path, Query, State},
4 +
    extract::{Form, Path, Query, Request, State},
5 5
    http::{HeaderValue, StatusCode},
6 +
    middleware::{self, Next},
6 7
    response::{Html, IntoResponse, Redirect, Response},
7 8
    routing::{get, post},
8 -
    Router,
9 +
    Json, Router,
9 10
};
10 11
use pulldown_cmark::{Options, Parser, html}; use rust_embed::Embed;
11 12
use std::sync::Arc;
17 18
pub struct AppState {
18 19
    pub db: Db,
19 20
    pub app_password: String,
21 +
    pub api_key: Option<String>,
20 22
    pub cookie_secure: bool,
21 23
}
22 24
78 80
struct NoteForm {
79 81
    title: String,
80 82
    content: String,
83 +
}
84 +
85 +
#[derive(serde::Deserialize)]
86 +
struct NoteJson {
87 +
    title: String,
88 +
    content: String,
89 +
}
90 +
91 +
// --- API key middleware ---
92 +
93 +
async fn api_key_guard(
94 +
    State(state): State<Arc<AppState>>,
95 +
    req: Request,
96 +
    next: Next,
97 +
) -> Response {
98 +
    let expected = match &state.api_key {
99 +
        Some(k) if !k.is_empty() => k.clone(),
100 +
        _ => {
101 +
            return (StatusCode::FORBIDDEN, "API key not configured on server").into_response();
102 +
        }
103 +
    };
104 +
105 +
    let provided = req
106 +
        .headers()
107 +
        .get("x-api-key")
108 +
        .and_then(|v| v.to_str().ok())
109 +
        .unwrap_or("");
110 +
111 +
    if !andromeda_auth::verify_api_key(provided, &expected) {
112 +
        return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response();
113 +
    }
114 +
115 +
    next.run(req).await
116 +
}
117 +
118 +
// --- JSON API handlers ---
119 +
120 +
async fn api_list_notes(State(state): State<Arc<AppState>>) -> Response {
121 +
    match db::get_all_notes(&state.db) {
122 +
        Ok(notes) => Json(notes).into_response(),
123 +
        Err(e) => {
124 +
            tracing::error!("Failed to list notes: {}", e);
125 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
126 +
        }
127 +
    }
128 +
}
129 +
130 +
async fn api_get_note(
131 +
    State(state): State<Arc<AppState>>,
132 +
    Path(short_id): Path<String>,
133 +
) -> Response {
134 +
    match db::get_note_by_short_id(&state.db, &short_id) {
135 +
        Ok(Some(note)) => Json(note).into_response(),
136 +
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
137 +
        Err(e) => {
138 +
            tracing::error!("Failed to get note: {}", e);
139 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
140 +
        }
141 +
    }
142 +
}
143 +
144 +
async fn api_create_note(
145 +
    State(state): State<Arc<AppState>>,
146 +
    Json(body): Json<NoteJson>,
147 +
) -> Response {
148 +
    let title = body.title.trim();
149 +
    if title.is_empty() {
150 +
        return (StatusCode::BAD_REQUEST, "title required").into_response();
151 +
    }
152 +
    match db::create_note(&state.db, title, &body.content) {
153 +
        Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
154 +
        Err(e) => {
155 +
            tracing::error!("Failed to create note: {}", e);
156 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
157 +
        }
158 +
    }
159 +
}
160 +
161 +
async fn api_update_note(
162 +
    State(state): State<Arc<AppState>>,
163 +
    Path(short_id): Path<String>,
164 +
    Json(body): Json<NoteJson>,
165 +
) -> Response {
166 +
    let title = body.title.trim();
167 +
    if title.is_empty() {
168 +
        return (StatusCode::BAD_REQUEST, "title required").into_response();
169 +
    }
170 +
    match db::update_note_by_short_id(&state.db, &short_id, title, &body.content) {
171 +
        Ok(Some(note)) => Json(note).into_response(),
172 +
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
173 +
        Err(e) => {
174 +
            tracing::error!("Failed to update note: {}", e);
175 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
176 +
        }
177 +
    }
178 +
}
179 +
180 +
async fn api_delete_note(
181 +
    State(state): State<Arc<AppState>>,
182 +
    Path(short_id): Path<String>,
183 +
) -> Response {
184 +
    match db::delete_note_by_short_id(&state.db, &short_id) {
185 +
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
186 +
        Ok(false) => StatusCode::NOT_FOUND.into_response(),
187 +
        Err(e) => {
188 +
            tracing::error!("Failed to delete note: {}", e);
189 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
190 +
        }
191 +
    }
81 192
}
82 193
83 194
// --- Static file handlers ---
359 470
        .map(|v| v == "true")
360 471
        .unwrap_or(false);
361 472
473 +
    let api_key = std::env::var("JOTTS_API_KEY")
474 +
        .ok()
475 +
        .filter(|k| !k.is_empty());
476 +
    if api_key.is_none() {
477 +
        tracing::info!("JOTTS_API_KEY not set, /api/* will return 403");
478 +
    }
479 +
362 480
    let state = Arc::new(AppState {
363 481
        db,
364 482
        app_password,
483 +
        api_key,
365 484
        cookie_secure,
366 485
    });
367 486
487 +
    let api_router = Router::new()
488 +
        .route("/api/notes", get(api_list_notes).post(api_create_note))
489 +
        .route(
490 +
            "/api/notes/{short_id}",
491 +
            get(api_get_note)
492 +
                .put(api_update_note)
493 +
                .delete(api_delete_note),
494 +
        )
495 +
        .route_layer(middleware::from_fn_with_state(
496 +
            state.clone(),
497 +
            api_key_guard,
498 +
        ));
499 +
368 500
    let app = Router::new()
369 501
        // Public routes
370 502
        .route("/login", get(get_login).post(post_login))
379 511
        .route("/notes/{short_id}/delete", post(post_delete_note))
380 512
        // Static assets
381 513
        .route("/static/{*path}", get(serve_static))
514 +
        .merge(api_router)
382 515
        .with_state(state);
383 516
384 517
    let addr = format!("{}:{}", host, port);
apps/jotts/src/tui.rs (added) +1066 −0
1 +
use crate::backend::Backend;
2 +
use crate::config;
3 +
use crate::db::Note;
4 +
use crate::highlight::Highlighter;
5 +
use arboard::Clipboard;
6 +
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
7 +
use ratatui::{
8 +
    DefaultTerminal,
9 +
    layout::{Alignment, Constraint, Layout},
10 +
    style::{Color, Modifier, Style},
11 +
    text::{Line, Span, Text},
12 +
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget, Wrap},
13 +
};
14 +
use std::time::{Duration, Instant};
15 +
16 +
enum Focus {
17 +
    List,
18 +
    Content,
19 +
    CreateTitle,
20 +
    CreateContent,
21 +
    EditTitle,
22 +
    EditContent,
23 +
    Search,
24 +
}
25 +
26 +
struct App {
27 +
    notes: Vec<Note>,
28 +
    list_state: ListState,
29 +
    should_quit: bool,
30 +
    status_message: Option<(String, Instant)>,
31 +
    focus: Focus,
32 +
    content_scroll: u16,
33 +
    show_help: bool,
34 +
    confirm_delete: bool,
35 +
    highlighter: Highlighter,
36 +
    edit_title: String,
37 +
    edit_content: String,
38 +
    edit_short_id: Option<String>,
39 +
    search_query: String,
40 +
    filtered_indices: Option<Vec<usize>>,
41 +
    is_remote: bool,
42 +
    remote_url: Option<String>,
43 +
    wrap_content: bool,
44 +
    edit_scroll: u16,
45 +
}
46 +
47 +
impl App {
48 +
    fn new(notes: Vec<Note>, is_remote: bool, remote_url: Option<String>) -> Self {
49 +
        let mut list_state = ListState::default();
50 +
        if !notes.is_empty() {
51 +
            list_state.select(Some(0));
52 +
        }
53 +
        Self {
54 +
            notes,
55 +
            list_state,
56 +
            should_quit: false,
57 +
            status_message: None,
58 +
            focus: Focus::List,
59 +
            content_scroll: 0,
60 +
            show_help: false,
61 +
            confirm_delete: false,
62 +
            highlighter: Highlighter::new(),
63 +
            edit_title: String::new(),
64 +
            edit_content: String::new(),
65 +
            edit_short_id: None,
66 +
            search_query: String::new(),
67 +
            filtered_indices: None,
68 +
            is_remote,
69 +
            remote_url,
70 +
            wrap_content: true,
71 +
            edit_scroll: 0,
72 +
        }
73 +
    }
74 +
75 +
    fn selected_note(&self) -> Option<&Note> {
76 +
        self.list_state.selected().and_then(|i| {
77 +
            if let Some(indices) = &self.filtered_indices {
78 +
                indices.get(i).and_then(|&real| self.notes.get(real))
79 +
            } else {
80 +
                self.notes.get(i)
81 +
            }
82 +
        })
83 +
    }
84 +
85 +
    fn visible_count(&self) -> usize {
86 +
        match &self.filtered_indices {
87 +
            Some(indices) => indices.len(),
88 +
            None => self.notes.len(),
89 +
        }
90 +
    }
91 +
92 +
    fn move_up(&mut self) {
93 +
        let count = self.visible_count();
94 +
        if count == 0 {
95 +
            return;
96 +
        }
97 +
        let i = match self.list_state.selected() {
98 +
            Some(i) if i > 0 => i - 1,
99 +
            Some(_) => count - 1,
100 +
            None => 0,
101 +
        };
102 +
        self.list_state.select(Some(i));
103 +
        self.content_scroll = 0;
104 +
    }
105 +
106 +
    fn move_down(&mut self) {
107 +
        let count = self.visible_count();
108 +
        if count == 0 {
109 +
            return;
110 +
        }
111 +
        let i = match self.list_state.selected() {
112 +
            Some(i) if i < count - 1 => i + 1,
113 +
            Some(_) => 0,
114 +
            None => 0,
115 +
        };
116 +
        self.list_state.select(Some(i));
117 +
        self.content_scroll = 0;
118 +
    }
119 +
120 +
    fn scroll_up(&mut self) {
121 +
        self.content_scroll = self.content_scroll.saturating_sub(1);
122 +
    }
123 +
124 +
    fn scroll_down(&mut self, max_lines: u16) {
125 +
        if self.content_scroll < max_lines {
126 +
            self.content_scroll += 1;
127 +
        }
128 +
    }
129 +
130 +
    fn copy_selected(&mut self) {
131 +
        if let Some(note) = self.selected_note() {
132 +
            if let Ok(mut clipboard) = Clipboard::new() {
133 +
                let _ = clipboard.set_text(&note.content);
134 +
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
135 +
            }
136 +
        }
137 +
    }
138 +
139 +
    fn copy_link(&mut self) {
140 +
        match &self.remote_url {
141 +
            Some(url) => {
142 +
                if let Some(note) = self.selected_note() {
143 +
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
144 +
                    if let Ok(mut clipboard) = Clipboard::new() {
145 +
                        let _ = clipboard.set_text(&link);
146 +
                        self.status_message =
147 +
                            Some(("Link copied!".to_string(), Instant::now()));
148 +
                    }
149 +
                }
150 +
            }
151 +
            None => {
152 +
                self.status_message =
153 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
154 +
            }
155 +
        }
156 +
    }
157 +
158 +
    fn open_in_browser(&mut self) {
159 +
        match &self.remote_url {
160 +
            Some(url) => {
161 +
                if let Some(note) = self.selected_note() {
162 +
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
163 +
                    if let Err(e) = open::that(&link) {
164 +
                        self.status_message =
165 +
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
166 +
                    } else {
167 +
                        self.status_message =
168 +
                            Some(("Opened in browser!".to_string(), Instant::now()));
169 +
                    }
170 +
                }
171 +
            }
172 +
            None => {
173 +
                self.status_message =
174 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
175 +
            }
176 +
        }
177 +
    }
178 +
179 +
    fn delete_selected(&mut self, backend: &Backend) {
180 +
        if let Some(selected_index) = self.list_state.selected() {
181 +
            let real_index = if let Some(indices) = &self.filtered_indices {
182 +
                match indices.get(selected_index) {
183 +
                    Some(&ri) => ri,
184 +
                    None => return,
185 +
                }
186 +
            } else {
187 +
                selected_index
188 +
            };
189 +
            if let Some(note) = self.notes.get(real_index) {
190 +
                let short_id = note.short_id.clone();
191 +
                match backend.delete_note(&short_id) {
192 +
                    Ok(true) => {
193 +
                        self.notes.remove(real_index);
194 +
                        if self.filtered_indices.is_some() {
195 +
                            self.update_search_filter();
196 +
                        }
197 +
                        let count = self.visible_count();
198 +
                        if count == 0 {
199 +
                            self.list_state.select(None);
200 +
                        } else if selected_index >= count {
201 +
                            self.list_state.select(Some(count - 1));
202 +
                        } else {
203 +
                            self.list_state.select(Some(selected_index));
204 +
                        }
205 +
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
206 +
                    }
207 +
                    Ok(false) => {
208 +
                        self.status_message =
209 +
                            Some(("Note not found".to_string(), Instant::now()));
210 +
                    }
211 +
                    Err(e) => {
212 +
                        self.status_message = Some((e.to_string(), Instant::now()));
213 +
                    }
214 +
                }
215 +
            }
216 +
        }
217 +
    }
218 +
219 +
    fn refresh(&mut self, backend: &Backend) {
220 +
        match backend.list_notes() {
221 +
            Ok(notes) => {
222 +
                self.notes = notes;
223 +
                self.filtered_indices = None;
224 +
                self.search_query.clear();
225 +
                if self.notes.is_empty() {
226 +
                    self.list_state.select(None);
227 +
                } else {
228 +
                    let idx = self.list_state.selected().unwrap_or(0);
229 +
                    if idx >= self.notes.len() {
230 +
                        self.list_state.select(Some(self.notes.len() - 1));
231 +
                    }
232 +
                }
233 +
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
234 +
            }
235 +
            Err(e) => {
236 +
                self.status_message = Some((e.to_string(), Instant::now()));
237 +
            }
238 +
        }
239 +
    }
240 +
241 +
    fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) {
242 +
        let w = width as usize;
243 +
        if w == 0 {
244 +
            return (0, 0);
245 +
        }
246 +
        let text = &self.edit_content;
247 +
        let mut visual_row: usize = 0;
248 +
        let lines: Vec<&str> = if text.is_empty() {
249 +
            vec![""]
250 +
        } else {
251 +
            text.split('\n').collect()
252 +
        };
253 +
        let last_idx = lines.len() - 1;
254 +
        for (i, line) in lines.iter().enumerate() {
255 +
            let line_len = line.len();
256 +
            let wrapped_lines = if line_len == 0 {
257 +
                1
258 +
            } else {
259 +
                (line_len + w - 1) / w
260 +
            };
261 +
            if i < last_idx {
262 +
                visual_row += wrapped_lines;
263 +
            } else {
264 +
                let cursor_col = if text.ends_with('\n') { 0 } else { line_len };
265 +
                let extra_rows = cursor_col / w;
266 +
                let col = cursor_col % w;
267 +
                visual_row += extra_rows;
268 +
                return (col as u16, visual_row as u16);
269 +
            }
270 +
        }
271 +
        (0, visual_row as u16)
272 +
    }
273 +
274 +
    fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) {
275 +
        if visible_height == 0 {
276 +
            return;
277 +
        }
278 +
        if cursor_visual_row < self.edit_scroll {
279 +
            self.edit_scroll = cursor_visual_row;
280 +
        } else if cursor_visual_row >= self.edit_scroll + visible_height {
281 +
            self.edit_scroll = cursor_visual_row - visible_height + 1;
282 +
        }
283 +
    }
284 +
285 +
    fn start_create(&mut self) {
286 +
        self.edit_title.clear();
287 +
        self.edit_content.clear();
288 +
        self.edit_scroll = 0;
289 +
        self.focus = Focus::CreateTitle;
290 +
    }
291 +
292 +
    fn save_create(&mut self, backend: &Backend) {
293 +
        if self.edit_title.trim().is_empty() {
294 +
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
295 +
            return;
296 +
        }
297 +
        match backend.create_note(&self.edit_title, &self.edit_content) {
298 +
            Ok(note) => {
299 +
                self.notes.insert(0, note);
300 +
                self.list_state.select(Some(0));
301 +
                self.filtered_indices = None;
302 +
                self.search_query.clear();
303 +
                self.status_message = Some(("Created!".to_string(), Instant::now()));
304 +
                self.focus = Focus::List;
305 +
                self.edit_title.clear();
306 +
                self.edit_content.clear();
307 +
            }
308 +
            Err(e) => {
309 +
                self.status_message = Some((e.to_string(), Instant::now()));
310 +
            }
311 +
        }
312 +
    }
313 +
314 +
    fn cancel_create(&mut self) {
315 +
        self.edit_title.clear();
316 +
        self.edit_content.clear();
317 +
        self.focus = Focus::List;
318 +
    }
319 +
320 +
    fn start_edit(&mut self) {
321 +
        let data = self
322 +
            .selected_note()
323 +
            .map(|n| (n.title.clone(), n.content.clone(), n.short_id.clone()));
324 +
        if let Some((title, content, short_id)) = data {
325 +
            self.edit_title = title;
326 +
            self.edit_content = content;
327 +
            self.edit_short_id = Some(short_id);
328 +
            self.edit_scroll = 0;
329 +
            self.focus = Focus::EditTitle;
330 +
        }
331 +
    }
332 +
333 +
    fn save_edit(&mut self, backend: &Backend) {
334 +
        if self.edit_title.trim().is_empty() {
335 +
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
336 +
            return;
337 +
        }
338 +
        let short_id = match &self.edit_short_id {
339 +
            Some(id) => id.clone(),
340 +
            None => return,
341 +
        };
342 +
        match backend.update_note(&short_id, &self.edit_title, &self.edit_content) {
343 +
            Ok(Some(updated)) => {
344 +
                if let Some(pos) = self.notes.iter().position(|n| n.short_id == short_id) {
345 +
                    self.notes[pos] = updated;
346 +
                }
347 +
                self.status_message = Some(("Updated!".to_string(), Instant::now()));
348 +
                self.focus = Focus::List;
349 +
                self.edit_title.clear();
350 +
                self.edit_content.clear();
351 +
                self.edit_short_id = None;
352 +
            }
353 +
            Ok(None) => {
354 +
                self.status_message = Some(("Note not found".to_string(), Instant::now()));
355 +
            }
356 +
            Err(e) => {
357 +
                self.status_message = Some((e.to_string(), Instant::now()));
358 +
            }
359 +
        }
360 +
    }
361 +
362 +
    fn cancel_edit(&mut self) {
363 +
        self.edit_title.clear();
364 +
        self.edit_content.clear();
365 +
        self.edit_short_id = None;
366 +
        self.focus = Focus::List;
367 +
    }
368 +
369 +
    fn start_search(&mut self) {
370 +
        self.search_query.clear();
371 +
        self.filtered_indices = Some((0..self.notes.len()).collect());
372 +
        self.focus = Focus::Search;
373 +
        self.list_state
374 +
            .select(if self.notes.is_empty() { None } else { Some(0) });
375 +
    }
376 +
377 +
    fn update_search_filter(&mut self) {
378 +
        let query = self.search_query.to_lowercase();
379 +
        let indices: Vec<usize> = self
380 +
            .notes
381 +
            .iter()
382 +
            .enumerate()
383 +
            .filter(|(_, n)| n.title.to_lowercase().contains(&query))
384 +
            .map(|(i, _)| i)
385 +
            .collect();
386 +
        self.filtered_indices = Some(indices);
387 +
        if self.visible_count() == 0 {
388 +
            self.list_state.select(None);
389 +
        } else {
390 +
            self.list_state.select(Some(0));
391 +
        }
392 +
    }
393 +
394 +
    fn cancel_search(&mut self) {
395 +
        self.filtered_indices = None;
396 +
        self.search_query.clear();
397 +
        self.focus = Focus::List;
398 +
    }
399 +
400 +
    fn confirm_search(&mut self) {
401 +
        let real_index = self.list_state.selected().and_then(|i| {
402 +
            self.filtered_indices
403 +
                .as_ref()
404 +
                .and_then(|indices| indices.get(i).copied())
405 +
        });
406 +
        self.filtered_indices = None;
407 +
        self.search_query.clear();
408 +
        self.focus = Focus::List;
409 +
        if let Some(ri) = real_index {
410 +
            self.list_state.select(Some(ri));
411 +
        }
412 +
    }
413 +
414 +
    fn clear_expired_status(&mut self) {
415 +
        if let Some((_, time)) = &self.status_message {
416 +
            if time.elapsed() > Duration::from_secs(2) {
417 +
                self.status_message = None;
418 +
            }
419 +
        }
420 +
    }
421 +
}
422 +
423 +
fn db_path() -> String {
424 +
    std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string())
425 +
}
426 +
427 +
fn resolve_backend(
428 +
    remote: Option<String>,
429 +
    api_key: Option<String>,
430 +
) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> {
431 +
    if let Some(url) = remote {
432 +
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
433 +
    }
434 +
435 +
    if !std::path::Path::new(&db_path()).exists() {
436 +
        let cfg = config::load_config();
437 +
        let url = cfg
438 +
            .remote_url
439 +
            .unwrap_or_else(|| "http://localhost:3000".to_string());
440 +
        let api_key = api_key.or(cfg.api_key);
441 +
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
442 +
    }
443 +
444 +
    Ok((
445 +
        Backend::local(),
446 +
        false,
447 +
        Some("http://localhost:3000".to_string()),
448 +
    ))
449 +
}
450 +
451 +
pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
452 +
    use std::io::{self, Write};
453 +
454 +
    print!("Remote URL: ");
455 +
    io::stdout().flush()?;
456 +
    let mut remote_url = String::new();
457 +
    io::stdin().read_line(&mut remote_url)?;
458 +
    let remote_url = remote_url.trim().to_string();
459 +
460 +
    print!("API Key: ");
461 +
    io::stdout().flush()?;
462 +
    let api_key = rpassword::read_password()?;
463 +
    let api_key = api_key.trim().to_string();
464 +
465 +
    let cfg = config::Config {
466 +
        remote_url: if remote_url.is_empty() {
467 +
            None
468 +
        } else {
469 +
            Some(remote_url)
470 +
        },
471 +
        api_key: if api_key.is_empty() {
472 +
            None
473 +
        } else {
474 +
            Some(api_key)
475 +
        },
476 +
    };
477 +
478 +
    config::save_config(&cfg)?;
479 +
    println!("Config saved to {}", config::config_path().display());
480 +
    Ok(())
481 +
}
482 +
483 +
pub fn run_interactive(
484 +
    remote: Option<String>,
485 +
    api_key: Option<String>,
486 +
) -> Result<(), Box<dyn std::error::Error>> {
487 +
    let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?;
488 +
489 +
    let notes = match backend.list_notes() {
490 +
        Ok(n) => n,
491 +
        Err(e) => {
492 +
            eprintln!("Failed to load notes: {}", e);
493 +
            Vec::new()
494 +
        }
495 +
    };
496 +
497 +
    ratatui::run(|terminal| run_app(terminal, App::new(notes, is_remote, remote_url), &backend))
498 +
}
499 +
500 +
fn run_app(
501 +
    terminal: &mut DefaultTerminal,
502 +
    mut app: App,
503 +
    backend: &Backend,
504 +
) -> Result<(), Box<dyn std::error::Error>> {
505 +
    while !app.should_quit {
506 +
        app.clear_expired_status();
507 +
508 +
        let content_line_count = app
509 +
            .selected_note()
510 +
            .map(|n| n.content.lines().count() as u16)
511 +
            .unwrap_or(0);
512 +
513 +
        terminal.draw(|frame| {
514 +
            let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
515 +
                .split(frame.area());
516 +
517 +
            let chunks = Layout::horizontal([
518 +
                Constraint::Percentage(30),
519 +
                Constraint::Percentage(70),
520 +
            ])
521 +
            .split(outer[0]);
522 +
523 +
            let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
524 +
                indices
525 +
                    .iter()
526 +
                    .filter_map(|&i| app.notes.get(i))
527 +
                    .map(|n| ListItem::new(n.title.as_str()))
528 +
                    .collect()
529 +
            } else {
530 +
                app.notes
531 +
                    .iter()
532 +
                    .map(|n| ListItem::new(n.title.as_str()))
533 +
                    .collect()
534 +
            };
535 +
536 +
            let list_border_style = match app.focus {
537 +
                Focus::List | Focus::Search => Style::default().fg(Color::Yellow),
538 +
                _ => Style::default().fg(Color::DarkGray),
539 +
            };
540 +
            let content_border_style = match app.focus {
541 +
                Focus::Content => Style::default().fg(Color::Yellow),
542 +
                _ => Style::default().fg(Color::DarkGray),
543 +
            };
544 +
545 +
            let list = List::new(items)
546 +
                .block(
547 +
                    Block::default()
548 +
                        .title(" Notes ")
549 +
                        .borders(Borders::ALL)
550 +
                        .border_style(list_border_style),
551 +
                )
552 +
                .highlight_style(
553 +
                    Style::default()
554 +
                        .fg(Color::Yellow)
555 +
                        .add_modifier(Modifier::BOLD),
556 +
                )
557 +
                .highlight_symbol("▶ ");
558 +
559 +
            if matches!(app.focus, Focus::Search) {
560 +
                let search_split =
561 +
                    Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]);
562 +
563 +
                let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
564 +
                    indices
565 +
                        .iter()
566 +
                        .filter_map(|&i| app.notes.get(i))
567 +
                        .map(|n| ListItem::new(n.title.as_str()))
568 +
                        .collect()
569 +
                } else {
570 +
                    app.notes
571 +
                        .iter()
572 +
                        .map(|n| ListItem::new(n.title.as_str()))
573 +
                        .collect()
574 +
                };
575 +
                let search_list = List::new(search_items)
576 +
                    .block(
577 +
                        Block::default()
578 +
                            .title(" Notes ")
579 +
                            .borders(Borders::ALL)
580 +
                            .border_style(list_border_style),
581 +
                    )
582 +
                    .highlight_style(
583 +
                        Style::default()
584 +
                            .fg(Color::Yellow)
585 +
                            .add_modifier(Modifier::BOLD),
586 +
                    )
587 +
                    .highlight_symbol("▶ ");
588 +
                frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state);
589 +
590 +
                let search_input = Paragraph::new(app.search_query.as_str()).block(
591 +
                    Block::default()
592 +
                        .title(" Search ")
593 +
                        .borders(Borders::ALL)
594 +
                        .border_style(Style::default().fg(Color::Yellow)),
595 +
                );
596 +
                frame.render_widget(search_input, search_split[1]);
597 +
598 +
                let x = search_split[1].x + 1 + app.search_query.len() as u16;
599 +
                let y = search_split[1].y + 1;
600 +
                frame.set_cursor_position((x, y));
601 +
            } else {
602 +
                frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
603 +
            }
604 +
605 +
            match app.focus {
606 +
                Focus::CreateTitle
607 +
                | Focus::CreateContent
608 +
                | Focus::EditTitle
609 +
                | Focus::EditContent => {
610 +
                    let form_title = match app.focus {
611 +
                        Focus::EditTitle | Focus::EditContent => " Edit Note ",
612 +
                        _ => " New Note ",
613 +
                    };
614 +
                    let create_block = Block::default()
615 +
                        .title(form_title)
616 +
                        .borders(Borders::ALL)
617 +
                        .border_style(Style::default().fg(Color::Yellow));
618 +
619 +
                    let inner = create_block.inner(chunks[1]);
620 +
                    frame.render_widget(create_block, chunks[1]);
621 +
622 +
                    let form_layout =
623 +
                        Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner);
624 +
625 +
                    let title_style = match app.focus {
626 +
                        Focus::CreateTitle | Focus::EditTitle => Style::default().fg(Color::Yellow),
627 +
                        _ => Style::default().fg(Color::DarkGray),
628 +
                    };
629 +
                    let title_input = Paragraph::new(app.edit_title.as_str()).block(
630 +
                        Block::default()
631 +
                            .title(" Title ")
632 +
                            .borders(Borders::ALL)
633 +
                            .border_style(title_style),
634 +
                    );
635 +
                    frame.render_widget(title_input, form_layout[0]);
636 +
637 +
                    let content_style = match app.focus {
638 +
                        Focus::CreateContent | Focus::EditContent => {
639 +
                            Style::default().fg(Color::Yellow)
640 +
                        }
641 +
                        _ => Style::default().fg(Color::DarkGray),
642 +
                    };
643 +
                    let mut content_input = Paragraph::new(app.edit_content.as_str()).block(
644 +
                        Block::default()
645 +
                            .title(" Content ")
646 +
                            .borders(Borders::ALL)
647 +
                            .border_style(content_style),
648 +
                    );
649 +
                    if app.wrap_content {
650 +
                        content_input = content_input.wrap(Wrap { trim: false });
651 +
                    }
652 +
                    content_input = content_input.scroll((app.edit_scroll, 0));
653 +
                    frame.render_widget(content_input, form_layout[1]);
654 +
655 +
                    let content_inner =
656 +
                        Block::default().borders(Borders::ALL).inner(form_layout[1]);
657 +
                    let inner_width = content_inner.width;
658 +
                    let inner_height = content_inner.height;
659 +
660 +
                    match app.focus {
661 +
                        Focus::CreateTitle | Focus::EditTitle => {
662 +
                            let x = form_layout[0].x + 1 + app.edit_title.len() as u16;
663 +
                            let y = form_layout[0].y + 1;
664 +
                            frame.set_cursor_position((x, y));
665 +
                        }
666 +
                        Focus::CreateContent | Focus::EditContent => {
667 +
                            let (cx, cy) = if app.wrap_content {
668 +
                                app.cursor_position_wrapped(inner_width)
669 +
                            } else {
670 +
                                let last_line = app.edit_content.lines().last().unwrap_or("");
671 +
                                let line_count = app.edit_content.lines().count()
672 +
                                    + if app.edit_content.ends_with('\n') { 1 } else { 0 };
673 +
                                let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
674 +
                                let col = if app.edit_content.ends_with('\n') {
675 +
                                    0
676 +
                                } else {
677 +
                                    last_line.len() as u16
678 +
                                };
679 +
                                (col, y_offset as u16)
680 +
                            };
681 +
                            app.auto_scroll_edit(cy, inner_height);
682 +
                            let screen_y = cy.saturating_sub(app.edit_scroll);
683 +
                            let x = content_inner.x + cx;
684 +
                            let y = content_inner.y + screen_y;
685 +
                            frame.set_cursor_position((x, y));
686 +
                        }
687 +
                        _ => {}
688 +
                    }
689 +
                }
690 +
                _ => {
691 +
                    let highlighted = match app.selected_note() {
692 +
                        Some(n) => app.highlighter.highlight_markdown(&n.content),
693 +
                        None => Text::raw(""),
694 +
                    };
695 +
696 +
                    let paragraph = Paragraph::new(highlighted)
697 +
                        .block(
698 +
                            Block::default()
699 +
                                .title(" Content ")
700 +
                                .borders(Borders::ALL)
701 +
                                .border_style(content_border_style),
702 +
                        )
703 +
                        .scroll((app.content_scroll, 0));
704 +
705 +
                    frame.render_widget(paragraph, chunks[1]);
706 +
                }
707 +
            }
708 +
709 +
            let hints = match app.focus {
710 +
                Focus::List => Line::from(vec![
711 +
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
712 +
                    Span::raw(": Navigate  "),
713 +
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
714 +
                    Span::raw(": View  "),
715 +
                    Span::styled("y", Style::default().fg(Color::Yellow)),
716 +
                    Span::raw(": Copy  "),
717 +
                    Span::styled("e", Style::default().fg(Color::Yellow)),
718 +
                    Span::raw(": Edit  "),
719 +
                    Span::styled("d", Style::default().fg(Color::Yellow)),
720 +
                    Span::raw(": Delete  "),
721 +
                    Span::styled("c", Style::default().fg(Color::Yellow)),
722 +
                    Span::raw(": Create  "),
723 +
                    Span::styled("/", Style::default().fg(Color::Yellow)),
724 +
                    Span::raw(": Search  "),
725 +
                    Span::styled("?", Style::default().fg(Color::Yellow)),
726 +
                    Span::raw(": Help  "),
727 +
                    Span::styled("q", Style::default().fg(Color::Yellow)),
728 +
                    Span::raw(": Quit"),
729 +
                ]),
730 +
                Focus::Content => Line::from(vec![
731 +
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
732 +
                    Span::raw(": Scroll  "),
733 +
                    Span::styled("y", Style::default().fg(Color::Yellow)),
734 +
                    Span::raw(": Copy  "),
735 +
                    Span::styled("e", Style::default().fg(Color::Yellow)),
736 +
                    Span::raw(": Edit  "),
737 +
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
738 +
                    Span::raw(": Back  "),
739 +
                    Span::styled("?", Style::default().fg(Color::Yellow)),
740 +
                    Span::raw(": Help"),
741 +
                ]),
742 +
                Focus::CreateTitle
743 +
                | Focus::CreateContent
744 +
                | Focus::EditTitle
745 +
                | Focus::EditContent => Line::from(vec![
746 +
                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
747 +
                    Span::raw(": Switch field  "),
748 +
                    Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
749 +
                    Span::raw(": Save  "),
750 +
                    Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)),
751 +
                    Span::raw(": Wrap  "),
752 +
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
753 +
                    Span::raw(": Cancel"),
754 +
                ]),
755 +
                Focus::Search => Line::from(vec![
756 +
                    Span::styled("Type", Style::default().fg(Color::Yellow)),
757 +
                    Span::raw(": Filter  "),
758 +
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
759 +
                    Span::raw(": Select  "),
760 +
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
761 +
                    Span::raw(": Cancel"),
762 +
                ]),
763 +
            };
764 +
            frame.render_widget(Paragraph::new(hints), outer[1]);
765 +
766 +
            if let Some((msg, _)) = &app.status_message {
767 +
                let area = frame.area();
768 +
                let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
769 +
                let popup_area = ratatui::layout::Rect {
770 +
                    x: (area.width.saturating_sub(msg_width)) / 2,
771 +
                    y: (area.height.saturating_sub(3)) / 2,
772 +
                    width: msg_width,
773 +
                    height: 3,
774 +
                };
775 +
                Clear.render(popup_area, frame.buffer_mut());
776 +
                let status_popup = Paragraph::new(Line::from(msg.as_str()))
777 +
                    .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
778 +
                    .alignment(Alignment::Center)
779 +
                    .block(
780 +
                        Block::default()
781 +
                            .borders(Borders::ALL)
782 +
                            .border_style(Style::default().fg(Color::Green)),
783 +
                    );
784 +
                frame.render_widget(status_popup, popup_area);
785 +
            }
786 +
787 +
            if app.confirm_delete {
788 +
                let delete_msg = match app.selected_note() {
789 +
                    Some(n) => format!("Delete {}? (y/n)", n.title),
790 +
                    None => "Delete note? (y/n)".to_string(),
791 +
                };
792 +
                let area = frame.area();
793 +
                let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4));
794 +
                let popup_area = ratatui::layout::Rect {
795 +
                    x: (area.width.saturating_sub(msg_width)) / 2,
796 +
                    y: (area.height.saturating_sub(3)) / 2,
797 +
                    width: msg_width,
798 +
                    height: 3,
799 +
                };
800 +
                Clear.render(popup_area, frame.buffer_mut());
801 +
                let confirm_popup = Paragraph::new(Line::from(delete_msg))
802 +
                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
803 +
                    .alignment(Alignment::Center)
804 +
                    .block(
805 +
                        Block::default()
806 +
                            .borders(Borders::ALL)
807 +
                            .border_style(Style::default().fg(Color::Red)),
808 +
                    );
809 +
                frame.render_widget(confirm_popup, popup_area);
810 +
            }
811 +
812 +
            if app.show_help {
813 +
                let area = frame.area();
814 +
                let popup_width = 34u16.min(area.width.saturating_sub(4));
815 +
                let popup_height = 21u16.min(area.height.saturating_sub(4));
816 +
                let popup_area = ratatui::layout::Rect {
817 +
                    x: (area.width.saturating_sub(popup_width)) / 2,
818 +
                    y: (area.height.saturating_sub(popup_height)) / 2,
819 +
                    width: popup_width,
820 +
                    height: popup_height,
821 +
                };
822 +
823 +
                let mut help_lines = vec![
824 +
                    Line::from(""),
825 +
                    Line::from(vec![
826 +
                        Span::styled("  j/↓  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
827 +
                        Span::raw("Move down / Scroll down"),
828 +
                    ]),
829 +
                    Line::from(vec![
830 +
                        Span::styled("  k/↑  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
831 +
                        Span::raw("Move up / Scroll up"),
832 +
                    ]),
833 +
                    Line::from(vec![
834 +
                        Span::styled("  Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
835 +
                        Span::raw("  Focus content pane"),
836 +
                    ]),
837 +
                    Line::from(vec![
838 +
                        Span::styled("  Esc  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
839 +
                        Span::raw("Back / Quit"),
840 +
                    ]),
841 +
                    Line::from(vec![
842 +
                        Span::styled("  y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
843 +
                        Span::raw("Copy note"),
844 +
                    ]),
845 +
                    Line::from(vec![
846 +
                        Span::styled("  Y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
847 +
                        Span::raw("Copy link"),
848 +
                    ]),
849 +
                    Line::from(vec![
850 +
                        Span::styled("  o    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
851 +
                        Span::raw("Open in browser"),
852 +
                    ]),
853 +
                    Line::from(vec![
854 +
                        Span::styled("  d    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
855 +
                        Span::raw("Delete note"),
856 +
                    ]),
857 +
                    Line::from(vec![
858 +
                        Span::styled("  c    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
859 +
                        Span::raw("Create note"),
860 +
                    ]),
861 +
                    Line::from(vec![
862 +
                        Span::styled("  e    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
863 +
                        Span::raw("Edit note"),
864 +
                    ]),
865 +
                    Line::from(vec![
866 +
                        Span::styled("  /    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
867 +
                        Span::raw("Search notes"),
868 +
                    ]),
869 +
                    Line::from(vec![
870 +
                        Span::styled("  ^W   ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
871 +
                        Span::raw("Toggle word wrap (edit)"),
872 +
                    ]),
873 +
                ];
874 +
875 +
                if app.is_remote {
876 +
                    help_lines.push(Line::from(vec![
877 +
                        Span::styled("  r    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
878 +
                        Span::raw("Refresh notes"),
879 +
                    ]));
880 +
                }
881 +
882 +
                help_lines.extend([
883 +
                    Line::from(vec![
884 +
                        Span::styled("  q    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
885 +
                        Span::raw("Quit"),
886 +
                    ]),
887 +
                    Line::from(vec![
888 +
                        Span::styled("  ?    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
889 +
                        Span::raw("Toggle this help"),
890 +
                    ]),
891 +
                    Line::from(""),
892 +
                    Line::from(Span::styled(
893 +
                        "  Press any key to close",
894 +
                        Style::default().fg(Color::DarkGray),
895 +
                    )),
896 +
                ]);
897 +
898 +
                let help_text = Text::from(help_lines);
899 +
900 +
                Clear.render(popup_area, frame.buffer_mut());
901 +
                let help = Paragraph::new(help_text).block(
902 +
                    Block::default()
903 +
                        .title(" Keybindings ")
904 +
                        .borders(Borders::ALL)
905 +
                        .border_style(Style::default().fg(Color::Yellow)),
906 +
                );
907 +
                frame.render_widget(help, popup_area);
908 +
            }
909 +
        })?;
910 +
911 +
        if event::poll(Duration::from_millis(100))? {
912 +
            if let Event::Key(key) = event::read()? {
913 +
                if app.show_help {
914 +
                    app.show_help = false;
915 +
                } else if app.status_message.is_some() {
916 +
                    app.status_message = None;
917 +
                } else if app.confirm_delete {
918 +
                    if key.code == KeyCode::Char('y') {
919 +
                        app.delete_selected(backend);
920 +
                    }
921 +
                    app.confirm_delete = false;
922 +
                } else {
923 +
                    match app.focus {
924 +
                        Focus::List => match key.code {
925 +
                            KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
926 +
                            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
927 +
                            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
928 +
                            KeyCode::Char('y') => app.copy_selected(),
929 +
                            KeyCode::Char('Y') => app.copy_link(),
930 +
                            KeyCode::Char('d') => app.confirm_delete = true,
931 +
                            KeyCode::Char('c') => app.start_create(),
932 +
                            KeyCode::Char('e') => app.start_edit(),
933 +
                            KeyCode::Char('/') => app.start_search(),
934 +
                            KeyCode::Char('o') => app.open_in_browser(),
935 +
                            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
936 +
                            KeyCode::Char('?') => app.show_help = true,
937 +
                            KeyCode::Enter | KeyCode::Char('l') => {
938 +
                                if app.selected_note().is_some() {
939 +
                                    app.focus = Focus::Content;
940 +
                                }
941 +
                            }
942 +
                            _ => {}
943 +
                        },
944 +
                        Focus::Content => match key.code {
945 +
                            KeyCode::Char(' ')
946 +
                            | KeyCode::Esc
947 +
                            | KeyCode::Char('q')
948 +
                            | KeyCode::Char('h') => {
949 +
                                app.focus = Focus::List;
950 +
                            }
951 +
                            KeyCode::Char('j') | KeyCode::Down => {
952 +
                                app.scroll_down(content_line_count);
953 +
                            }
954 +
                            KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
955 +
                            KeyCode::Char('y') => app.copy_selected(),
956 +
                            KeyCode::Char('Y') => app.copy_link(),
957 +
                            KeyCode::Char('e') => app.start_edit(),
958 +
                            KeyCode::Char('o') => app.open_in_browser(),
959 +
                            KeyCode::Char('?') => app.show_help = true,
960 +
                            _ => {}
961 +
                        },
962 +
                        Focus::CreateTitle => {
963 +
                            if key.modifiers.contains(KeyModifiers::CONTROL)
964 +
                                && key.code == KeyCode::Char('s')
965 +
                            {
966 +
                                app.save_create(backend);
967 +
                            } else {
968 +
                                match key.code {
969 +
                                    KeyCode::Esc => app.cancel_create(),
970 +
                                    KeyCode::Enter | KeyCode::Tab => {
971 +
                                        app.focus = Focus::CreateContent
972 +
                                    }
973 +
                                    KeyCode::Backspace => {
974 +
                                        app.edit_title.pop();
975 +
                                    }
976 +
                                    KeyCode::Char(c) => app.edit_title.push(c),
977 +
                                    _ => {}
978 +
                                }
979 +
                            }
980 +
                        }
981 +
                        Focus::CreateContent => {
982 +
                            if key.modifiers.contains(KeyModifiers::CONTROL) {
983 +
                                match key.code {
984 +
                                    KeyCode::Char('s') => app.save_create(backend),
985 +
                                    KeyCode::Char('w') => {
986 +
                                        app.wrap_content = !app.wrap_content;
987 +
                                        app.edit_scroll = 0;
988 +
                                    }
989 +
                                    _ => {}
990 +
                                }
991 +
                            } else {
992 +
                                match key.code {
993 +
                                    KeyCode::Esc => app.cancel_create(),
994 +
                                    KeyCode::Tab => app.focus = Focus::CreateTitle,
995 +
                                    KeyCode::Enter => app.edit_content.push('\n'),
996 +
                                    KeyCode::Backspace => {
997 +
                                        app.edit_content.pop();
998 +
                                    }
999 +
                                    KeyCode::Char(c) => app.edit_content.push(c),
1000 +
                                    _ => {}
1001 +
                                }
1002 +
                            }
1003 +
                        }
1004 +
                        Focus::EditTitle => {
1005 +
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1006 +
                                && key.code == KeyCode::Char('s')
1007 +
                            {
1008 +
                                app.save_edit(backend);
1009 +
                            } else {
1010 +
                                match key.code {
1011 +
                                    KeyCode::Esc => app.cancel_edit(),
1012 +
                                    KeyCode::Enter | KeyCode::Tab => {
1013 +
                                        app.focus = Focus::EditContent
1014 +
                                    }
1015 +
                                    KeyCode::Backspace => {
1016 +
                                        app.edit_title.pop();
1017 +
                                    }
1018 +
                                    KeyCode::Char(c) => app.edit_title.push(c),
1019 +
                                    _ => {}
1020 +
                                }
1021 +
                            }
1022 +
                        }
1023 +
                        Focus::EditContent => {
1024 +
                            if key.modifiers.contains(KeyModifiers::CONTROL) {
1025 +
                                match key.code {
1026 +
                                    KeyCode::Char('s') => app.save_edit(backend),
1027 +
                                    KeyCode::Char('w') => {
1028 +
                                        app.wrap_content = !app.wrap_content;
1029 +
                                        app.edit_scroll = 0;
1030 +
                                    }
1031 +
                                    _ => {}
1032 +
                                }
1033 +
                            } else {
1034 +
                                match key.code {
1035 +
                                    KeyCode::Esc => app.cancel_edit(),
1036 +
                                    KeyCode::Tab => app.focus = Focus::EditTitle,
1037 +
                                    KeyCode::Enter => app.edit_content.push('\n'),
1038 +
                                    KeyCode::Backspace => {
1039 +
                                        app.edit_content.pop();
1040 +
                                    }
1041 +
                                    KeyCode::Char(c) => app.edit_content.push(c),
1042 +
                                    _ => {}
1043 +
                                }
1044 +
                            }
1045 +
                        }
1046 +
                        Focus::Search => match key.code {
1047 +
                            KeyCode::Esc => app.cancel_search(),
1048 +
                            KeyCode::Enter => app.confirm_search(),
1049 +
                            KeyCode::Backspace => {
1050 +
                                app.search_query.pop();
1051 +
                                app.update_search_filter();
1052 +
                            }
1053 +
                            KeyCode::Char(c) => {
1054 +
                                app.search_query.push(c);
1055 +
                                app.update_search_filter();
1056 +
                            }
1057 +
                            _ => {}
1058 +
                        },
1059 +
                    }
1060 +
                }
1061 +
            }
1062 +
        }
1063 +
    }
1064 +
1065 +
    Ok(())
1066 +
}
crates/auth/src/lib.rs +51 −0
23 23
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
24 24
}
25 25
26 +
/// Constant-time API key comparison. Same shape as `verify_password`.
27 +
pub fn verify_api_key(input: &str, expected: &str) -> bool {
28 +
    const LEN: usize = 256;
29 +
    let mut a = [0u8; LEN];
30 +
    let mut b = [0u8; LEN];
31 +
    let ib = input.as_bytes();
32 +
    let eb = expected.as_bytes();
33 +
    a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]);
34 +
    b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]);
35 +
    let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8);
36 +
    (lengths_match & a.ct_eq(&b)).into()
37 +
}
38 +
39 +
/// Generate a 32-byte cryptographically random hex API key.
40 +
pub fn generate_api_key() -> String {
41 +
    let mut bytes = [0u8; 32];
42 +
    rand::rngs::OsRng.fill_bytes(&mut bytes);
43 +
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
44 +
}
45 +
26 46
/// Build a session cookie with HttpOnly, SameSite=Strict, 7-day Max-Age.
27 47
pub fn build_session_cookie(token: &str, secure: bool) -> String {
28 48
    let mut cookie = format!(
127 147
        let a = generate_session_token();
128 148
        let b = generate_session_token();
129 149
        assert_ne!(a, b);
150 +
    }
151 +
152 +
    // ── verify_api_key ─────────────────────────────────────────────────
153 +
154 +
    #[test]
155 +
    fn verify_api_key_correct() {
156 +
        assert!(verify_api_key("abc123", "abc123"));
157 +
    }
158 +
159 +
    #[test]
160 +
    fn verify_api_key_wrong() {
161 +
        assert!(!verify_api_key("abc123", "abc124"));
162 +
    }
163 +
164 +
    #[test]
165 +
    fn verify_api_key_length_mismatch() {
166 +
        assert!(!verify_api_key("short", "longer_key"));
167 +
    }
168 +
169 +
    // ── generate_api_key ───────────────────────────────────────────────
170 +
171 +
    #[test]
172 +
    fn api_key_is_64_hex_chars() {
173 +
        let key = generate_api_key();
174 +
        assert_eq!(key.len(), 64);
175 +
        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
176 +
    }
177 +
178 +
    #[test]
179 +
    fn api_key_unique_across_calls() {
180 +
        assert_ne!(generate_api_key(), generate_api_key());
130 181
    }
131 182
132 183
    // ── build_session_cookie ───────────────────────────────────────────