feat(snippets): implement Search Snippets command

This commit implements the "Search Snippets" feature, providing a dedicated user interface accessible from the main command palette. The new view allows users to search their snippets by name, keyword, or content and see details like usage statistics. This change adds the necessary backend logic for searching and tracking usage, and a new frontend component.
This commit is contained in:
ByteAtATime 2025-06-25 11:32:02 -07:00
parent 59174dddca
commit e32cc6223d
No known key found for this signature in database
8 changed files with 439 additions and 63 deletions

View file

@ -8,9 +8,9 @@ use std::thread;
const BUFFER_SIZE: usize = 30;
struct ResolvedSnippet {
content: String,
cursor_pos: Option<usize>,
pub struct ResolvedSnippet {
pub content: String,
pub cursor_pos: Option<usize>,
}
pub struct ExpansionEngine {
@ -64,7 +64,7 @@ impl ExpansionEngine {
}
}
if let Ok(snippets) = self.snippet_manager.list_snippets() {
if let Ok(snippets) = self.snippet_manager.list_snippets(None) {
for snippet in snippets {
if buffer.ends_with(&snippet.keyword) {
let (keyword, content) = (snippet.keyword.clone(), snippet.content.clone());
@ -76,56 +76,13 @@ impl ExpansionEngine {
}
}
fn parse_and_resolve_placeholders(&self, raw_content: &str) -> ResolvedSnippet {
let mut resolved_content = String::with_capacity(raw_content.len());
let mut cursor_pos: Option<usize> = None;
let mut last_end = 0;
for (start, _) in raw_content.match_indices('{') {
if start < last_end {
continue;
}
if let Some(end) = raw_content[start..].find('}') {
let placeholder = &raw_content[start + 1..start + end];
resolved_content.push_str(&raw_content[last_end..start]);
let replacement = match placeholder {
"cursor" => {
if cursor_pos.is_none() {
cursor_pos = Some(resolved_content.chars().count());
}
String::new()
}
"clipboard" => Clipboard::new()
.ok()
.and_then(|mut c| c.get_text().ok())
.unwrap_or_default(),
"date" => Local::now().format("%d %b %Y").to_string(),
"time" => Local::now().format("%H:%M").to_string(),
"datetime" => Local::now().format("%d %b %Y at %H:%M").to_string(),
"day" => Local::now().format("%A").to_string(),
_ => raw_content[start..start + end + 1].to_string(),
};
resolved_content.push_str(&replacement);
last_end = start + end + 1;
}
}
resolved_content.push_str(&raw_content[last_end..]);
ResolvedSnippet {
content: resolved_content,
cursor_pos,
}
}
fn expand_snippet(&self, keyword: &str, content: &str) {
let mut backspaces = String::new();
for _ in 0..keyword.len() {
backspaces.push('\u{8}');
}
let resolved = self.parse_and_resolve_placeholders(content);
let resolved = parse_and_resolve_placeholders(content);
let content_to_paste = resolved.content;
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
@ -159,3 +116,46 @@ impl ExpansionEngine {
buffer.clear();
}
}
pub fn parse_and_resolve_placeholders(raw_content: &str) -> ResolvedSnippet {
let mut resolved_content = String::with_capacity(raw_content.len());
let mut cursor_pos: Option<usize> = None;
let mut last_end = 0;
for (start, _) in raw_content.match_indices('{') {
if start < last_end {
continue;
}
if let Some(end) = raw_content[start..].find('}') {
let placeholder = &raw_content[start + 1..start + end];
resolved_content.push_str(&raw_content[last_end..start]);
let replacement = match placeholder {
"cursor" => {
if cursor_pos.is_none() {
cursor_pos = Some(resolved_content.chars().count());
}
String::new()
}
"clipboard" => Clipboard::new()
.ok()
.and_then(|mut c| c.get_text().ok())
.unwrap_or_default(),
"date" => Local::now().format("%d %b %Y").to_string(),
"time" => Local::now().format("%H:%M").to_string(),
"datetime" => Local::now().format("%d %b %Y at %H:%M").to_string(),
"day" => Local::now().format("%A").to_string(),
_ => raw_content[start..start + end + 1].to_string(),
};
resolved_content.push_str(&replacement);
last_end = start + end + 1;
}
}
resolved_content.push_str(&raw_content[last_end..]);
ResolvedSnippet {
content: resolved_content,
cursor_pos,
}
}

View file

@ -36,6 +36,25 @@ impl SnippetManager {
)",
[],
)?;
let mut stmt = db.prepare("PRAGMA table_info(snippets)")?;
let columns: Vec<String> = stmt
.query_map([], |row| row.get(1))?
.collect::<Result<Vec<_>, _>>()?;
if !columns.contains(&"times_used".to_string()) {
db.execute(
"ALTER TABLE snippets ADD COLUMN times_used INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
if !columns.contains(&"last_used_at".to_string()) {
db.execute(
"ALTER TABLE snippets ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
Ok(())
}
@ -48,19 +67,34 @@ impl SnippetManager {
let db = self.db.lock().unwrap();
let now = Utc::now().timestamp();
db.execute(
"INSERT INTO snippets (name, keyword, content, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?4)",
"INSERT INTO snippets (name, keyword, content, created_at, updated_at, times_used, last_used_at)
VALUES (?1, ?2, ?3, ?4, ?4, 0, 0)",
params![name, keyword, content, now],
)?;
Ok(db.last_insert_rowid())
}
pub fn list_snippets(&self) -> Result<Vec<Snippet>, AppError> {
pub fn list_snippets(&self, search_term: Option<String>) -> Result<Vec<Snippet>, AppError> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare("SELECT id, name, keyword, content, created_at, updated_at FROM snippets ORDER BY name ASC")?;
let snippets_iter = stmt.query_map([], |row| {
let mut query = "SELECT id, name, keyword, content, created_at, updated_at, times_used, last_used_at FROM snippets".to_string();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![];
if let Some(term) = search_term {
if !term.is_empty() {
query.push_str(" WHERE name LIKE ?1 OR keyword LIKE ?1 OR content LIKE ?1");
params_vec.push(Box::new(format!("%{}%", term)));
}
}
query.push_str(" ORDER BY updated_at DESC");
let params_ref: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
let mut stmt = db.prepare(&query)?;
let snippets_iter = stmt.query_map(&params_ref[..], |row| {
let created_at_ts: i64 = row.get(4)?;
let updated_at_ts: i64 = row.get(5)?;
let last_used_at_ts: i64 = row.get(7)?;
Ok(Snippet {
id: row.get(0)?,
name: row.get(1)?,
@ -68,6 +102,8 @@ impl SnippetManager {
content: row.get(3)?,
created_at: DateTime::from_timestamp(created_at_ts, 0).unwrap_or_default(),
updated_at: DateTime::from_timestamp(updated_at_ts, 0).unwrap_or_default(),
times_used: row.get(6)?,
last_used_at: DateTime::from_timestamp(last_used_at_ts, 0).unwrap_or_default(),
})
})?;
@ -98,12 +134,23 @@ impl SnippetManager {
Ok(())
}
pub fn snippet_was_used(&self, id: i64) -> Result<(), AppError> {
let db = self.db.lock().unwrap();
let now = Utc::now().timestamp();
db.execute(
"UPDATE snippets SET times_used = times_used + 1, last_used_at = ?1 WHERE id = ?2",
params![now, id],
)?;
Ok(())
}
pub fn find_snippet_by_keyword(&self, keyword: &str) -> Result<Option<Snippet>, AppError> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare("SELECT id, name, keyword, content, created_at, updated_at FROM snippets WHERE keyword = ?1")?;
let mut stmt = db.prepare("SELECT id, name, keyword, content, created_at, updated_at, times_used, last_used_at FROM snippets WHERE keyword = ?1")?;
let mut rows = stmt.query_map(params![keyword], |row| {
let created_at_ts: i64 = row.get(4)?;
let updated_at_ts: i64 = row.get(5)?;
let last_used_at_ts: i64 = row.get(7)?;
Ok(Snippet {
id: row.get(0)?,
name: row.get(1)?,
@ -111,6 +158,8 @@ impl SnippetManager {
content: row.get(3)?,
created_at: DateTime::from_timestamp(created_at_ts, 0).unwrap_or_default(),
updated_at: DateTime::from_timestamp(updated_at_ts, 0).unwrap_or_default(),
times_used: row.get(6)?,
last_used_at: DateTime::from_timestamp(last_used_at_ts, 0).unwrap_or_default(),
})
})?;
@ -120,4 +169,4 @@ impl SnippetManager {
Ok(None)
}
}
}
}

View file

@ -5,6 +5,7 @@ pub mod types;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use types::Snippet;
@ -36,9 +37,9 @@ pub fn create_snippet(
}
#[tauri::command]
pub fn list_snippets(app: AppHandle) -> Result<Vec<Snippet>, String> {
pub fn list_snippets(app: AppHandle, search_term: Option<String>) -> Result<Vec<Snippet>, String> {
app.state::<manager::SnippetManager>()
.list_snippets()
.list_snippets(search_term)
.map_err(|e| e.to_string())
}
@ -62,6 +63,46 @@ pub fn delete_snippet(app: AppHandle, id: i64) -> Result<(), String> {
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn snippet_was_used(app: AppHandle, id: i64) -> Result<(), String> {
app.state::<manager::SnippetManager>()
.snippet_was_used(id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn paste_snippet_content(app: AppHandle, content: String) -> Result<(), String> {
let input_manager = app
.state::<Arc<dyn input_manager::InputManager>>()
.inner()
.clone();
let resolved = engine::parse_and_resolve_placeholders(&content);
let content_to_paste = resolved.content;
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
content_to_paste.chars().count() - pos
} else {
0
};
std::thread::spawn(move || {
if let Err(e) = input_manager.inject_text(&content_to_paste) {
eprintln!("Failed to inject snippet content: {}", e);
}
if chars_to_move_left > 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
if let Err(e) =
input_manager.inject_key_clicks(enigo::Key::LeftArrow, chars_to_move_left)
{
eprintln!("Failed to inject cursor movement: {}", e);
}
}
});
Ok(())
}
#[tauri::command]
pub fn import_snippets(app: AppHandle, json_content: String) -> Result<ImportResult, String> {
let snippets: Vec<ImportSnippet> =
@ -90,4 +131,4 @@ pub fn import_snippets(app: AppHandle, json_content: String) -> Result<ImportRes
snippets_added,
duplicates_skipped,
})
}
}

View file

@ -10,4 +10,6 @@ pub struct Snippet {
pub content: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub times_used: i32,
pub last_used_at: DateTime<Utc>,
}