mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-09-12 17:06:26 +00:00
feat: add basic UI for viewing clipboard history
This commit is contained in:
parent
47265d772d
commit
c8aced53c4
4 changed files with 364 additions and 4 deletions
|
@ -234,6 +234,13 @@ impl ClipboardHistoryManager {
|
||||||
items.collect::<Result<Vec<_>, _>>().map_err(|e| e.into())
|
items.collect::<Result<Vec<_>, _>>().map_err(|e| e.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn item_was_copied(&self, id: i64) -> RusqliteResult<usize> {
|
||||||
|
self.db.lock().unwrap().execute(
|
||||||
|
"UPDATE clipboard_history SET last_copied_at = ?, times_copied = times_copied + 1 WHERE id = ?",
|
||||||
|
params![Utc::now().timestamp(), id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn delete_item(&self, id: i64) -> RusqliteResult<usize> {
|
fn delete_item(&self, id: i64) -> RusqliteResult<usize> {
|
||||||
self.db
|
self.db
|
||||||
.lock()
|
.lock()
|
||||||
|
@ -316,7 +323,7 @@ fn start_monitoring(_app_handle: AppHandle) {
|
||||||
let current_hash = hex::encode(Sha256::digest(&image_data.bytes));
|
let current_hash = hex::encode(Sha256::digest(&image_data.bytes));
|
||||||
if current_hash != last_image_hash {
|
if current_hash != last_image_hash {
|
||||||
if let Some(manager) = MANAGER.lock().unwrap().as_ref() {
|
if let Some(manager) = MANAGER.lock().unwrap().as_ref() {
|
||||||
let image_path = manager.image_dir.join(format!("{}.png", ¤t_hash));
|
let image_path = manager.image_dir.join(format!("{}.png", current_hash));
|
||||||
match image::save_buffer(
|
match image::save_buffer(
|
||||||
&image_path,
|
&image_path,
|
||||||
&image_data.bytes,
|
&image_data.bytes,
|
||||||
|
@ -356,6 +363,16 @@ pub fn history_get_items(filter: String, limit: u32) -> Result<Vec<ClipboardItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn history_item_was_copied(id: i64) -> Result<(), String> {
|
||||||
|
if let Some(manager) = MANAGER.lock().unwrap().as_ref() {
|
||||||
|
manager.item_was_copied(id).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Clipboard history manager not initialized".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn history_delete_item(id: i64) -> Result<(), String> {
|
pub fn history_delete_item(id: i64) -> Result<(), String> {
|
||||||
if let Some(manager) = MANAGER.lock().unwrap().as_ref() {
|
if let Some(manager) = MANAGER.lock().unwrap().as_ref() {
|
||||||
|
|
|
@ -143,7 +143,8 @@ pub fn run() {
|
||||||
clipboard_history::history_get_items,
|
clipboard_history::history_get_items,
|
||||||
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
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
|
|
318
src/lib/components/ClipboardHistoryView.svelte
Normal file
318
src/lib/components/ClipboardHistoryView.svelte
Normal file
|
@ -0,0 +1,318 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ArrowLeft, Pin, Trash } from '@lucide/svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import ListItemBase from './nodes/shared/ListItemBase.svelte';
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import { Kbd } from './ui/kbd';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import { shortcutToText } from '$lib/renderKey';
|
||||||
|
import * as Select from './ui/select';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { onBack }: Props = $props();
|
||||||
|
|
||||||
|
type ClipboardItem = {
|
||||||
|
id: number;
|
||||||
|
hash: string;
|
||||||
|
contentType: 'text' | 'image' | 'color' | 'link' | 'file';
|
||||||
|
contentValue: string;
|
||||||
|
sourceAppName: string | null;
|
||||||
|
firstCopiedAt: string;
|
||||||
|
lastCopiedAt: string;
|
||||||
|
timesCopied: number;
|
||||||
|
isPinned: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let allItems = $state<ClipboardItem[]>([]);
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let searchText = $state('');
|
||||||
|
let filter = $state('all');
|
||||||
|
let listElement: HTMLElement | undefined = $state();
|
||||||
|
let actionMenuOpen = $state(false);
|
||||||
|
|
||||||
|
const pinnedItems = $derived(allItems.filter((item) => item.isPinned));
|
||||||
|
const recentItems = $derived(allItems.filter((item) => !item.isPinned));
|
||||||
|
|
||||||
|
const fuse = $derived(
|
||||||
|
new Fuse(recentItems, {
|
||||||
|
keys: ['contentValue'],
|
||||||
|
threshold: 0.3
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRecentItems = $derived(
|
||||||
|
searchText ? fuse.search(searchText).map((r) => r.item) : recentItems
|
||||||
|
);
|
||||||
|
const displayedItems = $derived([...pinnedItems, ...filteredRecentItems]);
|
||||||
|
const selectedItem = $derived(displayedItems[selectedIndex]);
|
||||||
|
|
||||||
|
async function fetchHistory() {
|
||||||
|
try {
|
||||||
|
const items = await invoke<ClipboardItem[]>('history_get_items', {
|
||||||
|
filter,
|
||||||
|
limit: 200
|
||||||
|
});
|
||||||
|
console.log('items', items);
|
||||||
|
allItems = items;
|
||||||
|
selectedIndex = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch clipboard history:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (listElement && selectedIndex >= 0) {
|
||||||
|
const el = listElement.querySelector(`[data-index="${selectedIndex}"]`);
|
||||||
|
el?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getIconForType(type: ClipboardItem['contentType']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
return 'text-16';
|
||||||
|
case 'image':
|
||||||
|
return 'image-16';
|
||||||
|
case 'color':
|
||||||
|
return 'swatch-16';
|
||||||
|
case 'link':
|
||||||
|
return 'link-16';
|
||||||
|
case 'file':
|
||||||
|
return 'blank-document-16';
|
||||||
|
default:
|
||||||
|
return 'question-mark-circle-16';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateString: string) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return `Today at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(item: ClipboardItem) {
|
||||||
|
await writeText(item.contentValue);
|
||||||
|
await invoke('history_item_was_copied', { id: item.id });
|
||||||
|
await fetchHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePin(item: ClipboardItem) {
|
||||||
|
await invoke('history_toggle_pin', { id: item.id });
|
||||||
|
await fetchHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: ClipboardItem) {
|
||||||
|
await invoke('history_delete_item', { id: item.id });
|
||||||
|
await fetchHistory();
|
||||||
|
if (selectedIndex >= displayedItems.length - 1) {
|
||||||
|
selectedIndex = Math.max(0, displayedItems.length - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (displayedItems.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.max(0, selectedIndex - 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.min(displayedItems.length - 1, selectedIndex + 1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedItem) handleCopy(selectedItem);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (e.metaKey && e.shiftKey && e.key.toLowerCase() === 'p') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedItem) handlePin(selectedItem);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key.toLowerCase() === 'x') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedItem) handleDelete(selectedItem);
|
||||||
|
}
|
||||||
|
if (e.metaKey && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
actionMenuOpen = !actionMenuOpen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspect(selectedItem);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<Input
|
||||||
|
class="rounded-none border-none !bg-transparent pr-0"
|
||||||
|
placeholder="Type to filter entries..."
|
||||||
|
bind:value={searchText}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<Select.Root bind:value={filter} type="single">
|
||||||
|
<Select.Trigger class="w-32">
|
||||||
|
{filter ?? 'All Types'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value="all">All Types</Select.Item>
|
||||||
|
<Select.Item value="text">Text</Select.Item>
|
||||||
|
<Select.Item value="image">Images</Select.Item>
|
||||||
|
<Select.Item value="link">Links</Select.Item>
|
||||||
|
<Select.Item value="color">Colors</Select.Item>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</header>
|
||||||
|
<div class="grid grow grid-cols-[minmax(0,_1.5fr)_minmax(0,_2.5fr)] overflow-y-hidden">
|
||||||
|
<div class="flex-grow overflow-y-auto border-r" bind:this={listElement}>
|
||||||
|
{#if pinnedItems.length > 0}
|
||||||
|
<h3 class="text-muted-foreground px-4 pt-2.5 pb-1 text-xs font-semibold uppercase">
|
||||||
|
Pinned
|
||||||
|
</h3>
|
||||||
|
{#each pinnedItems as item, i (item.id)}
|
||||||
|
<button
|
||||||
|
class="w-full"
|
||||||
|
data-index={i}
|
||||||
|
onclick={() => (selectedIndex = i)}
|
||||||
|
onfocus={() => (selectedIndex = i)}
|
||||||
|
>
|
||||||
|
<ListItemBase
|
||||||
|
icon={getIconForType(item.contentType)}
|
||||||
|
title={item.contentValue}
|
||||||
|
isSelected={selectedIndex === i}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filteredRecentItems.length > 0}
|
||||||
|
<h3 class="text-muted-foreground px-4 pt-2.5 pb-1 text-xs font-semibold uppercase">
|
||||||
|
Most Recent
|
||||||
|
</h3>
|
||||||
|
{#each filteredRecentItems as item, i (item.id)}
|
||||||
|
{@const listIndex = i + pinnedItems.length}
|
||||||
|
<button
|
||||||
|
class="w-full"
|
||||||
|
data-index={listIndex}
|
||||||
|
onclick={() => (selectedIndex = listIndex)}
|
||||||
|
onfocus={() => (selectedIndex = listIndex)}
|
||||||
|
>
|
||||||
|
<ListItemBase
|
||||||
|
icon={getIconForType(item.contentType)}
|
||||||
|
title={item.contentValue}
|
||||||
|
isSelected={selectedIndex === listIndex}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col overflow-y-auto p-4">
|
||||||
|
{#if selectedItem}
|
||||||
|
<div class="h-full flex-grow overflow-y-auto">
|
||||||
|
{#if selectedItem.contentType === 'color'}
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 py-8">
|
||||||
|
<div
|
||||||
|
class="size-24 rounded-full border"
|
||||||
|
style:background-color={selectedItem.contentValue}
|
||||||
|
></div>
|
||||||
|
<p class="font-mono text-lg">{selectedItem.contentValue}</p>
|
||||||
|
</div>
|
||||||
|
{:else if selectedItem.contentType === 'image'}
|
||||||
|
<img
|
||||||
|
src={convertFileSrc(selectedItem.contentValue)}
|
||||||
|
alt="Clipboard content"
|
||||||
|
class="max-h-60 w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded bg-black/10 p-4 font-mono text-sm whitespace-pre-wrap">
|
||||||
|
{selectedItem.contentValue}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="border-t py-4">
|
||||||
|
<h3 class="text-muted-foreground mb-2 text-xs font-semibold uppercase">Information</h3>
|
||||||
|
<div class="flex flex-col gap-3 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Application</span>
|
||||||
|
<span>{selectedItem.sourceAppName ?? 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Content type</span>
|
||||||
|
<span class="capitalize">{selectedItem.contentType}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Times copied</span>
|
||||||
|
<span>{selectedItem.timesCopied}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Last copied</span>
|
||||||
|
<span>{formatDateTime(selectedItem.lastCopiedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">First copied</span>
|
||||||
|
<span>{formatDateTime(selectedItem.firstCopiedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<Button size="sm" onclick={() => handleCopy(selectedItem)}>
|
||||||
|
Copy to Clipboard <Kbd>⏎</Kbd>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu.Root bind:open={actionMenuOpen}>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<Button variant="outline" size="sm">Actions <Kbd>⌘ K</Kbd></Button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Item onclick={() => handlePin(selectedItem)}>
|
||||||
|
<Pin class="mr-2 size-4" />
|
||||||
|
<span>{selectedItem.isPinned ? 'Unpin' : 'Pin'}</span>
|
||||||
|
<DropdownMenu.Shortcut>
|
||||||
|
{shortcutToText({ key: 'P', modifiers: ['cmd', 'shift'] })}
|
||||||
|
</DropdownMenu.Shortcut>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onclick={() => handleDelete(selectedItem)}>
|
||||||
|
<Trash class="mr-2 size-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
<DropdownMenu.Shortcut>
|
||||||
|
{shortcutToText({ key: 'x', modifiers: ['ctrl'] })}
|
||||||
|
</DropdownMenu.Shortcut>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
|
@ -12,8 +12,14 @@
|
||||||
import Extensions from '$lib/components/Extensions.svelte';
|
import Extensions from '$lib/components/Extensions.svelte';
|
||||||
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';
|
||||||
|
|
||||||
type ViewState = 'plugin-list' | 'plugin-running' | 'settings' | 'extensions-store';
|
type ViewState =
|
||||||
|
| 'plugin-list'
|
||||||
|
| 'plugin-running'
|
||||||
|
| 'settings'
|
||||||
|
| 'extensions-store'
|
||||||
|
| 'clipboard-history';
|
||||||
|
|
||||||
let viewState = $state<ViewState>('plugin-list');
|
let viewState = $state<ViewState>('plugin-list');
|
||||||
let installedApps = $state<any[]>([]);
|
let installedApps = $state<any[]>([]);
|
||||||
|
@ -31,8 +37,20 @@
|
||||||
mode: 'view'
|
mode: 'view'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clipboardHistoryPlugin: PluginInfo = {
|
||||||
|
title: 'Clipboard History',
|
||||||
|
description: 'View, search, and manage your clipboard history',
|
||||||
|
pluginTitle: 'Clipboard History',
|
||||||
|
pluginName: 'Clipboard History',
|
||||||
|
commandName: 'index',
|
||||||
|
pluginPath: 'builtin:history',
|
||||||
|
icon: 'copy-clipboard-16',
|
||||||
|
preferences: [],
|
||||||
|
mode: 'view'
|
||||||
|
};
|
||||||
|
|
||||||
const { pluginList, currentPreferences } = $derived(uiStore);
|
const { pluginList, currentPreferences } = $derived(uiStore);
|
||||||
const allPlugins = $derived([...pluginList, storePlugin]);
|
const allPlugins = $derived([...pluginList, storePlugin, clipboardHistoryPlugin]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
@ -126,6 +144,10 @@
|
||||||
viewState = 'extensions-store';
|
viewState = 'extensions-store';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (plugin.pluginPath === 'builtin:history') {
|
||||||
|
viewState = 'clipboard-history';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uiStore.setCurrentRunningPlugin(plugin);
|
uiStore.setCurrentRunningPlugin(plugin);
|
||||||
sidecarService.dispatchEvent('run-plugin', {
|
sidecarService.dispatchEvent('run-plugin', {
|
||||||
|
@ -213,4 +235,6 @@
|
||||||
onToastAction={handleToastAction}
|
onToastAction={handleToastAction}
|
||||||
onHideToast={handleHideToast}
|
onHideToast={handleHideToast}
|
||||||
/>
|
/>
|
||||||
|
{:else if viewState === 'clipboard-history'}
|
||||||
|
<ClipboardHistoryView onBack={() => (viewState = 'plugin-list')} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue