<script lang="ts">
import type {
CreateGistRequest,
GitHubGist,
UpdateGistRequest,
} from "../../api/types.js";
import MonacoEditor from "../layout/MonacoEditor.svelte";
interface EditableFile {
key: string;
filename: string;
content: string;
existing: boolean;
}
interface Props {
gist: GitHubGist | null;
mode: "create" | "edit";
loading: boolean;
saving: boolean;
error: string | null;
onsave: (payload: CreateGistRequest | UpdateGistRequest) => Promise<void>;
oncancelcreate: () => void;
}
let {
gist,
mode,
loading,
saving,
error,
onsave,
oncancelcreate,
}: Props = $props();
let description = $state("");
let isPublic = $state(false);
let files = $state<EditableFile[]>([]);
let validationError = $state<string | null>(null);
function syncFromProps() {
description = gist?.description ?? "";
isPublic = gist?.public ?? false;
files = gist
? Object.entries(gist.files).map(([key, file], index) => ({
key: `${key}-${index}`,
filename: file.filename ?? key,
content: file.content ?? "",
existing: true,
}))
: [{ key: crypto.randomUUID(), filename: "", content: "", existing: false }];
validationError = null;
}
$effect(() => {
mode;
gist?.id;
syncFromProps();
});
function addFile() {
files = [
...files,
{ key: crypto.randomUUID(), filename: "", content: "", existing: false },
];
}
function detectLanguage(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "ts":
case "tsx":
return "typescript";
case "js":
case "jsx":
case "mjs":
return "javascript";
case "json":
case "jsonc":
return "json";
case "md":
case "mdx":
return "markdown";
case "html":
return "html";
case "css":
case "scss":
return "css";
case "rs":
return "rust";
case "toml":
return "ini";
case "yml":
case "yaml":
return "yaml";
case "sh":
return "shell";
default:
return "plaintext";
}
}
function removeFile(key: string) {
const target = files.find((file) => file.key === key);
if (!target) return;
if (mode === "edit" && target.existing) {
validationError = "Deleting existing gist files is not supported from this editor.";
return;
}
files = files.filter((file) => file.key !== key);
if (files.length === 0) {
addFile();
}
}
async function handleSave() {
validationError = null;
const normalized = files.map((file) => ({
filename: file.filename.trim(),
content: file.content,
}));
if (normalized.length === 0) {
validationError = "At least one file is required.";
return;
}
if (normalized.some((file) => file.filename.length === 0)) {
validationError = "Every file must have a filename.";
return;
}
const uniqueNames = new Set(normalized.map((file) => file.filename));
if (uniqueNames.size !== normalized.length) {
validationError = "Filenames must be unique within a gist.";
return;
}
const payloadFiles = Object.fromEntries(
normalized.map((file) => [
file.filename,
{ content: file.content },
]),
);
if (mode === "create") {
await onsave({
description: description.trim() || null,
public: isPublic,
files: payloadFiles,
});
return;
}
await onsave({
description: description.trim() || null,
files: payloadFiles,
});
}
</script>
<div class="gist-editor">
<div class="gist-editor-header">
<div>
<h2>{mode === "create" ? "New Gist" : "Gist Details"}</h2>
{#if gist}
<a href={gist.html_url} target="_blank" rel="noopener noreferrer">
Open on GitHub
</a>
{/if}
</div>
{#if mode === "create"}
<button class="secondary-btn" onclick={oncancelcreate}>
Cancel
</button>
{/if}
</div>
{#if loading}
<div class="gist-editor-empty">Loading gist...</div>
{:else}
<div class="editor-form">
<label class="field">
<span>Description</span>
<input bind:value={description} placeholder="Worktree notes" />
</label>
{#if mode === "create"}
<label class="toggle-field">
<input type="checkbox" bind:checked={isPublic} />
<span>Create as public gist</span>
</label>
{:else if gist}
<div class="gist-visibility">
Visibility: {gist.public ? "Public" : "Secret"}
</div>
{/if}
<div class="files-header">
<h3>Files</h3>
<button class="secondary-btn" onclick={addFile}>Add file</button>
</div>
<div class="file-list">
{#each files as file}
<div class="file-card">
<div class="file-card-header">
<input
bind:value={file.filename}
placeholder="notes.md"
readonly={mode === "edit" && file.existing}
/>
<button
class="danger-btn"
disabled={mode === "edit" && file.existing}
onclick={() => removeFile(file.key)}
>
{mode === "edit" && file.existing ? "Locked" : "Remove"}
</button>
</div>
<MonacoEditor
value={file.content}
language={detectLanguage(file.filename)}
onchange={(next) => {
file.content = next;
files = [...files];
}}
height="260px"
/>
</div>
{/each}
</div>
{#if validationError}
<div class="gist-error">{validationError}</div>
{/if}
{#if error}
<div class="gist-error">{error}</div>
{/if}
<div class="editor-actions">
<button class="primary-btn" disabled={saving} onclick={handleSave}>
{saving ? "Saving..." : mode === "create" ? "Create gist" : "Save changes"}
</button>
</div>
</div>
{/if}
</div>
<style>
.gist-editor {
height: 100%;
display: flex;
flex-direction: column;
background: var(--vscode-sideBar-background);
}
.gist-editor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--wb-spacing-12);
padding: var(--wb-spacing-16) var(--wb-spacing-18);
border-bottom: 1px solid var(--vscode-panel-border);
}
.gist-editor-header h2,
.files-header h3 {
margin: 0;
font-size: var(--wb-font-size-16);
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.gist-editor-header a {
font-size: var(--wb-font-size-12);
color: var(--vscode-focusBorder);
text-decoration: none;
}
.editor-form {
padding: var(--wb-spacing-18);
display: flex;
flex-direction: column;
gap: var(--wb-spacing-16);
overflow-y: auto;
}
.field,
.toggle-field {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-8);
font-size: var(--wb-font-size-12);
color: var(--vscode-foreground);
}
.toggle-field {
flex-direction: row;
align-items: center;
}
.field input,
.file-card-header input {
width: 100%;
border: 1px solid var(--vscode-widget-border);
border-radius: var(--wsb-radius-sm);
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
font-size: var(--wb-font-size-12);
}
.field input,
.file-card-header input {
height: var(--wb-control-size-34);
padding: 0 var(--wb-spacing-10);
}
.files-header,
.file-card-header,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--wb-spacing-12);
}
.file-list {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-14);
}
.file-card {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-10);
padding: var(--wb-spacing-14);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wsb-radius-md);
background: color-mix(in srgb, var(--vscode-sideBar-background) 92%, black 8%);
}
.gist-visibility {
font-size: var(--wb-font-size-12);
color: var(--vscode-disabledForeground);
}
.primary-btn,
.secondary-btn,
.danger-btn {
height: var(--wb-control-size-32);
padding: 0 var(--wb-spacing-12);
border-radius: var(--wsb-radius-sm);
font-size: var(--wb-font-size-12);
font-weight: 600;
cursor: pointer;
}
.primary-btn {
border: none;
background: var(--vscode-focusBorder);
color: white;
}
.secondary-btn {
border: 1px solid var(--vscode-widget-border);
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
}
.danger-btn {
border: 1px solid color-mix(in srgb, var(--vscode-errorForeground) 25%, transparent);
background: color-mix(in srgb, var(--vscode-errorForeground) 10%, transparent);
color: var(--vscode-errorForeground);
}
.danger-btn:disabled {
cursor: default;
opacity: 0.6;
}
.gist-error {
padding: var(--wb-spacing-10) var(--wb-spacing-12);
border-radius: var(--wsb-radius-sm);
color: var(--vscode-errorForeground);
background: color-mix(in srgb, var(--vscode-errorForeground) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-errorForeground) 22%, transparent);
font-size: var(--wb-font-size-12);
}
.gist-editor-empty {
padding: var(--wb-spacing-24) var(--wb-spacing-18);
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-12);
}
</style>