[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
This commit is contained in:
Huakun Shen 2024-11-08 15:12:20 -05:00 committed by GitHub
parent ce42409a39
commit 4a05c5a475
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 4111 additions and 111 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
pnpm-lock.yaml linguist-generated=true
packages/tauri-plugins/jarvis/permissions/autogenerated linguist-generated=true

8
apps/desktop/app.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import type { AttributifyAttributes } from "@unocss/preset-attributify"
declare module "svelte/elements" {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> extends AttributifyAttributes {}
}
export {}

View File

@ -1,6 +1,6 @@
{
"name": "@kksh/desktop",
"version": "0.1.9-beta.8",
"version": "0.1.10",
"description": "",
"type": "module",
"scripts": {
@ -15,6 +15,7 @@
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@huakunshen/comlink": "^4.4.1",
"@kksh/extension": "workspace:*",
"@kksh/supabase": "workspace:*",
"@kksh/ui": "workspace:*",
@ -45,6 +46,7 @@
"@tauri-apps/cli": "^2.0.4",
"@types/bun": "latest",
"@types/semver": "^7.5.8",
"@unocss/preset-attributify": "^0.64.0",
"autoprefixer": "^10.4.20",
"clsx": "^2.1.1",
"embla-carousel-svelte": "^8.3.1",
@ -55,6 +57,7 @@
"tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"unocss": "^0.64.0",
"vaul-svelte": "^0.3.2",
"vite": "^5.4.10"
}

View File

@ -8,47 +8,14 @@ pub fn tauri_file_server(
extension_folder_path: PathBuf,
dist: Option<String>,
) -> tauri::http::Response<Vec<u8>> {
// let host = request.uri().host().unwrap();
// let host_parts: Vec<&str> = host.split(".").collect();
// if host_parts.len() != 3 {
// return tauri::http::Response::builder()
// .status(tauri::http::StatusCode::NOT_FOUND)
// .header("Access-Control-Allow-Origin", "*")
// .body("Invalid Host".as_bytes().to_vec())
// .unwrap();
// }
// expect 3 parts, ext_identifier, dist and ext_type
// let ext_identifier = host_parts[0];
// let dist = host_parts[1];
// let ext_type = host_parts[2]; // ext or dev-ext
// let app_state = app.state::<tauri_plugin_jarvis::model::app_state::AppState>();
// let app_state: tauri:State<tauri_plugin_jarvis::model::app_state::AppState> = app.state();
// let extension_folder_path: Option<PathBuf> = match ext_type {
// "ext" => Some(app_state.extension_path.lock().unwrap().clone()),
// "dev-ext" => app_state.dev_extension_path.lock().unwrap().clone(),
// _ => None,
// };
// let extension_folder_path = match extension_folder_path {
// Some(path) => path,
// None => {
// return tauri::http::Response::builder()
// .status(tauri::http::StatusCode::NOT_FOUND)
// .header("Access-Control-Allow-Origin", "*")
// .body("Extension Folder Not Found".as_bytes().to_vec())
// .unwrap()
// }
// };
println!("dist: {:?}", dist);
let path = &request.uri().path()[1..]; // skip the first /
let path = urlencoding::decode(path).unwrap().to_string();
let mut url_file_path = extension_folder_path;
// .join(ext_identifier)
match dist {
Some(dist) => url_file_path = url_file_path.join(dist),
None => {}
}
url_file_path = url_file_path.join(path);
println!("url_file_path: {:?}", url_file_path);
// check if it's file or directory, if file and exist, return file, if directory, return index.html, if neither, check .html
if url_file_path.is_file() {
// println!("1st case url_file_path: {:?}", url_file_path);

View File

@ -54,16 +54,15 @@ export const builtinCmds: BuiltinCmd[] = [
}, 2_000)
}
},
// {
// name: "Add Dev Extension",
// iconifyIcon: "lineicons:dev",
// description: "",
// function: async () => {
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// goto("/add-dev-ext")
// }
// },
{
name: "Add Dev Extension",
iconifyIcon: "lineicons:dev",
description: "",
function: async () => {
appState.clearSearchTerm()
goto("/settings/add-dev-extension")
}
},
{
name: "Kunkun Version",
iconifyIcon: "stash:version-solid",

View File

@ -2,14 +2,11 @@ import { appState } from "@/stores"
import { winExtMap } from "@/stores/winExtMap"
import { trimSlash } from "@/utils/url"
import { constructExtensionSupportDir } from "@kksh/api"
import { CmdTypeEnum, CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@kksh/api/models"
import { CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@kksh/api/models"
import { launchNewExtWindow } from "@kksh/extension"
import { convertFileSrc } from "@tauri-apps/api/core"
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"
import * as fs from "@tauri-apps/plugin-fs"
import { debug } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import * as v from "valibot"
export async function createExtSupportDir(extPath: string) {
const extSupportDir = await constructExtensionSupportDir(extPath)
@ -24,7 +21,19 @@ export async function onTemplateUiCmdSelect(
{ isDev, hmr }: { isDev: boolean; hmr: boolean }
) {
await createExtSupportDir(ext.extPath)
console.log("onTemplateUiCmdSelect", ext, cmd, isDev, hmr)
// console.log("onTemplateUiCmdSelect", ext, cmd, isDev, hmr)
const url = `/extension/ui-worker?extPath=${encodeURIComponent(ext.extPath)}&cmdName=${encodeURIComponent(cmd.name)}`
if (cmd.window) {
const winLabel = await winExtMap.registerExtensionWithWindow({ extPath: ext.extPath })
const window = launchNewExtWindow(winLabel, url, cmd.window)
window.onCloseRequested(async (event) => {
await winExtMap.unregisterExtensionFromWindow(winLabel)
})
} else {
return winExtMap
.registerExtensionWithWindow({ windowLabel: "main", extPath: ext.extPath })
.then(() => goto(url))
}
}
export async function onCustomUiCmdSelect(
@ -32,7 +41,7 @@ export async function onCustomUiCmdSelect(
cmd: CustomUiCmd,
{ isDev, hmr }: { isDev: boolean; hmr: boolean }
) {
console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
// console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
await createExtSupportDir(ext.extPath)
let url = cmd.main
@ -55,11 +64,7 @@ export async function onCustomUiCmdSelect(
} else {
console.log("Launch main window")
return winExtMap
.registerExtensionWithWindow({
windowLabel: "main",
extPath: ext.extPath,
dist: cmd.dist
})
.registerExtensionWithWindow({ windowLabel: "main", extPath: ext.extPath, dist: cmd.dist })
.then(() => goto(url2))
}
appState.clearSearchTerm()

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { listen, TauriEvent, type UnlistenFn } from "@tauri-apps/api/event"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { onDestroy, onMount, type Snippet } from "svelte"
let unlisteners: UnlistenFn[] = []
const {
children,
onEnter,
onDrop,
onCancelled,
onOver
}: {
children: Snippet
onEnter?: (event: any) => void
onDrop?: (event: any) => void
onCancelled?: (event: any) => void
onOver?: (event: any) => void
} = $props()
const appWin = getCurrentWebviewWindow()
onMount(async () => {
if (onEnter) await appWin.listen(TauriEvent.DRAG_ENTER, onEnter)
if (onDrop) await appWin.listen(TauriEvent.DRAG_DROP, onDrop)
if (onCancelled) await appWin.listen(TauriEvent.DRAG_LEAVE, onCancelled)
if (onOver) await appWin.listen(TauriEvent.DRAG_OVER, onOver)
})
onDestroy(() => {
for (const unlisten of unlisteners) {
unlisten()
}
})
</script>
<span>
{@render children()}
</span>

View File

@ -0,0 +1,149 @@
<script lang="ts">
import DragNDrop from "@/components/common/DragNDrop.svelte"
import DevExtPathForm from "@/components/standalone/settings/DevExtPathForm.svelte"
import { appConfig, extensions } from "@/stores"
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { IconEnum } from "@kksh/api/models"
import * as extAPI from "@kksh/extension"
import { installFromNpmPackageName } from "@kksh/extension"
import { Button, Card } from "@kksh/svelte5"
import { cn } from "@kksh/svelte5/utils"
import { IconMultiplexer, Layouts, StrikeSeparator } from "@kksh/ui"
import { open as openFileSelector } from "@tauri-apps/plugin-dialog"
import * as fs from "@tauri-apps/plugin-fs"
import { enhance } from "$app/forms"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import * as v from "valibot"
import InstallNpmPackageNameForm from "./install-npm-package-name-form.svelte"
import InstallTarballUrlForm from "./install-tarball-url-form.svelte"
let dragging = $state(false)
async function handleDragNDropInstall(paths: string[]) {
dragging = false
console.log(paths)
for (const path of paths) {
const stat = await fs.stat(path)
if (await stat.isDirectory) {
await extensions
.installDevExtensionDir(path)
.then((ext) => {
toast.success("Success", {
description: `Extension from ${ext.extPath} installed successfully`
})
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
} else if (await stat.isFile) {
if (!$appConfig.devExtensionPath) {
toast.warning(
"Please set the dev extension path in the settings to install tarball extension"
)
continue
}
await extensions
.installTarball(path, $appConfig.devExtensionPath)
.then((ext) => {
toast.success("Success", {
description: `Extension from ${path} installed successfully`
})
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
} else {
toast.warning(`Unsupported file type: ${path}`)
}
// await installDevExtensionDir(path)
}
}
async function pickExtFolders() {
const selected = await openFileSelector({
directory: true,
multiple: true // allow install multiple extensions at once
})
if (!selected) {
return toast.warning("No File Selected")
}
for (const dir of selected) {
await extensions
.installDevExtensionDir(dir)
.then((ext) => {
toast.success("Success", {
description: `Extension from ${ext.extPath} installed successfully`
})
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
}
}
async function pickExtFiles() {
if (!$appConfig.devExtensionPath) {
toast.warning("Please set the dev extension path in the settings")
return goto("/settings/set-dev-ext-path")
}
const selected = await openFileSelector({
directory: false,
multiple: true, // allow install multiple extensions at once
filters: [
{
name: "tarball file",
extensions: ["tgz", "gz", "kunkun"]
}
]
})
if (!selected) {
return toast.warning("No File Selected")
}
console.log(selected)
for (const tarballPath of selected) {
await extensions.installTarball(tarballPath, $appConfig.devExtensionPath)
}
}
</script>
<div class="flex justify-center gap-3">
<Button size="sm" onclick={pickExtFolders}>Install from Extension Folders</Button>
<Button size="sm" onclick={pickExtFiles}>Install from Extension Tarball File</Button>
</div>
<StrikeSeparator class="my-1">
<h3 class="text-muted-foreground font-mono text-sm">Drag and Drop</h3>
</StrikeSeparator>
<Layouts.Center>
<DragNDrop
onDrop={(e) => {
handleDragNDropInstall(e.payload.paths)
}}
onEnter={() => (dragging = true)}
onCancelled={() => (dragging = false)}
>
<Card.Root
class={cn("h-36 w-96", dragging ? "border-lime-400/30" : "text-white hover:text-blue-200")}
>
<div class="flex h-full cursor-pointer items-center justify-center">
<div class={cn("flex flex-col items-center", dragging ? "text-lime-400/70" : "")}>
<IconMultiplexer
icon={{ value: "mdi:folder-cog-outline", type: IconEnum.Iconify }}
class="h-10 w-10"
/>
<small class="select-none font-mono text-xs">Drag and Drop</small>
<small class="select-none font-mono text-xs">Extension Folder or Tarball</small>
</div>
</div>
</Card.Root>
</DragNDrop>
</Layouts.Center>
<StrikeSeparator class="my-1">
<h3 class="text-muted-foreground font-mono text-sm">Install Tarball From URL</h3>
</StrikeSeparator>
<InstallTarballUrlForm />
<InstallNpmPackageNameForm />

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { appConfig, extensions } from "@/stores"
import { Input } from "@kksh/svelte5"
import { Form } from "@kksh/ui"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
const npmPackageNameFormSchema = v.object({
name: v.pipe(v.string(), v.minLength(1))
})
async function onNpmPackageNameSubmit(data: v.InferOutput<typeof npmPackageNameFormSchema>) {
if (!$appConfig.devExtensionPath) {
toast.warning(
"Please set the dev extension path in the settings to install tarball extension"
)
return goto("/settings/set-dev-ext-path")
}
await extensions
.installFromNpmPackageName(data.name, $appConfig.devExtensionPath)
.then(() => {
toast.success("Success", { description: "Extension installed successfully" })
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
}
const form = superForm(defaults(valibot(npmPackageNameFormSchema)), {
validators: valibotClient(npmPackageNameFormSchema),
SPA: true,
onUpdate({ form, cancel }) {
if (!form.valid) {
console.log("invalid")
return
}
console.log(form.data)
onNpmPackageNameSubmit(form.data)
cancel()
}
})
const { form: formData, enhance, errors } = form
</script>
<form method="POST" use:enhance>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<flex items-center gap-2>
<Input {...props} bind:value={$formData.name} placeholder="NPM Package Name" />
<Form.Button class="my-1">Install</Form.Button>
</flex>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</form>

View File

@ -0,0 +1,62 @@
<script lang="ts">
import { appConfig, extensions } from "@/stores"
import { Input } from "@kksh/svelte5"
import { Form } from "@kksh/ui"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
const urlFormSchema = v.object({
url: v.pipe(v.string(), v.url())
})
async function onUrlSubmit(data: v.InferOutput<typeof urlFormSchema>) {
// data.url
// https://storage.huakun.tech/vscode-0.0.6.tgz
if (!$appConfig.devExtensionPath) {
toast.warning(
"Please set the dev extension path in the settings to install tarball extension"
)
return goto("/settings/set-dev-ext-path")
}
await extensions
.installFromTarballUrl(data.url, $appConfig.devExtensionPath)
.then(() => {
toast.success("Sucecss", { description: "Extension installed successfully" })
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
}
const form = superForm(defaults(valibot(urlFormSchema)), {
validators: valibotClient(urlFormSchema),
SPA: true,
onUpdate({ form, cancel }) {
if (!form.valid) {
console.log("invalid")
return
}
console.log(form.data)
onUrlSubmit(form.data)
cancel()
}
})
const { form: formData, enhance, errors } = form
</script>
<form method="POST" use:enhance>
<Form.Field {form} name="url">
<Form.Control>
{#snippet children({ props })}
<flex items-center gap-2>
<Input {...props} bind:value={$formData.url} placeholder="Tarball URL" />
<Form.Button class="my-1">Install</Form.Button>
</flex>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</form>

View File

@ -1,17 +1,23 @@
import { findAllArgsInLink } from "@/cmds/quick-links"
import { CmdTypeEnum } from "@kksh/api/models"
import { Action as ActionSchema, CmdTypeEnum } from "@kksh/api/models"
import type { AppState } from "@kksh/types"
import type { CmdValue } from "@kksh/ui/types"
import { derived, get, writable, type Writable } from "svelte/store"
export const defaultAppState: AppState = {
searchTerm: "",
highlightedCmd: ""
highlightedCmd: "",
loadingBar: false,
defaultAction: "",
actionPanel: undefined
}
interface AppStateAPI {
clearSearchTerm: () => void
get: () => AppState
setLoadingBar: (loadingBar: boolean) => void
setDefaultAction: (defaultAction: string) => void
setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => void
}
function createAppState(): Writable<AppState> & AppStateAPI {
@ -22,18 +28,17 @@ function createAppState(): Writable<AppState> & AppStateAPI {
get: () => get(store),
clearSearchTerm: () => {
store.update((state) => ({ ...state, searchTerm: "" }))
},
setLoadingBar: (loadingBar: boolean) => {
store.update((state) => ({ ...state, loadingBar }))
},
setDefaultAction: (defaultAction: string) => {
store.update((state) => ({ ...state, defaultAction }))
},
setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => {
store.update((state) => ({ ...state, actionPanel }))
}
}
}
export const appState = createAppState()
// export const cmdQueries = derived(appState, ($appState) => {
// if ($appState.highlightedCmd.startsWith("{")) {
// const parsedCmd = JSON.parse($appState.highlightedCmd) as CmdValue
// if (parsedCmd.cmdType === CmdTypeEnum.QuickLink && parsedCmd.data) {
// return findAllArgsInLink(parsedCmd.data).map((arg) => ({ name: arg, value: "" }))
// }
// }
// return []
// })

View File

@ -10,7 +10,10 @@ import { appConfig } from "./appConfig"
function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
init: () => Promise<void>
getExtensionsFromStore: () => ExtPackageJsonExtra[]
installTarball: (tarballPath: string, extsDir: string) => Promise<ExtPackageJsonExtra>
installDevExtensionDir: (dirPath: string) => Promise<ExtPackageJsonExtra>
installFromTarballUrl: (tarballUrl: string, installDir: string) => Promise<ExtPackageJsonExtra>
installFromNpmPackageName: (name: string, installDir: string) => Promise<ExtPackageJsonExtra>
findStoreExtensionByIdentifier: (identifier: string) => ExtPackageJsonExtra | undefined
registerNewExtensionByPath: (extPath: string) => Promise<ExtPackageJsonExtra>
uninstallStoreExtensionByIdentifier: (identifier: string) => Promise<ExtPackageJsonExtra>
@ -56,12 +59,36 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
})
}
/**
* Install extension from tarball file
* @param tarballPath absolute path to the tarball file
* @param extsDir absolute path to the extensions directory
* @returns loaded extension
*/
async function installTarball(tarballPath: string, extsDir: string) {
return extAPI.installTarballUrl(tarballPath, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(extInstallPath)
})
}
async function installDevExtensionDir(dirPath: string) {
return extAPI.installDevExtensionDir(dirPath).then((ext) => {
return registerNewExtensionByPath(ext.extPath)
})
}
async function installFromTarballUrl(tarballUrl: string, extsDir: string) {
return extAPI.installTarballUrl(tarballUrl, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(extInstallPath)
})
}
async function installFromNpmPackageName(name: string, extsDir: string) {
return extAPI.installFromNpmPackageName(name, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(extInstallPath)
})
}
async function uninstallExtensionByPath(targetPath: string) {
const targetExt = get(extensions).find((ext) => ext.extPath === targetPath)
if (!targetExt) throw new Error(`Extension ${targetPath} not registered in DB`)
@ -96,7 +123,10 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
getExtensionsFromStore,
findStoreExtensionByIdentifier,
registerNewExtensionByPath,
installTarball,
installDevExtensionDir,
installFromTarballUrl,
installFromNpmPackageName,
uninstallStoreExtensionByIdentifier,
upgradeStoreExtension
}

View File

@ -19,8 +19,6 @@ function createQuickLinksStore(): Writable<QuickLink[]> & QuickLinkAPI {
async function refresh() {
const cmds = await getAllQuickLinkCommands()
console.log(cmds)
store.set(cmds.map((cmd) => ({ link: cmd.data.link, name: cmd.name, icon: cmd.data.icon })))
}

View File

@ -17,6 +17,12 @@
import { attachConsole } from "@tauri-apps/plugin-log"
import { onDestroy, onMount } from "svelte"
onMount(() => {
setTimeout(() => {
import("virtual:uno.css")
}, 1000)
})
let { children } = $props()
const unlisteners: UnlistenFn[] = []

View File

@ -5,7 +5,6 @@
import { systemCommands } from "@/cmds/system"
import { appConfig, appState, devStoreExts, installedStoreExts, quickLinks } from "@/stores"
import { cmdQueries } from "@/stores/cmdQuery"
import { commandScore } from "@/utils/command-score"
import { getActiveElementNodeName } from "@/utils/dom"
import { openDevTools } from "@kksh/api/commands"
import type { ExtPackageJsonExtra } from "@kksh/api/models"
@ -21,7 +20,7 @@
SystemCmds
} from "@kksh/ui/main"
import type { BuiltinCmd, CmdValue, CommandLaunchers } from "@kksh/ui/types"
import { cn } from "@kksh/ui/utils"
import { cn, commandScore } from "@kksh/ui/utils"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { exit } from "@tauri-apps/plugin-process"
import { EllipsisVerticalIcon } from "lucide-svelte"
@ -105,7 +104,6 @@
</CustomCommandInput>
<Command.List class="max-h-screen grow">
<Command.Empty data-tauri-drag-region>No results found.</Command.Empty>
<Command.Separator />
{#if $appConfig.extensionsInstallDir && $devStoreExts.length > 0}
<ExtCmdsGroup
extensions={$devStoreExts}
@ -114,7 +112,6 @@
onExtCmdSelect={commandLaunchers.onExtCmdSelect}
hmr={$appConfig.hmr}
/>
<Command.Separator />
{/if}
{#if $appConfig.extensionsInstallDir && $installedStoreExts.length > 0}
<ExtCmdsGroup
@ -124,11 +121,9 @@
hmr={false}
onExtCmdSelect={commandLaunchers.onExtCmdSelect}
/>
<Command.Separator />
{/if}
<QuickLinks quickLinks={$quickLinks} />
<BuiltinCmds {builtinCmds} />
<Command.Separator />
<SystemCmds {systemCommands} />
</Command.List>
<GlobalCommandPaletteFooter />

View File

@ -3,19 +3,18 @@
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { Icon, IconEnum, IconType } from "@kksh/api/models"
import { createQuickLinkCommand } from "@kksh/extension/db"
import { Button, Input } from "@kksh/svelte5"
import { Form, IconSelector } from "@kksh/ui"
import { dev } from "$app/environment"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient, zod, zodClient } from "sveltekit-superforms/adapters"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
const formSchema = v.object({
name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
link: v.pipe(v.string(), v.minLength(5), v.maxLength(1000)),
link: v.pipe(v.string(), v.url(), v.minLength(5), v.maxLength(1000)),
iconType: IconType,
iconValue: v.string(),
invertIcon: v.boolean()
@ -63,7 +62,7 @@
$effect(() => {
$formData.iconType = icon.type
$formData.iconValue = icon.value
$formData.invertIcon = icon.invert
$formData.invertIcon = icon.invert ?? false
})
</script>
@ -99,12 +98,11 @@
<input name="iconType" hidden type="text" bind:value={$formData.iconType} />
<input name="iconValue" hidden type="text" bind:value={$formData.iconValue} />
<input name="invertIcon" hidden type="text" bind:value={$formData.invertIcon} />
<br />
<Form.Button>Submit</Form.Button>
<Form.Button class="my-1">Submit</Form.Button>
</form>
</div>
{#if dev}
<div class="p-3">
<div class="p-5">
<SuperDebug data={$formData} />
</div>
{/if}

View File

@ -1 +1,289 @@
<script lang="ts"></script>
<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}

View File

@ -0,0 +1,76 @@
import { db, unregisterExtensionWindow } from "@kksh/api/commands"
import type { Ext as ExtInfoInDB, ExtPackageJsonExtra } from "@kksh/api/models"
import { loadExtensionManifestFromDisk } from "@kksh/extension"
import { error as sbError } from "@sveltejs/kit"
import { join } from "@tauri-apps/api/path"
import { exists, readTextFile } from "@tauri-apps/plugin-fs"
import { error } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import type { PageLoad } from "./$types"
// : Promise<{
// extPath: string
// scriptPath: string
// // workerScript: string
// pkgJsonPath: string
// cmdName: string
// loadedExt: ExtPackageJsonExtra
// extInfoInDB: ExtInfoInDB
// }>
export const load: PageLoad = async ({ url }) => {
// both query parameter must exist
const extPath = url.searchParams.get("extPath")
const cmdName = url.searchParams.get("cmdName")
if (!extPath || !cmdName) {
toast.error("Invalid extension path or url")
error("Invalid extension path or url")
goto("/")
}
let _loadedExt: ExtPackageJsonExtra | undefined
try {
_loadedExt = await loadExtensionManifestFromDisk(await join(extPath!, "package.json"))
} catch (err) {
error(`Error loading extension manifest: ${err}`)
toast.error("Error loading extension manifest", {
description: `${err}`
})
goto("/")
}
const loadedExt = _loadedExt!
const extInfoInDB = await db.getUniqueExtensionByPath(loadedExt.extPath)
if (!extInfoInDB) {
toast.error("Unexpected Error", {
description: `Extension ${loadedExt.kunkun.identifier} not found in database. Run Troubleshooter.`
})
goto("/")
}
const pkgJsonPath = await join(extPath!, "package.json")
if (!(await exists(extPath!))) {
sbError(404, `Extension not found at ${extPath}`)
}
if (!(await exists(pkgJsonPath))) {
sbError(404, `Extension package.json not found at ${pkgJsonPath}`)
}
const cmd = loadedExt.kunkun.templateUiCmds.find((cmd) => cmd.name === cmdName)
if (!cmd) {
sbError(404, `Command ${cmdName} not found in extension ${loadedExt.kunkun.identifier}`)
}
const scriptPath = await join(loadedExt.extPath, cmd.main)
if (!(await exists(scriptPath))) {
sbError(404, `Command script not found at ${scriptPath}`)
}
// const workerScript = await readTextFile(scriptPath)
return {
extPath: extPath!,
pkgJsonPath,
scriptPath,
// workerScript,
cmdName: cmdName!,
loadedExt,
extInfoInDB: extInfoInDB!
}
}

View File

@ -0,0 +1,31 @@
<script lang="ts">
import AddDevExtForm from "@/components/standalone/settings/AddDevExtForm.svelte"
import DevExtPathForm from "@/components/standalone/settings/DevExtPathForm.svelte"
import { appConfig, extensions } from "@/stores"
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import * as extAPI from "@kksh/extension"
import { installFromNpmPackageName } from "@kksh/extension"
import { Button, Separator } from "@kksh/svelte5"
import { StrikeSeparator } from "@kksh/ui"
import { open as openFileSelector } from "@tauri-apps/plugin-dialog"
import * as fs from "@tauri-apps/plugin-fs"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import * as v from "valibot"
</script>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="fixed left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="absolute left-0 top-0 h-10 w-screen" data-tauri-drag-region></div>
<main class="container pt-10">
<h2 class="text-2xl font-bold">Add Dev Extension</h2>
<small>
There are 4 options to install an extension in developer mode. Either load it from your local
tarball file, a tarball remote URL, npm package name or load from a remote URL.
</small>
<AddDevExtForm />
</main>

View File

@ -7,7 +7,7 @@
</script>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="absolute left-2 top-2 z-50" onclick={goBack}>
<Button variant="outline" size="icon" class="fixed left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="absolute left-0 top-0 h-10 w-screen" data-tauri-drag-region></div>

View File

@ -1,3 +1,4 @@
import typography from "@tailwindcss/typography"
import type { Config } from "tailwindcss"
import tailwindcssAnimate from "tailwindcss-animate"
import { fontFamily } from "tailwindcss/defaultTheme"
@ -94,7 +95,7 @@ const config: Config = {
}
}
},
plugins: [tailwindcssAnimate]
plugins: [tailwindcssAnimate, typography]
}
export default config

View File

@ -0,0 +1,5 @@
import { defineConfig, presetAttributify, presetTagify, presetUno } from "unocss"
export default defineConfig({
presets: [presetUno(), presetAttributify(), presetTagify()]
})

View File

@ -1,4 +1,5 @@
import { sveltekit } from "@sveltejs/kit/vite"
import UnoCSS from "unocss/vite"
import { defineConfig } from "vite"
// @ts-expect-error process is a nodejs global
@ -6,7 +7,7 @@ const host = process.env.TAURI_DEV_HOST
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
plugins: [UnoCSS(), sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//

View File

@ -1,7 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@kunkun/api",
"version": "0.0.27",
"version": "0.0.28",
"license": "MIT",
"exports": {
".": "./src/index.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@kksh/api",
"version": "0.0.27",
"version": "0.0.28",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@ -7,7 +7,7 @@ export enum KUNKUN_EXT_IDENTIFIER {
}
export const KUNKUN_DESKTOP_APP_SERVER_PORTS = [1566, 1567, 1568, 9559, 9560, 9561]
export const DESKTOP_SERVICE_NAME = "Kunkun"
export const DESKTOP_SERVICE_NAME = "kunkun"
/* -------------------------------------------------------------------------- */
/* Deep Link */

View File

@ -15,7 +15,7 @@ export function checkLocalKunkunService(port: number): Promise<boolean> {
return res.json()
})
.then((data) => {
return data["service_name"] === DESKTOP_SERVICE_NAME
return data["service_name"].toLowerCase() === DESKTOP_SERVICE_NAME.toLowerCase()
})
.catch((err) => {
// fetch fail, i.e. server not on this port

View File

@ -33,6 +33,7 @@ import type { fileSearch } from "../commands/fileSearch"
import { type AppInfo } from "../models/apps"
import type { LightMode, Position, Radius, ThemeColor } from "../models/styles"
import type { DenoSysOptions } from "../permissions/schema"
import type { MarkdownSchema } from "./worker"
import { type IComponent } from "./worker/components/interfaces"
import type { Markdown } from "./worker/components/markdown"
import * as FormSchema from "./worker/schema/form"
@ -116,7 +117,7 @@ export interface IToast {
}
export interface IUiWorker {
render: (view: IComponent<ListSchema.List | FormSchema.Form | Markdown>) => Promise<void>
render: (view: IComponent<ListSchema.List | FormSchema.Form | MarkdownSchema>) => Promise<void>
goBack: () => Promise<void>
showLoadingBar: (loading: boolean) => Promise<void>
setScrollLoading: (loading: boolean) => Promise<void>

View File

@ -145,6 +145,9 @@ export class Form implements IComponent<FormSchema.Form> {
constructor(model: OmitNodeName<FormSchema.Form & { fields: (AllFormFields | Form)[] }>) {
this.fields = model.fields
this.key = model.key
this.title = model.title
this.description = model.description
this.submitBtnText = model.submitBtnText
}
toModel(): FormSchema.Form {

View File

@ -65,7 +65,8 @@ export type BaseField = InferOutput<typeof BaseField>
export const InputField = object({
...BaseField.entries,
type: optional(InputTypes),
component: optional(union([literal("textarea"), literal("default")]))
component: optional(union([literal("textarea"), literal("default")])),
default: optional(string())
})
export type InputField = InferOutput<typeof InputField>
@ -74,7 +75,8 @@ export type InputField = InferOutput<typeof InputField>
/* -------------------------------------------------------------------------- */
export const NumberField = object({
...BaseField.entries,
nodeName: FormNodeName
nodeName: FormNodeName,
default: optional(number())
})
export type NumberField = InferOutput<typeof NumberField>
@ -84,7 +86,8 @@ export type NumberField = InferOutput<typeof NumberField>
// with zod enum
export const SelectField = object({
...BaseField.entries,
options: array(string())
options: array(string()),
default: optional(string())
})
export type SelectField = InferOutput<typeof SelectField>
@ -101,7 +104,8 @@ export type BooleanField = InferOutput<typeof BooleanField>
/* Date */
/* -------------------------------------------------------------------------- */
export const DateField = object({
...BaseField.entries
...BaseField.entries,
default: optional(string())
})
export type DateField = InferOutput<typeof DateField>
@ -121,14 +125,22 @@ export type ArrayField = InferOutput<typeof ArrayField>
/* -------------------------------------------------------------------------- */
export const FormField = union([
ArrayField, // this must be placed first, otherwise its content field won't be parsed
SelectField,
InputField,
NumberField,
SelectField,
BooleanField,
DateField
])
export type FormField = InferOutput<typeof FormField>
// export type Form = InferOutput<typeof Form>
export const Form: GenericSchema<Form> = object({
nodeName: FormNodeName,
key: string(),
fields: array(union([lazy(() => Form), FormField])),
title: optional(string()),
description: optional(string()),
submitBtnText: optional(string())
})
export type Form = {
nodeName: FormNodeName
title?: string
@ -137,8 +149,3 @@ export type Form = {
key: string
fields: (FormField | Form)[]
}
export const Form: GenericSchema<Form> = object({
nodeName: FormNodeName,
key: string(),
fields: array(union([lazy(() => Form), FormField]))
})

View File

@ -13,7 +13,7 @@ export const breakingChangesVersionCheckpoints = [
const checkpointVersions = breakingChangesVersionCheckpoints.map((c) => c.version)
const sortedCheckpointVersions = sort(checkpointVersions)
export const version = "0.0.27"
export const version = "0.0.28"
export function isVersionBetween(v: string, start: string, end: string) {
const vCleaned = clean(v)

View File

@ -88,6 +88,11 @@ export async function installTarballUrl(tarballUrl: string, extsDir: string): Pr
}
}
/**
* Install dev extension from a local directory
* @param extPath Path to the extension directory
* @returns
*/
export async function installDevExtensionDir(extPath: string): Promise<ExtPackageJsonExtra> {
const manifestPath = await path.join(extPath, "package.json")
if (!(await fs.exists(manifestPath))) {

View File

@ -0,0 +1,176 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
extensions_support/

View File

@ -0,0 +1,8 @@
# demo-template-extension
## 0.0.3
### Patch Changes
- Updated dependencies
- @kksh/api@0.0.9

View File

@ -0,0 +1,15 @@
# tempalte-ext-worker
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.1.20. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@ -0,0 +1,3 @@
import Buffer from "node:buffer"
console.log(Buffer)

View File

@ -0,0 +1,31 @@
import { watch } from "fs"
import { join } from "path"
import { refreshTemplateWorkerExtension } from "@kksh/api/dev"
import { $ } from "bun"
async function build() {
try {
// await $`bun build --minify --target=browser --outdir=./dist ./src/index.ts`
const output = await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
minify: true,
target: "browser"
})
console.log(output)
await refreshTemplateWorkerExtension()
} catch (error) {
console.error(error)
}
}
const srcDir = join(import.meta.dir, "src")
await build()
if (Bun.argv.includes("dev")) {
console.log(`Watching ${srcDir} for changes...`)
watch(srcDir, { recursive: true }, async (event, filename) => {
await build()
})
}

View File

@ -0,0 +1,5 @@
{
"imports": {
"@kunkun/api": "jsr:@kunkun/api@^0.0.14"
}
}

View File

@ -0,0 +1,23 @@
{
"version": "4",
"specifiers": {
"jsr:@hk/comlink-stdio@~0.1.5": "0.1.5",
"jsr:@kunkun/api@^0.0.14": "0.0.14"
},
"jsr": {
"@hk/comlink-stdio@0.1.5": {
"integrity": "1fd67d5d53ab4571e745584d66b480b5be402f6ca6b2c9e591230fa1d23f85ee"
},
"@kunkun/api@0.0.14": {
"integrity": "a21a255748164992ca93fc292451677261dffca336922a6bed7eb8703c6e880b",
"dependencies": [
"jsr:@hk/comlink-stdio"
]
}
},
"workspace": {
"dependencies": [
"jsr:@kunkun/api@^0.0.14"
]
}
}

View File

@ -0,0 +1,13 @@
import { expose } from "@kunkun/api/runtime/deno"
export interface API {
add(a: number, b: number): Promise<number>
subtract(a: number, b: number): Promise<number>
}
// Define your API methods
export const apiMethods: API = {
add: async (a: number, b: number) => a + b,
subtract: async (a: number, b: number) => a - b
}
expose(apiMethods)

View File

@ -0,0 +1,114 @@
{
"$schema": "../../schema/manifest-json-schema.json",
"name": "demo-template-extension",
"version": "0.0.3",
"type": "module",
"kunkun": {
"name": "Demo Template Extension",
"shortDescription": "Demo Template Extension",
"longDescription": "Demo Template Extension",
"identifier": "demo-worker-template-ext",
"permissions": [
"fetch:all",
"shell:kill",
"security:mac:all",
{
"permission": "shell:deno:execute",
"allow": [
{
"path": "$EXTENSION/deno-src/deno-script.ts",
"env": [
"npm_package_config_libvips",
"CWD"
],
"ffi": "*",
"read": [
"$DESKTOP"
]
},
{
"path": "$EXTENSION/deno-src/rpc.ts",
"ffi": "*"
}
]
},
{
"permission": "open:file",
"allow": [
{
"path": "$EXTENSION/src/deno-script.ts"
}
]
},
"shell:stdin-write",
{
"permission": "shell:execute",
"allow": [
{
"cmd": {
"program": "ls",
"args": [
"-l"
]
}
},
{
"cmd": {
"program": "bash",
"args": [
"-c",
".+"
]
}
},
{
"cmd": {
"program": "deno",
"args": [
"-A",
".+",
".+"
]
}
}
]
}
],
"demoImages": [],
"icon": {
"type": "iconify",
"value": "carbon:demo"
},
"customUiCmds": [],
"templateUiCmds": [
{
"name": "Demo Worker Template",
"main": "dist/index.js",
"cmds": []
}
]
},
"scripts": {
"dev": "bun build.ts dev",
"build": "bun build.ts"
},
"dependencies": {
"@hk/comlink-stdio": "npm:@jsr/hk__comlink-stdio@^0.1.6",
"@kksh/api": "workspace:*",
"@kunkun/api": "npm:@jsr/kunkun__api@^0.0.13"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@types/bun": "latest",
"rollup-plugin-visualizer": "^5.12.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"files": [
"./dist",
".gitignore"
]
}

View File

@ -0,0 +1,20 @@
import { visualizer } from "rollup-plugin-visualizer";
import resolve from '@rollup/plugin-node-resolve';
import typescript from "@rollup/plugin-typescript";
import commonjs from '@rollup/plugin-commonjs';
export default {
input: "src/index.ts",
output: {
dir: "dist",
format: "esm",
},
plugins: [
typescript(),
resolve(),
commonjs(),
// put it the last one
visualizer(),
],
};

View File

@ -0,0 +1,181 @@
import type { RPCChannel } from "@hk/comlink-stdio/browser"
import {
Action,
app,
Child,
expose,
Form,
fs,
Icon,
IconEnum,
List,
Markdown,
open,
path,
security,
shell,
toast,
ui,
WorkerExtension
} from "@kksh/api/ui/worker"
import { IconType } from "@kunkun/api/models"
const nums = Array.from({ length: 20 }, (_, i) => i + 1)
const categories = ["Suggestion", "Advice", "Idea"]
const itemsTitle = nums.map((n) => categories.map((c) => `${c} ${n}`)).flat()
const allItems: List.Item[] = itemsTitle.map(
(title) =>
new List.Item({
title,
value: title,
defaultAction: "Item Default Action"
})
)
class ExtensionTemplate extends WorkerExtension {
async onBeforeGoBack() {
console.log("onBeforeGoBack")
// console.log(`Try killing pid: ${this.apiProcess?.pid}`)
// await this.apiProcess?.kill()
// console.log("apiProcess killed")
}
async onFormSubmit(value: Record<string, any>): Promise<void> {
console.log("Form submitted", value)
}
async onEnterPressedOnSearchBar(): Promise<void> {
console.log("Enter pressed on search bar")
}
async load() {
// console.log("Check screen capture permission:", await security.mac.checkScreenCapturePermission())
// await security.mac.revealSecurityPane("AllFiles")
// console.log(await security.mac.verifyFingerprint())
ui.setSearchBarPlaceholder("Search for items")
ui.showLoadingBar(true)
setTimeout(() => {
ui.showLoadingBar(false)
}, 2000)
const { rpcChannel, process } = await shell.createDenoRpcChannel<
{},
{
add(a: number, b: number): Promise<number>
subtract(a: number, b: number): Promise<number>
}
>("$EXTENSION/deno-src/rpc.ts", [], {}, {})
const api = rpcChannel.getApi()
await api.add(1, 2).then(console.log)
await api.subtract(1, 2).then(console.log)
await process.kill()
const extPath = await path.extensionDir()
// console.log("Extension path:", extPath)
const tagList = new List.ItemDetailMetadataTagList({
title: "Tag List Title",
tags: [
new List.ItemDetailMetadataTagListItem({
text: "red",
color: "#ff0000"
}),
new List.ItemDetailMetadataTagListItem({
text: "yellow",
color: "#ffff00"
})
]
})
const list = new List.List({
items: allItems,
defaultAction: "Top Default Action",
detail: new List.ItemDetail({
children: [
new List.ItemDetailMetadata([
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
}),
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text",
icon: new Icon({
type: IconType.enum.Iconify,
value: "mingcute:appstore-fill"
})
}),
new List.ItemDetailMetadataSeparator(),
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
}),
new List.ItemDetailMetadataLink({
title: "Link Title",
text: "Link Text",
url: "https://github.com/huakunshen"
}),
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
}),
tagList
]),
new Markdown(`
# Hello World
<img src="https://github.com/huakunshen.png" />
<img src="https://github.com/huakunshen.png" />
<img src="https://github.com/huakunshen.png" />
`)
],
width: 50
}),
actions: new Action.ActionPanel({
items: [
new Action.Action({
title: "Action 1",
value: "action 1",
icon: new Icon({ type: IconType.enum.Iconify, value: "material-symbols:add-reaction" })
}),
new Action.Action({ title: "Action 2", value: "action 2" }),
new Action.Action({ title: "Action 3", value: "action 3" }),
new Action.Action({ title: "Action 4", value: "action 4" })
]
})
})
return ui.render(list)
}
async onSearchTermChange(term: string): Promise<void> {
return ui.render(
new List.List({
// items: allItems.filter((item) => item.title.toLowerCase().includes(term.toLowerCase())),
inherits: ["items", "sections"],
defaultAction: "Top Default Action",
detail: new List.ItemDetail({
children: [
new List.ItemDetailMetadata([
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
})
])
// new Markdown(`
// ## Search results for "${term}"
// <img src="https://github.com/huakunshen.png" />
// <img src="https://github.com/huakunshen.png" />
// <img src="https://github.com/huakunshen.png" />
// `)
],
width: term.length > 3 ? 70 : 30
})
})
)
}
async onListItemSelected(value: string): Promise<void> {
console.log("Item selected:", value)
}
async onActionSelected(value: string): Promise<void> {
console.log("Action selected:", value)
}
}
expose(new ExtensionTemplate())

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

177
packages/extensions/form-view/.gitignore vendored Normal file
View File

@ -0,0 +1,177 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
extensions_support/

View File

@ -0,0 +1,125 @@
# Kunkun Template UI Extension
This is a template for a template UI extension. (UI follows pre-defined template)
[./src/index.ts](./src/index.ts) is the default entrypoint for the extension. You can import any other files in this file, but the build process will bundle them into a single file.
## Pros and Cons
This type of extension is suitable for simple use cases, such as a list or form. All components are pre-defined, so there is not much room for customization. If you want more flexibility on the UI, consider using [Custom UI Extension](https://docs.kunkun.sh/extensions/custom-ui-ext/), which requires some frontend knowledge but gives you full control over the UI.
Read documentation at https://docs.kunkun.sh/extensions/worker-template/
Make sure you understand what this type of extension is capable of.
### Pros
- Simple to develop, no need for any frontend knowledge.
- Small bundle size (~40KB)
- [Custom UI Extension](https://docs.kunkun.sh/extensions/custom-ui-ext/) are usually larger than 300KB.
### Cons
- Limited UI customization. Not suitable for complex use cases.
Consider [Custom UI Extension](https://docs.kunkun.sh/extensions/custom-ui-ext/) if you need more complex UI.
## Development
```bash
pnpm install
```
Start extension in development mode. Every save will trigger a hot reload in Kunkun.
```bash
pnpm dev
```
- During development, right click in Kunkun to open the developer tools.
- Error messages will be shown in the console.
- If you got any permission error while calling Kunknu's APIs, make sure you've declared the permission in `package.json`. Then go back to home page and enter the extension again to re-apply the permission.
- To develop and preview the extension in Kunkun, you need to run the `Add Dev Extension` command in Kunkun, and register this extension's path.
Build the extension. Your extension source code can contain many files, but the build process will bundle them into a single file.
```bash
pnpm build
# Due to Bun's bug, if you are on windows, and install dependencies with pnpm, you may get error during build.
# Try install dependencies with bun or npm instead.
```
## i18n
[./src/i18n](./src/i18n/) contains optional internationalization support starter code.
If you want to support i18n, you can use the `t` function to translate the strings in the extension.
User's language setting is available via `app.language()`.
```ts
import { app } from "@kksh/api/ui/worker"
import { setupI18n, t } from "./src/i18n"
setupI18n("zh")
console.log(t("welcome"))
setupI18n(await app.language())
console.log(t("welcome"))
```
## Add More Commands
If you want to add more template worker extension commands, simply modify the `entrypoints` array in [./build.ts](./build.ts).
Then in `package.json`, register the new command.
## Verify Build and Publish
```bash
pnpm build # make sure the build npm script works
npx kksh@latest verify # Verify some basic settings
npx kksh@latest verify --publish # Verify some basic settings before publishing
```
It is recommended to build the extension with the same environment our CI uses.
The docker image used by our CI is `huakunshen/kunkun-ext-builder:latest`.
You can use the following command to build the extension with the same environment our CI uses.
This requires you to have docker installed, and the shell you are using has access to it via `docker` command.
```bash
npx kksh@latest build # Build the extension with
```
`pnpm` is used to install dependencies and build the extension.
The docker image environment also has `node`, `pnpm`, `npm`, `bun`, `deno` installed.
If your build failed, try debug with `huakunshen/kunkun-ext-builder:latest` image in interative mode and bind your extension volume to `/workspace`.
After build successfully, you should find a tarball file ends with `.tgz` in the root of your extension.
The tarball is packaged with `npm pack` command. You can uncompress it to see if it contains all the necessary files.
This tarball is the final product that will be published and installed in Kunkun. You can further verify your extension by installing this tarball directly in Kunkun.
After verifying the tarball, it's ready to be published.
Fork [KunkunExtensions](https://github.com/kunkunsh/KunkunExtensions) repo, add your extension to the `extensions` directory, and create a PR.
Once CI passed and PR merged, you can use your extension in Kunkun.
## Potential Error
Our CI uses `pnpm` to install dependencies. If you are on Windows, you may get error during build.
See issue https://github.com/kunkunsh/kunkun/issues/78
`bun` had problem building the extension when `pnpm` is used to install dependencies.
### Options
1. Install an older version of `bun` (1.1.27 should work)
2. Install dependencies with `bun` or `npm` instead of `pnpm`
Our CI always builds the extension with on Linux and shouldn't have this problem.

View File

@ -0,0 +1,30 @@
import { watch } from "fs"
import { join } from "path"
import { refreshTemplateWorkerExtension } from "@kksh/api/dev"
import { $ } from "bun"
const entrypoints = ["./src/index.ts"]
async function build() {
try {
for (const entrypoint of entrypoints) {
await $`bun build --minify --target=browser --outdir=./dist ${entrypoint}`
}
if (Bun.argv.includes("dev")) {
await refreshTemplateWorkerExtension()
}
} catch (error) {
console.error(error)
}
}
const srcDir = join(import.meta.dir, "src")
await build()
if (Bun.argv.includes("dev")) {
console.log(`Watching ${srcDir} for changes...`)
watch(srcDir, { recursive: true }, async (event, filename) => {
await build()
})
}

View File

@ -0,0 +1,47 @@
{
"$schema": "https://schema.kunkun.sh",
"name": "form-view",
"version": "0.0.2",
"type": "module",
"kunkun": {
"name": "Form View",
"shortDescription": "A Worker Extension Template",
"longDescription": "A Worker Extension Template",
"identifier": "form-view",
"permissions": [
"fetch:all",
"clipboard:read-all"
],
"demoImages": [],
"icon": {
"type": "iconify",
"value": "fluent:form-multiple-28-filled"
},
"customUiCmds": [],
"templateUiCmds": [
{
"name": "Dev Form View",
"main": "dist/index.js",
"cmds": []
}
]
},
"scripts": {
"dev": "bun build.ts dev",
"build": "bun build.ts"
},
"dependencies": {
"@kksh/api": "workspace:*",
"i18next": "^23.15.1"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"files": [
"./dist",
".gitignore"
]
}

View File

@ -0,0 +1,5 @@
const en = {
welcome: "Welcome to Kunkun"
}
export default en
export type Translation = typeof en

View File

@ -0,0 +1,20 @@
import i18next from "i18next"
import en, { type Translation } from "./en"
import zh from "./zh"
export function setupI18n(language: "en" | "zh" = "en") {
i18next.init({
resources: {
en: {
translation: en
},
zh: {
translation: zh
}
},
lng: language, // default language
fallbackLng: "en"
})
}
export const t = (key: keyof Translation, options?: any) => i18next.t(key, options)

View File

@ -0,0 +1,5 @@
import type { Translation } from "./en"
export default {
welcome: "欢迎来到Kunkun"
} satisfies Translation

View File

@ -0,0 +1,95 @@
import {
Action,
app,
expose,
Form,
fs,
Icon,
IconEnum,
List,
Markdown,
path,
shell,
toast,
ui,
WorkerExtension
} from "@kksh/api/ui/worker"
class ExtensionTemplate extends WorkerExtension {
async onFormSubmit(value: Record<string, any>): Promise<void> {
console.log("Form submitted", value)
toast.success(`Form submitted: ${JSON.stringify(value)}`)
}
async load() {
const markdown = new Markdown(`# Hello World
<img src="https://github.com/huakunshen.png" />`)
// markdown.toModel
return ui.render(markdown)
const form = new Form.Form({
title: "Form 1",
key: "form1",
submitBtnText: "Download",
fields: [
new Form.DateField({
key: "birthday",
label: "Date of Birth",
hideLabel: false,
description: "Enter your date of birth"
}),
new Form.NumberField({
key: "age",
label: "Age",
default: 18,
placeholder: "Enter your age",
optional: true,
description: "Enter your age"
}),
new Form.InputField({
key: "name",
label: "Name",
default: "Huakun"
}),
new Form.InputField({
key: "name2",
label: "Name 2"
}),
new Form.BooleanField({
key: "isActive",
label: "Is Active",
description: "Is the user active?"
}),
new Form.SelectField({
key: "gender",
label: "Gender",
options: ["Male", "Female", "Other"],
description: "Select your gender"
})
]
})
console.log(form)
console.log(form.toModel())
return ui.render(form)
}
async onActionSelected(actionValue: string): Promise<void> {
switch (actionValue) {
case "open":
break
default:
break
}
}
onSearchTermChange(term: string): Promise<void> {
console.log("Search term changed to:", term)
return Promise.resolve()
}
onListItemSelected(value: string): Promise<void> {
console.log("Item selected:", value)
return Promise.resolve()
}
}
expose(new ExtensionTemplate())

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -1,4 +1,9 @@
import { Action as ActionSchema } from "@kksh/api/models"
export interface AppState {
searchTerm: string
highlightedCmd: string
loadingBar: boolean
defaultAction: string
actionPanel?: ActionSchema.ActionPanel
}

View File

@ -34,11 +34,11 @@
"lint": "eslint ."
},
"devDependencies": {
"@iconify/svelte": "^4.0.2",
"@kksh/api": "workspace:*",
"@kksh/svelte5": "^0.1.2-beta.8",
"@types/bun": "latest",
"bits-ui": "1.0.0-next.45",
"@iconify/svelte": "^4.0.2",
"clsx": "^2.1.1",
"formsnap": "2.0.0-next.1",
"lucide-svelte": "^0.454.0",
@ -56,7 +56,9 @@
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.6",
"@std/semver": "npm:@jsr/std__semver@^1.0.3",
"gsap": "^3.12.5"
"gsap": "^3.12.5",
"svelte-markdown": "^0.4.1"
}
}

View File

@ -58,8 +58,6 @@
</Select.Content>
</Select.Root>
<!-- <div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right">Name</Label>

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from "@kksh/ui/utils"
import type { Snippet } from "svelte"
let { class: className, children }: { class?: string; children: Snippet } = $props()
</script>
<kbd
class={cn(
"text-md bg-muted flex h-5 w-5 min-w-5 items-center justify-center rounded-sm border p-1 font-mono",
className
)}
>
{@render children()}
</kbd>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import { cn } from "@kksh/ui/utils"
import type { HTMLAttributes } from "svelte/elements"
const {
class: className,
color = "white",
duration
}: { class?: string; color?: string; duration?: number } = $props()
</script>
<div class={cn("h-0.5 w-full", className)}>
<div class="relative h-full overflow-hidden">
<div class="loading-bar h-full" style="--color: {color}; --width: 30em"></div>
</div>
</div>
<style>
.loading-bar::before {
content: "";
position: absolute;
display: block;
width: var(--width);
height: 100%;
background: linear-gradient(
to right,
transparent,
var(--color) 40%,
var(--color) 60%,
transparent
);
opacity: 0.5;
animation: moveLoadingBar 2s linear infinite;
}
@keyframes moveLoadingBar {
0% {
left: calc(var(--width) * -1 / 2);
}
50% {
left: calc(100% - var(--width) / 2);
}
100% {
left: calc(var(--width) * -1 / 2);
}
}
</style>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { Separator } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import { type Snippet } from "svelte"
const { class: className, children }: { class?: string; children: Snippet } = $props()
</script>
<div
class={cn(
"flex cursor-default select-none items-center justify-center space-x-5 whitespace-nowrap",
className
)}
>
<span class="w-full">
<Separator />
</span>
{@render children()}
<span class="w-full">
<Separator />
</span>
</div>

View File

@ -0,0 +1,30 @@
<script lang="ts">
import { cn } from "@kksh/ui/utils"
import type { Snippet } from "svelte"
import type { HTMLAttributes } from "svelte/elements"
import { open } from "tauri-plugin-shellx-api"
const {
href,
class: className = "",
children
}: {
href: string
class?: HTMLAttributes<HTMLAnchorElement>["class"]
children: Snippet
} = $props()
function handleClick() {
open(href)
}
</script>
<button
class={cn(
"text-left font-medium text-blue-600 hover:cursor-pointer hover:underline dark:text-blue-500",
className
)}
onclick={handleClick}
>
{@render children?.()}
</button>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date"
import { ButtonModule, Calendar, Popover } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import CalendarIcon from "lucide-svelte/icons/calendar"
const df = new DateFormatter("en-US", {
dateStyle: "long"
})
let {
date = $bindable(),
value = $bindable(),
class: className
}: { date?: DateValue; value?: string; class?: string } = $props()
let contentRef = $state<HTMLElement | null>(null)
$effect(() => {
value = date ? date.toString() : ""
})
</script>
<Popover.Root>
<Popover.Trigger
class={cn(
ButtonModule.buttonVariants({
variant: "outline",
class: cn("w-[280px] justify-start text-left font-normal", className)
}),
!date && "text-muted-foreground"
)}
>
<CalendarIcon class="mr-2 size-4" />
{date ? df.format(date.toDate(getLocalTimeZone())) : "Pick a date"}
</Popover.Trigger>
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
<Calendar.Calendar type="single" bind:value={date} />
</Popover.Content>
</Popover.Root>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { DateFormatter, getLocalTimeZone, today, type DateValue } from "@internationalized/date"
import { Button, ButtonModule, Calendar, Popover, Select } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import CalendarIcon from "lucide-svelte/icons/calendar"
const df = new DateFormatter("en-US", {
dateStyle: "long"
})
let {
date = $bindable(),
class: className,
value = $bindable()
}: { date?: DateValue; class?: string; value?: string } = $props()
const valueString = $derived(date ? df.format(date.toDate(getLocalTimeZone())) : "")
$effect(() => {
value = date ? date.toString() : ""
})
const items = [
{ value: 0, label: "Today" },
{ value: 1, label: "Tomorrow" },
{ value: 3, label: "In 3 days" },
{ value: 7, label: "In a week" }
]
</script>
<Popover.Root>
<Popover.Trigger
class={cn(
ButtonModule.buttonVariants({
variant: "outline",
class: "w-[280px] justify-start text-left font-normal"
}),
!date && "text-muted-foreground",
className
)}
>
<CalendarIcon class="mr-2 size-4" />
{date ? df.format(date.toDate(getLocalTimeZone())) : "Pick a date"}
</Popover.Trigger>
<Popover.Content class="flex w-auto flex-col space-y-2 p-2">
<Select.Root
type="single"
value={valueString}
controlledValue
onValueChange={(v) => {
if (!v) return
date = today(getLocalTimeZone()).add({ days: Number.parseInt(v) })
}}
>
<Select.Trigger>
{valueString}
</Select.Trigger>
<Select.Content>
{#each items as item}
<Select.Item value={`${item.value}`}>{item.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="rounded-md border">
<Calendar.Calendar type="single" bind:value={date} />
</div>
</Popover.Content>
</Popover.Root>

View File

@ -0,0 +1,2 @@
export { default as DatePicker } from "./DatePicker.svelte"
export { default as DatePickerWithPreset } from "./DatePickerWithPreset.svelte"

View File

@ -1,2 +1,5 @@
export { default as IconMultiplexer } from "./IconMultiplexer.svelte"
export { default as IconSelector } from "./IconSelector.svelte"
export { default as StrikeSeparator } from "./StrikeSeparator.svelte"
export { default as LoadingBar } from "./LoadingBar.svelte"
export * from "./date"

View File

@ -1,3 +1,4 @@
export { default as ExtListItem } from "./ExtListItem.svelte"
export { default as StoreExtDetail } from "./StoreExtDetail.svelte"
export { default as PermissionInspector } from "./PermissionInspector.svelte"
export * as Templates from "./templates"

View File

@ -0,0 +1,10 @@
<script lang="ts">
import { cn } from "@kksh/ui/utils"
import SvelteMarkdown from "svelte-markdown"
const { markdown, class: className }: { markdown: string; class?: string } = $props()
</script>
<div class={cn("prose dark:prose-invert", className)}>
<SvelteMarkdown source={markdown} />
</div>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import { MarkdownSchema } from "@kksh/api/ui/worker"
import { Button } from "@kksh/svelte5"
import { ArrowLeftIcon } from "lucide-svelte"
import Markdown from "./Markdown.svelte"
const {
markdownViewContent,
onGoBack
}: {
markdownViewContent: MarkdownSchema
onGoBack?: () => void
} = $props()
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === "Escape") {
onGoBack?.()
}
}}
/>
<Button class="fixed left-2 top-2" onclick={onGoBack} variant="outline" size="icon">
<ArrowLeftIcon />
</Button>
<main class="container my-5">
<Markdown markdown={markdownViewContent.content} />
</main>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { FormNodeNameEnum, FormSchema } from "@kksh/api/ui/worker"
import { Button } from "@kksh/svelte5"
import { ArrowLeftIcon } from "lucide-svelte"
import Form from "./form.svelte"
let { formViewContent, onGoBack }: { formViewContent: FormSchema.Form; onGoBack: () => void } =
$props()
</script>
<div data-tauri-drag-region class="h-12 w-full"></div>
<Button class="fixed left-2 top-2" size="icon" variant="outline" onclick={onGoBack}>
<ArrowLeftIcon />
</Button>
<main class="container flex flex-col gap-2 pb-4">
<h1 class="text-2xl font-bold">{formViewContent.title}</h1>
<Form {formViewContent} />
</main>
<!-- <pre>{JSON.stringify(formViewContent, null, 2)}</pre> -->

View File

@ -0,0 +1,136 @@
<script lang="ts">
import type { DateValue } from "@internationalized/date"
import { FormNodeNameEnum, FormSchema } from "@kksh/api/ui/worker"
import { Button, Checkbox, Form, Input, Label, Select } from "@kksh/svelte5"
import { DatePickerWithPreset, Shiki } from "@kksh/ui"
import { buildFormSchema, cn } from "@kksh/ui/utils"
import { onMount } from "svelte"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
import DatePicker from "../../common/date/DatePicker.svelte"
import TauriLink from "../../common/TauriLink.svelte"
let {
formViewContent,
class: className,
onSubmit
}: {
formViewContent: FormSchema.Form
class?: string
onSubmit?: (formData: Record<string, any>) => void
} = $props()
const formSchema = $derived(buildFormSchema(formViewContent))
onMount(() => {
console.log(formSchema)
})
const form = $derived(
superForm(defaults(valibot(formSchema)), {
validators: valibotClient(formSchema),
SPA: true,
onUpdate({ form, cancel }) {
cancel()
console.log($formData)
if (!form.valid) return
const parsedData = v.parse(formSchema, $formData)
console.log(parsedData)
onSubmit?.(parsedData)
}
})
)
const { form: formData, enhance, errors } = $derived(form)
</script>
{#snippet error(messages?: string[])}
{#if messages}
<ul>
{#each messages as message}
<li><small class="text-red-500">{message}</small></li>
{/each}
</ul>
{/if}
{/snippet}
{#key formViewContent}
<form class={cn("flex flex-col gap-2", className)} use:enhance>
{#each formViewContent.fields as field}
{@const _field = field as FormSchema.BaseField}
{#if _field.label && !_field.hideLabel}
<Label class="select-none" for={field.key}>{_field.label}</Label>
{/if}
{#if field.nodeName === FormNodeNameEnum.Number}
{@const field2 = field as FormSchema.NumberField}
<Input
type="number"
name={field.key}
bind:value={$formData[field.key]}
placeholder={field2.placeholder}
/>
{:else if field.nodeName === FormNodeNameEnum.Input}
{@const field2 = field as FormSchema.InputField}
<Input
type="text"
name={field2.key}
bind:value={$formData[field2.key]}
placeholder={field2.placeholder}
/>
{:else if field.nodeName === FormNodeNameEnum.Date}
{@const field2 = field as FormSchema.DateField}
<DatePickerWithPreset class="w-full" bind:value={$formData[field2.key]} />
{:else if field.nodeName === FormNodeNameEnum.Select}
{@const field2 = field as FormSchema.SelectField}
<Select.Root type="single" name="favoriteFruit" bind:value={$formData[field2.key]}>
<Select.Trigger class="w-80">
{$formData[field2.key] ? $formData[field2.key] : "Select"}
</Select.Trigger>
<Select.Content>
<Select.Group>
<!-- <Select.GroupHeading>Fruits</Select.GroupHeading> -->
{#each field2.options as option}
<Select.Item value={option} label={option}>{option}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
{:else if field.nodeName === FormNodeNameEnum.Array}
<span>
Array is not supported yet
<TauriLink href="https://github.com/kunkunsh/kunkun/issues/19"
>Tracked at https://github.com/kunkunsh/kunkun/issues/19</TauriLink
>
</span>
{:else if field.nodeName === FormNodeNameEnum.Form}
<span>
Nested Form is not supported yet
<TauriLink href="https://github.com/kunkunsh/kunkun/issues/19"
>Tracked at https://github.com/kunkunsh/kunkun/issues/19</TauriLink
>
</span>
{:else if field.nodeName === FormNodeNameEnum.Boolean}
{@const field2 = field as FormSchema.InputField}
<div class="flex items-center space-x-2">
<Checkbox name={field2.key} bind:checked={$formData[field2.key]} />
<Label
id="terms-label"
for={field2.key}
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{field2.description}
</Label>
</div>
{:else}
<span>
{field.nodeName} is not supported yet
<TauriLink href="https://github.com/kunkunsh/kunkun/issues/19"
>Tracked at https://github.com/kunkunsh/kunkun/issues/19</TauriLink
>
</span>
{/if}
{#if field.description}
<p class="text-muted-foreground select-none text-sm">{field.description}</p>
{/if}
{@render error($errors[field.key] as string[] | undefined)}
{/each}
<Button type="submit">{formViewContent.submitBtnText ?? "Submit"}</Button>
</form>
{/key}
<!-- <SuperDebug data={$formData} /> -->

View File

@ -0,0 +1,3 @@
export { default as ListView } from "./list-view.svelte"
export { default as FormView } from "./form-view.svelte"
export { default as MarkdownView } from "./MarkdownView.svelte"

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { ListSchema, MarkdownSchema, NodeNameEnum } from "@kksh/api/ui/worker"
import Markdown from "./Markdown.svelte"
import Metadata from "./metadata/Metadata.svelte"
const { detail }: { detail: ListSchema.ItemDetail } = $props()
</script>
<div class="h-full overflow-auto">
{#each detail.children as child}
{#if child.nodeName === NodeNameEnum.Markdown}
<Markdown markdown={(child as MarkdownSchema).content} />
{:else if child.nodeName === NodeNameEnum.ListItemDetailMetadata}
<Metadata items={(child as ListSchema.ItemDetailMetadata).items} />
{:else}
<div>Unhandled Component</div>
{/if}
{/each}
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { List, ListSchema, WorkerExtension } from "@kksh/api/ui/worker"
import { Command } from "@kksh/svelte5"
import { IconMultiplexer } from "../../common"
let { item, onSelect }: { item: ListSchema.Item; onSelect?: () => void } = $props()
</script>
<Command.Item class="gap-2" {onSelect} value={JSON.stringify(item)}>
{#if item.icon}
<IconMultiplexer icon={item.icon} class="h-5 w-5" />
{/if}
<span class="truncate">{item.title}</span>
<span class="text-muted-foreground">{item.subTitle}</span>
<Command.Shortcut>
<div class="flex gap-2">
{#each item.accessories ?? [] as acc}
<span class="flex items-center gap-1">
{#if acc.icon}
<IconMultiplexer icon={acc.icon} class="h-4 w-4" />
{/if}
<span>{acc.text}</span>
</span>
{/each}
</div>
</Command.Shortcut>
</Command.Item>

View File

@ -0,0 +1,156 @@
<script lang="ts">
import { ListSchema } from "@kksh/api/ui/worker"
import { Button, Command, Progress, Resizable } from "@kksh/svelte5"
import { CustomCommandInput } from "@kksh/ui/main"
import { commandScore } from "@kksh/ui/utils"
import { ArrowLeftIcon } from "lucide-svelte"
import { type PaneAPI } from "paneforge"
import { onMount, type Snippet } from "svelte"
import { StrikeSeparator } from "../../common"
import ListDetail from "./list-detail.svelte"
import ListItem from "./list-item.svelte"
let {
searchTerm = $bindable(""),
searchBarPlaceholder = $bindable(""),
pbar,
onGoBack,
onListScrolledToBottom,
onEnterKeyPressed,
onListItemSelected,
onSearchTermChange,
footer,
onHighlightedItemChanged,
loading,
listViewContent
}: {
searchTerm: string
searchBarPlaceholder: string
pbar: number | null
onGoBack?: () => void
onListScrolledToBottom?: () => void
onEnterKeyPressed?: () => void
onListItemSelected?: (value: string) => void
onSearchTermChange?: (searchTerm: string) => void
onHighlightedItemChanged?: (value: string) => void
footer: Snippet
loading: boolean
listViewContent: ListSchema.List
} = $props()
let mounted = $state(false)
let leftPane: PaneAPI | undefined
let rightPane: PaneAPI | undefined
let isScrolling = $state(false)
let highlightedValue = $state<string>("")
let privateSearchTerm = $state("")
// let detailWidth = $derived()
let prevDetailWidth = $state(0)
const detailWidth = $derived(listViewContent.detail ? (listViewContent.detail?.width ?? 70) : 0)
$effect(() => {
onHighlightedItemChanged?.(highlightedValue)
})
$effect(() => {
onSearchTermChange?.(searchTerm)
})
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)
}
}
$effect(() => {
if (detailWidth != prevDetailWidth) {
// this watches width update from extension, when pane is resized manually, this will not trigger
prevDetailWidth = detailWidth
rightPane?.resize(detailWidth)
}
})
</script>
<Command.Root
class="h-screen w-full rounded-lg border shadow-md"
shouldFilter={listViewContent.filter !== "none"}
bind:value={highlightedValue}
filter={(value, search, keywords) => {
if (!value.startsWith("{")) {
return -1
}
const item = JSON.parse(value) as ListSchema.Item
return (
commandScore(item.title, search, keywords) +
(item.subTitle ? commandScore(item.subTitle, search, keywords) : 0)
)
}}
>
<CustomCommandInput
bind:value={searchTerm}
placeholder={searchBarPlaceholder}
autofocus
onkeydown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
onEnterKeyPressed?.()
} else if (e.key === "Escape") {
e.preventDefault()
if (searchTerm.length > 0) {
searchTerm = ""
} else {
onGoBack?.()
}
}
}}
>
{#snippet leftSlot()}
<Button variant="outline" size="icon" onclick={onGoBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
{/snippet}
</CustomCommandInput>
{#if pbar}
<Progress value={50} class="h-0.4 rounded-none" />
{/if}
<Resizable.PaneGroup direction="horizontal">
<Resizable.Pane bind:this={leftPane}>
<Command.List class="h-full max-h-screen" onscroll={onScroll}>
<Command.Empty>No results found.</Command.Empty>
{#each listViewContent.sections || [] as section}
<Command.Group heading={section.title}>
{#each section.items as item}
<ListItem {item} />
{/each}
</Command.Group>
{/each}
{#each listViewContent.items || [] as item}
<ListItem
{item}
onSelect={() => {
onListItemSelected?.(item.value)
}}
/>
{/each}
{#if loading}
<StrikeSeparator class="h-20">
<span>Loading</span>
</StrikeSeparator>
{/if}
</Command.List>
</Resizable.Pane>
<Resizable.Handle withHandle />
<Resizable.Pane defaultSize={detailWidth} bind:this={rightPane}>
{#if listViewContent.detail}
<ListDetail detail={listViewContent.detail} />
{/if}
</Resizable.Pane>
</Resizable.PaneGroup>
{@render footer?.()}
</Command.Root>

View File

@ -0,0 +1,43 @@
<script lang="ts">
import {
List,
ListSchema,
MarkdownSchema,
NodeNameEnum,
WorkerExtension
} from "@kksh/api/ui/worker"
import { Separator } from "@kksh/svelte5"
import Label from "./label.svelte"
import Link from "./link.svelte"
import Tags from "./tags.svelte"
const { items }: { items: ListSchema.ItemDetailMetadataItem[] } = $props()
</script>
<div class="px-3">
{#each items as item}
{#if item.nodeName === NodeNameEnum.ListItemDetailMetadataLabel}
<Label
title={(item as ListSchema.ItemDetailMetadataLabel).title}
text={(item as ListSchema.ItemDetailMetadataLabel).text}
icon={(item as ListSchema.ItemDetailMetadataLabel).icon}
/>
{/if}
{#if item.nodeName === NodeNameEnum.ListItemDetailMetadataSeparator}
<Separator />
{/if}
{#if item.nodeName === NodeNameEnum.ListItemDetailMetadataLink}
<Link
title={(item as ListSchema.ItemDetailMetadataLink).title}
text={(item as ListSchema.ItemDetailMetadataLink).text}
url={(item as ListSchema.ItemDetailMetadataLink).url}
/>
{/if}
{#if item.nodeName === NodeNameEnum.ListItemDetailMetadataTagList}
<Tags
tags={(item as ListSchema.ItemDetailMetadataTagList).tags}
title={(item as ListSchema.ItemDetailMetadataTagList).title}
/>
{/if}
{/each}
</div>

View File

@ -0,0 +1,32 @@
<script lang="ts">
import type { IconType, List, ListSchema } from "@kksh/api/ui/worker"
import { IconMultiplexer } from "../../../common"
const {
title,
text,
icon
}: {
title: string
text?:
| string
| {
text: string
color: string
}
icon?: {
type: IconType
value: string
}
} = $props()
</script>
<div class="flex justify-between gap-1 py-1">
<span class="text-muted-foreground text-sm font-semibold">{title}</span>
<span class="flex items-center gap-2">
{#if icon}
<IconMultiplexer {icon} class="h-4 w-4" />
{/if}
<span class="text-sm">{text}</span>
</span>
</div>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import Icon from "@iconify/svelte"
import TauriLink from "../../../common/TauriLink.svelte"
const {
text,
title,
url
}: {
text: string
title: string
url: string
} = $props()
</script>
<div class="flex justify-between gap-1 py-1">
<span class="text-muted-foreground text-sm font-semibold">{title}</span>
<TauriLink href={url} class="flex items-center justify-center gap-1">
<span class="text-sm">{text}</span>
<Icon icon="gridicons:external" />
</TauriLink>
</div>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import type { Color, ListSchema } from "@kksh/api/ui/worker"
import { Badge } from "@kksh/svelte5"
const {
text,
color
}: {
text?: string
color?: Color
// icon?: Icon
} = $props()
</script>
<Badge
style={`style: ${color ? color : "var(--muted-foreground)"}; background-color: ${color ? `${color}` : "var(--muted)"};`}
>
{text}
</Badge>

View File

@ -0,0 +1,21 @@
<script lang="ts">
import type { Color, ListSchema } from "@kksh/api/ui/worker"
import Tag from "./tag.svelte"
const {
tags,
title
}: {
tags: Array<{ text?: string; color?: Color }>
title: string
} = $props()
</script>
<div class="flex justify-between gap-1 py-1">
<span class="text-muted-foreground text-sm font-semibold">{title}</span>
<span class="flex gap-1">
{#each tags as tag}
<Tag text={tag.text} color={tag.color} />
{/each}
</span>
</div>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import Icon from "@iconify/svelte"
import { Action as ActionSchema } from "@kksh/api/models"
import { Button, ButtonModule, Command, Input, Label, Popover } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import { Check, ChevronsUpDown } from "lucide-svelte"
import { tick } from "svelte"
import Kbd from "../common/Kbd.svelte"
let {
actionPanel,
open = $bindable(false),
onActionSelected
}: {
actionPanel?: ActionSchema.ActionPanel
open?: boolean
onActionSelected?: (value: string) => void
} = $props()
let value = $state("")
let triggerRef = $state<HTMLButtonElement>(null!)
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false
tick().then(() => {
triggerRef.focus()
})
}
</script>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button variant="ghost" class="" {...props} role="combobox" aria-expanded={open}>
Actions
<span class="flex items-center gap-0.5" data-tauri-drag-region>
<Kbd><Icon icon="ph-command" class="h-4 w-4 shrink-0" /></Kbd>
<Kbd>K</Kbd>
</span>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64 p-0">
<Command.Root>
<Command.Input placeholder="Select an Action" />
<Command.List>
<Command.Empty>No action found.</Command.Empty>
<Command.Group>
{#each actionPanel?.items ?? [] as action}
<Command.Item
value={action.value}
onSelect={() => {
value = action.value
closeAndFocusTrigger()
onActionSelected?.(action.value)
}}
>
{action.title}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@ -30,7 +30,8 @@
}}
value={JSON.stringify({
cmdName: cmd.name,
cmdType: cmd.type
cmdType: cmd.type,
data: { isDev: heading === "Dev Extensions" }
} satisfies CmdValue)}
>
<span class="flex gap-2">

View File

@ -1,12 +1,42 @@
<script lang="ts">
import { Avatar } from "@kksh/svelte5"
import Icon from "@iconify/svelte"
import { Action as ActionSchema } from "@kksh/api/models"
import { Avatar, Button } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import Kbd from "../common/Kbd.svelte"
import ActionPanel from "./ActionPanel.svelte"
const { class: className }: { class?: string } = $props()
const {
class: className,
defaultAction,
actionPanel,
onDefaultActionSelected,
onActionSelected
}: {
class?: string
defaultAction?: string
actionPanel?: ActionSchema.ActionPanel
onDefaultActionSelected?: () => void
onActionSelected?: (value: string) => void
} = $props()
</script>
<footer data-tauri-drag-region class={cn("h-12 select-none border-t", className)}>
<flex
data-tauri-drag-region
class={cn("h-12 select-none items-center justify-between gap-4 border-t px-2", className)}
>
<Avatar.Root class="p-2">
<Avatar.Image src="/favicon.png" alt="Kunkun Logo" class="select-none invert dark:invert-0" />
</Avatar.Root>
</footer>
<flex class="items-center gap-1">
{#if defaultAction}
<Button size="default" class="h-full" variant="ghost" onclick={onDefaultActionSelected}>
{defaultAction}
<Kbd><Icon icon="tdesign:enter" /></Kbd>
</Button>
{/if}
{#if actionPanel}
<ActionPanel {actionPanel} {onActionSelected} />
{/if}
</flex>
</flex>

View File

@ -3,6 +3,7 @@ export * from "./components/common"
export * as Layouts from "./components/layouts/index"
export * as Error from "./components/error/index"
export * as Common from "./components/common/index"
export * from "./components/common/index"
export * as Custom from "./components/custom"
export * as Main from "./components/main/index"
export * as Extension from "./components/extension/index"

View File

@ -0,0 +1,47 @@
import { FormNodeNameEnum, type FormSchema } from "@kksh/api/ui/worker"
import type { BaseIssue, BaseSchema } from "valibot"
import * as v from "valibot"
function addDefaultToSchema(
schema: BaseSchema<unknown, unknown, BaseIssue<unknown>>,
field: FormSchema.BaseField
) {
if (field.default) {
schema = v.optional(schema, field.default)
}
return schema
}
export function buildFormSchema(form: FormSchema.Form): v.ObjectSchema<any, undefined> {
let schema = v.object({})
for (const field of form.fields) {
let fieldSchema: any = undefined
if (field.nodeName === FormNodeNameEnum.Input) {
fieldSchema = v.string()
} else if (field.nodeName === FormNodeNameEnum.Number) {
fieldSchema = v.number()
} else if (field.nodeName === FormNodeNameEnum.Select) {
fieldSchema = v.string()
// fieldSchema = v.picklist((field as FormSchema.SelectField).options)
// schema = v.object({ ...schema.entries, [field.key]: fieldSchema })
// continue
} else if (field.nodeName === FormNodeNameEnum.Boolean) {
fieldSchema = v.boolean()
} else if (field.nodeName === FormNodeNameEnum.Date) {
fieldSchema = v.date()
} else {
console.warn(`Unknown field type: ${field.nodeName}`)
}
fieldSchema = addDefaultToSchema(fieldSchema, field)
if ((field as FormSchema.BaseField).optional) {
fieldSchema = v.nullable(v.optional(fieldSchema))
}
if ((field as FormSchema.BaseField).description) {
fieldSchema = v.pipe(fieldSchema, v.description((field as FormSchema.BaseField).description!))
}
if (fieldSchema) {
schema = v.object({ ...schema.entries, [field.key]: fieldSchema })
}
}
return schema
}

View File

@ -1,2 +1,4 @@
export * from "./tailwind"
export * from "./format"
export { commandScore } from "./command-score"
export * from "./form"

View File

@ -1,4 +1,7 @@
{
"extends": "../typescript-config/base.json",
"compilerOptions": {
"verbatimModuleSyntax": true
},
"include": ["src/**/*"]
}

1195
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff