Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add repo pinning
✓ CI success Rūdolfs Ošiņš committed 8 days ago
commit 071504fda70e9bcbc4f8c508eff408b3f75cd61b
parent c9a641da1355086b90c475d2715051ab94b7f0b5
1 passed (1 total) View logs
3 files changed +473 -92
modified src/components/Icon.svelte
@@ -44,6 +44,7 @@
    | "disconnect"
    | "document"
    | "download"
+
    | "drag-handle"
    | "edit"
    | "ellipsis"
    | "ellipsis-vertical"
@@ -350,6 +351,19 @@
      d="M7.5 2.5H8.5V9.79297L11 7.29297L11.707 8L8 11.707L4.29297 8L5 7.29297L7.5 9.79297V2.5Z" />
    <path
      d="M3.5 11L3.5 13.5L12.5 13.5L12.5 11L13.5 11L13.5 14.5L2.5 14.5L2.5 11L3.5 11Z" />
+
  {:else if name === "drag-handle"}
+
    <path
+
      d="M5.75 4.375C6.09518 4.375 6.375 4.09518 6.375 3.75C6.375 3.40482 6.09518 3.125 5.75 3.125C5.40482 3.125 5.125 3.40482 5.125 3.75C5.125 4.09518 5.40482 4.375 5.75 4.375Z" />
+
    <path
+
      d="M10.25 4.375C10.5952 4.375 10.875 4.09518 10.875 3.75C10.875 3.40482 10.5952 3.125 10.25 3.125C9.90482 3.125 9.625 3.40482 9.625 3.75C9.625 4.09518 9.90482 4.375 10.25 4.375Z" />
+
    <path
+
      d="M5.75 8.625C6.09518 8.625 6.375 8.34518 6.375 8C6.375 7.65482 6.09518 7.375 5.75 7.375C5.40482 7.375 5.125 7.65482 5.125 8C5.125 8.34518 5.40482 8.625 5.75 8.625Z" />
+
    <path
+
      d="M10.25 8.625C10.5952 8.625 10.875 8.34518 10.875 8C10.875 7.65482 10.5952 7.375 10.25 7.375C9.90482 7.375 9.625 7.65482 9.625 8C9.625 8.34518 9.90482 8.625 10.25 8.625Z" />
+
    <path
+
      d="M5.75 12.875C6.09518 12.875 6.375 12.5952 6.375 12.25C6.375 11.9048 6.09518 11.625 5.75 11.625C5.40482 11.625 5.125 11.9048 5.125 12.25C5.125 12.5952 5.40482 12.875 5.75 12.875Z" />
+
    <path
+
      d="M10.25 12.875C10.5952 12.875 10.875 12.5952 10.875 12.25C10.875 11.9048 10.5952 11.625 10.25 11.625C9.90482 11.625 9.625 11.9048 9.625 12.25C9.625 12.5952 9.90482 12.875 10.25 12.875Z" />
  {:else if name === "edit"}
    <path
      d="M14.707 5L6.20703 13.5H2.5V9.79297L11 1.29297L14.707 5ZM3.5 10.207V12.5H5.79297L11.293 7L9 4.70703L3.5 10.207ZM9.70703 4L12 6.29297L13.293 5L11 2.70703L9.70703 4Z" />
modified src/components/SidebarRepoList.svelte
@@ -3,7 +3,9 @@
  import type { RepoSummary } from "@bindings/repo/RepoSummary";

  import { onMount } from "svelte";
-
  import { boolean } from "zod";
+
  import { flip } from "svelte/animate";
+
  import { crossfade } from "svelte/transition";
+
  import { array, boolean, string } from "zod";

  import { nodeRunning } from "@app/lib/events";
  import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
@@ -17,6 +19,10 @@
  import Icon from "@app/components/Icon.svelte";
  import RepoAvatar from "@app/components/RepoAvatar.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
+
  import usePinnedDragReorder, {
+
    DRAG_RID_ATTRIBUTE,
+
    PINNED_LIST_CLASS,
+
  } from "@app/components/usePinnedDragReorder.svelte";

  interface Props {
    initialRepos: RepoSummary[];
@@ -104,6 +110,64 @@
    !window.localStorage,
  );

