Huakun Shen 383270c93a
[features] UI Worker Extension, Troubleshooters, Quick Link (#21)
* perf: reduce desktop frontend bundle from 10 to 2MB

Use shiki fine-grained bundle, avoid bundling all languages and themes

* feat: add cross-page transition for ext store back button with gasp Flip

* refactor: move StoreListing.svelte in @kksh/ui back to desktop

I realized that StoreListing is a pure wrapper, all the interactions are done with props. Even if this component is later used in other projects, it either lacks flexibility or require more changes. So it's moved back to desktop as a regular +page.svelte

* feat: Add a bunch of builtin commands for app internal control

* feat: add system commands

* feat: add extensionsInstallDir var to +layout.ts, exposed to all pages

All pages won't need to get the path asynchronously, it's kind of like a global constant

* [feat] troubleshooters (#15)

* feat: add extension loading troubleshooter

* feat: add extension permission inspector

* feat: add extension window map troubleshooter (WIP)

* fix: unregister extension when window is closed

* Feature: Deep Link + Supabase OAuth + open extension in store with deep link (#16)

* feat(auth): add deep link and supabase auth

* fix(deep-link): fix some routing and reactive page rendering

* feat: implement supabase auth with pkce auth flow

* feat: add createTauriSyncStore factory function for creating sync svelte store

* Feature: Quick Link (#17)

* feat: add page for add quick link (not working yet)

* upgrade @kksh/svelte5

* fix: infinite recursive footer

* dep: add @kksh/svelte5 to ui package

* dep: add supabase-js

* dep: add @iconify/svelte

* style: modify StoreExtDetail width control

* fixed: UI for extension store detail

* feat: add page to create quick link

* feat: display quick links in cmd palette

* snapshot

* show queries in command input

* feat: quick link fully implemented

* refactor: format all with prettier

* feat: add icon picker for quick link adder

* fix: make invert for icon optional, caused many types to crash

* [Feature] Implement UI template worker command (#20)

* feat: add ui worker command loading code (not working yet)

* feat: add unocss

* feat: add-dev-extension page

* feat: implemented list view template

* feat: implement list view detail view width, add demo extension for dev

* fix: resize listview, add metadata component

* fix: metadata tag component  background color

* feat: implement boolean (checkbox), date fields for form template

* feat: support default, optional, placeholder for form fields

* feat: implemented form view Select Field

* feat: markdown view

* feat: fixed a markdown schema type error

* fix: markdown styling

* feat: implement action panel for UI worker template list view

* format: format all

* chore: bump desktop version

* fix: fix search term bind in list view
2024-11-08 15:34:37 -05:00

290 lines
8.4 KiB
Svelte

<script lang="ts">
import { appState } from "@/stores/appState.js"
import { winExtMap } from "@/stores/winExtMap.js"
import { listenToRefreshDevExt } from "@/utils/tauri-events.js"
import { isInMainWindow } from "@/utils/window.js"
import { type Remote } from "@huakunshen/comlink"
import { db } from "@kksh/api/commands"
import {
constructJarvisServerAPIWithPermissions,
exposeApiToWorker,
type IApp,
type IUiWorker
} from "@kksh/api/ui"
import {
// constructJarvisExtDBToServerDbAPI,
FormNodeNameEnum,
FormSchema,
ListSchema,
Markdown,
MarkdownSchema,
NodeNameEnum,
toast,
wrap,
type IComponent,
type IDb,
type WorkerExtension
} from "@kksh/api/ui/worker"
import { Button } from "@kksh/svelte5"
import { LoadingBar } from "@kksh/ui"
import { Templates } from "@kksh/ui/extension"
import { GlobalCommandPaletteFooter } from "@kksh/ui/main"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { readTextFile } from "@tauri-apps/plugin-fs"
import { debug } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { onDestroy, onMount } from "svelte"
import * as v from "valibot"
const { data } = $props()
let { loadedExt, scriptPath, extInfoInDB } = $derived(data)
let workerAPI: Remote<WorkerExtension> | undefined = undefined
let unlistenRefreshWorkerExt: UnlistenFn | undefined
let worker: Worker | undefined
let listViewContent = $state<ListSchema.List>()
let formViewContent = $state<FormSchema.Form>()
let markdownViewContent = $state<MarkdownSchema>()
let extensionLoadingBar = $state(false) // whether extension called showLoadingBar
let pbar = $state<number | null>(null)
let loading = $state(false)
let searchTerm = $state("")
let searchBarPlaceholder = $state("")
const appWin = getCurrentWebviewWindow()
const loadingBar = $derived($appState.loadingBar || extensionLoadingBar)
let loaded = $state(false)
async function goBack() {
if (isInMainWindow()) {
// if in main window, then winExtMap store must contain this
winExtMap.unregisterExtensionFromWindow(appWin.label)
goto("/")
} else {
appWin.close()
}
}
function clearViewContent(keep?: "list" | "form" | "markdown") {
if (keep !== "list") {
listViewContent = undefined
}
if (keep !== "form") {
formViewContent = undefined
}
if (keep !== "markdown") {
markdownViewContent = undefined
}
}
const extUiAPI: IUiWorker = {
async render(view: IComponent<ListSchema.List | FormSchema.Form | MarkdownSchema>) {
if (view.nodeName === NodeNameEnum.List) {
clearViewContent("list")
const parsedListView = v.parse(ListSchema.List, view)
const updateFields = {
sections: true,
items: true,
detail: true,
filter: true,
actions: true,
defaultAction: true
}
if (listViewContent) {
if (parsedListView.inherits && parsedListView.inherits.length > 0) {
if (parsedListView.inherits.includes("items")) {
updateFields.items = false
}
if (parsedListView.inherits.includes("sections")) {
updateFields.sections = false
}
if (parsedListView.inherits.includes("detail")) {
updateFields.detail = false
}
if (parsedListView.inherits.includes("filter")) {
updateFields.filter = false
}
if (parsedListView.inherits.includes("actions")) {
updateFields.actions = false
}
if (parsedListView.inherits.includes("defaultAction")) {
updateFields.defaultAction = false
}
if (updateFields.items) {
listViewContent.items = parsedListView.items
}
if (updateFields.sections) {
listViewContent.sections = parsedListView.sections
}
if (updateFields.detail) {
listViewContent.detail = parsedListView.detail
}
if (updateFields.filter) {
listViewContent.filter = parsedListView.filter
}
if (updateFields.actions) {
listViewContent.actions = parsedListView.actions
}
if (updateFields.defaultAction) {
listViewContent.defaultAction = parsedListView.defaultAction
}
listViewContent.inherits = parsedListView.inherits
} else {
listViewContent = parsedListView
}
} else {
listViewContent = parsedListView
}
// if (parsedListView.updateDetailOnly) {
// if (listViewContent) {
// listViewContent.detail = parsedListView.detail
// } else {
// listViewContent = parsedListView
// }
// } else {
// listViewContent = parsedListView
// }
} else if (view.nodeName === FormNodeNameEnum.Form) {
listViewContent = undefined
clearViewContent("form")
const parsedForm = v.parse(FormSchema.Form, view)
formViewContent = parsedForm
// TODO: convert form to zod schema
// const zodSchema = convertFormToZod(parsedForm)
// formViewZodSchema = zodSchema
// formFieldConfig = buildFieldConfig(parsedForm)
} else if (view.nodeName === NodeNameEnum.Markdown) {
clearViewContent("markdown")
markdownViewContent = v.parse(MarkdownSchema, view)
} else {
toast.error(`Unsupported view type: ${view.nodeName}`)
}
},
async showLoadingBar(loading: boolean) {
// appState.setLoadingBar(loading)
extensionLoadingBar = loading
},
async setProgressBar(progress: number | null) {
pbar = progress
},
async setScrollLoading(_loading: boolean) {
loading = _loading
},
async setSearchTerm(term: string) {
searchTerm = term
},
async setSearchBarPlaceholder(placeholder: string) {
console.log("setSearchBarPlaceholder", placeholder)
searchBarPlaceholder = placeholder
},
async goBack() {
goBack()
}
}
async function launchWorkerExt() {
if (worker) {
worker.terminate()
worker = undefined
}
const workerScript = await readTextFile(scriptPath)
const blob = new Blob([workerScript], { type: "application/javascript" })
const blobURL = URL.createObjectURL(blob)
worker = new Worker(blobURL)
const serverAPI: Record<string, any> = constructJarvisServerAPIWithPermissions(
loadedExt.kunkun.permissions,
loadedExt.extPath
)
serverAPI.iframeUi = undefined
serverAPI.workerUi = extUiAPI
serverAPI.db = new db.JarvisExtDB(extInfoInDB.extId)
serverAPI.app = {
language: () => Promise.resolve("en")
} satisfies IApp
exposeApiToWorker(worker, serverAPI)
workerAPI = wrap<WorkerExtension>(worker)
await workerAPI.load()
}
$effect(() => {
launchWorkerExt()
return () => {
worker?.terminate()
}
})
onMount(async () => {
setTimeout(() => {
appState.setLoadingBar(true)
appWin.show()
}, 100)
unlistenRefreshWorkerExt = await listenToRefreshDevExt(() => {
debug("Refreshing Worker Extension")
launchWorkerExt()
})
setTimeout(() => {
appState.setLoadingBar(false)
loaded = true
}, 500)
})
onDestroy(() => {
unlistenRefreshWorkerExt?.()
extensionLoadingBar = false
appState.setActionPanel(undefined)
})
</script>
{#if loadingBar}
<LoadingBar class="fixed left-0 top-0 w-full" color="white" />
{/if}
{#if loaded && listViewContent !== undefined}
<Templates.ListView
bind:searchTerm
bind:searchBarPlaceholder
{pbar}
{listViewContent}
{loading}
onGoBack={goBack}
onListScrolledToBottom={() => {
workerAPI?.onListScrolledToBottom()
}}
onEnterKeyPressed={() => {
workerAPI?.onEnterPressedOnSearchBar()
}}
onListItemSelected={(value: string) => {
workerAPI?.onListItemSelected(value)
}}
onSearchTermChange={(searchTerm) => {
workerAPI?.onSearchTermChange(searchTerm)
}}
onHighlightedItemChanged={(value) => {
workerAPI?.onHighlightedListItemChanged(value)
if (listViewContent?.defaultAction) {
appState.setDefaultAction(listViewContent.defaultAction)
}
if (listViewContent?.actions) {
appState.setActionPanel(listViewContent.actions)
}
}}
>
{#snippet footer()}
<GlobalCommandPaletteFooter
defaultAction={$appState.defaultAction}
actionPanel={$appState.actionPanel}
onDefaultActionSelected={() => {
workerAPI?.onEnterPressedOnSearchBar()
}}
onActionSelected={(value) => {
workerAPI?.onActionSelected(value)
}}
/>
{/snippet}
</Templates.ListView>
{:else if loaded && formViewContent !== undefined}
<Templates.FormView {formViewContent} onGoBack={goBack} />
{:else if loaded && markdownViewContent !== undefined}
<Templates.MarkdownView {markdownViewContent} onGoBack={goBack} />
{/if}