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 { GitHubIssue } from "../../api/types/issues.js";
  import { contextMenu as placeMenu } from "../../utils/menu.js";
  import { copyToClipboard } from "../../utils/clipboard.js";
  import AppIcon from "../layout/AppIcon.svelte";

  let {
    issue,
    selected = false,
    onclick,
  }: {
    issue: GitHubIssue;
    selected?: boolean;
    onclick: () => void;
  } = $props();

  let contextMenu: { x: number; y: number } | null = $state(null);

  function portal(node: HTMLElement) {
    document.body.appendChild(node);
    return { destroy() { node.remove(); } };
  }

  function handleContextMenu(e: MouseEvent) {
    e.preventDefault();
    contextMenu = { x: e.clientX, y: e.clientY };
  }

  function closeContextMenu() { contextMenu = null; }

  function openInBrowser() {
    const [owner, repo] = issue.repo.split("/");
    window.open(`https://github.com/${owner}/${repo}/issues/${issue.number}`, "_blank");
    closeContextMenu();
  }

  function copyLink() {
    const [owner, repo] = issue.repo.split("/");
    void copyToClipboard(`https://github.com/${owner}/${repo}/issues/${issue.number}`).catch(() => {});
    closeContextMenu();
  }

  function copyNumber() {
    void copyToClipboard(`${issue.repo}#${issue.number}`).catch(() => {});
    closeContextMenu();
  }

  function copyTitle() {
    void copyToClipboard(issue.title).catch(() => {});
    closeContextMenu();
  }

  $effect(() => {
    if (!contextMenu) return;
    function handler() { contextMenu = null; }
    const id = setTimeout(() => {
      document.addEventListener("click", handler, { once: true });
      document.addEventListener("contextmenu", handler, { once: true });
    }, 0);
    return () => {
      clearTimeout(id);
      document.removeEventListener("click", handler);
      document.removeEventListener("contextmenu", handler);
    };
  });

  $effect(() => {
    if (!contextMenu) return;
    function handler(e: KeyboardEvent) { if (e.key === "Escape") contextMenu = null; }
    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  });

  function timeAgo(dateStr: string): string {
    const now = Date.now();
    const then = new Date(dateStr).getTime();
    const diffMs = now - then;
    const diffMins = Math.floor(diffMs / 60000);
    if (diffMins < 60) return `${diffMins}m ago`;
    const diffHrs = Math.floor(diffMins / 60);
    if (diffHrs < 24) return `${diffHrs}h ago`;
    const diffDays = Math.floor(diffHrs / 24);
    if (diffDays < 30) return `${diffDays}d ago`;
    const diffMonths = Math.floor(diffDays / 30);
    return `${diffMonths}mo ago`;
  }
</script>

<button
  class="issue-item"
  class:selected
  {onclick}
  oncontextmenu={handleContextMenu}
>
  <div class="issue-top">
    <span class="issue-state" class:open={issue.state === "open"} class:closed={issue.state === "closed"}>
      {#if issue.state === "open"}
        <AppIcon name="circle" size={10} />
      {:else}
        <AppIcon name="circle-check" size={10} />
      {/if}
    </span>
    <span class="issue-title">{issue.title}</span>
  </div>
  <div class="issue-meta">
    <span class="issue-repo">{issue.repo}</span>
    <span class="issue-number">#{issue.number}</span>
    <span class="issue-time">{timeAgo(issue.created_at)}</span>
    {#if issue.comments > 0}
      <span class="issue-comments">
        <AppIcon name="message-circle" size={10} />
        {issue.comments}
      </span>
    {/if}
  </div>
  {#if issue.labels.length > 0}
    <div class="issue-labels">
      {#each issue.labels as label}
        <span
          class="issue-label"
          style:background="#{label.color}20"
          style:color="#{label.color}"
          style:border-color="#{label.color}40"
        >
          {label.name}
        </span>
      {/each}
    </div>
  {/if}
</button>

{#if contextMenu}
  <div
    class="context-menu"
    use:placeMenu={contextMenu}
  >
    <button class="context-menu-item" onclick={openInBrowser}>Open in Browser</button>
    <div class="context-menu-separator"></div>
    <button class="context-menu-item" onclick={copyLink}>Copy Link</button>
    <button class="context-menu-item" onclick={copyNumber}>Copy Reference</button>
    <button class="context-menu-item" onclick={copyTitle}>Copy Title</button>
  </div>
{/if}

<style>
  .issue-item {
    display: block;
    width: 100%;
    padding: var(--wb-spacing-10) var(--wb-spacing-14);
    text-align: left;
    border-bottom: 1px solid var(--vscode-widget-border);
    cursor: pointer;
    transition: background 0.08s;
  }

  .issue-item:hover {
    background: var(--vscode-list-hoverBackground);
  }

  .issue-item.selected {
    background: color-mix(in srgb, var(--vscode-focusBorder) 6%, transparent);
    border-left: 2px solid var(--vscode-focusBorder);
    padding-left: var(--wb-spacing-12);
  }

  .issue-top {
    display: flex;
    align-items: flex-start;
    gap: var(--wb-spacing-6);
  }

  .issue-state {
    flex-shrink: 0;
    margin-top: var(--wb-spacing-2);
  }

  .issue-state.open {
    color: var(--vscode-testing-iconPassed);
  }

  .issue-state.closed {
    color: var(--vscode-textPreformat-foreground);
  }

  .issue-title {
    font-size: var(--wb-font-size-13);
    font-weight: 500;
    color: var(--vscode-editor-foreground);
    line-height: 1.3;
  }

  .issue-meta {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
    margin-top: var(--wb-spacing-4);
    padding-left: var(--wb-spacing-16);
    font-size: var(--wb-font-size-11);
    color: var(--vscode-disabledForeground);
  }

  .issue-repo {
    font-family: var(--wsb-font-mono);
    font-size: var(--wb-font-size-10);
  }

  .issue-number {
    font-family: var(--wsb-font-mono);
  }

  .issue-comments {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-3);
  }

  .issue-labels {
    display: flex;
    flex-wrap: wrap;
    gap: var(--wb-spacing-4);
    margin-top: var(--wb-spacing-6);
    padding-left: var(--wb-spacing-16);
  }

  .issue-label {
    font-size: var(--wb-font-size-10);
    font-weight: 500;
    padding: var(--wb-spacing-1) var(--wb-spacing-6);
    border-radius: var(--wb-corner-radius-10);
    border: 1px solid;
    white-space: nowrap;
  }

</style>