+
  const pinnedRepoIds = useLocalStorage<string[]>(
+
    "sidebarPinnedRepos",
+
    array(string()),
+
    [],
+
    !window.localStorage,
+
  );
+

+
  const pinnedRepos = $derived.by(() => {
+
    const byRid = new Map(repos.map(r => [r.rid, r]));
+
    return pinnedRepoIds.value
+
      .map(rid => byRid.get(rid))
+
      .filter((r): r is RepoSummary => r !== undefined);
+
  });
+

+
  const unpinnedFilteredRepos = $derived(
+
    filteredRepos.filter(r => !pinnedRepoIds.value.includes(r.rid)),
+
  );
+

+
  const unpinnedReposCount = $derived(
+
    repos.filter(r => !pinnedRepoIds.value.includes(r.rid)).length,
+
  );
+

+
  const ANIMATION_DURATION_MS = 220;
+
  let animatingPinnedList = $state(false);
+
  let animationTimeout: ReturnType<typeof setTimeout> | undefined;
+
  const animationDuration = $derived(
+
    animatingPinnedList ? ANIMATION_DURATION_MS : 0,
+
  );
+
  const [send, receive] = crossfade({
+
    duration: ANIMATION_DURATION_MS,
+
    fallback: () => ({ duration: 0 }),
+
  });
+

+
  function withPinAnimation(fn: () => void) {
+
    animatingPinnedList = true;
+
    if (animationTimeout !== undefined) clearTimeout(animationTimeout);
+
    animationTimeout = setTimeout(() => {
+
      animatingPinnedList = false;
+
    }, ANIMATION_DURATION_MS);
+
    fn();
+
  }
+

+
  function togglePin(rid: string) {
+
    withPinAnimation(() => {
+
      if (pinnedRepoIds.value.includes(rid)) {
+
        pinnedRepoIds.value = pinnedRepoIds.value.filter(r => r !== rid);
+
      } else {
+
        pinnedRepoIds.value = [rid, ...pinnedRepoIds.value];
+
      }
+
    });
+
  }
+

+
  const drag = usePinnedDragReorder({
+
    pinnedRepos: () => pinnedRepos,
+
    getOrder: () => pinnedRepoIds.value,
+
    setOrder: rids => withPinAnimation(() => (pinnedRepoIds.value = rids)),
+
  });
