|
@@ -0,0 +1,300 @@
|
|
|
|
|
+<script lang="ts">
|
|
|
|
|
+ import { Pencil, Plus, Save, SquarePen, Trash2 } from '@lucide/svelte';
|
|
|
|
|
+ import { tick } from 'svelte';
|
|
|
|
|
+
|
|
|
|
|
+ import { ConfirmDialog } from '$lib/components/common/confirmation';
|
|
|
|
|
+ import { Button, buttonVariants } from '$lib/components/controls/button';
|
|
|
|
|
+ import * as Dialog from '$lib/components/controls/dialog';
|
|
|
|
|
+ import * as Field from '$lib/components/controls/field';
|
|
|
|
|
+ import { Input } from '$lib/components/controls/input';
|
|
|
|
|
+ import * as Resizable from '$lib/components/controls/resizable';
|
|
|
|
|
+ import type { Skill, SkillFile } from '$lib/types/entities';
|
|
|
|
|
+
|
|
|
|
|
+ interface FileEntry {
|
|
|
|
|
+ path: string;
|
|
|
|
|
+ parents: string[];
|
|
|
|
|
+ isDirectory: boolean;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ content: string;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let {
|
|
|
|
|
+ skill,
|
|
|
|
|
+ editingContent = $bindable(false),
|
|
|
|
|
+ onSkillChange,
|
|
|
|
|
+ onFileChange,
|
|
|
|
|
+ }: {
|
|
|
|
|
+ skill: Skill;
|
|
|
|
|
+ editingContent?: boolean;
|
|
|
|
|
+ onSkillChange: (skill: Skill) => Promise<void>;
|
|
|
|
|
+ onFileChange: (filePath: string | undefined, newFile: SkillFile | undefined) => Promise<void>;
|
|
|
|
|
+ } = $props();
|
|
|
|
|
+ let fileFormShown = $state(false);
|
|
|
|
|
+ let addingFile = $state(false);
|
|
|
|
|
+ let inputFilePath = $state('');
|
|
|
|
|
+ let deleteConfirmationShown = $state(false);
|
|
|
|
|
+ let fileEntries: FileEntry[] = $derived.by(() => {
|
|
|
|
|
+ const entries: FileEntry[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ path: 'SKILL.md',
|
|
|
|
|
+ parents: [],
|
|
|
|
|
+ isDirectory: false,
|
|
|
|
|
+ name: 'SKILL.md',
|
|
|
|
|
+ content: `---
|
|
|
|
|
+name: ${skill.name}
|
|
|
|
|
+description: ${skill.description}
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+${skill.body}
|
|
|
|
|
+`,
|
|
|
|
|
+ },
|
|
|
|
|
+ ];
|
|
|
|
|
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
|
|
|
+ const seenDirectories = new Set<string>();
|
|
|
|
|
+ for (const file of skill.files) {
|
|
|
|
|
+ const directories = file.path.split('/');
|
|
|
|
|
+ const name = directories.pop();
|
|
|
|
|
+ if (name === undefined) continue;
|
|
|
|
|
+ if (directories.length > 0) {
|
|
|
|
|
+ const dirPath = directories.join('/');
|
|
|
|
|
+ if (!seenDirectories.has(dirPath)) {
|
|
|
|
|
+ seenDirectories.add(dirPath);
|
|
|
|
|
+ entries.push({
|
|
|
|
|
+ path: dirPath,
|
|
|
|
|
+ parents: [...directories],
|
|
|
|
|
+ isDirectory: true,
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ content: '',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ entries.push({
|
|
|
|
|
+ path: file.path,
|
|
|
|
|
+ parents: directories,
|
|
|
|
|
+ isDirectory: false,
|
|
|
|
|
+ name,
|
|
|
|
|
+ content: file.content,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return entries.sort((a, b) => {
|
|
|
|
|
+ for (let i = 0; i < Math.min(a.parents.length, b.parents.length); i++) {
|
|
|
|
|
+ if (a.parents[i] !== b.parents[i]) {
|
|
|
|
|
+ return a.parents[i].localeCompare(b.parents[i]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (a.parents.length !== b.parents.length) {
|
|
|
|
|
+ return a.parents.length - b.parents.length;
|
|
|
|
|
+ }
|
|
|
|
|
+ return a.name.localeCompare(b.name);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ let selectedFile: FileEntry | undefined = $derived($state.snapshot(fileEntries[0]));
|
|
|
|
|
+
|
|
|
|
|
+ const validateSkillMd = (content: string): boolean => {
|
|
|
|
|
+ const frontMatterRegex = /^---\n([\s\S]*?)\n---/;
|
|
|
|
|
+ const match = content.match(frontMatterRegex);
|
|
|
|
|
+ if (!match) return false;
|
|
|
|
|
+ const frontMatter = match[1];
|
|
|
|
|
+ return /name:/.test(frontMatter) && /description:/.test(frontMatter);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onAddFile = () => {
|
|
|
|
|
+ addingFile = true;
|
|
|
|
|
+ inputFilePath = '';
|
|
|
|
|
+ fileFormShown = true;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onEditFile = () => {
|
|
|
|
|
+ if (!selectedFile) return;
|
|
|
|
|
+ addingFile = false;
|
|
|
|
|
+ inputFilePath = selectedFile.path;
|
|
|
|
|
+ fileFormShown = true;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onEditFileContent = () => {
|
|
|
|
|
+ if (!selectedFile) return;
|
|
|
|
|
+ editingContent = true;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onUpdateFile = async () => {
|
|
|
|
|
+ if (selectedFile?.path === 'SKILL.md' || inputFilePath === 'SKILL.md') return;
|
|
|
|
|
+ await onFileChange(selectedFile?.path, {
|
|
|
|
|
+ path: inputFilePath,
|
|
|
|
|
+ content: selectedFile?.content || '',
|
|
|
|
|
+ });
|
|
|
|
|
+ await tick();
|
|
|
|
|
+ selectedFile = $state.snapshot(fileEntries.find((file) => file.path === inputFilePath));
|
|
|
|
|
+ fileFormShown = false;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onSaveContent = async () => {
|
|
|
|
|
+ if (!selectedFile) return;
|
|
|
|
|
+ editingContent = false;
|
|
|
|
|
+ const filePath = selectedFile.path;
|
|
|
|
|
+ if (filePath === 'SKILL.md') {
|
|
|
|
|
+ const frontmatter = selectedFile.content.match(/^---\n([\s\S]*?)\n---/)?.[1] || '';
|
|
|
|
|
+ const name = frontmatter.match(/name:\s*(.*)/)?.[1] || '';
|
|
|
|
|
+ const description = frontmatter.match(/description:\s*(.*)/)?.[1] || '';
|
|
|
|
|
+ const body = selectedFile.content.replace(/^---\n([\s\S]*?)\n---/, '').trim();
|
|
|
|
|
+ await onSkillChange({
|
|
|
|
|
+ ...skill,
|
|
|
|
|
+ name,
|
|
|
|
|
+ description,
|
|
|
|
|
+ body,
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await onFileChange(selectedFile.path, {
|
|
|
|
|
+ path: filePath,
|
|
|
|
|
+ content: selectedFile.content,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onDeleteFile = async () => {
|
|
|
|
|
+ if (!selectedFile) return;
|
|
|
|
|
+ await onFileChange(selectedFile.path, undefined);
|
|
|
|
|
+ deleteConfirmationShown = false;
|
|
|
|
|
+ selectedFile = undefined;
|
|
|
|
|
+ };
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<Resizable.PaneGroup
|
|
|
|
|
+ class="min-h-96 rounded-lg border border-white/10 bg-black/50"
|
|
|
|
|
+ direction="horizontal"
|
|
|
|
|
+>
|
|
|
|
|
+ <Resizable.Pane defaultSize={25}>
|
|
|
|
|
+ <div class="flex h-16 items-center justify-between border-b border-b-white/10 p-4">
|
|
|
|
|
+ <div class="text-sm font-semibold">Files</div>
|
|
|
|
|
+ <Button size="icon-sm" title="New file" variant="ghost" onclick={() => onAddFile()}>
|
|
|
|
|
+ <Plus size={14} strokeWidth={2} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex flex-col gap-1 p-2">
|
|
|
|
|
+ {#each fileEntries as file (file.path)}
|
|
|
|
|
+ {#if file.isDirectory}
|
|
|
|
|
+ <div class="pl-2 text-xs text-white/25">{file.parents.join('/') + '/'}</div>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <Button
|
|
|
|
|
+ class="justify-start"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ variant={selectedFile?.path === file.path ? 'outline' : 'ghost'}
|
|
|
|
|
+ onclick={() => (selectedFile = $state.snapshot(file))}
|
|
|
|
|
+ >
|
|
|
|
|
+ {file.name}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ {/each}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Resizable.Pane>
|
|
|
|
|
+ <Resizable.Handle />
|
|
|
|
|
+ <Resizable.Pane defaultSize={75}>
|
|
|
|
|
+ {#if selectedFile}
|
|
|
|
|
+ <div class="flex h-16 items-center justify-between border-b border-b-white/10 p-4">
|
|
|
|
|
+ <div class="flex flex-1 items-center gap-2 overflow-hidden">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="text-[10px] leading-none text-white/25">
|
|
|
|
|
+ {selectedFile.parents.join('/') + '/'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="overflow-hidden text-sm font-semibold text-ellipsis whitespace-nowrap">
|
|
|
|
|
+ {selectedFile.name}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {#if selectedFile.path !== 'SKILL.md'}
|
|
|
|
|
+ <Button size="icon-xs" title="Rename" variant="ghost" onclick={() => onEditFile()}>
|
|
|
|
|
+ <Pencil size={14} strokeWidth={2} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex flex-none items-center gap-2">
|
|
|
|
|
+ {#if editingContent}
|
|
|
|
|
+ <Button
|
|
|
|
|
+ disabled={selectedFile.path === 'SKILL.md' && !validateSkillMd(selectedFile.content)}
|
|
|
|
|
+ size="icon-sm"
|
|
|
|
|
+ title="Save"
|
|
|
|
|
+ onclick={() => onSaveContent()}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Save size={14} strokeWidth={2} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="icon-sm"
|
|
|
|
|
+ title="Edit"
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ onclick={() => onEditFileContent()}
|
|
|
|
|
+ >
|
|
|
|
|
+ <SquarePen size={14} strokeWidth={2} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ {#if selectedFile.path !== 'SKILL.md'}
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="icon-sm"
|
|
|
|
|
+ title="Delete"
|
|
|
|
|
+ variant="destructive"
|
|
|
|
|
+ onclick={() => (deleteConfirmationShown = true)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 size={14} strokeWidth={2} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {#if editingContent}
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ class="block h-100 w-full resize-none border-none bg-white/10 p-4 font-mono text-xs outline-none"
|
|
|
|
|
+ bind:value={selectedFile.content}
|
|
|
|
|
+ ></textarea>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <pre class="p-4 text-xs whitespace-pre-wrap">{selectedFile.content}</pre>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </Resizable.Pane>
|
|
|
|
|
+</Resizable.PaneGroup>
|
|
|
|
|
+
|
|
|
|
|
+<!-- File form dialog -->
|
|
|
|
|
+<Dialog.Root bind:open={fileFormShown}>
|
|
|
|
|
+ <Dialog.Content class="sm:max-w-xl">
|
|
|
|
|
+ <Dialog.Header>
|
|
|
|
|
+ {#if addingFile}
|
|
|
|
|
+ <Dialog.Title>Add File</Dialog.Title>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <Dialog.Title>Edit File - {selectedFile?.path}</Dialog.Title>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </Dialog.Header>
|
|
|
|
|
+ <Field.Set>
|
|
|
|
|
+ <Field.Field>
|
|
|
|
|
+ <Field.Label for="filePath">File Path</Field.Label>
|
|
|
|
|
+ <Input id="filePath" bind:value={inputFilePath} />
|
|
|
|
|
+ </Field.Field>
|
|
|
|
|
+ </Field.Set>
|
|
|
|
|
+ <Dialog.Footer>
|
|
|
|
|
+ <Dialog.Close class={buttonVariants({ variant: 'outline' })} type="button">
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Dialog.Close>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ disabled={!!fileEntries.find((file) => file.path === inputFilePath)}
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ onclick={() => onUpdateFile()}
|
|
|
|
|
+ >
|
|
|
|
|
+ {#if addingFile}
|
|
|
|
|
+ Add
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ Save
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Dialog.Footer>
|
|
|
|
|
+ </Dialog.Content>
|
|
|
|
|
+</Dialog.Root>
|
|
|
|
|
+
|
|
|
|
|
+<!-- Delete confirmation dialog -->
|
|
|
|
|
+<ConfirmDialog
|
|
|
|
|
+ confirmLabel="Delete"
|
|
|
|
|
+ confirmingLabel="Deleting..."
|
|
|
|
|
+ title="Delete file?"
|
|
|
|
|
+ variant="destructive"
|
|
|
|
|
+ onconfirm={() => onDeleteFile()}
|
|
|
|
|
+ bind:open={deleteConfirmationShown}
|
|
|
|
|
+>
|
|
|
|
|
+ {#snippet description()}
|
|
|
|
|
+ <strong class="text-white/90">"{selectedFile?.name}"</strong> will be permanently deleted.
|
|
|
|
|
+ {/snippet}
|
|
|
|
|
+</ConfirmDialog>
|