diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 21c0bd737..faa809209 100644 --- a/src/components/Icon.svelte +++ b/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" /> + {:else if name === "drag-handle"} + + + + + + {:else if name === "edit"} diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index b7198bb60..e0bb9f0f6 100644 --- a/src/components/SidebarRepoList.svelte +++ b/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( + "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 | 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("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,64 +256,7 @@ align-items: center; gap: 0.25rem; } - .nav-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.5rem; - border-radius: var(--border-radius-sm); - font: var(--txt-body-m-regular); - color: var(--color-text-primary); - cursor: pointer; - width: 100%; - text-decoration: none; - } - .nav-item:hover { - background-color: var(--color-surface-subtle); - } - .nav-item.active { - background-color: var(--color-surface-subtle); - } - .nav-item .global-counter-badge { - margin-left: auto; - } - .sub-item { - padding-left: 2rem; - } - .pending-item { - color: var(--color-text-secondary); - cursor: default; - } - .pending-avatar { - width: 1rem; - height: 1rem; - flex-shrink: 0; - border: 1px solid var(--color-border-subtle); - } - .pending-item .remove-icon { - display: none; - margin-left: auto; - color: var(--color-text-tertiary); - border-radius: var(--border-radius-sm); - } - .pending-item:hover .remove-icon { - display: flex; - } - .pending-item .remove-icon:hover { - background-color: var(--color-surface-mid); - } - .nav-item .copy-rid { - visibility: hidden; - margin-left: auto; - color: var(--color-text-tertiary); - border-radius: var(--border-radius-sm); - } - .nav-item:hover .copy-rid { - visibility: visible; - } - .nav-item .copy-rid:hover { - background-color: var(--color-surface-mid); - } + .filter-button { display: flex; align-items: center; @@ -272,6 +285,146 @@ .filter-input::placeholder { color: var(--color-text-secondary); } + + .nav-item { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: var(--border-radius-sm); + font: var(--txt-body-m-regular); + color: var(--color-text-primary); + 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); + } + .nav-item.active { + background-color: var(--color-surface-subtle); + } + .nav-item .global-counter-badge { + margin-left: auto; + } + .sub-item { + padding-left: 2rem; + } + + .pending-item { + color: var(--color-text-secondary); + cursor: default; + } + .pending-avatar { + width: 1rem; + height: 1rem; + flex-shrink: 0; + border: 1px solid var(--color-border-subtle); + } + .pending-item .remove-icon { + display: none; + margin-left: auto; + color: var(--color-text-tertiary); + border-radius: var(--border-radius-sm); + } + .pending-item:hover .remove-icon { + display: flex; + } + .pending-item .remove-icon:hover { + background-color: var(--color-surface-mid); + } + + .nav-item .row-actions { + visibility: hidden; + margin-left: auto; + display: flex; + align-items: center; + gap: 0.125rem; + color: var(--color-text-tertiary); + } + .nav-item:hover .row-actions, + .nav-item .row-actions:has(:focus-visible) { + visibility: visible; + } + .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; + color: inherit; + cursor: pointer; + } + .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); + } + :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); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + max-width: 14rem; + } + .icon { color: var(--color-text-tertiary); } @@ -319,6 +472,27 @@ {/if} {/if} + + {#each pinnedRepos as repo (repo.rid)} + + {@render repoRowInner(repo, true)} + + {/each} + + { @@ -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 @@ - All Repos{repos.length > 1 ? ` (${repos.length})` : ""} + All Repos{unpinnedReposCount > 1 ? ` (${unpinnedReposCount})` : ""} @@ -398,77 +572,117 @@ +{#snippet repoRowInner(repo: RepoSummary, pinned: boolean = false)} + {@const pinState = pinnedRepoIds.value.includes(repo.rid)} + drag.onMouseDown(e, repo.rid) : undefined} + onclick={pinned ? drag.onClick : undefined} + href={router.routeToPath({ resource: "repo.home", rid: repo.rid })}> + + {repo.name} + { + e.preventDefault(); + e.stopPropagation(); + }}> + togglePin(repo.rid)}> + + + + + + {#if pinned} + + + + {/if} + + + {#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, +)} + + + {label} + {#if count !== undefined} + {count} + {/if} + +{/snippet} + {#if reposExpanded.value} - {#each filteredRepos as repo (repo.rid)} - - - {repo.name} - { - e.preventDefault(); - e.stopPropagation(); - }}> - - - - {#if activeRid() === repo.rid} - {@const activeProject = activeRepo?.payloads["xyz.radicle.project"]} - - - Commits - {#if activeCommitCount !== undefined} - {activeCommitCount} - {/if} - - - - Issues - {#if activeProject && activeProject.meta.issues.open > 0} - - {activeProject.meta.issues.open} - - {/if} - - - - Patches - {#if activeProject && activeProject.meta.patches.open > 0} - - {activeProject.meta.patches.open} - - {/if} - - {/if} + {#each unpinnedFilteredRepos as repo (repo.rid)} + + {@render repoRowInner(repo, false)} + {/each} {/if} + +{#if drag.draggedRepo} + + + {drag.draggedRepo.name} + +{/if} diff --git a/src/components/usePinnedDragReorder.svelte.ts b/src/components/usePinnedDragReorder.svelte.ts new file mode 100644 index 000000000..2580f59c6 --- /dev/null +++ b/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(undefined); + let dropTargetRid = $state(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, + }; +}