+

  async function reloadRepos() {
    [repos, seededNotReplicated] = await Promise.all([
      invoke<RepoSummary[]>("list_repos_summary"),
@@ -162,6 +226,12 @@
    gap: 0.25rem;
    padding: 0.5rem 0;
  }
+
  .pinned-list.empty {
+
    height: 0;
+
    padding: 0;
+
    overflow: visible;
+
  }
+

  .section-header {
    font: var(--txt-body-m-regular);
    font-variant-ligatures: none;
@@ -186,7 +256,38 @@
    align-items: center;
    gap: 0.25rem;
  }
+

+
  .filter-button {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    background: none;
+
    border: 0;
+
    padding: 0.125rem;
+
    margin-left: -0.125rem;
+
    border-radius: var(--border-radius-sm);
+
    color: var(--color-text-secondary);
+
    cursor: pointer;
+
  }
+
  .filter-button:hover {
+
    color: var(--color-text-primary);
+
    background-color: var(--color-surface-subtle);
+
  }
+
  .filter-input {
+
    background: none;
+
    border: 0;
+
    outline: none;
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-primary);
+
    flex: 1;
+
    min-width: 0;
+
  }
+
  .filter-input::placeholder {
+
    color: var(--color-text-secondary);
+
  }
+

  .nav-item {
+
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.5rem;
@@ -197,6 +298,12 @@
    cursor: pointer;
    width: 100%;
    text-decoration: none;
+
    user-select: none;
+
    -webkit-user-select: none;
+
  }
+
  .nav-item :global(img),
+
  .nav-item :global(svg) {
+
    -webkit-user-drag: none;
  }
  .nav-item:hover {
    background-color: var(--color-surface-subtle);
@@ -210,6 +317,7 @@
  .sub-item {
    padding-left: 2rem;
  }
+

  .pending-item {
    color: var(--color-text-secondary);
    cursor: default;
@@ -232,46 +340,91 @@
  .pending-item .remove-icon:hover {
    background-color: var(--color-surface-mid);
  }
-
  .nav-item .copy-rid {
+

+
  .nav-item .row-actions {
    visibility: hidden;
    margin-left: auto;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.125rem;
    color: var(--color-text-tertiary);
-
    border-radius: var(--border-radius-sm);
  }
-
  .nav-item:hover .copy-rid {
+
  .nav-item:hover .row-actions,
+
  .nav-item .row-actions:has(:focus-visible) {
    visibility: visible;
  }
-
  .nav-item .copy-rid:hover {
-
    background-color: var(--color-surface-mid);
-
  }
-
  .filter-button {
+
  .nav-item .row-actions :global(.clipboard),
+
  .nav-item .row-actions .pin-button,
+
  .nav-item .row-actions .drag-handle {
+
    width: 1.5rem;
+
    height: 1.5rem;
+
    border-radius: var(--border-radius-sm);
    display: flex;
    align-items: center;
    justify-content: center;
+
  }
+
  .nav-item .row-actions :global(.clipboard):hover,
+
  .nav-item .row-actions .pin-button:hover {
+
    background-color: var(--color-surface-mid);
+
  }
+
  .pin-button {
    background: none;
    border: 0;
-
    padding: 0.125rem;
-
    margin-left: -0.125rem;
-
    border-radius: var(--border-radius-sm);
-
    color: var(--color-text-secondary);
+
    color: inherit;
    cursor: pointer;
  }
-
  .filter-button:hover {
-
    color: var(--color-text-primary);
+
  .drag-handle {
+
    cursor: grab;
+
  }
+

+
  .repo-row-group {
+
    position: relative;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.25rem;
+
  }
+
  .repo-row-group.drop-before::before,
+
  .repo-row-group.drop-after::after {
+
    content: "";
+
    position: absolute;
+
    left: 0;
+
    right: 0;
+
    height: 2px;
+
    background-color: var(--color-border-mid);
+
    border-radius: 1px;
+
    pointer-events: none;
+
  }
+
  .repo-row-group.drop-before::before {
+
    top: -3px;
+
  }
+
  .repo-row-group.drop-after::after {
+
    bottom: -3px;
+
  }
+
  .nav-item.dragging {
+
    opacity: 0.35;
    background-color: var(--color-surface-subtle);
  }
-
  .filter-input {
-
    background: none;
-
    border: 0;
-
    outline: none;
+
  :global(body.dragging-pinned-repo),
+
  :global(body.dragging-pinned-repo *) {
+
    cursor: grabbing !important;
+
  }
+
  .drag-ghost {
+
    position: fixed;
+
    pointer-events: none;
+
    z-index: 9999;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    padding: 0.375rem 0.5rem;
+
    background-color: var(--color-surface-strong);
+
    border: 1px solid var(--color-border-mid);
+
    border-radius: var(--border-radius-sm);
    font: var(--txt-body-m-regular);
    color: var(--color-text-primary);
-
    flex: 1;
-
    min-width: 0;
-
  }
-
  .filter-input::placeholder {
-
    color: var(--color-text-secondary);
+
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+
    max-width: 14rem;
  }
+

  .icon {
    color: var(--color-text-tertiary);
  }
@@ -320,6 +473,27 @@
{/if}

<div
+
  class="repos-list {PINNED_LIST_CLASS}"
+
  class:empty={pinnedRepos.length === 0}>
+
  {#each pinnedRepos as repo (repo.rid)}
+
    <div
+
      class="repo-row-group"
+
      class:drop-before={drag.dropTargetRid === repo.rid &&
+
        drag.dropPosition === "before" &&
+
        drag.draggingRid !== repo.rid}
+
      class:drop-after={drag.dropTargetRid === repo.rid &&
+
        drag.dropPosition === "after" &&
+
        drag.draggingRid !== repo.rid}
+
      {...{ [DRAG_RID_ATTRIBUTE]: repo.rid }}
+
      animate:flip={{ duration: animationDuration }}
+
      in:receive={{ key: repo.rid, duration: animationDuration }}
+
      out:send={{ key: repo.rid, duration: animationDuration }}>
+
      {@render repoRowInner(repo, true)}
+
    </div>
+
  {/each}
+
</div>
+

+
<div
  class="section-header"
  onclick={() => {
    if (!filterOpen) {
@@ -358,10 +532,10 @@
          if (e.key === "Escape") {
            filterOpen = false;
            filterQuery = "";
-
          } else if (e.key === "Enter" && filteredRepos.length > 0) {
+
          } else if (e.key === "Enter" && unpinnedFilteredRepos.length > 0) {
            void router.push({
              resource: "repo.home",
-
              rid: filteredRepos[0].rid,
+
              rid: unpinnedFilteredRepos[0].rid,
            });
            filterQuery = "";
          }
@@ -381,7 +555,7 @@
          <span class="icon"><Icon name="filter" /></span>
        </button>
      </span>
-
      All Repos{repos.length > 1 ? ` (${repos.length})` : ""}
+
      All Repos{unpinnedReposCount > 1 ? ` (${unpinnedReposCount})` : ""}
      <span class="icon">
        <Icon name={reposExpanded.value ? "chevron-down" : "chevron-up"} />
      </span>
@@ -398,77 +572,117 @@
  </span>
</div>

+
{#snippet repoRowInner(repo: RepoSummary, pinned: boolean = false)}
+
  {@const pinState = pinnedRepoIds.value.includes(repo.rid)}
+
  <a
+
    class="nav-item"
+
    class:active={isRepoHome(repo.rid)}
+
    class:dragging={pinned && drag.draggingRid === repo.rid}
+
    draggable="false"
+
    onmousedown={pinned ? e => drag.onMouseDown(e, repo.rid) : undefined}
+
    onclick={pinned ? drag.onClick : undefined}
+
    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>
+
    <span
+
      class="row-actions"
+
      role="none"
+
      onclick={e => {
+
        e.preventDefault();
+
        e.stopPropagation();
+
      }}>
+
      <button
+
        class="pin-button"
+
        title={pinState ? "Unpin repository" : "Pin repository"}
+
        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" />
+
        </span>
+
      {/if}
+
    </span>
+
  </a>
+
  {#if activeRid() === repo.rid}
+
    {@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
+
    {@render subItem(
+
      router.routeToPath({ resource: "repo.commits", rid: repo.rid }),
+
      "branch",
+
      "Commits",
+
      isCommits(repo.rid),
+
      activeCommitCount,
+
    )}
+
    {@render subItem(
+
      router.routeToPath({
+
        resource: "repo.issues",
+
        rid: repo.rid,
+
        status: "open",
+
      }),
+
      "issue",
+
      "Issues",
+
      isIssues(repo.rid),
+
      activeProject?.meta.issues.open || undefined,
+
    )}
+
    {@render subItem(
+
      router.routeToPath({
+
        resource: "repo.patches",
+
        rid: repo.rid,
+
        status: "open",
+
      }),
+
      "patch",
+
      "Patches",
+
      isPatches(repo.rid),
+
      activeProject?.meta.patches.open || undefined,
+
    )}
+
  {/if}
+
{/snippet}
+

+
{#snippet subItem(
+
  href: string,
+
  icon: "branch" | "issue" | "patch",
+
  label: string,
+
  active: boolean,
+
  count: number | undefined,
+
)}
+
  <a class="nav-item sub-item" class:active {href}>
+
    <span class="icon"><Icon name={icon} /></span>
+
    {label}
+
    {#if count !== undefined}
+
      <span class="global-counter-badge">{count}</span>
+
    {/if}
+
  </a>
+
{/snippet}
+

{#if reposExpanded.value}
  <ScrollArea
    style="flex: 1; min-height: 0; mask-image: linear-gradient(to bottom, transparent 0, black 0.5rem, black calc(100% - 0.5rem), transparent 100%);">
    <div class="repos-list">
-
      {#each filteredRepos as repo (repo.rid)}
-
        <a
-
          class="nav-item"
-
          class:active={isRepoHome(repo.rid)}
-
          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>
-
          <span
-
            class="copy-rid"
-
            role="none"
-
            title="Copy RID"
-
            onclick={e => {
-
              e.preventDefault();
-
              e.stopPropagation();
-
            }}>
-
            <Clipboard text={repo.rid} noPopover />
-
          </span>
-
        </a>
-
        {#if activeRid() === repo.rid}
-
          {@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
-
          <a
-
            class="nav-item sub-item"
-
            class:active={isCommits(repo.rid)}
-
            href={router.routeToPath({
-
              resource: "repo.commits",
-
              rid: repo.rid,
-
            })}>
-
            <span class="icon"><Icon name="branch" /></span>
-
            Commits
-
            {#if activeCommitCount !== undefined}
-
              <span class="global-counter-badge">{activeCommitCount}</span>
-
            {/if}
-
          </a>
-
          <a
-
            class="nav-item sub-item"
-
            class:active={isIssues(repo.rid)}
-
            href={router.routeToPath({
-
              resource: "repo.issues",
-
              rid: repo.rid,
-
              status: "open",
-
            })}>
-
            <span class="icon"><Icon name="issue" /></span>
-
            Issues
-
            {#if activeProject && activeProject.meta.issues.open > 0}
-
              <span class="global-counter-badge">
-
                {activeProject.meta.issues.open}
-
              </span>
-
            {/if}
-
          </a>
-
          <a
-
            class="nav-item sub-item"
-
            class:active={isPatches(repo.rid)}
-
            href={router.routeToPath({
-
              resource: "repo.patches",
-
              rid: repo.rid,
-
              status: "open",
-
            })}>
-
            <span class="icon"><Icon name="patch" /></span>
-
            Patches
-
            {#if activeProject && activeProject.meta.patches.open > 0}
-
              <span class="global-counter-badge">
-
                {activeProject.meta.patches.open}
-
              </span>
-
            {/if}
-
          </a>
-
        {/if}
+
      {#each unpinnedFilteredRepos as repo (repo.rid)}
+
        <div
+
          class="repo-row-group"
+
          animate:flip={{ duration: animationDuration }}
+
          in:receive={{ key: repo.rid, duration: animationDuration }}
+
          out:send={{ key: repo.rid, duration: animationDuration }}>
+
          {@render repoRowInner(repo, false)}
+
        </div>
      {/each}
    </div>
  </ScrollArea>
{/if}
+

+
{#if drag.draggedRepo}
+
  <div
+
    class="drag-ghost"
+
    style:left="{drag.ghostX + 12}px"
+
    style:top="{drag.ghostY + 8}px">
+
    <RepoAvatar
+
      name={drag.draggedRepo.name}
+
      rid={drag.draggedRepo.rid}
+
      styleWidth="1rem" />
+
    <span class="txt-overflow">{drag.draggedRepo.name}</span>
+
  </div>
+
{/if}
added src/components/usePinnedDragReorder.svelte.ts
@@ -0,0 +1,153 @@
+
import type { RepoSummary } from "@bindings/repo/RepoSummary";
+

+
interface Options {
+
  pinnedRepos: () => RepoSummary[];
+
  getOrder: () => string[];
+
  setOrder: (rids: string[]) => void;
+
}
+

+
export const PINNED_LIST_CLASS = "pinned-list";
+
export const DRAG_RID_ATTRIBUTE = "data-drag-rid";
+

+
const THRESHOLD_PX = 4;
+
const BODY_DRAGGING_CLASS = "dragging-pinned-repo";
+

+
export default function usePinnedDragReorder(options: Options) {
+
  let dragStart: { rid: string; x: number; y: number } | undefined;
+
  let suppressClick = false;
+
  let draggingRid = $state<string | undefined>(undefined);
+
  let dropTargetRid = $state<string | undefined>(undefined);
+
  let dropPosition = $state<"before" | "after" | undefined>(undefined);
+
  let ghostX = $state(0);
+
  let ghostY = $state(0);
+

+
  const draggedRepo = $derived(
+
    draggingRid !== undefined
+
      ? options.pinnedRepos().find(r => r.rid === draggingRid)
+
      : undefined,
+
  );
+

+
  $effect(() => {
+
    if (draggingRid !== undefined) {
+
      document.body.classList.add(BODY_DRAGGING_CLASS);
+
      return () => document.body.classList.remove(BODY_DRAGGING_CLASS);
+
    }
+
  });
+

+
  $effect(() => {
+
    return () => {
+
      window.removeEventListener("mousemove", onWindowMouseMove);
+
      window.removeEventListener("mouseup", onWindowMouseUp);
+
    };
+
  });
+

+
  function onMouseDown(e: MouseEvent, rid: string) {
+
    if (e.button !== 0) return;
+
    const target = e.target as HTMLElement;
+
    if (target.closest("button, .clipboard")) return;
+
    suppressClick = false;
+
    dragStart = { rid, x: e.clientX, y: e.clientY };
+
    window.addEventListener("mousemove", onWindowMouseMove);
+
    window.addEventListener("mouseup", onWindowMouseUp);
+
  }
+

+
  function onWindowMouseMove(e: MouseEvent) {
+
    if (!dragStart) return;
+
    if (!draggingRid) {
+
      const dx = e.clientX - dragStart.x;
+
      const dy = e.clientY - dragStart.y;
+
      if (Math.hypot(dx, dy) < THRESHOLD_PX) return;
+
      draggingRid = dragStart.rid;
+
    }
+
    ghostX = e.clientX;
+
    ghostY = e.clientY;
+
    updateDropTarget(e.clientX, e.clientY);
+
  }
+

+
  function updateDropTarget(x: number, y: number) {
+
    const el = document.elementFromPoint(x, y) as HTMLElement | null;
+
    const list = el?.closest(`.${PINNED_LIST_CLASS}`) as HTMLElement | null;
+
    if (!list) {
+
      dropTargetRid = undefined;
+
      dropPosition = undefined;
+
      return;
+
    }
+
    let row = el?.closest(`[${DRAG_RID_ATTRIBUTE}]`) as HTMLElement | null;
+
    if (!row) {
+
      const rows = Array.from(
+
        list.querySelectorAll(`[${DRAG_RID_ATTRIBUTE}]`),
+
      ) as HTMLElement[];
+
      let nearestDistance = Infinity;
+
      for (const r of rows) {
+
        const rect = r.getBoundingClientRect();
+
        const distance = Math.abs(y - (rect.top + rect.height / 2));
+
        if (distance < nearestDistance) {
+
          nearestDistance = distance;
+
          row = r;
+
        }
+
      }
+
    }
+
    if (!row) return;
+
    const rid = row.getAttribute(DRAG_RID_ATTRIBUTE);
+
    if (!rid) return;
+
    const rect = row.getBoundingClientRect();
+
    dropTargetRid = rid;
+
    dropPosition = y - rect.top < rect.height / 2 ? "before" : "after";
+
  }
+

+
  function onWindowMouseUp() {
+
    window.removeEventListener("mousemove", onWindowMouseMove);
+
    window.removeEventListener("mouseup", onWindowMouseUp);
+

+
    const dragging = draggingRid;
+
    const target = dropTargetRid;
+
    const position = dropPosition;
+

+
    if (dragging && target && position && dragging !== target) {
+
      const without = options.getOrder().filter(r => r !== dragging);
+
      const targetIdx = without.indexOf(target);
+
      if (targetIdx !== -1) {
+
        const insertAt = position === "after" ? targetIdx + 1 : targetIdx;
+
        without.splice(insertAt, 0, dragging);
+
        options.setOrder(without);
+
      }
+
    }
+

+
    suppressClick = dragging !== undefined;
+
    dragStart = undefined;
+
    draggingRid = undefined;
+
    dropTargetRid = undefined;
+
    dropPosition = undefined;
+
  }
+

+
  function onClick(e: MouseEvent) {
+
    if (suppressClick) {
+
      e.preventDefault();
+
      e.stopPropagation();
+
      suppressClick = false;
+
    }
+
  }
+

+
  return {
+
    get draggingRid() {
+
      return draggingRid;
+
    },
+
    get dropTargetRid() {
+
      return dropTargetRid;
+
    },
+
    get dropPosition() {
+
      return dropPosition;
+
    },
+
    get draggedRepo() {
+
      return draggedRepo;
+
    },
+
    get ghostX() {
+
      return ghostX;
+
    },
+
    get ghostY() {
+
      return ghostY;
+
    },
+
    onMouseDown,
+
    onClick,
+
  };
+
}