Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add custom context menu for sidebar items
✓ CI success Rūdolfs Ošiņš committed 28 days ago
commit 4b87e7f9de43b563d2f4ddf34adf1a9a4f3b54dd
parent 071504fda70e9bcbc4f8c508eff408b3f75cd61b
1 passed (1 total) View logs
6 files changed +228 -7
modified crates/radicle-tauri/capabilities/default.json
@@ -19,6 +19,9 @@
    "core:window:default",
    "dialog:default",
    "log:default",
-
    "shell:allow-open"
+
    {
+
      "identifier": "shell:allow-open",
+
      "allow": [{ "url": "https://radicle.network/**" }]
+
    }
  ]
}
modified src/components/AppSidebar.svelte
@@ -173,6 +173,13 @@
      stylePadding="0 4px">
      <span class="icon"><Icon name="arrow-right" /></span>
    </Button>
+
    <Button
+
      variant="naked"
+
      title="Reload"
+
      onclick={() => window.location.reload()}
+
      stylePadding="0 4px">
+
      <span class="icon"><Icon name="refresh" /></span>
+
    </Button>
  </div>

  <div class="nav">
added src/components/ContextMenu.svelte
@@ -0,0 +1,100 @@
+
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

+
  import { autoUpdate, computePosition, flip, shift } from "@floating-ui/dom";
+

+
  import { portal } from "@app/lib/portal";
+

+
  interface Props {
+
    x: number;
+
    y: number;
+
    onclose: () => void;
+
    children: Snippet;
+
  }
+

+
  const { x, y, onclose, children }: Props = $props();
+

+
  let menuEl: HTMLDivElement | undefined = $state();
+
  let active = $state(false);
+

+
  const virtualEl = {
+
    getBoundingClientRect: () => ({
+
      x,
+
      y,
+
      top: y,
+
      left: x,
+
      right: x,
+
      bottom: y,
+
      width: 0,
+
      height: 0,
+
      toJSON: () => undefined,
+
    }),
+
  };
+

+
  function onWindowClick(ev: MouseEvent | TouchEvent) {
+
    if (!active || !menuEl) return;
+
    if (!ev.composedPath().includes(menuEl)) {
+
      onclose();
+
    }
+
  }
+

+
  function onWindowKeydown(ev: KeyboardEvent) {
+
    if (!active) return;
+
    if (ev.key === "Escape") onclose();
+
  }
+

+
  $effect(() => {
+
    const id = requestAnimationFrame(() => {
+
      active = true;
+
    });
+
    return () => cancelAnimationFrame(id);
+
  });
+

+
  $effect(() => {
+
    if (!menuEl) return;
+
    return autoUpdate(virtualEl, menuEl, () => {
+
      void computePosition(virtualEl, menuEl!, {
+
        strategy: "fixed",
+
        placement: "bottom-start",
+
        middleware: [flip(), shift({ padding: 8 })],
+
      }).then(({ x: px, y: py }) => {
+
        if (menuEl) {
+
          menuEl.style.left = `${px}px`;
+
          menuEl.style.top = `${py}px`;
+
          menuEl.style.visibility = "visible";
+
        }
+
      });
+
    });
+
  });
+
</script>
+

+
<style>
+
  .menu {
+
    position: fixed;
+
    top: 0;
+
    left: 0;
+
    visibility: hidden;
+
    z-index: 100;
+
    min-width: 12rem;
+
    padding: 0.25rem;
+
    background-color: var(--color-surface-canvas);
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-md);
+
    box-shadow: var(--elevation-low);
+
  }
+
</style>
+

+
<svelte:window
+
  onclick={onWindowClick}
+
  oncontextmenu={onWindowClick}
+
  onkeydown={onWindowKeydown} />
+

+
<div
+
  use:portal
+
  bind:this={menuEl}
+
  class="menu"
+
  role="menu"
+
  tabindex="-1"
+
  oncontextmenu={e => e.preventDefault()}>
+
  {@render children()}
+
</div>
modified src/components/Icon.svelte
@@ -91,6 +91,7 @@
    | "play"
    | "plus"
    | "question-mark"
+
    | "refresh"
    | "reply"
    | "repository"
    | "revision"
