Initial commit
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
---
|
||||||
|
|
||||||
|
## 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) |
|
||||||
|

|
||||||
|
---
|
||||||
|
|
||||||
|
## 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, 10–24px. 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)
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.2 MiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod snippets;
|
||||||
|
pub mod tags;
|
||||||
|
pub mod settings;
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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('<b> & "x"');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<x>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
const { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||