mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-09-12 17:06:26 +00:00
feat: implement quicklinks
This commit is contained in:
parent
a0521b7b4d
commit
1ae8a571cb
8 changed files with 576 additions and 63 deletions
|
@ -55,4 +55,13 @@ impl std::fmt::Display for AppError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for AppError {}
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,9 +8,11 @@ mod error;
|
||||||
mod extensions;
|
mod extensions;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
|
mod quicklinks;
|
||||||
|
|
||||||
use crate::{app::App, cache::AppCache};
|
use crate::{app::App, cache::AppCache};
|
||||||
use browser_extension::WsState;
|
use browser_extension::WsState;
|
||||||
|
use quicklinks::QuicklinkManager;
|
||||||
use selection::get_text;
|
use selection::get_text;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
@ -144,7 +146,12 @@ pub fn run() {
|
||||||
clipboard_history::history_delete_item,
|
clipboard_history::history_delete_item,
|
||||||
clipboard_history::history_toggle_pin,
|
clipboard_history::history_toggle_pin,
|
||||||
clipboard_history::history_clear_all,
|
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| {
|
.setup(|app| {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
|
@ -153,6 +160,10 @@ pub fn run() {
|
||||||
let app_handle_for_history = app.handle().clone();
|
let app_handle_for_history = app.handle().clone();
|
||||||
clipboard_history::init(app_handle_for_history);
|
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_background_refresh();
|
||||||
setup_global_shortcut(app)?;
|
setup_global_shortcut(app)?;
|
||||||
|
|
||||||
|
|
168
src-tauri/src/quicklinks.rs
Normal file
168
src-tauri/src/quicklinks.rs
Normal file
|
@ -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<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct QuicklinkManager {
|
||||||
|
db: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuicklinkManager {
|
||||||
|
pub fn new(app_handle: AppHandle) -> Result<Self, AppError> {
|
||||||
|
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<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
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<Vec<Quicklink>, 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::<Result<Vec<_>, _>>().map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_quicklink(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
link: String,
|
||||||
|
application: Option<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
) -> 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<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
) -> Result<i64, String> {
|
||||||
|
app.state::<QuicklinkManager>()
|
||||||
|
.create_quicklink(name, link, application, icon)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_quicklinks(app: AppHandle) -> Result<Vec<Quicklink>, String> {
|
||||||
|
app.state::<QuicklinkManager>()
|
||||||
|
.list_quicklinks()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_quicklink(
|
||||||
|
app: AppHandle,
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
link: String,
|
||||||
|
application: Option<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.state::<QuicklinkManager>()
|
||||||
|
.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::<QuicklinkManager>()
|
||||||
|
.delete_quicklink(id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn execute_quicklink(link: String, application: Option<String>) -> 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::<String>).map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
open_path(link, None::<String>).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,12 +7,18 @@
|
||||||
itemSnippet: Snippet<[{ item: T; isSelected: boolean; onclick: () => void }]>;
|
itemSnippet: Snippet<[{ item: T; isSelected: boolean; onclick: () => void }]>;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
onenter: (item: T) => void;
|
onenter: (item: T) => void;
|
||||||
|
selectedIndex?: number;
|
||||||
|
listElement?: HTMLElement | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { items, itemSnippet, autofocus = false, onenter }: Props = $props();
|
let {
|
||||||
|
items,
|
||||||
let selectedIndex = $state(0);
|
itemSnippet,
|
||||||
let listElement: HTMLElement | null = $state(null);
|
autofocus = false,
|
||||||
|
onenter,
|
||||||
|
selectedIndex = $bindable(0),
|
||||||
|
listElement = $bindable()
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedIndex >= items.length) {
|
if (selectedIndex >= items.length) {
|
||||||
|
|
|
@ -9,68 +9,57 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { create, all } from 'mathjs';
|
import { create, all } from 'mathjs';
|
||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
|
import type { Quicklink } from '$lib/quicklinks.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
plugins: PluginInfo[];
|
plugins: PluginInfo[];
|
||||||
onRunPlugin: (plugin: PluginInfo) => void;
|
onRunPlugin: (plugin: PluginInfo) => void;
|
||||||
installedApps?: any[];
|
installedApps?: any[];
|
||||||
|
quicklinks?: Quicklink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UnifiedItem =
|
type UnifiedItem =
|
||||||
| { type: 'calculator'; id: 'calculator'; value: string; result: string; resultType: string }
|
| { type: 'calculator'; id: 'calculator'; value: string; result: string; resultType: string }
|
||||||
| { type: 'plugin'; id: string; data: PluginInfo }
|
| { 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 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 math = create(all);
|
||||||
|
|
||||||
const calculatorResult = $derived.by(() => {
|
const calculatorResult = $derived.by(() => {
|
||||||
if (!searchText.trim()) {
|
if (!searchText.trim() || selectedQuicklinkForArgument) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = math.evaluate(searchText.trim());
|
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 });
|
let resultString = math.format(result, { precision: 14 });
|
||||||
|
if (resultString === searchText.trim()) return null;
|
||||||
if (resultString === searchText.trim()) {
|
return { value: resultString, type: math.typeOf(result) };
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: resultString,
|
|
||||||
type: math.typeOf(result)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const pluginFuse = $derived(
|
const pluginFuse = $derived(
|
||||||
new Fuse(plugins, {
|
new Fuse(plugins, { keys: ['title', 'description', 'pluginName'], threshold: 0.4 })
|
||||||
keys: [
|
|
||||||
{ name: 'title', weight: 0.7 },
|
|
||||||
{ name: 'description', weight: 0.2 },
|
|
||||||
{ name: 'pluginName', weight: 0.1 }
|
|
||||||
],
|
|
||||||
threshold: 0.4
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appFuse = $derived(
|
const appFuse = $derived(
|
||||||
new Fuse(installedApps, {
|
new Fuse(installedApps, { keys: ['name', 'comment', 'exec'], threshold: 0.4 })
|
||||||
keys: ['name', 'comment', 'exec'],
|
|
||||||
threshold: 0.4
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
const quicklinkFuse = $derived(new Fuse(quicklinks, { keys: ['name', 'link'], threshold: 0.4 }));
|
||||||
|
|
||||||
const displayItems = $derived.by(() => {
|
const displayItems = $derived.by(() => {
|
||||||
const items: UnifiedItem[] = [];
|
const items: UnifiedItem[] = [];
|
||||||
|
@ -85,27 +74,54 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPlugins = searchText
|
const filterAndMap = (
|
||||||
? pluginFuse.search(searchText)
|
data: any[],
|
||||||
: plugins.map((p) => ({ item: p }));
|
fuse: Fuse<any>,
|
||||||
const uniquePlugins = [...new Map(filteredPlugins.map((p) => [p.item.pluginPath, p])).values()];
|
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
|
items.push(...filterAndMap(plugins, pluginFuse, 'plugin', 'pluginPath'));
|
||||||
? appFuse.search(searchText)
|
items.push(...filterAndMap(installedApps, appFuse, 'app', 'exec'));
|
||||||
: installedApps.map((a) => ({ item: a }));
|
items.push(...filterAndMap(quicklinks, quicklinkFuse, 'quicklink', 'id'));
|
||||||
const uniqueApps = [...new Map(filteredApps.map((a) => [a.item.exec, a])).values()];
|
|
||||||
|
|
||||||
items.push(
|
return items as UnifiedItem[];
|
||||||
...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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
switch (item.type) {
|
||||||
case 'calculator':
|
case 'calculator':
|
||||||
writeText(item.result);
|
writeText(item.result);
|
||||||
|
@ -118,21 +134,78 @@
|
||||||
invoke('launch_app', { exec: item.data.exec }).catch(console.error);
|
invoke('launch_app', { exec: item.data.exec }).catch(console.error);
|
||||||
}
|
}
|
||||||
break;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="bg-background text-foreground flex h-screen flex-col">
|
<main class="bg-background text-foreground flex h-screen flex-col">
|
||||||
<header class="flex h-12 shrink-0 items-center border-b px-2">
|
<header class="flex h-12 shrink-0 items-center border-b px-2">
|
||||||
|
<div class="relative flex w-full items-center">
|
||||||
<Input
|
<Input
|
||||||
class="rounded-none border-none !bg-transparent pr-0"
|
class="w-full rounded-none border-none !bg-transparent pr-0 text-base"
|
||||||
placeholder="Search for extensions and commands..."
|
placeholder={selectedQuicklinkForArgument ? '' : 'Search for extensions and commands...'}
|
||||||
bind:value={searchText}
|
bind:value={searchText}
|
||||||
|
bind:ref={searchInputEl}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if selectedQuicklinkForArgument}
|
||||||
|
<div class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center">
|
||||||
|
<span class="whitespace-pre text-transparent">{searchText}</span>
|
||||||
|
<span class="w-2"></span>
|
||||||
|
<div class="pointer-events-auto">
|
||||||
|
<div class="inline-grid items-center">
|
||||||
|
<span
|
||||||
|
class="invisible col-start-1 row-start-1 px-3 text-base whitespace-pre md:text-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{quicklinkArgument || 'Query'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
class="col-start-1 row-start-1 h-7 w-full"
|
||||||
|
placeholder="Query"
|
||||||
|
bind:value={quicklinkArgument}
|
||||||
|
bind:ref={argumentInputEl}
|
||||||
|
onkeydown={handleArgumentKeydown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grow overflow-y-auto">
|
<div class="grow overflow-y-auto">
|
||||||
<BaseList items={displayItems} onenter={handleEnter}>
|
<BaseList
|
||||||
|
items={displayItems}
|
||||||
|
onenter={handleEnter}
|
||||||
|
bind:selectedIndex
|
||||||
|
bind:listElement
|
||||||
|
autofocus={!selectedQuicklinkForArgument}
|
||||||
|
>
|
||||||
{#snippet itemSnippet({ item, isSelected, onclick })}
|
{#snippet itemSnippet({ item, isSelected, onclick })}
|
||||||
{#if item.type === 'calculator'}
|
{#if item.type === 'calculator'}
|
||||||
<Calculator
|
<Calculator
|
||||||
|
@ -170,6 +243,20 @@
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListItemBase>
|
</ListItemBase>
|
||||||
|
{:else if item.type === 'quicklink'}
|
||||||
|
<ListItemBase
|
||||||
|
title={item.data.name}
|
||||||
|
subtitle={item.data.link.replace(/\{argument\}/g, '...')}
|
||||||
|
icon={item.data.icon ?? 'link-16'}
|
||||||
|
{isSelected}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{#snippet accessories()}
|
||||||
|
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">
|
||||||
|
Quicklink
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</ListItemBase>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</BaseList>
|
</BaseList>
|
||||||
|
|
134
src/lib/components/QuicklinkForm.svelte
Normal file
134
src/lib/components/QuicklinkForm.svelte
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { ArrowLeft, Save } from '@lucide/svelte';
|
||||||
|
import { quicklinksStore, type Quicklink } from '$lib/quicklinks.svelte';
|
||||||
|
|
||||||
|
type AppInfo = {
|
||||||
|
name: string;
|
||||||
|
exec: string;
|
||||||
|
icon_path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
quicklink?: Quicklink;
|
||||||
|
onBack: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { quicklink, onBack, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let name = $state(quicklink?.name ?? '');
|
||||||
|
let link = $state(quicklink?.link ?? '');
|
||||||
|
let application = $state(quicklink?.application ?? 'Default');
|
||||||
|
let icon = $state(quicklink?.icon ?? 'link-16');
|
||||||
|
|
||||||
|
let applications = $state<AppInfo[]>([]);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
applications = (await invoke('get_installed_apps')) as AppInfo[];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch installed apps:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
error = 'Name cannot be empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
link,
|
||||||
|
application: application === 'Default' ? undefined : application,
|
||||||
|
icon: icon === 'link-16' ? undefined : icon
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (quicklink) {
|
||||||
|
await quicklinksStore.update(quicklink.id, data);
|
||||||
|
} else {
|
||||||
|
await quicklinksStore.create(data);
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="bg-background text-foreground flex h-screen flex-col">
|
||||||
|
<header class="flex h-12 shrink-0 items-center border-b px-2">
|
||||||
|
<Button variant="ghost" size="icon" onclick={onBack}>
|
||||||
|
<ArrowLeft class="size-5" />
|
||||||
|
</Button>
|
||||||
|
<div class="flex items-center gap-3 px-2">
|
||||||
|
<Icon icon="link-16" class="size-6" />
|
||||||
|
<h1 class="text-lg font-medium">{quicklink ? 'Edit Quicklink' : 'Create Quicklink'}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="grow overflow-y-auto p-6">
|
||||||
|
<div class="mx-auto max-w-xl space-y-6">
|
||||||
|
<div class="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<label for="name" class="text-right text-sm text-gray-400">Name</label>
|
||||||
|
<Input id="name" placeholder="Quicklink name" bind:value={name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[120px_1fr] items-start gap-4">
|
||||||
|
<label for="link" class="pt-2 text-right text-sm text-gray-400">Link</label>
|
||||||
|
<div>
|
||||||
|
<Textarea
|
||||||
|
id="link"
|
||||||
|
placeholder="https://google.com/search?q={'{argument}'}"
|
||||||
|
bind:value={link}
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
|
Include <span class="text-foreground font-mono">{'{argument}'}</span> for context like the
|
||||||
|
selected or copied text in the link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<label for="open-with" class="text-right text-sm text-gray-400">Open With</label>
|
||||||
|
<Select.Root bind:value={application} type="single">
|
||||||
|
<Select.Trigger id="open-with" class="w-full">
|
||||||
|
{@const selectedApp = applications.find((a) => a.exec === application)}
|
||||||
|
{selectedApp?.name ?? 'Default'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value="Default">Default</Select.Item>
|
||||||
|
{#each applications as app (app.exec)}
|
||||||
|
<Select.Item value={app.exec}>{app.name}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<label for="icon" class="text-right text-sm text-gray-400">Icon</label>
|
||||||
|
<Input id="icon" placeholder="link-16" bind:value={icon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-center text-red-500">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="bg-card flex h-12 shrink-0 items-center justify-between border-t px-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="link-16" class="size-5" />
|
||||||
|
<span class="text-sm font-medium">Create Quicklink</span>
|
||||||
|
</div>
|
||||||
|
<Button onclick={handleSave}><Save class="mr-2 size-4" /> Save Quicklink</Button>
|
||||||
|
</footer>
|
||||||
|
</main>
|
70
src/lib/quicklinks.svelte.ts
Normal file
70
src/lib/quicklinks.svelte.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export type Quicklink = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
application: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class QuicklinksStore {
|
||||||
|
quicklinks = $state<Quicklink[]>([]);
|
||||||
|
isLoading = $state(true);
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.fetchQuicklinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchQuicklinks() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const result = await invoke<Quicklink[]>('list_quicklinks');
|
||||||
|
this.quicklinks = result;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error('Failed to fetch quicklinks:', e);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: { name: string; link: string; application?: string; icon?: string }) {
|
||||||
|
try {
|
||||||
|
await invoke('create_quicklink', data);
|
||||||
|
await this.fetchQuicklinks();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create quicklink:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
data: { name: string; link: string; application?: string; icon?: string }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await invoke('update_quicklink', { id, ...data });
|
||||||
|
await this.fetchQuicklinks();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update quicklink:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: number) {
|
||||||
|
try {
|
||||||
|
await invoke('delete_quicklink', { id });
|
||||||
|
await this.fetchQuicklinks();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete quicklink:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quicklinksStore = new QuicklinksStore();
|
|
@ -13,13 +13,16 @@
|
||||||
import OAuthView from '$lib/components/OAuthView.svelte';
|
import OAuthView from '$lib/components/OAuthView.svelte';
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import ClipboardHistoryView from '$lib/components/ClipboardHistoryView.svelte';
|
import ClipboardHistoryView from '$lib/components/ClipboardHistoryView.svelte';
|
||||||
|
import QuicklinkForm from '$lib/components/QuicklinkForm.svelte';
|
||||||
|
import { quicklinksStore } from '$lib/quicklinks.svelte';
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
| 'plugin-list'
|
| 'plugin-list'
|
||||||
| 'plugin-running'
|
| 'plugin-running'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'extensions-store'
|
| 'extensions-store'
|
||||||
| 'clipboard-history';
|
| 'clipboard-history'
|
||||||
|
| 'create-quicklink';
|
||||||
|
|
||||||
let viewState = $state<ViewState>('plugin-list');
|
let viewState = $state<ViewState>('plugin-list');
|
||||||
let installedApps = $state<any[]>([]);
|
let installedApps = $state<any[]>([]);
|
||||||
|
@ -49,8 +52,26 @@
|
||||||
mode: 'view'
|
mode: 'view'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createQuicklinkPlugin: PluginInfo = {
|
||||||
|
title: 'Create Quicklink',
|
||||||
|
description: 'Create a new Quicklink',
|
||||||
|
pluginTitle: 'Quicklinks',
|
||||||
|
pluginName: 'Quicklinks',
|
||||||
|
commandName: 'create-quicklink',
|
||||||
|
pluginPath: 'builtin:create-quicklink',
|
||||||
|
icon: 'link-16',
|
||||||
|
preferences: [],
|
||||||
|
mode: 'view'
|
||||||
|
};
|
||||||
|
|
||||||
const { pluginList, currentPreferences } = $derived(uiStore);
|
const { pluginList, currentPreferences } = $derived(uiStore);
|
||||||
const allPlugins = $derived([...pluginList, storePlugin, clipboardHistoryPlugin]);
|
const { quicklinks } = $derived(quicklinksStore);
|
||||||
|
const allPlugins = $derived([
|
||||||
|
...pluginList,
|
||||||
|
storePlugin,
|
||||||
|
clipboardHistoryPlugin,
|
||||||
|
createQuicklinkPlugin
|
||||||
|
]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
@ -59,6 +80,7 @@
|
||||||
uiStore.setCurrentRunningPlugin(null);
|
uiStore.setCurrentRunningPlugin(null);
|
||||||
});
|
});
|
||||||
sidecarService.start();
|
sidecarService.start();
|
||||||
|
quicklinksStore.fetchQuicklinks();
|
||||||
return () => sidecarService.stop();
|
return () => sidecarService.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -148,6 +170,10 @@
|
||||||
viewState = 'clipboard-history';
|
viewState = 'clipboard-history';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (plugin.pluginPath === 'builtin:create-quicklink') {
|
||||||
|
viewState = 'create-quicklink';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uiStore.setCurrentRunningPlugin(plugin);
|
uiStore.setCurrentRunningPlugin(plugin);
|
||||||
sidecarService.dispatchEvent('run-plugin', {
|
sidecarService.dispatchEvent('run-plugin', {
|
||||||
|
@ -217,7 +243,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if viewState === 'plugin-list'}
|
{#if viewState === 'plugin-list'}
|
||||||
<CommandPalette plugins={allPlugins} onRunPlugin={handleRunPlugin} {installedApps} />
|
<CommandPalette plugins={allPlugins} onRunPlugin={handleRunPlugin} {installedApps} {quicklinks} />
|
||||||
{:else if viewState === 'settings'}
|
{:else if viewState === 'settings'}
|
||||||
<SettingsView
|
<SettingsView
|
||||||
plugins={pluginList}
|
plugins={pluginList}
|
||||||
|
@ -237,4 +263,6 @@
|
||||||
/>
|
/>
|
||||||
{:else if viewState === 'clipboard-history'}
|
{:else if viewState === 'clipboard-history'}
|
||||||
<ClipboardHistoryView onBack={() => (viewState = 'plugin-list')} />
|
<ClipboardHistoryView onBack={() => (viewState = 'plugin-list')} />
|
||||||
|
{:else if viewState === 'create-quicklink'}
|
||||||
|
<QuicklinkForm onBack={handleBackToPluginList} onSave={handleBackToPluginList} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue