Initial commit

This commit is contained in:
Bogdan
2026-06-15 09:41:14 +03:00
commit 5f389bee0f
105 changed files with 11511 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Rust build artifacts
src-tauri/target/
/docs
/.vscode
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bogdan Cornescu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+145
View File
@@ -0,0 +1,145 @@
# Faro
> *A lighthouse for your knowledge. Local-first snippet and knowledge management for developers.*
Faro is a desktop app for capturing, organizing, and retrieving developer snippets: code, CLI commands, and notes. The core philosophy is **dump-first, organize-later**: zero friction on capture, smart retrieval when you need it.
Built with Tauri v2 + Svelte 5.
---
## Philosophy
- **Dump first, organize later.** Capture friction kills the habit. Retrieval is the product.
- **Local-first.** Your snippets live on your machine. No accounts, no cloud, no sync.
- **Intentional minimalism.** Every feature cut is a feature that doesn't need maintaining.
---
## Features
### Quick Note
Global hotkey (`Ctrl+Shift+Space` by default) opens a focused floating window — anywhere, anytime, even when Faro's main window isn't focused. Paste your snippet, add a title and tags, save. It auto-detects the content type and language on save. No mode-switching, no navigation, no friction.
### Full-Text Search
FTS5-powered search across all snippet titles and content. Results update as you type.
### Auto Content Detection
highlight.js inspects content on save and automatically classifies it as `code`, `cli`, or `text`, plus detects the specific language (Rust, Python, bash, SQL, etc.) and creates system tags. You can override the type manually.
### Three-Panel Layout
![Main view](assets/Faro_1.png)
**Left panel** — timeline navigation, tag browser with inline filter, new snippet button, settings access.
**Center panel** — full-text search bar, snippet list with type icons and 2-line previews, tag chips (user tags and auto-detected language tags are visually distinct).
**Right panel** — snippet content, directly editable in place. Explicit save keeps accidental edits from destroying data.
### Tag System
Two tag sources coexist on each snippet:
- **User tags**: whatever you type in the tags field
- **System tags**: auto-detected language labels (e.g. `rust`, `python`, `bash`)
Both are browsable from the left panel. Clicking a tag filters the center panel to matching snippets.
### Themes
Dark (default), Light, and Nord.
![Main view](assets/faro_3.webp)
---
## Keyboard Shortcuts
### Main Window
| Shortcut | Action |
|---|---|
| `Ctrl+F` | Focus the search bar |
| `Ctrl+N` | New snippet |
| `↑` / `↓` | Navigate the snippet list |
### Quick Note Window
| Shortcut | Action |
|---|---|
| `Ctrl+Enter` | Save snippet |
| `Esc` | Discard and close |
### Global (system-wide)
| Shortcut | Action |
|---|---|
| `Ctrl+Shift+Space` | Open Quick Note (configurable) |
![Main view](assets/Faro_2.png)
---
## Settings
Open via the gear icon in the bottom-left corner.
| Setting | Description |
|---|---|
| **Global hotkey** | The system-wide shortcut that opens Quick Note. Default: `Ctrl+Shift+Space`. |
| **Editor font size** | Code area font size, 1024px. Previews live. |
| **Quick Note stay open** | When enabled, Quick Note clears and stays open after saving instead of closing. |
| **Theme** | Dark / Light / Nord. Previews live before saving. |
---
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | Rust (Tauri v2) |
| Frontend | Svelte 5 + TypeScript |
| Database | SQLite via `rusqlite` + FTS5 |
| Language detection | highlight.js |
| UI font | IBM Plex Sans |
| Editor font | JetBrains Mono |
**Platform targets:** Windows (primary), Linux (secondary). No macOS.
---
## Building from Source
**Prerequisites:**
- [Rust toolchain](https://rustup.rs/) (stable)
- [Node.js](https://nodejs.org/) 18+
- [pnpm](https://pnpm.io/) (`npm install -g pnpm`)
- Tauri v2 system dependencies — see the [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/) for your OS
**Install dependencies:**
```bash
pnpm install
```
**Run in development:**
```bash
pnpm tauri dev
```
**Build a release binary:**
```bash
pnpm tauri build
```
The installer/binary lands in `src-tauri/target/release/bundle/`.
---
## Name & Identity
**Faro** — Spanish/Portuguese for *lighthouse*. A reference tool that helps you find things. The purple accent aligns with dusk and lighthouse imagery, which is either a happy accident or destiny, depending on how dramatic you're feeling.
---
## License
[MIT](LICENSE)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

+44
View File
@@ -0,0 +1,44 @@
{
"name": "faro",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/language": "^6.12.3",
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-store": "^2.4.3",
"highlight.js": "^11.11.1",
"lucide-svelte": "^1.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"@types/node": "^25.9.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^4.1.8"
}
}
+2019
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- '.'
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5110
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "faro"
version = "0.1.0"
description = "A local-first developer snippet manager"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "faro_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
thiserror = "2"
tauri-plugin-store = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-clipboard-manager = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main and quicknote windows",
"windows": ["main", "quicknote"],
"permissions": [
"core:default",
"core:window:allow-close",
"global-shortcut:allow-register",
"clipboard-manager:allow-write-text",
"store:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

+3
View File
@@ -0,0 +1,3 @@
pub mod snippets;
pub mod tags;
pub mod settings;
+13
View File
@@ -0,0 +1,13 @@
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use crate::error::AppError;
#[tauri::command]
pub fn update_hotkey(app: tauri::AppHandle, hotkey: String) -> Result<(), AppError> {
app.global_shortcut()
.unregister_all()
.map_err(|e| AppError::Other(e.to_string()))?;
app.global_shortcut()
.register(hotkey.as_str())
.map_err(|e| AppError::Other(e.to_string()))?;
Ok(())
}
+530
View File
@@ -0,0 +1,530 @@
use std::sync::Mutex;
use rusqlite::Connection;
use tauri::State;
use crate::error::AppError;
use crate::models::{ContentType, Snippet, Tag, TagInput};
use crate::commands::tags::attach_tags;
// ── Private DB helpers ───────────────────────────────────────────────────────
fn load_tags(conn: &Connection, snippet_id: i64) -> Result<Vec<Tag>, AppError> {
let mut stmt = conn.prepare(
"SELECT t.id, t.name, st.source \
FROM tag t \
JOIN snippet_tag st ON st.tag_id = t.id \
WHERE st.snippet_id = ?1 \
ORDER BY t.name",
)?;
let tags = stmt
.query_map([snippet_id], |r| {
Ok(Tag { id: r.get(0)?, name: r.get(1)?, source: r.get(2)?, count: 0 })
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(tags)
}
fn load_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
let mut snippet = conn.query_row(
"SELECT id, title, content, content_type, created_at, updated_at \
FROM snippet WHERE id = ?1",
[id],
|r| {
let ct: String = r.get(3)?;
Ok(Snippet {
id: r.get(0)?,
title: r.get(1)?,
content: r.get(2)?,
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
created_at: r.get(4)?,
updated_at: r.get(5)?,
tags: vec![],
})
},
)?;
snippet.tags = load_tags(conn, snippet.id)?;
Ok(snippet)
}
fn db_create_snippet(
conn: &Connection,
title: &str,
content: &str,
content_type: &str,
tags: &[TagInput],
) -> Result<Snippet, AppError> {
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
rusqlite::params![title, content, content_type],
)?;
let id = conn.last_insert_rowid();
attach_tags(conn, id, tags)?;
load_snippet(conn, id)
}
fn load_snippets_for_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<Snippet>, AppError> {
if ids.is_empty() {
return Ok(vec![]);
}
let placeholders: String = std::iter::repeat("?")
.take(ids.len())
.collect::<Vec<_>>()
.join(", ");
let snippet_sql = format!(
"SELECT id, title, content, content_type, created_at, updated_at \
FROM snippet WHERE id IN ({placeholders})"
);
let mut stmt = conn.prepare(&snippet_sql)?;
let mut map: std::collections::HashMap<i64, Snippet> = stmt
.query_map(rusqlite::params_from_iter(ids.iter().copied()), |r| {
let ct: String = r.get(3)?;
Ok(Snippet {
id: r.get(0)?,
title: r.get(1)?,
content: r.get(2)?,
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
created_at: r.get(4)?,
updated_at: r.get(5)?,
tags: vec![],
})
})?
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|s| (s.id, s))
.collect();
let tag_sql = format!(
"SELECT st.snippet_id, t.id, t.name, st.source \
FROM tag t JOIN snippet_tag st ON st.tag_id = t.id \
WHERE st.snippet_id IN ({placeholders}) \
ORDER BY t.name"
);
let mut tag_stmt = conn.prepare(&tag_sql)?;
let tag_rows: Vec<(i64, Tag)> = tag_stmt
.query_map(rusqlite::params_from_iter(ids.iter().copied()), |r| {
Ok((r.get::<_, i64>(0)?, Tag { id: r.get(1)?, name: r.get(2)?, source: r.get(3)?, count: 0 }))
})?
.collect::<Result<Vec<_>, _>>()?;
for (snippet_id, tag) in tag_rows {
if let Some(s) = map.get_mut(&snippet_id) {
s.tags.push(tag);
}
}
Ok(ids.iter().filter_map(|id| map.remove(id)).collect())
}
fn db_list_snippets(
conn: &Connection,
tag_filter: Option<&str>,
) -> Result<Vec<Snippet>, AppError> {
let ids: Vec<i64> = match tag_filter {
None => {
let mut stmt = conn
.prepare("SELECT id FROM snippet ORDER BY created_at DESC, id DESC")?;
let rows = stmt.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
rows
}
Some(tag) => {
let mut stmt = conn.prepare(
"SELECT s.id FROM snippet s \
JOIN snippet_tag st ON st.snippet_id = s.id \
JOIN tag t ON t.id = st.tag_id \
WHERE t.name = ?1 AND st.source != 'suppressed' \
ORDER BY s.created_at DESC, s.id DESC",
)?;
let rows = stmt.query_map([tag], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
rows
}
};
load_snippets_for_ids(conn, &ids)
}
fn db_get_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
load_snippet(conn, id)
}
fn db_update_snippet(
conn: &Connection,
id: i64,
title: &str,
content: &str,
content_type: &str,
tags: &[TagInput],
) -> Result<Snippet, AppError> {
let rows = conn.execute(
"UPDATE snippet SET title=?1, content=?2, content_type=?3, \
updated_at=datetime('now') WHERE id=?4",
rusqlite::params![title, content, content_type, id],
)?;
if rows == 0 {
return Err(AppError::Other(format!("snippet {id} not found")));
}
conn.execute("DELETE FROM snippet_tag WHERE snippet_id=?1", [id])?;
attach_tags(conn, id, tags)?;
load_snippet(conn, id)
}
fn db_delete_snippet(conn: &Connection, id: i64) -> Result<(), AppError> {
let rows = conn.execute("DELETE FROM snippet WHERE id=?1", [id])?;
if rows == 0 {
return Err(AppError::Other(format!("snippet {id} not found")));
}
Ok(())
}
fn fts_prefix_query(query: &str) -> String {
query
.split_whitespace()
.map(|token| {
if token.ends_with('*') { token.to_string() } else { format!("{token}*") }
})
.collect::<Vec<_>>()
.join(" ")
}
fn db_search_snippets(conn: &Connection, query: &str) -> Result<Vec<Snippet>, AppError> {
if query.trim().is_empty() {
return Ok(vec![]);
}
let fts_query = fts_prefix_query(query);
let mut stmt = conn.prepare(
"SELECT s.id \
FROM snippet s \
JOIN snippet_fts ON snippet_fts.rowid = s.id \
WHERE snippet_fts MATCH ?1 \
ORDER BY bm25(snippet_fts)",
)?;
let ids: Vec<i64> = stmt
.query_map([fts_query], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
load_snippets_for_ids(conn, &ids)
}
fn db_list_snippets_by_period(
conn: &Connection,
period: &str,
) -> Result<Vec<Snippet>, AppError> {
let date_filter = match period {
"today" => "date(created_at, 'localtime') = date('now', 'localtime')",
"yesterday" => "date(created_at, 'localtime') = date('now', 'localtime', '-1 day')",
"this-week" => "date(created_at, 'localtime') >= date('now', 'localtime', '-6 days') AND date(created_at, 'localtime') < date('now', 'localtime', '-1 day')",
"last-week" => "date(created_at, 'localtime') >= date('now', 'localtime', '-13 days') AND date(created_at, 'localtime') < date('now', 'localtime', '-6 days')",
"older" => "date(created_at, 'localtime') < date('now', 'localtime', '-13 days')",
_ => return Err(AppError::Other(format!("unknown period: {period}"))),
};
let sql = format!(
"SELECT id FROM snippet WHERE {} ORDER BY created_at DESC, id DESC",
date_filter
);
let mut stmt = conn.prepare(&sql)?;
let ids: Vec<i64> = stmt
.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
load_snippets_for_ids(conn, &ids)
}
fn db_record_copy(conn: &Connection, id: i64) -> Result<(), AppError> {
let rows = conn.execute(
"UPDATE snippet SET copy_count = copy_count + 1, last_used_at = datetime('now') WHERE id = ?1",
[id],
)?;
if rows == 0 {
return Err(AppError::Other(format!("snippet {id} not found")));
}
Ok(())
}
// ── Tauri commands ───────────────────────────────────────────────────────────
#[tauri::command]
pub fn create_snippet(
db: State<Mutex<Connection>>,
title: String,
content: String,
content_type: String,
tags: Vec<TagInput>,
) -> Result<Snippet, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_create_snippet(&conn, &title, &content, &content_type, &tags)
}
#[tauri::command]
pub fn list_snippets(
db: State<Mutex<Connection>>,
tag_filter: Option<String>,
) -> Result<Vec<Snippet>, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_list_snippets(&conn, tag_filter.as_deref())
}
#[tauri::command]
pub fn get_snippet(db: State<Mutex<Connection>>, id: i64) -> Result<Snippet, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_get_snippet(&conn, id)
}
#[tauri::command]
pub fn update_snippet(
db: State<Mutex<Connection>>,
id: i64,
title: String,
content: String,
content_type: String,
tags: Vec<TagInput>,
) -> Result<Snippet, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_update_snippet(&conn, id, &title, &content, &content_type, &tags)
}
#[tauri::command]
pub fn delete_snippet(db: State<Mutex<Connection>>, id: i64) -> Result<(), AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_delete_snippet(&conn, id)
}
#[tauri::command]
pub fn search_snippets(
db: State<Mutex<Connection>>,
query: String,
) -> Result<Vec<Snippet>, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_search_snippets(&conn, &query)
}
#[tauri::command]
pub fn list_snippets_by_period(
db: State<Mutex<Connection>>,
period: String,
) -> Result<Vec<Snippet>, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_list_snippets_by_period(&conn, &period)
}
#[tauri::command]
pub fn record_copy(db: State<Mutex<Connection>>, id: i64) -> Result<(), AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_record_copy(&conn, id)
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use crate::db::apply_migrations;
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
apply_migrations(&conn).unwrap();
conn
}
fn tag(name: &str, source: &str) -> TagInput {
TagInput { name: name.to_string(), source: source.to_string() }
}
#[test]
fn create_snippet_returns_snippet_with_tags() {
let conn = setup();
let s = db_create_snippet(
&conn, "My title", "fn main() {}", "code", &[tag("rust", "user")],
)
.unwrap();
assert_eq!(s.title, "My title");
assert_eq!(s.content_type, ContentType::Code);
assert_eq!(s.tags.len(), 1);
assert_eq!(s.tags[0].name, "rust");
assert_eq!(s.tags[0].source, "user");
}
#[test]
fn create_snippet_no_tags() {
let conn = setup();
let s = db_create_snippet(&conn, "Bare", "bare content", "text", &[]).unwrap();
assert!(s.tags.is_empty());
}
#[test]
fn list_snippets_ordered_newest_first() {
let conn = setup();
let s1 = db_create_snippet(&conn, "First", "a", "text", &[]).unwrap();
let s2 = db_create_snippet(&conn, "Second", "b", "text", &[]).unwrap();
let snippets = db_list_snippets(&conn, None).unwrap();
assert_eq!(snippets.len(), 2);
assert_eq!(snippets[0].id, s2.id, "newer snippet must come first");
assert_eq!(snippets[1].id, s1.id);
}
#[test]
fn list_snippets_filtered_by_tag() {
let conn = setup();
db_create_snippet(&conn, "Rust snippet", "code", "code", &[tag("rust", "user")]).unwrap();
db_create_snippet(&conn, "Plain snippet", "text", "text", &[]).unwrap();
let snippets = db_list_snippets(&conn, Some("rust")).unwrap();
assert_eq!(snippets.len(), 1);
assert_eq!(snippets[0].title, "Rust snippet");
}
#[test]
fn get_snippet_returns_with_tags() {
let conn = setup();
let created = db_create_snippet(
&conn, "JS snippet", "const x=1", "code", &[tag("js", "system")],
)
.unwrap();
let fetched = db_get_snippet(&conn, created.id).unwrap();
assert_eq!(fetched.id, created.id);
assert_eq!(fetched.tags[0].source, "system");
}
#[test]
fn update_snippet_replaces_tags() {
let conn = setup();
let original = db_create_snippet(
&conn, "Title", "content", "code", &[tag("rust", "user")],
)
.unwrap();
let updated = db_update_snippet(
&conn, original.id, "New title", "new", "text", &[tag("python", "user")],
)
.unwrap();
assert_eq!(updated.title, "New title");
assert_eq!(updated.content_type, ContentType::Text);
assert_eq!(updated.tags.len(), 1);
assert_eq!(updated.tags[0].name, "python");
}
#[test]
fn delete_snippet_removes_row() {
let conn = setup();
let s = db_create_snippet(&conn, "To delete", "x", "text", &[]).unwrap();
db_delete_snippet(&conn, s.id).unwrap();
assert!(db_list_snippets(&conn, None).unwrap().is_empty());
}
#[test]
fn delete_snippet_removes_snippet_tag_rows() {
let conn = setup();
let s = db_create_snippet(&conn, "Tagged", "x", "text", &[tag("go", "user")]).unwrap();
db_delete_snippet(&conn, s.id).unwrap();
let orphans: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_tag WHERE snippet_id=?1",
[s.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(orphans, 0, "cascade must remove snippet_tag rows");
}
#[test]
fn search_snippets_matches_title() {
let conn = setup();
db_create_snippet(&conn, "Rust async tips", "use tokio::runtime", "code", &[]).unwrap();
db_create_snippet(&conn, "Python basics", "print('hello')", "code", &[]).unwrap();
let results = db_search_snippets(&conn, "Rust").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Rust async tips");
}
#[test]
fn search_snippets_matches_content() {
let conn = setup();
db_create_snippet(&conn, "Untitled", "SELECT * FROM users", "code", &[]).unwrap();
db_create_snippet(&conn, "Other", "print('hello')", "code", &[]).unwrap();
let results = db_search_snippets(&conn, "SELECT").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].content, "SELECT * FROM users");
}
#[test]
fn search_snippets_prefix_matches_partial_word() {
let conn = setup();
db_create_snippet(&conn, "Website tips", "visit the website", "text", &[]).unwrap();
db_create_snippet(&conn, "Other", "unrelated content", "text", &[]).unwrap();
let results = db_search_snippets(&conn, "web").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Website tips");
}
#[test]
fn search_snippets_empty_query_returns_empty() {
let conn = setup();
db_create_snippet(&conn, "Some snippet", "content here", "text", &[]).unwrap();
let results = db_search_snippets(&conn, "").unwrap();
assert!(results.is_empty());
}
#[test]
fn list_snippets_by_period_today() {
let conn = setup();
db_create_snippet(&conn, "Today snippet", "x", "text", &[]).unwrap();
let results = db_list_snippets_by_period(&conn, "today").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Today snippet");
}
#[test]
fn list_snippets_by_period_unknown_period_errors() {
let conn = setup();
let result = db_list_snippets_by_period(&conn, "bogus");
assert!(result.is_err());
}
#[test]
fn record_copy_increments_count() {
let conn = setup();
let s = db_create_snippet(&conn, "t", "c", "text", &[]).unwrap();
db_record_copy(&conn, s.id).unwrap();
db_record_copy(&conn, s.id).unwrap();
let (count, last_used): (i64, Option<String>) = conn
.query_row(
"SELECT copy_count, last_used_at FROM snippet WHERE id = ?1",
[s.id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(count, 2);
assert!(last_used.is_some());
}
#[test]
fn record_copy_unknown_id_errors() {
let conn = setup();
assert!(db_record_copy(&conn, 9999).is_err());
}
#[test]
fn load_snippets_for_ids_batches_tags() {
let conn = setup();
let s1 = db_create_snippet(&conn, "A", "a", "text", &[tag("go", "user")]).unwrap();
let s2 = db_create_snippet(&conn, "B", "b", "code", &[tag("rust", "user"), tag("async", "user")]).unwrap();
let result = load_snippets_for_ids(&conn, &[s1.id, s2.id]).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].id, s1.id);
assert_eq!(result[0].tags.len(), 1);
assert_eq!(result[1].id, s2.id);
assert_eq!(result[1].tags.len(), 2);
}
#[test]
fn load_snippets_for_ids_empty_returns_empty() {
let conn = setup();
let result = load_snippets_for_ids(&conn, &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn tag_filter_excludes_suppressed_snippets() {
let conn = setup();
let s = db_create_snippet(&conn, "Tagged", "x", "text", &[
tag("rust", "suppressed"),
]).unwrap();
// Suppressed tag should not match the filter
let results = db_list_snippets(&conn, Some("rust")).unwrap();
assert!(!results.iter().any(|r| r.id == s.id));
}
}
+146
View File
@@ -0,0 +1,146 @@
use rusqlite::Connection;
use crate::error::AppError;
use crate::models::{Tag, TagInput};
/// Creates a tag if it does not exist, then inserts the snippet_tag join row.
/// INSERT OR IGNORE makes both operations idempotent.
pub fn attach_tags(conn: &Connection, snippet_id: i64, tags: &[TagInput]) -> Result<(), AppError> {
for tag in tags {
let name = tag.name.to_lowercase();
conn.execute(
"INSERT OR IGNORE INTO tag (name) VALUES (?1)",
[&name],
)?;
let tag_id: i64 = conn.query_row(
"SELECT id FROM tag WHERE name = ?1",
[&name],
|r| r.get(0),
)?;
conn.execute(
"INSERT OR IGNORE INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, ?3)",
rusqlite::params![snippet_id, tag_id, &tag.source],
)?;
}
Ok(())
}
fn db_list_tags(conn: &Connection) -> Result<Vec<Tag>, AppError> {
let mut stmt = conn.prepare(
"SELECT t.id, t.name, count(st.snippet_id) \
FROM tag t \
LEFT JOIN snippet_tag st ON st.tag_id = t.id AND st.source != 'suppressed' \
GROUP BY t.id \
ORDER BY t.name",
)?;
let tags = stmt
.query_map([], |r| {
Ok(Tag { id: r.get(0)?, name: r.get(1)?, source: String::new(), count: r.get(2)? })
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(tags)
}
#[tauri::command]
pub fn list_tags(
db: tauri::State<std::sync::Mutex<Connection>>,
) -> Result<Vec<Tag>, AppError> {
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
db_list_tags(&conn)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use crate::db::apply_migrations;
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
apply_migrations(&conn).unwrap();
conn
}
#[test]
fn attach_tags_creates_tag_if_missing() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let snippet_id = conn.last_insert_rowid();
attach_tags(
&conn,
snippet_id,
&[TagInput { name: "rust".to_string(), source: "user".to_string() }],
)
.unwrap();
let tag_count: i64 = conn
.query_row("SELECT count(*) FROM tag WHERE name='rust'", [], |r| r.get(0))
.unwrap();
assert_eq!(tag_count, 1);
let link_count: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_tag WHERE snippet_id=?1",
[snippet_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(link_count, 1);
}
#[test]
fn attach_tags_reuses_existing_tag() {
let conn = setup();
conn.execute("INSERT INTO tag (name) VALUES ('rust')", []).unwrap();
let existing_tag_id: i64 = conn
.query_row("SELECT id FROM tag WHERE name='rust'", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let snippet_id = conn.last_insert_rowid();
attach_tags(
&conn,
snippet_id,
&[TagInput { name: "rust".to_string(), source: "system".to_string() }],
)
.unwrap();
let tag_count: i64 = conn
.query_row("SELECT count(*) FROM tag", [], |r| r.get(0))
.unwrap();
assert_eq!(tag_count, 1);
let linked_id: i64 = conn
.query_row(
"SELECT tag_id FROM snippet_tag WHERE snippet_id=?1",
[snippet_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(linked_id, existing_tag_id);
}
#[test]
fn db_list_tags_returns_all_sorted_by_name() {
let conn = setup();
conn.execute("INSERT INTO tag (name) VALUES ('zebra')", []).unwrap();
conn.execute("INSERT INTO tag (name) VALUES ('apple')", []).unwrap();
let tags = db_list_tags(&conn).unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].name, "apple");
assert_eq!(tags[1].name, "zebra");
}
}
+338
View File
@@ -0,0 +1,338 @@
use std::path::Path;
use rusqlite::Connection;
use crate::error::AppError;
const MIGRATION_1: &str = "
CREATE TABLE snippet (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT 'text',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE snippet_tag (
snippet_id INTEGER NOT NULL REFERENCES snippet(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
source TEXT NOT NULL DEFAULT 'user',
PRIMARY KEY (snippet_id, tag_id)
);
CREATE VIRTUAL TABLE snippet_fts USING fts5(
title,
content,
content='snippet',
content_rowid='id'
);
CREATE TRIGGER snippet_ai AFTER INSERT ON snippet BEGIN
INSERT INTO snippet_fts(rowid, title, content)
VALUES (new.id, new.title, new.content);
END;
CREATE TRIGGER snippet_ad AFTER DELETE ON snippet BEGIN
INSERT INTO snippet_fts(snippet_fts, rowid, title, content)
VALUES ('delete', old.id, old.title, old.content);
END;
CREATE TRIGGER snippet_au AFTER UPDATE ON snippet BEGIN
INSERT INTO snippet_fts(snippet_fts, rowid, title, content)
VALUES ('delete', old.id, old.title, old.content);
INSERT INTO snippet_fts(rowid, title, content)
VALUES (new.id, new.title, new.content);
END;
";
const MIGRATION_2: &str = "
ALTER TABLE snippet ADD COLUMN copy_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE snippet ADD COLUMN last_used_at TEXT;
-- Deduplicate tags case-insensitively before lowercasing:
-- 1. Reassign snippet_tag rows that point to a non-canonical (non-min-id) tag
UPDATE snippet_tag
SET tag_id = (
SELECT MIN(t2.id) FROM tag t2
WHERE LOWER(t2.name) = LOWER((SELECT t3.name FROM tag t3 WHERE t3.id = snippet_tag.tag_id))
)
WHERE tag_id NOT IN (SELECT MIN(id) FROM tag GROUP BY LOWER(name));
-- 2. Delete orphaned duplicates
DELETE FROM tag WHERE id NOT IN (SELECT MIN(id) FROM tag GROUP BY LOWER(name));
-- 3. Lowercase all tag names
UPDATE tag SET name = LOWER(name);
";
/// Open (or create) the database at `path`, enable WAL + foreign keys, run migrations.
pub fn open_db(path: &Path) -> Result<Connection, AppError> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
apply_migrations(&conn)?;
Ok(conn)
}
pub(crate) fn apply_migrations(conn: &Connection) -> Result<(), AppError> {
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version < 1 {
conn.execute_batch(&format!("BEGIN;\n{MIGRATION_1}\nCOMMIT;"))?;
conn.execute_batch("PRAGMA user_version = 1;")?;
}
if version < 2 {
conn.execute_batch(&format!("BEGIN;\n{MIGRATION_2}\nCOMMIT;"))?;
conn.execute_batch("PRAGMA user_version = 2;")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
super::apply_migrations(&conn).unwrap();
conn
}
#[test]
fn migration_sets_user_version_to_2() {
let conn = setup();
let ver: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(ver, 2);
}
#[test]
fn migration_2_adds_copy_columns() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let id = conn.last_insert_rowid();
let (copy_count, last_used_at): (i64, Option<String>) = conn
.query_row(
"SELECT copy_count, last_used_at FROM snippet WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(copy_count, 0);
assert!(last_used_at.is_none());
}
#[test]
fn migration_2_normalizes_tag_case() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
let m1 = super::MIGRATION_1;
conn.execute_batch(&format!("BEGIN;\n{m1}\nCOMMIT;")).unwrap();
conn.execute_batch("PRAGMA user_version = 1;").unwrap();
conn.execute("INSERT INTO tag (name) VALUES ('Rust')", []).unwrap();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let snippet_id = conn.last_insert_rowid();
let tag_id: i64 = conn
.query_row("SELECT id FROM tag WHERE name = 'Rust'", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, 'user')",
rusqlite::params![snippet_id, tag_id],
)
.unwrap();
// Now run migration 2
super::apply_migrations(&conn).unwrap();
let name: String = conn
.query_row("SELECT name FROM tag WHERE id = ?1", [tag_id], |r| r.get(0))
.unwrap();
assert_eq!(name, "rust");
}
#[test]
fn snippet_and_tag_round_trip() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
rusqlite::params!["My snippet", "fn main() {}", "code"],
)
.unwrap();
let snippet_id = conn.last_insert_rowid();
conn.execute("INSERT INTO tag (name) VALUES (?1)", ["rust"])
.unwrap();
let tag_id: i64 = conn
.query_row("SELECT id FROM tag WHERE name = ?1", ["rust"], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, 'user')",
rusqlite::params![snippet_id, tag_id],
)
.unwrap();
let title: String = conn
.query_row(
"SELECT title FROM snippet WHERE id = ?1",
[snippet_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(title, "My snippet");
let tag_name: String = conn
.query_row(
"SELECT t.name FROM tag t \
JOIN snippet_tag st ON st.tag_id = t.id \
WHERE st.snippet_id = ?1",
[snippet_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(tag_name, "rust");
}
#[test]
fn fts5_indexes_on_insert() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('Hello World', 'fn greet() {}', 'code', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let count: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Hello'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn fts5_deindexes_on_delete() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('Delete me', 'ephemeral', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let id = conn.last_insert_rowid();
let before: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Delete'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(before, 1);
conn.execute("DELETE FROM snippet WHERE id = ?1", [id])
.unwrap();
let after: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Delete'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(after, 0);
}
#[test]
fn fts5_reindexes_on_update() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('Old title', 'body', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let id = conn.last_insert_rowid();
conn.execute(
"UPDATE snippet SET title = 'New title', updated_at = datetime('now') WHERE id = ?1",
[id],
)
.unwrap();
let old_count: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Old'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(old_count, 0);
let new_count: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'New'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(new_count, 1);
}
#[test]
fn snippet_tag_cascade_deletes_with_snippet() {
let conn = setup();
conn.execute(
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
[],
)
.unwrap();
let snippet_id = conn.last_insert_rowid();
conn.execute("INSERT INTO tag (name) VALUES ('x')", [])
.unwrap();
let tag_id: i64 = conn
.query_row("SELECT id FROM tag WHERE name = 'x'", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, 'user')",
rusqlite::params![snippet_id, tag_id],
)
.unwrap();
conn.execute("DELETE FROM snippet WHERE id = ?1", [snippet_id])
.unwrap();
let orphan_count: i64 = conn
.query_row(
"SELECT count(*) FROM snippet_tag WHERE snippet_id = ?1",
[snippet_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(orphan_count, 0, "snippet_tag rows must be cascade-deleted");
}
}
+20
View File
@@ -0,0 +1,20 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("database error: {0}")]
Db(#[from] rusqlite::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Other(String),
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
+70
View File
@@ -0,0 +1,70 @@
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
pub mod commands;
pub mod db;
pub mod error;
pub mod models;
fn open_quick_note(app: &tauri::AppHandle) {
if let Some(w) = app.get_webview_window("quicknote") {
let _ = w.set_focus();
return;
}
match tauri::WebviewWindowBuilder::new(
app,
"quicknote",
tauri::WebviewUrl::App("quicknote".into()),
)
.title("Quick Note")
.inner_size(600.0, 480.0)
.always_on_top(true)
.center()
.resizable(false)
.build()
{
Ok(w) => { let _ = w.set_focus(); }
Err(e) => eprintln!("[faro] failed to open quick-note window: {e}"),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _shortcut, event| {
if event.state() == ShortcutState::Pressed {
open_quick_note(app);
}
})
.build(),
)
.setup(|app| {
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join("faro.db");
let conn = db::open_db(&db_path)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
app.manage(Mutex::new(conn));
app.global_shortcut().register("Ctrl+Shift+Space")?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::snippets::create_snippet,
commands::snippets::list_snippets,
commands::snippets::get_snippet,
commands::snippets::update_snippet,
commands::snippets::delete_snippet,
commands::snippets::search_snippets,
commands::snippets::list_snippets_by_period,
commands::snippets::record_copy,
commands::tags::list_tags,
commands::settings::update_hotkey,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
faro_lib::run()
}
+60
View File
@@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentType {
Code,
Cli,
Text,
}
impl std::fmt::Display for ContentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContentType::Code => write!(f, "code"),
ContentType::Cli => write!(f, "cli"),
ContentType::Text => write!(f, "text"),
}
}
}
impl std::str::FromStr for ContentType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"code" => Ok(ContentType::Code),
"cli" => Ok(ContentType::Cli),
"text" => Ok(ContentType::Text),
other => Err(format!("unknown content_type: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snippet {
pub id: i64,
pub title: String,
pub content: String,
pub content_type: ContentType,
pub created_at: String,
pub updated_at: String,
pub tags: Vec<Tag>,
}
/// A tag, optionally carrying its `source` from the `snippet_tag` join table.
/// `source` is always populated when a tag is loaded through a snippet;
/// it is an empty string when listing all tags without snippet context.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: i64,
pub name: String,
pub source: String,
#[serde(default)]
pub count: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TagInput {
pub name: String,
pub source: String,
}
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Faro",
"version": "0.1.0",
"identifier": "com.faro.app",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "Faro",
"width": 1100,
"height": 720,
"minWidth": 800,
"minHeight": 520,
"resizable": true
}
],
"security": {}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+198
View File
@@ -0,0 +1,198 @@
@import '@fontsource/ibm-plex-sans/400.css';
@import '@fontsource/ibm-plex-sans/500.css';
@import '@fontsource/ibm-plex-sans/600.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
:root {
--font-body: 'IBM Plex Sans', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-size-body: 14px;
--font-size-editor: 14px;
color-scheme: dark;
/* surfaces & lines */
--bg: #090b10;
--bg-sidebar: rgba(0, 0, 0, 0.15);
--bg-surface: rgba(255, 255, 255, 0.05);
--bg-surface-hover: rgba(255, 255, 255, 0.07);
--bg-card: rgba(255, 255, 255, 0.035);
--bg-card-hover: rgba(255, 255, 255, 0.055);
--bg-editor: rgba(255, 255, 255, 0.03);
--bg-code: transparent;
--bg-modal: #0f1117;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.15);
--preview-border: transparent;
/* text */
--text: #e2e8f0;
--text-secondary: rgba(255, 255, 255, 0.65);
--text-muted: rgba(255, 255, 255, 0.4);
--text-faint: rgba(255, 255, 255, 0.25);
/* accent & state */
--accent: #9d7dea;
--accent-strong: #9d7dea;
--accent-contrast: #ffffff;
--accent-tint: rgba(157, 125, 234, 0.15);
--accent-tint-strong: rgba(157, 125, 234, 0.22);
--accent-border: rgba(157, 125, 234, 0.35);
/* semantic */
--success: #c3e88d;
--danger: #ef4444;
/* syntax */
--syntax-keyword: #c792ea;
--syntax-type: #ffcb6b;
--syntax-func: #82aaff;
--syntax-string: #c3e88d;
--syntax-comment: #5c6370;
--syntax-number: #f78c6c;
--syntax-operator: #89ddff;
--syntax-tag: #f07178;
--syntax-attr: #80cbc4;
/* editor chrome */
--cm-selection: rgba(157, 125, 234, 0.25);
/* layout (Dark) */
--radius-card: 8px;
--radius-control: 6px;
--radius-chip: 4px;
--card-pad: 0.65rem 0.9rem;
--list-gap: 0.4rem;
--list-pad: 0.6rem 0.75rem;
--panel-pad: 1.25rem;
--preview-pad: 0;
}
[data-theme='light'] {
color-scheme: light;
--bg: #ffffff;
--bg-sidebar: #f9fafb;
--bg-surface: #ffffff;
--bg-surface-hover: #f9fafb;
--bg-card: #ffffff;
--bg-card-hover: #f9fafb;
--bg-editor: #f9fafb;
--bg-code: #f9fafb;
--bg-modal: #ffffff;
--border: #e5e7eb;
--border-strong: #d1d5db;
--preview-border: #e5e7eb;
--text: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--accent: #6d5dfc;
--accent-strong: #6d5dfc;
--accent-contrast: #ffffff;
--accent-tint: rgba(109, 93, 252, 0.1);
--accent-tint-strong: rgba(109, 93, 252, 0.18);
--accent-border: #6d5dfc;
--success: #16a34a;
--danger: #dc2626;
--syntax-keyword: #2563eb;
--syntax-type: #0891b2;
--syntax-func: #7c3aed;
--syntax-string: #16a34a;
--syntax-comment: #9ca3af;
--syntax-number: #dc2626;
--syntax-operator: #475569;
--syntax-tag: #be123c;
--syntax-attr: #0e7490;
--cm-selection: rgba(109, 93, 252, 0.2);
}
[data-theme='nord'] {
color-scheme: dark;
--bg: #2e3440;
--bg-sidebar: #2e3440;
--bg-surface: #3b4252;
--bg-surface-hover: #434c5e;
--bg-card: #3b4252;
--bg-card-hover: #3b4252;
--bg-editor: #3b4252;
--bg-code: #2e3440;
--bg-modal: #2e3440;
--border: #434c5e;
--border-strong: #4c566a;
--preview-border: #434c5e;
--text: #eceff4;
--text-secondary: #d8dee9;
--text-muted: #9aa5b8;
--text-faint: #828ea6;
--accent: #88c0d0;
--accent-strong: #5e81ac;
--accent-contrast: #eceff4;
--accent-tint: rgba(136, 192, 208, 0.1);
--accent-tint-strong: rgba(136, 192, 208, 0.2);
--accent-border: #88c0d0;
--success: #a3be8c;
--danger: #bf616a;
--syntax-keyword: #81a1c1;
--syntax-type: #8fbcbb;
--syntax-func: #88c0d0;
--syntax-string: #a3be8c;
--syntax-comment: #616e88;
--syntax-number: #b48ead;
--syntax-operator: #81a1c1;
--syntax-tag: #81a1c1;
--syntax-attr: #8fbcbb;
--cm-selection: rgba(136, 192, 208, 0.25);
}
/* Shared layout for the new (spacious) themes */
[data-theme='light'],
[data-theme='nord'] {
--radius-card: 12px;
--radius-control: 8px;
--radius-chip: 6px;
--card-pad: 1rem;
--list-gap: 1rem;
--list-pad: 1.5rem;
--panel-pad: 1.5rem;
--preview-pad: 0.5rem 0.6rem;
}
html,
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: var(--font-size-body);
margin: 0;
padding: 0;
overflow: hidden;
}
* {
box-sizing: border-box;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* Custom scrollbars */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
/* highlight.js tokens for static card previews */
.hljs-keyword, .hljs-built_in { color: var(--syntax-keyword); }
.hljs-string, .hljs-meta .hljs-string { color: var(--syntax-string); }
.hljs-comment { color: var(--syntax-comment); font-style: italic; }
.hljs-number, .hljs-literal { color: var(--syntax-number); }
.hljs-title, .hljs-title.function_ { color: var(--syntax-func); }
.hljs-type, .hljs-title.class_ { color: var(--syntax-type); }
.hljs-attr, .hljs-property { color: var(--syntax-attr); }
.hljs-tag, .hljs-name { color: var(--syntax-tag); }
.hljs-symbol, .hljs-operator, .hljs-punctuation { color: var(--syntax-operator); }
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Faro</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
import { invoke } from '@tauri-apps/api/core';
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod } from './types';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
export async function createSnippet(input: CreateSnippetInput): Promise<Snippet> {
return invoke('create_snippet', {
title: input.title,
content: input.content,
contentType: input.content_type,
tags: input.tags,
});
}
export async function listSnippets(tagFilter?: string): Promise<Snippet[]> {
return invoke('list_snippets', { tagFilter: tagFilter ?? null });
}
export async function getSnippet(id: number): Promise<Snippet> {
return invoke('get_snippet', { id });
}
export async function updateSnippet(input: UpdateSnippetInput): Promise<Snippet> {
return invoke('update_snippet', {
id: input.id,
title: input.title,
content: input.content,
contentType: input.content_type,
tags: input.tags,
});
}
export async function deleteSnippet(id: number): Promise<void> {
return invoke('delete_snippet', { id });
}
export async function listTags(): Promise<Tag[]> {
return invoke('list_tags');
}
export async function searchSnippets(query: string): Promise<Snippet[]> {
return invoke('search_snippets', { query });
}
export async function listSnippetsByPeriod(period: TimePeriod): Promise<Snippet[]> {
return invoke('list_snippets_by_period', { period });
}
export async function updateHotkey(hotkey: string): Promise<void> {
return invoke('update_hotkey', { hotkey });
}
export async function recordCopy(id: number): Promise<void> {
return invoke('record_copy', { id });
}
export async function copyToClipboard(text: string): Promise<void> {
return writeText(text);
}
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { untrack } from 'svelte';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Compartment } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import type { ContentType } from '$lib/types';
import { settings } from '$lib/stores/settings.svelte';
import { fontTheme, highlightExtension, resolveLanguage, darkFlag } from '$lib/editor';
let { value = $bindable(''), contentType, language = null, onblur }: {
value: string;
contentType: ContentType;
language?: string | null;
onblur?: () => void;
} = $props();
let container: HTMLDivElement;
let view: EditorView | null = null;
const fontCompartment = new Compartment();
const langCompartment = new Compartment();
const themeCompartment = new Compartment();
// Mount the editor once the container exists. value/contentType are read
// untracked so typing or type changes don't tear down and rebuild the editor —
// those updates are handled by the dedicated effects below.
$effect(() => {
if (!container) return;
const initialDoc = untrack(() => value);
const initialType = untrack(() => contentType);
const state = EditorState.create({
doc: initialDoc,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.lineWrapping,
EditorState.tabSize.of(2),
highlightExtension(),
fontCompartment.of(fontTheme(initialType)),
themeCompartment.of(darkFlag(settings.theme)),
langCompartment.of([]),
EditorView.updateListener.of((u) => {
if (u.docChanged) value = u.state.doc.toString();
}),
EditorView.domEventHandlers({
blur: () => {
onblur?.();
return false;
},
}),
],
});
view = new EditorView({ state, parent: container });
return () => {
view?.destroy();
view = null;
};
});
// Push external value changes into the editor without clobbering edits.
$effect(() => {
const v = value;
if (view && v !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: v } });
}
});
// Reconfigure font when the content type changes.
$effect(() => {
const ct = contentType;
if (view) view.dispatch({ effects: fontCompartment.reconfigure(fontTheme(ct)) });
});
// Reconfigure the light/dark base flag when the theme changes.
$effect(() => {
const theme = settings.theme;
if (view) view.dispatch({ effects: themeCompartment.reconfigure(darkFlag(theme)) });
});
// Reconfigure grammar (async) when type/language change.
$effect(() => {
const ct = contentType;
const lang = language;
let cancelled = false;
resolveLanguage(ct, lang).then((ext) => {
if (!cancelled && view) {
view.dispatch({ effects: langCompartment.reconfigure(ext ?? []) });
}
});
return () => {
cancelled = true;
};
});
</script>
<div class="editor" bind:this={container}></div>
<style>
.editor {
flex: 1;
min-height: 0;
height: 100%;
background: var(--bg-editor);
border: 1px solid var(--border);
border-radius: var(--radius-control);
overflow: hidden;
}
.editor :global(.cm-editor) {
height: 100%;
}
.editor :global(.cm-editor.cm-focused) {
border-color: var(--accent-border);
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { Search, X } from 'lucide-svelte';
let { value, onchange, searchInput = $bindable() }: {
value: string;
onchange: (q: string) => void;
searchInput?: HTMLInputElement;
} = $props();
let inputValue = $state(value);
let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { inputValue = value; });
$effect(() => { return () => { if (timer) clearTimeout(timer); }; });
function handleInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
inputValue = v;
if (timer) clearTimeout(timer);
timer = setTimeout(() => onchange(v), 250);
}
function clear() {
inputValue = '';
if (timer) clearTimeout(timer);
onchange('');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { clear(); (e.target as HTMLInputElement).blur(); }
}
</script>
<div class="search-bar">
<span class="icon-search"><Search size={14} strokeWidth={2} /></span>
<input
type="search"
placeholder="Search snippets…"
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
bind:this={searchInput}
/>
{#if inputValue}
<button class="clear-btn" onclick={clear}><X size={14} strokeWidth={2} /></button>
{/if}
</div>
<style>
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.icon-search {
position: absolute;
left: 0.6rem;
display: flex;
align-items: center;
color: var(--text-muted);
pointer-events: none;
flex-shrink: 0;
}
input {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 0.4rem 2rem 0.4rem 2.1rem;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
input:focus {
border-color: var(--accent-border);
background: var(--bg-surface-hover);
}
input::placeholder { color: var(--text-muted); }
input::-webkit-search-cancel-button { display: none; }
.clear-btn {
position: absolute;
right: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
padding: 0.15rem;
cursor: pointer;
border-radius: var(--radius-chip);
transition: color 0.1s;
}
.clear-btn:hover { color: var(--text); }
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { settings } from '$lib/stores/settings.svelte';
let { open, onclose }: { open: boolean; onclose: () => void } = $props();
let draftHotkey = $state(settings.hotkey);
let draftFontSize = $state(settings.fontSize);
let draftStayOpen = $state(settings.quickNoteStayOpen);
let draftTheme = $state(settings.theme);
let saving = $state(false);
let error = $state<string | null>(null);
let overlayEl = $state<HTMLDivElement | null>(null);
// Sync draft from store when modal opens; focus overlay for keyboard handling
$effect(() => {
if (open) {
draftHotkey = settings.hotkey;
draftFontSize = settings.fontSize;
draftStayOpen = settings.quickNoteStayOpen;
draftTheme = settings.theme;
error = null;
setTimeout(() => overlayEl?.focus(), 0);
}
});
// Live font-size preview
$effect(() => {
if (open) {
document.documentElement.style.setProperty('--font-size-editor', `${draftFontSize}px`);
}
});
// Live theme preview
$effect(() => {
if (open) {
document.documentElement.setAttribute('data-theme', draftTheme);
}
});
function handleCancel() {
document.documentElement.style.setProperty('--font-size-editor', `${settings.fontSize}px`);
document.documentElement.setAttribute('data-theme', settings.theme);
onclose();
}
async function handleSave() {
saving = true;
error = null;
try {
await settings.save({
hotkey: draftHotkey,
fontSize: draftFontSize,
quickNoteStayOpen: draftStayOpen,
theme: draftTheme,
});
onclose();
} catch (e) {
error = String(e);
document.documentElement.style.setProperty('--font-size-editor', `${settings.fontSize}px`);
document.documentElement.setAttribute('data-theme', settings.theme);
draftFontSize = settings.fontSize;
draftTheme = settings.theme;
} finally {
saving = false;
}
}
</script>
{#if open}
<div
class="overlay"
role="dialog"
aria-modal="true"
aria-label="Settings"
tabindex="-1"
bind:this={overlayEl}
onkeydown={(e) => { if (e.key === 'Escape') handleCancel(); }}
>
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Settings</h2>
</div>
<div class="modal-body">
<div class="setting-row">
<label for="hotkey-input" class="setting-label">Global hotkey</label>
<input
id="hotkey-input"
type="text"
bind:value={draftHotkey}
class="setting-input"
placeholder="e.g. Ctrl+Shift+Space"
/>
{#if error}
<span class="field-error">{error}</span>
{/if}
</div>
<div class="setting-row">
<label for="font-size-input" class="setting-label">Editor font size</label>
<div class="font-size-control">
<input
id="font-size-input"
type="number"
bind:value={draftFontSize}
min="10"
max="24"
step="1"
class="setting-input number-input"
/>
<span class="font-size-unit">px</span>
</div>
</div>
<div class="setting-row">
<label for="stay-open-toggle" class="setting-label">Quick Note stay open after save</label>
<input
id="stay-open-toggle"
type="checkbox"
bind:checked={draftStayOpen}
class="setting-checkbox"
/>
</div>
<div class="setting-row">
<span class="setting-label">Theme</span>
<div class="theme-buttons" role="group" aria-label="Theme">
{#each (['dark', 'light', 'nord'] as const) as t (t)}
<button
type="button"
class="theme-btn"
class:active={draftTheme === t}
onclick={() => (draftTheme = t)}
>{t}</button>
{/each}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={handleCancel} disabled={saving}>Cancel</button>
<button class="btn-primary" onclick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: 8px;
width: 420px;
max-width: calc(100vw - 2rem);
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.modal-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.modal-body {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.setting-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.setting-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-body);
font-size: 13px;
padding: 0.4rem 0.6rem;
outline: none;
width: 100%;
}
.setting-input:focus { border-color: var(--accent-border); }
.number-input { width: 80px; }
.font-size-control { display: flex; align-items: center; gap: 0.4rem; }
.font-size-unit { font-size: 12px; color: var(--text-muted); }
.setting-checkbox { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
.theme-buttons { display: flex; gap: 0.35rem; }
.theme-btn {
flex: 1;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
text-transform: capitalize;
padding: 0.3rem 0.5rem;
cursor: pointer;
}
.theme-btn.active {
background: var(--accent-tint);
border-color: var(--accent-border);
color: var(--accent);
}
.field-error { font-size: 11px; color: var(--danger); }
.modal-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-primary {
background: var(--accent-strong);
border: none;
border-radius: 4px;
color: var(--accent-contrast);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
padding: 0.4rem 1.1rem;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-primary:hover:not(:disabled) { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.35; cursor: default; }
.btn-secondary {
background: none;
border: 1px solid var(--border-strong);
border-radius: 4px;
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-body);
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.btn-secondary:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text); }
.btn-secondary:disabled { opacity: 0.35; cursor: default; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts">
import { Code2, Terminal, FileText, Copy, Check } from 'lucide-svelte';
import type { Snippet } from '$lib/types';
import { copyToClipboard, recordCopy } from '$lib/api';
import { highlightPreviewHtml } from '$lib/highlight';
let { snippet, selected = false, onselect }: {
snippet: Snippet;
selected?: boolean;
onselect?: (id: number) => void;
} = $props();
const previewLines = $derived(snippet.content.split('\n').slice(0, 2).join('\n'));
const previewLang = $derived(
snippet.content_type === 'code'
? (snippet.tags.find(t => t.source === 'system')?.name ?? null)
: snippet.content_type === 'cli'
? 'bash'
: null,
);
const previewHtml = $derived(highlightPreviewHtml(previewLines, previewLang));
const previewFont = $derived(
snippet.content_type === 'text' ? 'var(--font-body)' : 'var(--font-mono)',
);
let copied = $state(false);
async function copyContent(e: MouseEvent) {
e.stopPropagation();
try {
await copyToClipboard(snippet.content);
copied = true;
setTimeout(() => { copied = false; }, 1500);
recordCopy(snippet.id).catch(() => {});
} catch { /* clipboard unavailable */ }
}
</script>
<div
class="card"
class:selected
role="button"
tabindex="0"
data-snippet-id={snippet.id}
onclick={() => onselect?.(snippet.id)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onselect?.(snippet.id))}
>
<div class="header">
<span class="type-icon {snippet.content_type}">
{#if snippet.content_type === 'code'}
<Code2 size={13} strokeWidth={2} />
{:else if snippet.content_type === 'cli'}
<Terminal size={13} strokeWidth={2} />
{:else}
<FileText size={13} strokeWidth={2} />
{/if}
</span>
<span class="title">{snippet.title || 'Untitled'}</span>
<button class="copy-btn" onclick={copyContent}>
{#if copied}
<Check size={12} strokeWidth={2.5} />
{:else}
<Copy size={12} strokeWidth={2} />
{/if}
</button>
</div>
<pre class="preview" style:font-family={previewFont}>{@html previewHtml}</pre>
{#if snippet.tags.some(t => t.source !== 'suppressed')}
<div class="tags">
{#each snippet.tags.filter(t => t.source !== 'suppressed') as tag (tag.id)}
<span class="tag" class:system={tag.source === 'system'}>{tag.name}</span>
{/each}
</div>
{/if}
</div>
<style>
.card {
--icon-col: calc(20px + 0.5rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--card-pad);
cursor: pointer;
user-select: none;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.card:hover {
background: var(--bg-card-hover);
border-color: var(--border-strong);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.card.selected {
background: var(--accent-tint);
border-color: var(--accent-border);
box-shadow: 0 2px 8px var(--accent-tint);
}
.header {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.type-icon {
display: flex;
align-items: center;
width: 20px;
flex-shrink: 0;
justify-content: center;
}
.type-icon.code { color: var(--accent); }
.type-icon.cli { color: var(--success); }
.type-icon.text { color: var(--text-muted); }
.title {
flex: 1;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
width: 22px;
height: 22px;
border-radius: var(--radius-chip);
cursor: pointer;
opacity: 0;
transition: opacity 0.1s, background 0.1s, color 0.1s;
}
.card:hover .copy-btn,
.card.selected .copy-btn,
.copy-btn:focus { opacity: 1; }
.copy-btn:hover { background: var(--bg-surface-hover); color: var(--text); }
.preview {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 0.35rem var(--icon-col);
background: var(--bg-code);
border: 1px solid var(--preview-border);
border-radius: var(--radius-control);
padding: var(--preview-pad);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: pre-wrap;
word-break: break-all;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-left: var(--icon-col);
}
.tag {
font-size: 11px;
padding: 1px 6px;
border-radius: var(--radius-chip);
background: var(--accent-tint);
color: var(--accent);
}
.tag.system {
background: var(--bg-surface-hover);
color: var(--text-faint);
font-style: italic;
}
</style>
+388
View File
@@ -0,0 +1,388 @@
<script lang="ts">
import type { Snippet, ContentType, CreateSnippetInput, UpdateSnippetInput } from '$lib/types';
import { detectSnippet } from '$lib/highlight';
import CodeEditor from './CodeEditor.svelte';
import { LANGUAGE_OPTIONS } from '$lib/languages';
let { snippet = null, mode, onSave, onCancel, onDelete, dirty = $bindable(false) }: {
snippet?: Snippet | null;
mode: 'idle' | 'creating' | 'editing';
onSave: (input: CreateSnippetInput | UpdateSnippetInput) => void;
onCancel?: () => void;
onDelete?: () => void;
dirty?: boolean;
} = $props();
let draftTitle = $state('');
let draftContent = $state('');
let draftType = $state<ContentType>('text');
let draftTags = $state('');
let draftSystemTags = $state<string[]>([]);
let draftSuppressedTags = $state<string[]>([]);
let typeManuallySet = $state(false);
let systemTagsManuallySet = $state(false);
// Re-initialize draft whenever mode or snippet changes.
$effect(() => {
if (mode === 'editing' && snippet) {
draftTitle = snippet.title;
draftContent = snippet.content;
draftType = snippet.content_type;
draftTags = snippet.tags
.filter(t => t.source === 'user')
.map(t => t.name)
.join(', ');
draftSystemTags = snippet.tags
.filter(t => t.source === 'system')
.map(t => t.name);
draftSuppressedTags = snippet.tags
.filter(t => t.source === 'suppressed')
.map(t => t.name);
typeManuallySet = false;
systemTagsManuallySet = false;
dirty = false;
} else if (mode === 'creating') {
draftTitle = '';
draftContent = '';
draftType = 'text';
draftTags = '';
draftSystemTags = [];
draftSuppressedTags = [];
typeManuallySet = false;
systemTagsManuallySet = false;
dirty = false;
}
});
function removeSystemTag(tag: string) {
draftSystemTags = draftSystemTags.filter(t => t !== tag);
if (!draftSuppressedTags.includes(tag)) {
draftSuppressedTags = [...draftSuppressedTags, tag];
}
systemTagsManuallySet = true;
dirty = true;
}
const currentLanguage = $derived(draftSystemTags[0] ?? null);
function setLanguage(lang: string) {
draftSystemTags = lang ? [lang] : [];
systemTagsManuallySet = true;
dirty = true;
}
$effect(() => {
const content = draftContent;
if (mode === 'editing' && snippet && content !== snippet.content) dirty = true;
if (mode === 'creating' && content.trim()) dirty = true;
});
function runDetection() {
if (!draftContent.trim()) {
if (!systemTagsManuallySet && draftSystemTags.length > 0) dirty = true;
if (!systemTagsManuallySet) draftSystemTags = [];
return;
}
const { content_type, language } = detectSnippet(draftContent);
const newSystemTags = (language ? [language] : [])
.filter(t => !draftSuppressedTags.includes(t));
if (!typeManuallySet) {
if (content_type !== draftType) dirty = true;
draftType = content_type;
}
if (!systemTagsManuallySet) {
if (newSystemTags.join() !== draftSystemTags.join()) dirty = true;
draftSystemTags = newSystemTags;
}
}
function handleSave() {
runDetection();
const userTags = draftTags
.split(',')
.map(t => t.trim())
.filter(Boolean)
.map(name => ({ name, source: 'user' }));
const sysTags = draftSystemTags.map(name => ({ name, source: 'system' }));
const suppressedTags = draftSuppressedTags.map(name => ({ name, source: 'suppressed' }));
const tags = [...userTags, ...sysTags, ...suppressedTags];
if (mode === 'editing' && snippet) {
onSave({
id: snippet.id,
title: draftTitle,
content: draftContent,
content_type: draftType,
tags,
} as UpdateSnippetInput);
} else {
onSave({
title: draftTitle,
content: draftContent,
content_type: draftType,
tags,
} as CreateSnippetInput);
}
}
</script>
{#if mode === 'idle'}
<div class="placeholder">
<span>Select a snippet or create a new one</span>
</div>
{:else}
<div class="detail" onkeydown={(e) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); handleSave(); } }}>
<input
id="title-input"
type="text"
bind:value={draftTitle}
oninput={() => (dirty = true)}
placeholder="Untitled snippet"
class="title-input"
/>
<div class="field">
<label for="type-buttons">Type</label>
<div class="type-row">
<div class="type-buttons" id="type-buttons" role="group">
{#each (['code', 'cli', 'text'] as ContentType[]) as t (t)}
<button
class="type-btn"
class:active={draftType === t}
onclick={() => { draftType = t; typeManuallySet = true; dirty = true; }}
>{t}</button>
{/each}
</div>
{#if draftType === 'code'}
<select
class="lang-select"
value={currentLanguage ?? ''}
onchange={(e) => setLanguage((e.currentTarget as HTMLSelectElement).value)}
aria-label="Language"
>
<option value="">auto</option>
{#each LANGUAGE_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
</div>
<div class="field grow">
<CodeEditor
bind:value={draftContent}
contentType={draftType}
language={currentLanguage}
onblur={runDetection}
/>
</div>
<div class="field">
<label for="tags-input">Tags</label>
{#if draftSystemTags.length > 0}
<div class="system-tags">
{#each draftSystemTags as tag (tag)}
<span class="system-tag">
{tag}
<button class="system-tag-remove" onclick={() => removeSystemTag(tag)} title="Remove">×</button>
</span>
{/each}
</div>
{/if}
<input
id="tags-input"
type="text"
bind:value={draftTags}
oninput={() => (dirty = true)}
placeholder="rust, async, cli (comma-separated)"
class="tags-input"
/>
</div>
<div class="actions">
<div class="actions-left">
{#if mode === 'editing' && onDelete}
<button class="btn-danger" onclick={onDelete}>Delete</button>
{/if}
</div>
<div class="actions-right">
{#if onCancel}
<button class="btn-secondary" onclick={onCancel}>Cancel</button>
{/if}
<button
class="btn-primary"
onclick={handleSave}
disabled={mode === 'editing' ? !dirty : !draftTitle.trim() && !draftContent.trim()}
>
{mode === 'creating' ? 'Create' : 'Save'}
</button>
</div>
</div>
</div>
{/if}
<style>
.placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-faint);
font-size: 13px;
}
.detail {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--panel-pad);
gap: 0.75rem;
overflow: hidden;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field.grow { flex: 1; min-height: 0; }
label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.title-input {
background: none;
border: none;
border-bottom: 1px solid transparent;
border-radius: 0;
color: var(--text);
font-family: var(--font-body);
font-size: 18px;
font-weight: 600;
padding: 0.1rem 0;
outline: none;
width: 100%;
transition: border-color 0.15s;
margin-bottom: 0.25rem;
}
.title-input:hover:not(:focus) { border-bottom-color: var(--border); }
.title-input:focus { border-bottom-color: var(--accent-border); }
.title-input::placeholder { color: var(--text-faint); font-weight: 400; }
.system-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.3rem;
}
.system-tag {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: var(--accent-tint);
border: 1px solid var(--accent-border);
border-radius: var(--radius-chip);
color: var(--accent);
font-size: 11px;
padding: 0.1rem 0.35rem 0.1rem 0.45rem;
}
.system-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0.6;
padding: 0;
}
.system-tag-remove:hover { opacity: 1; }
.tags-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 0.4rem 0.6rem;
outline: none;
width: 100%;
}
.tags-input:focus { border-color: var(--accent-border); }
.type-row { display: flex; align-items: center; gap: 0.5rem; }
.type-buttons { display: flex; gap: 0.35rem; }
.lang-select {
background-color: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text);
font-family: var(--font-body);
font-size: 12px;
padding: 0.2rem 0.4rem;
outline: none;
cursor: pointer;
}
.lang-select:hover { border-color: var(--border-strong); }
.lang-select:focus { border-color: var(--accent-border); }
.lang-select option {
background-color: var(--bg-surface);
color: var(--text);
}
.type-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
padding: 0.2rem 0.75rem;
cursor: pointer;
}
.type-btn.active {
background: var(--accent-tint);
border-color: var(--accent-border);
color: var(--accent);
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.actions-left { display: flex; }
.actions-right { display: flex; gap: 0.5rem; }
.btn-danger {
background: none;
border: 1px solid var(--danger);
border-radius: var(--radius-control);
color: var(--danger);
font-size: 13px;
font-family: var(--font-body);
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.btn-danger:hover { border-color: var(--danger); color: var(--danger); }
.btn-primary {
background: var(--accent-strong);
border: none;
border-radius: var(--radius-control);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
padding: 0.4rem 1.1rem;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-primary:hover:not(:disabled) { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.35; cursor: default; }
.btn-secondary {
background: none;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-body);
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.btn-secondary:hover { border-color: var(--border-strong); color: var(--text); }
</style>
@@ -0,0 +1,85 @@
<script lang="ts">
import SnippetCard from '$lib/components/SnippetCard.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import type { Snippet, TimePeriod } from '$lib/types';
let { snippets, selectedId, loading = false, searchQuery, tagFilter = null, timePeriodFilter = null, onSelect, onSearch, searchInputEl = $bindable() }: {
snippets: Snippet[];
selectedId: number | null;
loading?: boolean;
searchQuery: string;
tagFilter?: string | null;
timePeriodFilter?: TimePeriod | null;
onSelect: (id: number) => void;
onSearch: (q: string) => void;
searchInputEl?: HTMLInputElement;
} = $props();
const PERIOD_LABELS: Record<TimePeriod, string> = {
'today': 'today',
'yesterday': 'yesterday',
'this-week': 'this week',
'last-week': 'last week',
'older': 'older than two weeks',
};
const emptyMessage = $derived(
searchQuery
? 'No snippets match your search.'
: timePeriodFilter
? `No snippets from ${PERIOD_LABELS[timePeriodFilter]}.`
: tagFilter
? `No snippets tagged "${tagFilter}".`
: 'No snippets yet — create one with the button on the left.'
);
</script>
<div class="center-panel">
<div class="search-bar-wrap">
<SearchBar value={searchQuery} onchange={onSearch} bind:searchInput={searchInputEl} />
</div>
<div class="card-list">
{#if loading}
<p class="empty">Loading…</p>
{:else if snippets.length === 0}
<p class="empty">{emptyMessage}</p>
{:else}
{#each snippets as snippet (snippet.id)}
<SnippetCard
{snippet}
selected={selectedId === snippet.id}
onselect={onSelect}
/>
{/each}
{/if}
</div>
</div>
<style>
.center-panel {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--border);
}
.search-bar-wrap {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.card-list {
flex: 1;
overflow-y: auto;
padding: var(--list-pad);
display: flex;
flex-direction: column;
gap: var(--list-gap);
}
.empty {
padding: 2.5rem 1.25rem;
color: var(--text-muted);
font-size: 13px;
text-align: center;
margin: 0;
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<script lang="ts">
import { Settings, Plus, Clock, Tag as TagIcon, Search } from 'lucide-svelte';
import type { Tag, TimePeriod } from '$lib/types';
const TIMELINE_ITEMS: { label: string; value: TimePeriod }[] = [
{ label: 'Today', value: 'today' },
{ label: 'Yesterday', value: 'yesterday' },
{ label: 'This week', value: 'this-week' },
{ label: 'Last week', value: 'last-week' },
{ label: 'Older', value: 'older' },
];
let {
tags,
activeFilter = null,
onFilterChange,
onNewSnippet,
onOpenSettings = () => {},
timePeriodFilter = null,
onTimePeriodChange = () => {},
}: {
tags: Tag[];
activeFilter?: string | null;
onFilterChange: (tag: string | null) => void;
onNewSnippet: () => void;
onOpenSettings?: () => void;
timePeriodFilter?: TimePeriod | null;
onTimePeriodChange?: (period: TimePeriod | null) => void;
} = $props();
let tagSearch = $state('');
const filteredTags = $derived(
tagSearch.trim()
? tags.filter(t => t.name.toLowerCase().includes(tagSearch.toLowerCase()))
: tags
);
</script>
<aside class="left-panel">
<!-- <div class="panel-header">
<img src="/logo.svg" alt="Faro Logo" width="64" height="64" />
<span class="brand">Faro</span>
</div> -->
<div class="panel-body">
<!-- Timeline section -->
<div class="section">
<div class="section-label"><Clock size={10} strokeWidth={2.5} />Timeline</div>
{#each TIMELINE_ITEMS as item (item.value)}
<button
class="nav-item"
class:active={timePeriodFilter === item.value}
onclick={() => onTimePeriodChange(item.value)}
>
{item.label}
</button>
{/each}
</div>
<div class="divider"></div>
<!-- Tags section -->
<div class="section tags-section">
<div class="section-label"><TagIcon size={10} strokeWidth={2.5} />Tags</div>
<div class="tag-search-wrap">
<span class="tag-search-icon"><Search size={11} strokeWidth={2} /></span>
<input
class="tag-search"
type="text"
placeholder="Filter tags…"
bind:value={tagSearch}
onkeydown={(e) => { if (e.key === 'Escape') tagSearch = ''; }}
/>
</div>
{#if !tagSearch.trim()}
<button
class="nav-item"
class:active={activeFilter === null && timePeriodFilter === null}
onclick={() => { onFilterChange(null); onTimePeriodChange(null); }}
>
All snippets
</button>
{/if}
{#each filteredTags as tag (tag.id)}
<button
class="nav-item"
class:active={activeFilter === tag.name}
onclick={() => onFilterChange(tag.name)}
>
<span class="tag-name">{tag.name}</span>
{#if tag.count}
<span class="tag-count">{tag.count}</span>
{/if}
</button>
{/each}
{#if tagSearch.trim() && filteredTags.length === 0}
<p class="tag-empty">No tags match</p>
{/if}
</div>
</div>
<div class="panel-footer">
<button class="new-btn" onclick={onNewSnippet}>
<Plus size={14} strokeWidth={2.5} />New Snippet
</button>
<button class="settings-btn" onclick={onOpenSettings} title="Settings">
<Settings size={14} strokeWidth={1.75} />
</button>
</div>
</aside>
<style>
.left-panel {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--border);
background: var(--bg-sidebar);
}
/* .panel-header {
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
display: flex;
flex-direction: column;
align-items: center;
}
.brand {
font-size: 28px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.06em;
} */
.panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
}
.section {
padding: 0.25rem 0;
}
.tags-section {
flex: 1;
overflow-y: auto;
}
.section-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 0.4rem 1rem 0.25rem;
}
.divider {
height: 1px;
background: var(--border);
margin: 0.25rem 0;
}
.tag-search-wrap {
position: relative;
display: flex;
align-items: center;
margin: 0.2rem 0.75rem 0.25rem;
}
.tag-search-icon {
position: absolute;
left: 0.5rem;
display: flex;
align-items: center;
color: var(--text-muted);
pointer-events: none;
}
.tag-search {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 12px;
padding: 0.3rem 0.5rem 0.3rem 1.7rem;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.tag-search:focus {
border-color: var(--accent-border);
background: var(--bg-surface-hover);
}
.tag-search::placeholder { color: var(--text-faint); }
.tag-empty {
font-size: 12px;
color: var(--text-muted);
padding: 0.5rem 1rem;
margin: 0;
}
.nav-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.35rem 1rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 14px;
font-family: var(--font-body);
text-align: left;
cursor: pointer;
border-radius: 0;
transition: background 0.1s, color 0.1s;
}
.nav-item:hover {
background: var(--bg-surface-hover);
color: var(--text);
}
.nav-item.active {
background: var(--accent-tint);
color: var(--accent);
}
.tag-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-count {
font-size: 11px;
color: var(--text-muted);
margin-left: 0.4rem;
flex-shrink: 0;
}
.nav-item.active .tag-count {
color: var(--accent);
}
.panel-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.new-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem;
background: var(--accent-tint);
border: 1px solid var(--accent-border);
border-radius: var(--radius-chip);
color: var(--accent);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
cursor: pointer;
transition: background 0.15s;
}
.new-btn:hover { background: var(--accent-tint-strong); }
.settings-btn {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 15px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
line-height: 1;
}
.settings-btn:hover { border-color: var(--border-strong); color: var(--text); }
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
import SnippetDetail from '$lib/components/SnippetDetail.svelte';
import type { Snippet, CreateSnippetInput, UpdateSnippetInput } from '$lib/types';
let { snippet = null, mode, onSave, onCancel, onDelete, dirty = $bindable(false) }: {
snippet?: Snippet | null;
mode: 'idle' | 'creating' | 'editing';
onSave: (input: CreateSnippetInput | UpdateSnippetInput) => void;
onCancel?: () => void;
onDelete?: () => void;
dirty?: boolean;
} = $props();
</script>
<div class="right-panel">
<SnippetDetail {snippet} {mode} {onSave} {onCancel} {onDelete} bind:dirty />
</div>
<style>
.right-panel { height: 100%; overflow: hidden; }
</style>
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { editorFontFamily } from './editor';
describe('editorFontFamily', () => {
it('uses the body font for text snippets', () => {
expect(editorFontFamily('text')).toBe('var(--font-body)');
});
it('uses the mono font for code snippets', () => {
expect(editorFontFamily('code')).toBe('var(--font-mono)');
});
it('uses the mono font for cli snippets', () => {
expect(editorFontFamily('cli')).toBe('var(--font-mono)');
});
});
+85
View File
@@ -0,0 +1,85 @@
import { EditorView } from '@codemirror/view';
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
LanguageDescription,
} from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { tags as t } from '@lezer/highlight';
import type { Extension } from '@codemirror/state';
import type { ContentType } from './types';
import { cmLanguageName } from './languages';
/** CSS font-family variable for a content type. */
export function editorFontFamily(contentType: ContentType): string {
return contentType === 'text' ? 'var(--font-body)' : 'var(--font-mono)';
}
/** Editor base theme: transparent background, font from CSS vars. */
export function fontTheme(contentType: ContentType): Extension {
const family = editorFontFamily(contentType);
return EditorView.theme(
{
'&': {
backgroundColor: 'transparent',
color: 'var(--text)',
fontSize: 'var(--font-size-editor, 14px)',
height: '100%',
},
'.cm-content': { fontFamily: family, lineHeight: '1.65', padding: '0.6rem' },
'.cm-gutters': { display: 'none' },
'.cm-scroller': { fontFamily: family, overflow: 'auto' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection)',
},
},
);
}
/** Base light/dark flag for CodeMirror built-ins, driven by the active theme. */
export function darkFlag(theme: string): Extension {
return EditorView.theme({}, { dark: theme !== 'light' });
}
const faroHighlight = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--syntax-keyword)' },
{ tag: [t.string, t.special(t.string)], color: 'var(--syntax-string)' },
{ tag: t.comment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
{ tag: [t.number, t.bool, t.null], color: 'var(--syntax-number)' },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: 'var(--syntax-func)' },
{ tag: [t.typeName, t.className], color: 'var(--syntax-type)' },
{ tag: [t.operator, t.punctuation], color: 'var(--syntax-operator)' },
{ tag: t.propertyName, color: 'var(--syntax-attr)' },
{ tag: [t.tagName], color: 'var(--syntax-tag)' },
]);
/** Syntax-coloring extension; colours resolve from the active theme's CSS vars. */
export function highlightExtension(): Extension {
return syntaxHighlighting(faroHighlight);
}
/**
* Resolves a CodeMirror language extension for the given content type/language.
* Loads grammars lazily; returns null when there is nothing to highlight.
*/
export async function resolveLanguage(
contentType: ContentType,
language: string | null,
): Promise<Extension | null> {
if (contentType === 'cli') {
const { shell } = await import('@codemirror/legacy-modes/mode/shell');
return StreamLanguage.define(shell);
}
if (contentType === 'code' && language) {
const name = cmLanguageName(language);
const desc = name ? LanguageDescription.matchLanguageName(languages, name, true) : null;
if (desc) {
const support = await desc.load();
return support;
}
}
return null;
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { detectSnippet, highlightPreviewHtml } from './highlight';
describe('detectSnippet', () => {
it('returns text for empty content', () => {
expect(detectSnippet('')).toEqual({ content_type: 'text', language: null });
});
it('detects a sudo/docker pipeline as cli', () => {
const result = detectSnippet(
"sudo docker logs --tail 50000 zones-module-container_01 | grep 'ERROR'",
);
expect(result.content_type).toBe('cli');
expect(result.language).toBeNull();
});
it('detects a shell prompt line as cli', () => {
const result = detectSnippet('$ npm install react');
expect(result.content_type).toBe('cli');
expect(result.language).toBeNull();
});
it('detects a multi-line shell script as cli', () => {
const result = detectSnippet('cd /tmp\nmkdir build\ntar -czf out.tgz build');
expect(result.content_type).toBe('cli');
});
it('detects a powershell pipeline as cli', () => {
const result = detectSnippet('Get-Process | Where-Object { $_.CPU -gt 10 }');
expect(result.content_type).toBe('cli');
});
it('detects a typescript function as code, not csharp', () => {
const content =
'function greet(name: string): string {\n' +
" const msg = 'Hello, ' + name;\n" +
' return msg;\n' +
'}';
const result = detectSnippet(content);
expect(result.content_type).toBe('code');
expect(['typescript', 'javascript']).toContain(result.language);
});
it('detects a json blob as code/json', () => {
const result = detectSnippet('{\n "name": "faro",\n "version": "0.1.0"\n}');
expect(result.content_type).toBe('code');
expect(result.language).toBe('json');
});
it('detects plain prose as text', () => {
const content =
'This is a note about my project. It has multiple sentences and no code at all.';
const result = detectSnippet(content);
expect(result.content_type).toBe('text');
expect(result.language).toBeNull();
});
});
describe('highlightPreviewHtml', () => {
it('escapes html when no language is given', () => {
expect(highlightPreviewHtml('<b> & "x"', null)).toBe('&lt;b&gt; &amp; &quot;x&quot;');
});
it('returns hljs-classed markup for a known language', () => {
const html = highlightPreviewHtml('const x = 1;', 'javascript');
expect(html).toContain('hljs-');
});
it('falls back to escaped text for an unknown language', () => {
expect(highlightPreviewHtml('<x>', 'cobol')).toBe('&lt;x&gt;');
});
});
+104
View File
@@ -0,0 +1,104 @@
import hljs from 'highlight.js/lib/common';
import type { ContentType } from './types';
import { SUBSET } from './languages';
// ── CLI recognition ──────────────────────────────────────────────────────────
const SHELL_COMMANDS = new Set([
'sudo', 'docker', 'kubectl', 'git', 'npm', 'pnpm', 'yarn', 'node', 'cargo',
'python', 'python3', 'pip', 'pip3', 'ssh', 'scp', 'rsync', 'curl', 'wget',
'cat', 'ls', 'cd', 'pwd', 'grep', 'egrep', 'awk', 'sed', 'rm', 'cp', 'mv',
'mkdir', 'touch', 'chmod', 'chown', 'tar', 'zip', 'unzip', 'apt', 'apt-get',
'yum', 'dnf', 'brew', 'systemctl', 'journalctl', 'service', 'make', 'cmake',
'ping', 'nslookup', 'dig', 'traceroute', 'ps', 'top', 'kill', 'killall',
'export', 'source', 'echo', 'printf', 'find', 'xargs', 'tee', 'head', 'tail',
'df', 'du', 'free', 'uname', 'whoami', 'ifconfig', 'ip', 'netstat', 'ss',
]);
// A leading shell prompt: "$ ", "PS C:\x>", "C:\x>". Bare "#"/">" are excluded
// to avoid matching comments or prose.
const PROMPT_RE = /^\s*(\$|PS[^>\n]*>|[A-Za-z]:\\[^>\n]*>)\s+/;
// Pipes and logical/redirection operators. A bare "-flag" is intentionally NOT
// matched here: it produced false-positive cli detection on prose that mentions
// command flags.
const OPERATOR_RE = /\s\|\s|&&|\|\||>>|2>&1/;
function lineLooksLikeCli(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
if (PROMPT_RE.test(line)) return true;
const first = trimmed.split(/\s+/)[0] ?? '';
if (SHELL_COMMANDS.has(first)) return true;
if (OPERATOR_RE.test(` ${trimmed} `) && /[A-Za-z]/.test(first)) return true;
return false;
}
/** True when the majority of non-empty lines look like shell commands. */
function looksLikeCli(content: string): boolean {
const lines = content.split('\n').filter((l) => l.trim().length > 0);
if (lines.length === 0) return false;
const hits = lines.filter(lineLooksLikeCli).length;
return hits / lines.length >= 0.5;
}
// ── Language detection ───────────────────────────────────────────────────────
const RELEVANCE_THRESHOLD = 4;
function hasTsJsMarkers(content: string): boolean {
return /\b(const|let|export)\b|=>|import\s+.*\sfrom\s/.test(content);
}
// highlightAuto leans toward C#/Java on short TS/JS; nudge it back when the
// content carries obvious TS/JS markers.
function tieBreak(content: string, language: string): string {
if ((language === 'csharp' || language === 'java') && hasTsJsMarkers(content)) {
const tsShaped = /:\s*(string|number|boolean)\b|\binterface\s|\btype\s+\w+\s*=/.test(content);
return tsShaped ? 'typescript' : 'javascript';
}
return language;
}
export function detectSnippet(
content: string,
): { content_type: ContentType; language: string | null } {
const trimmed = content.trim();
if (!trimmed) return { content_type: 'text', language: null };
if (looksLikeCli(trimmed)) {
return { content_type: 'cli', language: null };
}
const result = hljs.highlightAuto(trimmed, [...SUBSET]);
if ((result.relevance ?? 0) >= RELEVANCE_THRESHOLD && result.language) {
return { content_type: 'code', language: tieBreak(trimmed, result.language) };
}
return { content_type: 'text', language: null };
}
// ── Static preview highlighting (used by list cards) ─────────────────────────
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Returns hljs-classed HTML for a known language, otherwise escaped plain text.
* Safe to feed to {@html} — hljs escapes the content it wraps.
*/
export function highlightPreviewHtml(content: string, language: string | null): string {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch {
/* fall through to escaped text */
}
}
return escapeHtml(content);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { SUBSET, cmLanguageName, LANGUAGE_OPTIONS } from './languages';
describe('languages', () => {
it('SUBSET contains the common languages', () => {
expect(SUBSET).toContain('typescript');
expect(SUBSET).toContain('csharp');
expect(SUBSET).toContain('json');
});
it('cmLanguageName maps hljs names to CodeMirror display names', () => {
expect(cmLanguageName('typescript')).toBe('TypeScript');
expect(cmLanguageName('cpp')).toBe('C++');
expect(cmLanguageName('csharp')).toBe('C#');
});
it('cmLanguageName returns null for unknown languages', () => {
expect(cmLanguageName('cobol')).toBeNull();
});
it('LANGUAGE_OPTIONS pairs hljs value with display label', () => {
const ts = LANGUAGE_OPTIONS.find(o => o.value === 'typescript');
expect(ts?.label).toBe('TypeScript');
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Canonical language ids are highlight.js language names. These are what
* detectSnippet() stores as the system tag and what hljs.highlight() expects.
* cmLanguageName() maps them to @codemirror/language-data display names for
* grammar loading.
*/
/** highlight.js language names that detection is allowed to choose from. */
export const SUBSET = [
'typescript', 'javascript', 'python', 'rust', 'go', 'java',
'c', 'cpp', 'csharp', 'ruby', 'php', 'sql', 'json', 'yaml',
'xml', 'css', 'kotlin', 'swift',
] as const;
/** hljs name -> @codemirror/language-data display name. */
const CM_NAMES: Record<string, string> = {
typescript: 'TypeScript',
javascript: 'JavaScript',
python: 'Python',
rust: 'Rust',
go: 'Go',
java: 'Java',
c: 'C',
cpp: 'C++',
csharp: 'C#',
ruby: 'Ruby',
php: 'PHP',
sql: 'SQL',
json: 'JSON',
yaml: 'YAML',
xml: 'HTML',
css: 'CSS',
kotlin: 'Kotlin',
swift: 'Swift',
};
/** Returns the CodeMirror display name for an hljs language, or null. */
export function cmLanguageName(hljsName: string): string | null {
return CM_NAMES[hljsName] ?? null;
}
/** Options for the language override dropdown: { value: hljsName, label }. */
export const LANGUAGE_OPTIONS = SUBSET.map((value) => ({
value,
label: CM_NAMES[value] ?? value,
}));
+69
View File
@@ -0,0 +1,69 @@
import { load } from '@tauri-apps/plugin-store';
import { updateHotkey } from '$lib/api';
const DEFAULTS = {
hotkey: 'Ctrl+Shift+Space',
fontSize: 13,
quickNoteStayOpen: false,
theme: 'dark',
};
const STORE_FILE = 'settings.json';
class SettingsStore {
hotkey = $state(DEFAULTS.hotkey);
fontSize = $state(DEFAULTS.fontSize);
quickNoteStayOpen = $state(DEFAULTS.quickNoteStayOpen);
theme = $state(DEFAULTS.theme);
loaded = $state(false);
async load(): Promise<void> {
const store = await load(STORE_FILE, { autoSave: false, defaults: DEFAULTS });
this.hotkey = (await store.get<string>('hotkey')) ?? DEFAULTS.hotkey;
this.fontSize = (await store.get<number>('fontSize')) ?? DEFAULTS.fontSize;
this.quickNoteStayOpen = (await store.get<boolean>('quickNoteStayOpen')) ?? DEFAULTS.quickNoteStayOpen;
this.theme = (await store.get<string>('theme')) ?? DEFAULTS.theme;
this.loaded = true;
this.applyFontSize();
this.applyTheme();
}
applyFontSize(): void {
document.documentElement.style.setProperty('--font-size-editor', `${this.fontSize}px`);
}
applyTheme(): void {
document.documentElement.setAttribute('data-theme', this.theme);
}
async save(patch: {
hotkey?: string;
fontSize?: number;
quickNoteStayOpen?: boolean;
theme?: string;
}): Promise<void> {
const store = await load(STORE_FILE, { autoSave: false, defaults: DEFAULTS });
if (patch.hotkey !== undefined && patch.hotkey !== this.hotkey) {
await updateHotkey(patch.hotkey);
this.hotkey = patch.hotkey;
await store.set('hotkey', patch.hotkey);
}
if (patch.fontSize !== undefined) {
this.fontSize = patch.fontSize;
this.applyFontSize();
await store.set('fontSize', patch.fontSize);
}
if (patch.quickNoteStayOpen !== undefined) {
this.quickNoteStayOpen = patch.quickNoteStayOpen;
await store.set('quickNoteStayOpen', patch.quickNoteStayOpen);
}
if (patch.theme !== undefined) {
this.theme = patch.theme;
this.applyTheme();
await store.set('theme', patch.theme);
}
await store.save();
}
}
export const settings = new SettingsStore();
+97
View File
@@ -0,0 +1,97 @@
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod } from '$lib/types';
import { listSnippets, createSnippet, updateSnippet, deleteSnippet, listTags, searchSnippets, listSnippetsByPeriod } from '$lib/api';
class SnippetStore {
snippets = $state<Snippet[]>([]);
selectedId = $state<number | null>(null);
tags = $state<Tag[]>([]);
tagFilter = $state<string | null>(null);
searchQuery = $state<string>('');
timePeriodFilter = $state<TimePeriod | null>(null);
loading = $state(false);
error = $state<string | null>(null);
async load(): Promise<void> {
this.loading = true;
this.error = null;
try {
const [list, tagList] = await Promise.all([
this.searchQuery
? searchSnippets(this.searchQuery)
: this.timePeriodFilter
? listSnippetsByPeriod(this.timePeriodFilter)
: listSnippets(this.tagFilter ?? undefined),
listTags(),
]);
this.snippets = list;
this.tags = tagList;
} catch (e) {
this.error = String(e);
} finally {
this.loading = false;
}
}
async create(input: CreateSnippetInput): Promise<Snippet | null> {
this.error = null;
try {
const s = await createSnippet(input);
await this.load();
this.selectedId = s.id;
return s;
} catch (e) {
this.error = String(e);
return null;
}
}
async update(input: UpdateSnippetInput): Promise<void> {
this.error = null;
try {
const updated = await updateSnippet(input);
const idx = this.snippets.findIndex(x => x.id === updated.id);
if (idx >= 0) this.snippets[idx] = updated;
this.tags = await listTags();
} catch (e) {
this.error = String(e);
}
}
async remove(id: number): Promise<void> {
this.error = null;
try {
await deleteSnippet(id);
if (this.selectedId === id) this.selectedId = null;
this.snippets = this.snippets.filter(s => s.id !== id);
} catch (e) {
this.error = String(e);
}
}
select(id: number | null): void {
this.selectedId = id;
}
setTagFilter(tag: string | null): void {
this.tagFilter = tag;
this.searchQuery = '';
this.timePeriodFilter = null;
void this.load();
}
setSearch(query: string): void {
this.searchQuery = query;
this.tagFilter = null;
this.timePeriodFilter = null;
void this.load();
}
setTimePeriodFilter(period: TimePeriod | null): void {
this.timePeriodFilter = period;
this.tagFilter = null;
this.searchQuery = '';
void this.load();
}
}
export const snippets = new SnippetStore();
+48
View File
@@ -0,0 +1,48 @@
export type ContentType = 'code' | 'cli' | 'text';
export interface Tag {
id: number;
name: string;
/** 'user' | 'system' — empty string when loaded without snippet context (list_tags) */
source: string;
count?: number;
}
export type TimePeriod = 'today' | 'yesterday' | 'this-week' | 'last-week' | 'older';
export interface Snippet {
id: number;
title: string;
content: string;
content_type: ContentType;
created_at: string;
updated_at: string;
tags: Tag[];
}
export interface TagInput {
name: string;
source: string;
}
export interface CreateSnippetInput {
title: string;
content: string;
content_type: ContentType;
tags: TagInput[];
}
export interface UpdateSnippetInput {
id: number;
title: string;
content: string;
content_type: ContentType;
tags: TagInput[];
}
export interface Settings {
hotkey: string;
fontSize: number;
quickNoteStayOpen: boolean;
theme: string;
}
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
const { children } = $props();
</script>
{@render children()}
+5
View File
@@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { snippets as store } from '$lib/stores/snippets.svelte';
import { settings } from '$lib/stores/settings.svelte';
import LeftPanel from '$lib/components/layout/LeftPanel.svelte';
import CenterPanel from '$lib/components/layout/CenterPanel.svelte';
import RightPanel from '$lib/components/layout/RightPanel.svelte';
import SettingsModal from '$lib/components/SettingsModal.svelte';
import type { CreateSnippetInput, UpdateSnippetInput, TimePeriod } from '$lib/types';
let mode = $state<'idle' | 'creating' | 'editing'>('idle');
let formDirty = $state(false);
let settingsOpen = $state(false);
let searchInputEl = $state<HTMLInputElement | undefined>();
// Derive the currently selected Snippet object from the store
const selectedSnippet = $derived(
store.snippets.find(s => s.id === store.selectedId) ?? null
);
function isEditableTarget(e: KeyboardEvent) {
const t = e.target as HTMLElement;
return t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable;
}
function handleGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key === 'f') {
e.preventDefault();
searchInputEl?.focus();
searchInputEl?.select();
return;
}
if (ctrl && e.key === 'n') {
if (isEditableTarget(e)) return;
e.preventDefault();
handleNewSnippet();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (isEditableTarget(e)) return;
const snippets = store.snippets;
if (snippets.length === 0) return;
e.preventDefault();
const idx = snippets.findIndex(s => s.id === store.selectedId);
const next = e.key === 'ArrowDown'
? Math.min(snippets.length - 1, idx + 1)
: Math.max(0, idx - 1);
const target = snippets[next];
if (!target || target.id === store.selectedId) return;
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.select(target.id);
mode = 'editing';
requestAnimationFrame(() => {
document.querySelector(`[data-snippet-id="${target.id}"]`)?.scrollIntoView({ block: 'nearest' });
});
}
}
onMount(() => {
let unlisten: (() => void) | null = null;
listen('snippet-created', () => { store.load(); }).then(fn => {
unlisten = fn;
});
store.load();
settings.load();
window.addEventListener('keydown', handleGlobalKeydown);
return () => {
if (unlisten) unlisten();
window.removeEventListener('keydown', handleGlobalKeydown);
};
});
function handleNewSnippet() {
store.select(null);
mode = 'creating';
}
function handleSelectSnippet(id: number) {
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.select(id);
mode = 'editing';
}
async function handleSave(input: CreateSnippetInput | UpdateSnippetInput) {
if (mode === 'creating') {
const s = await store.create(input as CreateSnippetInput);
if (s) mode = 'editing';
} else {
await store.update(input as UpdateSnippetInput);
}
}
function handleCancel() {
formDirty = false;
store.select(null);
mode = 'idle';
}
async function handleDelete() {
if (!store.selectedId) return;
if (!confirm('Delete this snippet? This cannot be undone.')) return;
await store.remove(store.selectedId);
if (!store.error) {
formDirty = false;
mode = 'idle';
}
}
</script>
<div class="app-layout">
<LeftPanel
tags={store.tags}
activeFilter={store.tagFilter}
timePeriodFilter={store.timePeriodFilter}
onFilterChange={(tag) => {
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.setTagFilter(tag);
store.select(null);
mode = 'idle';
}}
onTimePeriodChange={(period: TimePeriod | null) => {
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.setTimePeriodFilter(period);
store.select(null);
mode = 'idle';
}}
onNewSnippet={handleNewSnippet}
onOpenSettings={() => { settingsOpen = true; }}
/>
<CenterPanel
snippets={store.snippets}
selectedId={store.selectedId}
loading={store.loading}
searchQuery={store.searchQuery}
tagFilter={store.tagFilter}
timePeriodFilter={store.timePeriodFilter}
onSelect={handleSelectSnippet}
onSearch={(q) => {
store.setSearch(q);
store.select(null);
mode = 'idle';
}}
bind:searchInputEl
/>
<RightPanel
snippet={selectedSnippet}
{mode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={handleDelete}
bind:dirty={formDirty}
/>
</div>
<SettingsModal open={settingsOpen} onclose={() => { settingsOpen = false; }} />
{#if store.error}
<div class="error-bar">{store.error}</div>
{/if}
<style>
.app-layout {
display: grid;
grid-template-columns: 220px 1fr 360px;
grid-template-rows: 100vh;
height: 100vh;
overflow: hidden;
background: var(--bg);
}
.error-bar {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: var(--danger);
color: var(--accent-contrast);
font-size: 12px;
padding: 0.4rem 1rem;
border-radius: var(--radius-control);
max-width: 80vw;
z-index: 100;
}
</style>
+326
View File
@@ -0,0 +1,326 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { emit } from '@tauri-apps/api/event';
import { createSnippet } from '$lib/api';
import { detectSnippet } from '$lib/highlight';
import { settings } from '$lib/stores/settings.svelte';
import type { ContentType } from '$lib/types';
let title = $state('');
let content = $state('');
let contentType = $state<ContentType>('text');
let tags = $state('');
let systemTags = $state<string[]>([]);
let saved = $state(false);
let saving = $state(false);
let saveError = $state<string | null>(null);
const canSave = $derived(!saving && (title.trim() !== '' || content.trim() !== ''));
function runDetection() {
if (!content.trim()) { systemTags = []; return; }
const { content_type, language } = detectSnippet(content);
contentType = content_type;
systemTags = language ? [language] : [];
}
async function handleSave() {
if (saved || !canSave) return;
saveError = null;
try { runDetection(); } catch { /* detection is best-effort */ }
saving = true;
try {
if (!settings.loaded) await settings.load();
const userTags = tags
.split(',')
.map(t => t.trim())
.filter(Boolean)
.map(name => ({ name, source: 'user' }));
const sysTags = systemTags.map(name => ({ name, source: 'system' }));
await createSnippet({
title: title.trim() || 'Untitled',
content,
content_type: contentType,
tags: [...userTags, ...sysTags],
});
try { await emit('snippet-created', {}); } catch { /* main window may be gone */ }
saved = true;
if (!settings.quickNoteStayOpen) {
await getCurrentWindow().close();
} else {
setTimeout(() => {
title = '';
content = '';
tags = '';
systemTags = [];
contentType = 'text';
saved = false;
saving = false;
}, 800);
}
} catch (err) {
console.error('[quicknote] save failed:', err);
saveError = String(err);
saved = false;
saving = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') getCurrentWindow().close();
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') handleSave();
}
onMount(() => {
settings.load().then(() => {
document.documentElement.style.setProperty('--font-size-editor', `${settings.fontSize}px`);
});
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if saved}
<div class="saved-flash">
<span>Saved</span>
</div>
{:else}
<div class="page">
<input
id="title-input"
type="text"
bind:value={title}
placeholder="Untitled snippet"
class="title-input"
/>
<div class="field">
<label for="type-buttons">Type</label>
<div class="type-buttons" id="type-buttons" role="group">
{#each (['code', 'cli', 'text'] as ContentType[]) as t (t)}
<button
class="type-btn"
class:active={contentType === t}
onclick={() => { contentType = t; }}
>{t}</button>
{/each}
</div>
</div>
<div class="field grow">
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="content-area"
bind:value={content}
onblur={runDetection}
placeholder="Paste your snippet here…"
class="content-area"
spellcheck="false"
autofocus
></textarea>
</div>
<div class="field">
<label for="tags-input">Tags</label>
<input
id="tags-input"
type="text"
bind:value={tags}
placeholder="rust, async, cli (comma-separated)"
class="tags-input"
/>
</div>
{#if saveError}
<div class="save-error">{saveError}</div>
{/if}
<div class="actions">
<span class="hint">Esc to discard · Ctrl+Enter to save</span>
<button
class="btn-primary"
onclick={handleSave}
disabled={!canSave}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{/if}
<style>
:global(html, body) {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
.saved-flash {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.saved-flash span {
font-size: 2rem;
font-weight: 600;
color: var(--accent);
font-family: var(--font-body);
}
.page {
height: 100vh;
display: flex;
flex-direction: column;
padding: 1.25rem;
gap: 0.75rem;
overflow: hidden;
background: var(--bg);
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field.grow {
flex: 1;
min-height: 0;
}
label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.title-input {
background: none;
border: none;
border-bottom: 1px solid transparent;
border-radius: 0;
color: var(--text);
font-family: var(--font-body);
font-size: 18px;
font-weight: 600;
padding: 0.1rem 0;
outline: none;
width: 100%;
transition: border-color 0.15s;
margin-bottom: 0.25rem;
}
.title-input:hover:not(:focus) { border-bottom-color: var(--border); }
.title-input:focus { border-bottom-color: var(--accent-border); }
.title-input::placeholder { color: var(--text-faint); font-weight: 400; }
.tags-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 0.4rem 0.6rem;
outline: none;
width: 100%;
}
.tags-input:focus {
border-color: var(--accent-border);
}
.type-buttons {
display: flex;
gap: 0.35rem;
}
.type-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
padding: 0.2rem 0.75rem;
cursor: pointer;
}
.type-btn.active {
background: var(--accent-tint);
border-color: var(--accent-border);
color: var(--accent);
}
.content-area {
flex: 1;
background: var(--bg-editor);
border: 1px solid var(--border);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--font-size-editor, 13px);
line-height: 1.65;
padding: 0.6rem;
resize: none;
outline: none;
width: 100%;
height: 100%;
}
.content-area:focus {
border-color: var(--accent-border);
}
.save-error {
background: rgba(239, 68, 68, 0.12);
border: 1px solid var(--danger);
border-radius: var(--radius-control);
color: var(--danger);
font-family: var(--font-mono);
font-size: 12px;
padding: 0.4rem 0.6rem;
white-space: pre-wrap;
word-break: break-word;
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.hint {
font-size: 11px;
color: var(--text-faint);
font-family: var(--font-body);
margin-right: auto;
}
.btn-primary {
background: var(--accent-strong);
border: none;
border-radius: var(--radius-control);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
padding: 0.4rem 1.1rem;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.btn-primary:disabled {
opacity: 0.35;
cursor: default;
}
</style>

Some files were not shown because too many files have changed in this diff Show More