Huakun 97cd20906f
Feature: add extension api (hide and paste) (#210)
* feat: add paste API to extension API

* feat(desktop): enhance clipboard and hotkey utilities

- Add `hideAndPaste` utility function to simplify window hiding and clipboard pasting
- Adjust key press and sleep timings for more reliable input simulation
- Implement window focus listener in clipboard extension
- Bind input element reference for automatic focus management

* feat(permissions): enhance clipboard permission handling

- Update clipboard permission schema to include paste permission
- Modify clipboard API to check for paste permission before executing
- Refactor permission map and schema for more flexible permission management

* feat(desktop): refactor extension command search and rendering

- Add `svelte-inspect-value` for debugging
- Implement new `ExtCmds` component to replace `ExtCmdsGroup`
- Enhance extension command search with separate Fuse.js instances for installed and dev extensions
- Simplify extension command filtering and rendering logic
- Add derived stores for extension commands with improved type safety

* feat(desktop): improve extension command search filtering

* bump @kksh/api version

* feat(permissions): add clipboard paste permission description
2025-02-26 04:47:43 -05:00

276 lines
7.7 KiB
Svelte

<script lang="ts">
import { hideAndPaste } from "@/utils/hotkey"
import { goHome } from "@/utils/route"
import { listenToNewClipboardItem, listenToWindowFocus } from "@/utils/tauri-events"
import Icon from "@iconify/svelte"
import { ClipboardContentType, db } from "@kksh/api/commands"
import { SearchModeEnum, SQLSortOrderEnum, type ExtData } from "@kksh/api/models"
import { Button, Command, Resizable } from "@kksh/svelte5"
import { Constants } from "@kksh/ui"
import { CustomCommandInput, GlobalCommandPaletteFooter } from "@kksh/ui/main"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { platform } from "@tauri-apps/plugin-os"
import { ArrowLeft, FileQuestionIcon, ImageIcon, LetterTextIcon } from "lucide-svelte"
import { onDestroy, onMount, type Snippet } from "svelte"
import { toast } from "svelte-sonner"
import clipboard from "tauri-plugin-clipboard-api"
import ContentPreview from "./content-preview.svelte"
const _platform = platform()
let inputEle = $state<HTMLInputElement | null>(null)
const curWin = getCurrentWebviewWindow()
let searchTerm = $state("")
let clipboardHistoryList = $state<ExtData[]>([])
let highlightedItemValue = $state<string>("")
let highlighted = $state<ExtData | null>(null)
let unlistenClipboard = $state<UnlistenFn | null>(null)
let unlistenFocusEvt = $state<UnlistenFn | null>(null)
let isScrolling = $state(false)
let page = $state(1)
let clipboardHistoryMap = $derived(
clipboardHistoryList.reduce(
(acc, item) => {
acc[item.dataId] = item
return acc
},
{} as Record<string, ExtData>
)
)
let clipboardHistoryIds = $derived(clipboardHistoryList.map((item) => item.dataId))
let clipboardHistoryIdsSet = $derived(new Set(clipboardHistoryIds))
async function initClipboardHistory() {
const result = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.FTS,
limit: 50,
offset: (page - 1) * 50,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
if (page === 1) {
// clear clipboardHistoryList when page is 1, because it's simply loading the first page, using previous search result will result in duplicate key error
clipboardHistoryList = result
} else {
clipboardHistoryList = [...result, ...clipboardHistoryList]
}
}
onMount(async () => {
listenToNewClipboardItem(async (evt) => {
const result = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.FTS,
limit: 1,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
if (result.length > 0) {
clipboardHistoryList = [result[0], ...clipboardHistoryList]
}
}).then((unlisten) => {
unlistenClipboard = unlisten
})
listenToWindowFocus(async () => {
if (inputEle) {
inputEle.focus()
}
}).then((unlisten) => {
unlistenFocusEvt = unlisten
})
})
onDestroy(() => {
unlistenClipboard?.()
unlistenFocusEvt?.()
})
$effect(() => {
// search sqlite when searchTerm changes
void searchTerm
;(async () => {
// console.log("searchTerm", searchTerm)
if (searchTerm === "") {
page = 1
initClipboardHistory()
return
}
const ftsResult = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.FTS,
searchText: `${searchTerm}*`,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
const likeResult = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.Like,
searchText: `%${searchTerm}%`,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
// merge ftsResult and likeResult, remove duplicate items
const result = [...ftsResult, ...likeResult]
// sort result by createdAt
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
// remove duplicate items from result list by dataId
const uniqueResult = result.filter(
(item, index, self) => index === self.findIndex((t) => t.dataId === item.dataId)
)
clipboardHistoryList = uniqueResult
if (uniqueResult.length > 0) {
highlightedItemValue = uniqueResult[0].dataId.toString()
}
})()
})
$effect(() => {
if (!highlightedItemValue) {
return
}
try {
const dataId = parseInt(highlightedItemValue)
highlighted = clipboardHistoryMap[dataId]
} catch (error) {
console.error(error)
}
})
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
const inputEle = event.target as HTMLInputElement
if (inputEle.value === "") {
goHome()
}
inputEle.value = ""
searchTerm = ""
}
}
async function onListScrolledToBottom() {
page++
await initClipboardHistory()
}
/**
* Handle scroll-to-bottom event
* @param e
*/
function onScroll(e: Event) {
const element = e.target as HTMLElement
if (!isScrolling && element?.scrollHeight - element?.scrollTop === element?.clientHeight) {
isScrolling = true
onListScrolledToBottom?.()
setTimeout(() => {
isScrolling = false
}, 500)
}
}
function writeToClipboard(data: ExtData) {
if (!data.data) {
toast.warning("No data found")
return Promise.reject(new Error("No data found"))
}
const dataType = data?.dataType as ClipboardContentType
switch (dataType) {
case "Text":
return clipboard.writeText(data.data)
case "Image":
return clipboard.writeImageBase64(data.data)
case "Html":
return clipboard.writeHtmlAndText(data.data, data.searchText ?? data.data)
case "Rtf":
return clipboard.writeRtf(data.data)
default:
return Promise.reject(new Error("Unsupported data type: " + dataType))
}
}
function onItemSelected(dataId: number) {
db.getExtensionDataById(dataId)
.then((data) => {
console.log("data", data)
if (!data) {
toast.warning("No data found")
return Promise.reject(new Error("No data found"))
}
return writeToClipboard(data).then(async () => {
return hideAndPaste(curWin)
})
})
.then(() => toast.success("Copied to clipboard"))
.catch((err) => {
console.error(err)
toast.error("Failed to fetch data from db", {
description: err.message
})
})
}
</script>
{#snippet leftSlot()}
<Button
variant="outline"
size="icon"
onclick={goHome}
class={Constants.CLASSNAMES.BACK_BUTTON}
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
>
<ArrowLeft class="size-4" />
</Button>
{/snippet}
{#snippet typeIcon(type: string)}
{#if type === "Text"}
<LetterTextIcon />
{:else if type === "Html"}
<Icon icon="skill-icons:html" />
{:else if type === "Image"}
<ImageIcon />
{:else}
<FileQuestionIcon />
{/if}
{/snippet}
<Command.Root
class="h-screen rounded-lg border shadow-md"
loop
bind:value={highlightedItemValue}
shouldFilter={false}
>
<CustomCommandInput
onkeydown={onKeyDown}
autofocus
placeholder="Type a command or search..."
leftSlot={leftSlot as Snippet}
bind:ref={inputEle}
bind:value={searchTerm}
/>
<Resizable.PaneGroup direction="horizontal" class="w-full rounded-lg">
<Resizable.Pane defaultSize={30} class="">
<Command.List class="h-full max-h-full grow" onscroll={onScroll}>
<Command.Empty>No results found.</Command.Empty>
{#each clipboardHistoryIds as dataId (dataId)}
<Command.Item value={dataId.toString()} onSelect={() => onItemSelected(dataId)}>
{@render typeIcon(clipboardHistoryMap[dataId].dataType)}
<span class="truncate">{clipboardHistoryMap[dataId].searchText}</span>
</Command.Item>
{/each}
</Command.List>
</Resizable.Pane>
<Resizable.Handle />
<Resizable.Pane defaultSize={50}>
{#if highlighted}
<ContentPreview {highlighted} />
{:else}
<div class="text-center">No content preview available</div>
{/if}
</Resizable.Pane>
</Resizable.PaneGroup>
<GlobalCommandPaletteFooter />
</Command.Root>