@@ -595,6 +596,9 @@
      d="M8.625 11.625C8.625 11.9702 8.34518 12.25 8 12.25C7.65482 12.25 7.375 11.9702 7.375 11.625C7.375 11.2798 7.65482 11 8 11C8.34518 11 8.625 11.2798 8.625 11.625Z" />
    <path
      d="M9.63379 6.5C9.63379 5.89312 9.41336 5.5453 9.13867 5.33496C8.84286 5.10866 8.42725 5 8 5C7.57445 5 7.20236 5.10724 6.94531 5.32129C6.7043 5.52213 6.5 5.87024 6.5 6.5H5.5C5.5 5.62992 5.79588 4.97787 6.30469 4.55371C6.79763 4.14292 7.42571 4 8 4C8.57275 4 9.22492 4.14134 9.74609 4.54004C10.2882 4.95474 10.6338 5.60725 10.6338 6.5C10.6338 7.32703 10.1246 7.91638 9.6123 8.29102C9.24736 8.55787 8.84058 8.74663 8.5 8.86816V10H7.5V8.10938L7.87891 8.01465C8.16659 7.9427 8.63623 7.76595 9.02148 7.48438C9.40947 7.20067 9.63379 6.87287 9.63379 6.5Z" />
+
  {:else if name === "refresh"}
+
    <path
+
      d="M5.96036 2.90149C6.96895 2.49575 8.07552 2.39324 9.142 2.60755C10.0752 2.79513 10.9424 3.21774 11.6606 3.83216L11.9594 4.10755L11.9604 4.1095L13.269 5.41712V2.63001H14.269V7.13001H9.76896V6.13001H12.5678L11.2533 4.81653C10.6227 4.19192 9.81944 3.76385 8.94474 3.58802C8.06925 3.41213 7.16092 3.4963 6.33341 3.82923C5.50583 4.16223 4.79588 4.72924 4.29239 5.45911C3.789 6.18894 3.51339 7.04944 3.5004 7.93274C3.48749 8.81618 3.73826 9.6848 4.22013 10.4288C4.70202 11.1727 5.39497 11.7597 6.21231 12.1163C7.02978 12.4729 7.93547 12.5835 8.81583 12.4327C9.69613 12.2819 10.5116 11.8769 11.1606 11.2697L11.8442 12.0001C11.0519 12.7415 10.057 13.2344 8.98478 13.4181C7.91231 13.6017 6.80843 13.4681 5.81192 13.0333C4.81535 12.5985 3.96941 11.8815 3.38028 10.9718C2.79121 10.0622 2.48556 8.99983 2.50138 7.91809C2.51727 6.83653 2.85387 5.78388 3.46915 4.89173C4.08457 3.99954 4.95161 3.3074 5.96036 2.90149Z" />
  {:else if name === "reply"}
    <path
      d="M5.35352 2.35352L3.20703 4.5H14.5V13.5H10V12.5H13.5V5.5H3.20703L5.35352 7.64648L4.64648 8.35352L1.29297 5L4.64648 1.64648L5.35352 2.35352Z" />
modified src/components/SidebarRepoList.svelte
@@ -9,13 +9,17 @@

  import { nodeRunning } from "@app/lib/events";
  import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
-
  import { cachedRepoCommitCount, invoke } from "@app/lib/invoke";
+
  import {
+
    cachedRepoCommitCount,
+
    invoke,
+
    writeToClipboard,
+
  } from "@app/lib/invoke";
  import * as router from "@app/lib/router";
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
-
  import { formatRepositoryId } from "@app/lib/utils";
+
  import { explorerUrl, formatRepositoryId } from "@app/lib/utils";

  import AddRepoButton from "@app/components/AddRepoButton.svelte";
-
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import ContextMenu from "@app/components/ContextMenu.svelte";
  import Icon from "@app/components/Icon.svelte";
  import RepoAvatar from "@app/components/RepoAvatar.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
@@ -47,6 +51,29 @@
    repos = initialRepos;
  });

+
  let contextMenu = $state<
+
    { x: number; y: number; repo: RepoSummary } | undefined
+
  >(undefined);
+

+
  function openContextMenu(event: MouseEvent, repo: RepoSummary) {
+
    event.preventDefault();
+
    event.stopPropagation();
+
    contextMenu = { x: event.clientX, y: event.clientY, repo };
+
  }
+

+
  function closeContextMenu() {
+
    contextMenu = undefined;
+
  }
