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 1464ef19fbd8ad928052488ddafaae6658eeb45f
parent 071504fda70e9bcbc4f8c508eff408b3f75cd61b
1 passed (1 total) View logs
5 files changed +224 -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
@@ -169,6 +169,13 @@
    </Button>
    <Button
      variant="naked"
+
      title="Reload"
+
      onclick={() => window.location.reload()}
+
      stylePadding="0 4px">
+
      <span class="icon"><Icon name="activity" /></span>
+
    </Button>
+
    <Button
+
      variant="naked"
      onclick={() => window.history.forward()}
      stylePadding="0 4px">
      <span class="icon"><Icon name="arrow-right" /></span>
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/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])]);