wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<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>