+

+
  async function openExplorer(url: string) {
+
    if (window.__TAURI_INTERNALS__) {
+
      const { open } = await import("@tauri-apps/plugin-shell");
+
      await open(url);
+
    } else {
+
      window.open(url, "_blank", "noreferrer");
+
    }
+
  }
+

  $effect(() => {
    seededNotReplicated = initialSeededNotReplicated;
  });
@@ -428,6 +455,33 @@
  .icon {
    color: var(--color-text-tertiary);
  }
+

+
  .menu-item {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    width: 100%;
+
    min-height: 2rem;
+
    padding: 0 0.75rem;
+
    background: transparent;
+
    border: 0;
+
    border-radius: var(--border-radius-sm);
+
    color: var(--color-text-primary);
+
    font: var(--txt-body-m-regular);
+
    text-align: left;
+
    white-space: nowrap;
+
    cursor: pointer;
+
  }
+
  .menu-item :global(svg) {
+
    color: var(--color-text-tertiary);
+
    flex-shrink: 0;
+
  }
+
  .menu-item:hover {
+
    background-color: var(--color-surface-subtle);
+
  }
+
  .menu-item:hover :global(svg) {
+
    color: var(--color-text-primary);
+
  }
</style>

{#if seededNotReplicated.length > 0}
@@ -581,6 +635,7 @@
    draggable="false"
    onmousedown={pinned ? e => drag.onMouseDown(e, repo.rid) : undefined}
    onclick={pinned ? drag.onClick : undefined}
+
    oncontextmenu={e => openContextMenu(e, repo)}
    href={router.routeToPath({ resource: "repo.home", rid: repo.rid })}>
    <RepoAvatar name={repo.name} rid={repo.rid} styleWidth="1rem" />
    <span class="txt-overflow">{repo.name}</span>
@@ -597,9 +652,6 @@
        onclick={() => togglePin(repo.rid)}>
        <Icon name={pinState ? "pin-filled" : "pin-hollow"} />
      </button>
-
      <span title="Copy RID">
-
        <Clipboard text={repo.rid} noPopover />
-
      </span>
      {#if pinned}
        <span class="drag-handle" title="Drag to reorder">
          <Icon name="drag-handle" />
@@ -686,3 +738,46 @@
    <span class="txt-overflow">{drag.draggedRepo.name}</span>
  </div>
{/if}
+

+
{#if contextMenu}
+
  {@const repo = contextMenu.repo}
+
  {@const url = explorerUrl(repo.rid)}
+
  <ContextMenu x={contextMenu.x} y={contextMenu.y} onclose={closeContextMenu}>
+
    <button
+
      class="menu-item"
+
      onclick={() => {
+
        void writeToClipboard(repo.rid);
+
        closeContextMenu();
+
      }}>
+
      <Icon name="copy" />
+
      Copy RID
+
    </button>
+
    <button
+
      class="menu-item"
+
      onclick={() => {
+
        void writeToClipboard(`rad checkout ${repo.rid}`);
+
        closeContextMenu();
+
      }}>
+
      <Icon name="copy" />
+
      Copy checkout command
+
    </button>
+
    <button
+
      class="menu-item"
+
      onclick={() => {
+
        void writeToClipboard(url);
+
        closeContextMenu();
+
      }}>
+
      <Icon name="copy" />
+
      Copy link to radicle.network
+
    </button>
+
    <button
+
      class="menu-item"
+
      onclick={() => {
+
        void openExplorer(url);
+
        closeContextMenu();
+
      }}>
+
      <Icon name="open-external" />
+
      Open on radicle.network
+
    </button>
+
  </ContextMenu>
+
{/if}
modified src/main.ts
@@ -6,6 +6,18 @@ import App from "./App.svelte";

const app = mount(App, { target: document.body });

+
window.addEventListener("contextmenu", e => {
+
  const selection = window.getSelection();
+
  if (selection && !selection.isCollapsed && selection.toString().length > 0) {
+
    return;
+
  }
+
  const target = e.target as HTMLElement | null;
+
  if (target?.closest("input, textarea, [contenteditable]")) {
+
    return;
+
  }
+
  e.preventDefault();
+
});
+

const mac = hotkeyMacCompat();
startKeyUX(window, [hotkeyKeyUX([mac])]);