diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 7fa1bab..16272b4 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -55,4 +55,13 @@ impl std::fmt::Display for AppError { } } -impl std::error::Error for AppError {} \ No newline at end of file +impl std::error::Error for AppError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + AppError::Io(err) => Some(err), + AppError::Rusqlite(err) => Some(err), + AppError::Keyring(err) => Some(err), + _ => None, + } + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 892d83b..b868788 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,9 +8,11 @@ mod error; mod extensions; mod filesystem; mod oauth; +mod quicklinks; use crate::{app::App, cache::AppCache}; use browser_extension::WsState; +use quicklinks::QuicklinkManager; use selection::get_text; use std::process::Command; use std::thread; @@ -144,7 +146,12 @@ pub fn run() { clipboard_history::history_delete_item, clipboard_history::history_toggle_pin, clipboard_history::history_clear_all, - clipboard_history::history_item_was_copied + clipboard_history::history_item_was_copied, + quicklinks::create_quicklink, + quicklinks::list_quicklinks, + quicklinks::update_quicklink, + quicklinks::delete_quicklink, + quicklinks::execute_quicklink ]) .setup(|app| { let app_handle = app.handle().clone(); @@ -153,6 +160,10 @@ pub fn run() { let app_handle_for_history = app.handle().clone(); clipboard_history::init(app_handle_for_history); + let quicklink_manager = QuicklinkManager::new(app.handle().clone())?; + quicklink_manager.init_db()?; + app.manage(quicklink_manager); + setup_background_refresh(); setup_global_shortcut(app)?; diff --git a/src-tauri/src/quicklinks.rs b/src-tauri/src/quicklinks.rs new file mode 100644 index 0000000..7def54a --- /dev/null +++ b/src-tauri/src/quicklinks.rs @@ -0,0 +1,168 @@ +use crate::error::AppError; +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Result as RusqliteResult}; +use serde::Serialize; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; +use tauri_plugin_opener::{open_path, open_url}; + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Quicklink { + id: i64, + name: String, + link: String, + application: Option, + icon: Option, + created_at: DateTime, + updated_at: DateTime, +} + +pub struct QuicklinkManager { + db: Mutex, +} + +impl QuicklinkManager { + pub fn new(app_handle: AppHandle) -> Result { + let data_dir = app_handle + .path() + .app_local_data_dir() + .map_err(|_| AppError::DirectoryNotFound)?; + let db_path = data_dir.join("quicklinks.sqlite"); + let db = Connection::open(db_path)?; + + Ok(Self { + db: Mutex::new(db), + }) + } + + pub fn init_db(&self) -> RusqliteResult<()> { + let db = self.db.lock().unwrap(); + db.execute( + "CREATE TABLE IF NOT EXISTS quicklinks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + link TEXT NOT NULL, + application TEXT, + icon TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + Ok(()) + } + + fn create_quicklink( + &self, + name: String, + link: String, + application: Option, + icon: Option, + ) -> Result { + let db = self.db.lock().unwrap(); + let now = Utc::now().timestamp(); + db.execute( + "INSERT INTO quicklinks (name, link, application, icon, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + params![name, link, application, icon, now, now], + )?; + Ok(db.last_insert_rowid()) + } + + fn list_quicklinks(&self) -> Result, AppError> { + let db = self.db.lock().unwrap(); + let mut stmt = db.prepare("SELECT id, name, link, application, icon, created_at, updated_at FROM quicklinks ORDER BY name ASC")?; + let quicklinks_iter = stmt.query_map([], |row| { + let created_at_ts: i64 = row.get(5)?; + let updated_at_ts: i64 = row.get(6)?; + Ok(Quicklink { + id: row.get(0)?, + name: row.get(1)?, + link: row.get(2)?, + application: row.get(3)?, + icon: row.get(4)?, + created_at: DateTime::from_timestamp(created_at_ts, 0).unwrap_or_default(), + updated_at: DateTime::from_timestamp(updated_at_ts, 0).unwrap_or_default(), + }) + })?; + + quicklinks_iter.collect::, _>>().map_err(|e| e.into()) + } + + fn update_quicklink( + &self, + id: i64, + name: String, + link: String, + application: Option, + icon: Option, + ) -> Result<(), AppError> { + let db = self.db.lock().unwrap(); + let now = Utc::now().timestamp(); + db.execute( + "UPDATE quicklinks SET name = ?, link = ?, application = ?, icon = ?, updated_at = ? + WHERE id = ?", + params![name, link, application, icon, now, id], + )?; + Ok(()) + } + + fn delete_quicklink(&self, id: i64) -> Result<(), AppError> { + let db = self.db.lock().unwrap(); + db.execute("DELETE FROM quicklinks WHERE id = ?", params![id])?; + Ok(()) + } +} + +#[tauri::command] +pub fn create_quicklink( + app: AppHandle, + name: String, + link: String, + application: Option, + icon: Option, +) -> Result { + app.state::() + .create_quicklink(name, link, application, icon) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn list_quicklinks(app: AppHandle) -> Result, String> { + app.state::() + .list_quicklinks() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_quicklink( + app: AppHandle, + id: i64, + name: String, + link: String, + application: Option, + icon: Option, +) -> Result<(), String> { + app.state::() + .update_quicklink(id, name, link, application, icon) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_quicklink(app: AppHandle, id: i64) -> Result<(), String> { + app.state::() + .delete_quicklink(id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn execute_quicklink(link: String, application: Option) -> Result<(), String> { + if let Some(app_name) = application { + open_path(link, Some(app_name)).map_err(|e| e.to_string()) + } else if link.starts_with("http://") || link.starts_with("https://") { + open_url(link, None::).map_err(|e| e.to_string()) + } else { + open_path(link, None::).map_err(|e| e.to_string()) + } +} \ No newline at end of file diff --git a/src/lib/components/BaseList.svelte b/src/lib/components/BaseList.svelte index 869d49a..1262503 100644 --- a/src/lib/components/BaseList.svelte +++ b/src/lib/components/BaseList.svelte @@ -7,12 +7,18 @@ itemSnippet: Snippet<[{ item: T; isSelected: boolean; onclick: () => void }]>; autofocus?: boolean; onenter: (item: T) => void; + selectedIndex?: number; + listElement?: HTMLElement | null; }; - let { items, itemSnippet, autofocus = false, onenter }: Props = $props(); - - let selectedIndex = $state(0); - let listElement: HTMLElement | null = $state(null); + let { + items, + itemSnippet, + autofocus = false, + onenter, + selectedIndex = $bindable(0), + listElement = $bindable() + }: Props = $props(); $effect(() => { if (selectedIndex >= items.length) { diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 64cb0ad..18e7ee4 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -9,68 +9,57 @@ import path from 'path'; import { create, all } from 'mathjs'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; + import type { Quicklink } from '$lib/quicklinks.svelte'; + import { tick } from 'svelte'; type Props = { plugins: PluginInfo[]; onRunPlugin: (plugin: PluginInfo) => void; installedApps?: any[]; + quicklinks?: Quicklink[]; }; type UnifiedItem = | { type: 'calculator'; id: 'calculator'; value: string; result: string; resultType: string } | { type: 'plugin'; id: string; data: PluginInfo } - | { type: 'app'; id: string; data: any }; + | { type: 'app'; id: string; data: any } + | { type: 'quicklink'; id: number; data: Quicklink }; - let { plugins, onRunPlugin, installedApps = [] }: Props = $props(); + let { plugins, onRunPlugin, installedApps = [], quicklinks = [] }: Props = $props(); let searchText = $state(''); + let quicklinkArgument = $state(''); + let selectedIndex = $state(0); + let listElement: HTMLElement | null = $state(null); + let searchInputEl: HTMLInputElement | null = $state(null); + let argumentInputEl: HTMLInputElement | null = $state(null); + let selectedQuicklinkForArgument: Quicklink | null = $state(null); const math = create(all); const calculatorResult = $derived.by(() => { - if (!searchText.trim()) { + if (!searchText.trim() || selectedQuicklinkForArgument) { return null; } try { const result = math.evaluate(searchText.trim()); - - if (typeof result === 'function' || typeof result === 'undefined') { - return null; - } - + if (typeof result === 'function' || typeof result === 'undefined') return null; let resultString = math.format(result, { precision: 14 }); - - if (resultString === searchText.trim()) { - return null; - } - - return { - value: resultString, - type: math.typeOf(result) - }; + if (resultString === searchText.trim()) return null; + return { value: resultString, type: math.typeOf(result) }; } catch (error) { return null; } }); const pluginFuse = $derived( - new Fuse(plugins, { - keys: [ - { name: 'title', weight: 0.7 }, - { name: 'description', weight: 0.2 }, - { name: 'pluginName', weight: 0.1 } - ], - threshold: 0.4 - }) + new Fuse(plugins, { keys: ['title', 'description', 'pluginName'], threshold: 0.4 }) ); - const appFuse = $derived( - new Fuse(installedApps, { - keys: ['name', 'comment', 'exec'], - threshold: 0.4 - }) + new Fuse(installedApps, { keys: ['name', 'comment', 'exec'], threshold: 0.4 }) ); + const quicklinkFuse = $derived(new Fuse(quicklinks, { keys: ['name', 'link'], threshold: 0.4 })); const displayItems = $derived.by(() => { const items: UnifiedItem[] = []; @@ -85,27 +74,54 @@ }); } - const filteredPlugins = searchText - ? pluginFuse.search(searchText) - : plugins.map((p) => ({ item: p })); - const uniquePlugins = [...new Map(filteredPlugins.map((p) => [p.item.pluginPath, p])).values()]; + const filterAndMap = ( + data: any[], + fuse: Fuse, + type: 'plugin' | 'app' | 'quicklink', + idKey: string + ) => { + const filtered = searchText ? fuse.search(searchText) : data.map((item) => ({ item })); + const unique = [...new Map(filtered.map((res) => [res.item[idKey], res.item])).values()]; + return unique.map((item) => ({ type, id: item[idKey], data: item })); + }; - const filteredApps = searchText - ? appFuse.search(searchText) - : installedApps.map((a) => ({ item: a })); - const uniqueApps = [...new Map(filteredApps.map((a) => [a.item.exec, a])).values()]; + items.push(...filterAndMap(plugins, pluginFuse, 'plugin', 'pluginPath')); + items.push(...filterAndMap(installedApps, appFuse, 'app', 'exec')); + items.push(...filterAndMap(quicklinks, quicklinkFuse, 'quicklink', 'id')); - items.push( - ...uniquePlugins.map( - (p) => ({ type: 'plugin', id: p.item.pluginPath, data: p.item }) as const - ) - ); - items.push(...uniqueApps.map((a) => ({ type: 'app', id: a.item.exec, data: a.item }) as const)); - - return items; + return items as UnifiedItem[]; }); - function handleEnter(item: UnifiedItem) { + $effect(() => { + const selectedItem = displayItems[selectedIndex]; + if (selectedItem?.type === 'quicklink') { + selectedQuicklinkForArgument = selectedItem.data; + searchInputEl?.focus(); + } else { + selectedQuicklinkForArgument = null; + } + }); + + function resetState() { + searchText = ''; + quicklinkArgument = ''; + selectedIndex = 0; + selectedQuicklinkForArgument = null; + tick().then(() => searchInputEl?.focus()); + } + + async function executeQuicklink(quicklink: Quicklink, argument?: string) { + const finalLink = argument + ? quicklink.link.replace(/\{argument\}/g, encodeURIComponent(argument)) + : quicklink.link.replace(/\{argument\}/g, ''); + await invoke('execute_quicklink', { + link: finalLink, + application: quicklink.application + }); + resetState(); + } + + async function handleEnter(item: UnifiedItem) { switch (item.type) { case 'calculator': writeText(item.result); @@ -118,21 +134,78 @@ invoke('launch_app', { exec: item.data.exec }).catch(console.error); } break; + case 'quicklink': + if (item.data.link.includes('{argument}')) { + await tick(); + argumentInputEl?.focus(); + } else { + executeQuicklink(item.data); + } + break; + } + } + + async function handleArgumentKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + if (selectedQuicklinkForArgument) { + await executeQuicklink(selectedQuicklinkForArgument, quicklinkArgument); + } + } else if (e.key === 'Escape' || (e.key === 'Backspace' && quicklinkArgument === '')) { + e.preventDefault(); + quicklinkArgument = ''; + await tick(); + searchInputEl?.focus(); } }
- +
+ + + {#if selectedQuicklinkForArgument} +
+ {searchText} + +
+
+ + + +
+
+
+ {/if} +
+
- + {#snippet itemSnippet({ item, isSelected, onclick })} {#if item.type === 'calculator'} {/snippet} + {:else if item.type === 'quicklink'} + + {#snippet accessories()} + + Quicklink + + {/snippet} + {/if} {/snippet} diff --git a/src/lib/components/QuicklinkForm.svelte b/src/lib/components/QuicklinkForm.svelte new file mode 100644 index 0000000..29f8453 --- /dev/null +++ b/src/lib/components/QuicklinkForm.svelte @@ -0,0 +1,134 @@ + + +
+
+ +
+ +

{quicklink ? 'Edit Quicklink' : 'Create Quicklink'}

+
+
+
+
+
+ + +
+ +
+ +
+