Add a collapsible, resizable sidebar

The sidebar collapses to a narrow rail of icons. Its collapsed state
and its width both persist across launches.
This commit is contained in:
Brandon Oxendine
2026-09-08 17:38:18 +02:00
committed by Rūdolfs Ošiņš
parent 535f5e2a18
commit ba3316c246
6 changed files with 932 additions and 192 deletions
+34 -24
View File
@@ -21,6 +21,7 @@
import { hide, toggle } from "@app/lib/modal";
import * as router from "@app/lib/router";
import { isLoadedRepoRoute } from "@app/lib/router/definitions";
import { toggleSidebar } from "@app/lib/sidebar.svelte";
import {
setUnlistenNodeEvents,
unlistenNodeEvents,
@@ -151,9 +152,13 @@
<style>
.layout {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 100%;
display: flex;
height: 100%;
overflow: hidden;
}
.content-pane {
flex: 1 1 0;
min-width: 0;
height: 100%;
overflow: hidden;
}
@@ -180,6 +185,9 @@
} else if (auxiliarKey && e.key === ",") {
e.preventDefault();
toggle({ component: SettingsView, props: {} });
} else if (auxiliarKey && e.key.toLowerCase() === "b") {
e.preventDefault();
toggleSidebar();
}
}} />
<FullscreenModalPortal />
@@ -213,26 +221,28 @@
<AppSidebar
sidebarData={$activeRouteStore.params.sidebarData}
{activeRepo} />
{#if $activeRouteStore.resource === "inbox"}
<InboxView {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "guide"}
<GuideView {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.home"}
<RepoHome {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commits"}
<RepoCommits {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commit"}
<RepoCommit {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issue"}
<Issue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issues"}
<Issues {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patch"}
<Patch {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patches"}
<Patches {...$activeRouteStore.params} />
{:else}
{unreachable($activeRouteStore)}
{/if}
<div class="content-pane">
{#if $activeRouteStore.resource === "inbox"}
<InboxView {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "guide"}
<GuideView {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.home"}
<RepoHome {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commits"}
<RepoCommits {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commit"}
<RepoCommit {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issue"}
<Issue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issues"}
<Issues {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patch"}
<Patch {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patches"}
<Patches {...$activeRouteStore.params} />
{:else}
{unreachable($activeRouteStore)}
{/if}
</div>
</div>
{/if}
+525 -117
View File
@@ -2,10 +2,14 @@
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onMount } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import { backOut } from "svelte/easing";
import { fade, scale } from "svelte/transition";
import { boolean } from "zod";
import { checkRadicleCLI } from "@app/lib/checkRadicleCLI.svelte";
import type { FlipSnapshot } from "@app/lib/flip";
import { captureFlip, playFlip } from "@app/lib/flip";
import { hints } from "@app/lib/hints";
import { dynamicInterval } from "@app/lib/interval";
import { invoke } from "@app/lib/invoke";
@@ -13,6 +17,20 @@
import { notificationCount } from "@app/lib/notificationCount.svelte";
import * as router from "@app/lib/router";
import type { SidebarData } from "@app/lib/router/definitions";
import {
commitSidebarWidth,
discardSidebarWidthPreview,
MIN_SIDEBAR_WIDTH,
previewSidebarWidth,
RAIL_WIDTH_REM,
renderedSidebarWidth,
rootFontSize,
setSidebarWidth,
sidebarCollapsed as collapsed,
sidebarResizing,
sidebarWidth,
toggleSidebar,
} from "@app/lib/sidebar.svelte";
import { updateChecker } from "@app/lib/updateChecker.svelte";
import useLocalStorage from "@app/lib/useLocalStorage.svelte";
import { isMac } from "@app/lib/utils";
@@ -32,6 +50,209 @@
const { sidebarData, activeRepo = undefined }: Props = $props();
const toggleShortcut = isMac() ? "⌘B" : "Ctrl+B";
const dragStripHeight = isMac() ? "2.75rem" : "1.75rem";
const mini = $derived(collapsed.value);
// The window controls move between a row in the drag strip when expanded and
// a column on the rail when collapsed. FLIP carries both the move and the
// height their row gives up, which lays out to `auto` and so can't be a CSS
// transition.
let controlsEl = $state<HTMLElement | undefined>();
let controlsFirst: FlipSnapshot | undefined;
const CONTROLS_FLIP = { animateHeight: true };
$effect.pre(() => {
// Measure before the DOM reflows for the new state.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
collapsed.value;
controlsFirst = captureFlip(controlsEl, CONTROLS_FLIP);
});
$effect(() => {
// ...and play once it has.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
collapsed.value;
playFlip(controlsEl, controlsFirst, CONTROLS_FLIP);
});
// How far below the minimum width the drag has to go before it collapses,
// rather than the edge just sticking at the minimum.
const COLLAPSE_DRAG_SLACK_REM = 2;
// While collapsed, the rail follows the drag at a fraction of its distance so
// it feels attached to the pointer, instead of sitting still until the
// threshold and then jumping open.
const RAIL_STRETCH_FACTOR = 0.35;
let railStretch = $state(0);
// Crossing the threshold is a jump between two widths rather than tracking
// the pointer, so the transition is switched back on to carry it. Matches the
// width transition on .slot.
const SNAP_DURATION_MS = 200;
let snapping = $state(false);
let snapTimeout: ReturnType<typeof setTimeout> | undefined;
// Below this a press is a click, not a drag.
const DRAG_SLOP_PX = 3;
function snapWidth() {
snapping = true;
if (snapTimeout !== undefined) clearTimeout(snapTimeout);
snapTimeout = setTimeout(() => (snapping = false), SNAP_DURATION_MS);
}
// Held so unmounting mid-drag doesn't leave listeners and the body class
// behind.
let endDrag: (() => void) | undefined;
// Two small drags in quick succession shouldn't also count as a double-press.
let lastPressResized = false;
let slotEl = $state<HTMLElement | undefined>();
// The width at which nothing truncates and no slack is left over. The style is
// restored before this returns, so `max-content` is measured but never
// painted.
function measureFitWidth(slot: HTMLElement): number {
const { maxWidth, transition, width } = slot.style;
// Where the sidebar visually is -- mid-animation if it is still opening.
// The fit animates from here.
const current = slot.getBoundingClientRect().width;
slot.style.transition = "none";
// Lifted to measure the true content width; the result is clamped anyway.
slot.style.maxWidth = "none";
slot.style.width = "max-content";
const fit = slot.getBoundingClientRect().width;
slot.style.maxWidth = maxWidth;
slot.style.width = `${current}px`;
// Flushed while the transition is off, so the animation starts from where
// the sidebar was and not from `max-content`.
void slot.offsetWidth;
slot.style.transition = transition;
// Back to Svelte's binding, which the caller is about to update; both land
// before the next frame, so this animates once.
slot.style.width = width;
return fit;
}
async function fitSidebarToContent() {
if (collapsed.value) {
collapsed.value = false;
// The rail renders different markup; nothing to fit until it is gone.
await tick();
}
if (!slotEl) return;
// Rounded up: the measurement is fractional, and landing a hair short would
// leave an ellipsis on the row we just fitted to.
setSidebarWidth(Math.ceil(measureFitWidth(slotEl)) / rootFontSize());
}
// A second press in quick succession fits the sidebar. Detected here rather
// than with `dblclick`, because the pointerdown below is cancelled to stop a
// Tauri window drag, which suppresses the mouse events `dblclick` comes from.
const DOUBLE_PRESS_MS = 500;
const DOUBLE_PRESS_SLOP_PX = 6;
let lastPressAt = 0;
let lastPressX = 0;
function isDoublePress(e: PointerEvent): boolean {
const paired =
// A press that ended in a resize isn't half of a pair.
!lastPressResized &&
e.timeStamp - lastPressAt < DOUBLE_PRESS_MS &&
Math.abs(e.clientX - lastPressX) <= DOUBLE_PRESS_SLOP_PX;
// Cleared so a third press starts a new pair instead of fitting again.
lastPressAt = paired ? 0 : e.timeStamp;
lastPressX = e.clientX;
return paired;
}
function onEdgePointerDown(e: PointerEvent) {
// Primary button only; a right-click here would otherwise start a drag.
if (e.button !== 0) return;
e.preventDefault();
if (isDoublePress(e)) {
void fitSidebarToContent();
return;
}
const edge = e.currentTarget as HTMLElement;
// Capture so the drag survives the pointer leaving the window: a pointerup
// out there never arrives, and the resize would never end.
edge.setPointerCapture(e.pointerId);
sidebarResizing.value = true;
document.body.classList.add("resizing-sidebar");
const startX = e.clientX;
// From the rail's own width when collapsed, so reopening starts where the
// edge visually sits rather than at the stored expanded width.
const startWidth = collapsed.value ? RAIL_WIDTH_REM : sidebarWidth.value;
const pxPerRem = rootFontSize();
let dragged = false;
const onMove = (ev: PointerEvent) => {
if (!dragged) {
if (Math.abs(ev.clientX - startX) <= DRAG_SLOP_PX) return;
dragged = true;
}
const width = startWidth + (ev.clientX - startX) / pxPerRem;
// One boundary serves both directions: below it the sidebar is collapsed,
// above it expanded, so a single drag can cross either way.
const collapse = width < MIN_SIDEBAR_WIDTH - COLLAPSE_DRAG_SLACK_REM;
if (collapsed.value !== collapse) {
collapsed.value = collapse;
snapWidth();
}
if (collapse) {
railStretch = Math.max(
0,
(width - RAIL_WIDTH_REM) * RAIL_STRETCH_FACTOR,
);
// Anything previewed before the threshold was crossed is on the way to
// collapsing, not a width to remember.
discardSidebarWidthPreview();
} else {
railStretch = 0;
// Off storage; persisted once when the drag ends.
previewSidebarWidth(width);
}
};
const finish = () => {
endDrag = undefined;
edge.removeEventListener("pointermove", onMove);
edge.removeEventListener("pointerup", finish);
edge.removeEventListener("pointercancel", finish);
if (edge.hasPointerCapture(e.pointerId)) {
edge.releasePointerCapture(e.pointerId);
}
sidebarResizing.value = false;
railStretch = 0;
document.body.classList.remove("resizing-sidebar");
commitSidebarWidth();
lastPressResized = dragged;
};
endDrag = finish;
edge.addEventListener("pointermove", onMove);
edge.addEventListener("pointerup", finish);
edge.addEventListener("pointercancel", finish);
}
onDestroy(() => {
endDrag?.();
if (snapTimeout !== undefined) clearTimeout(snapTimeout);
});
const firstLaunchStorage = useLocalStorage(
"appFirstLaunch",
boolean(),
@@ -96,23 +317,109 @@
</script>
<style>
.sidebar {
display: flex;
flex-direction: column;
.slot {
position: relative;
height: 100%;
min-height: 0;
width: 16.5rem;
border-right: 1px solid var(--color-border-subtle);
flex-shrink: 0;
transition: width 0.2s ease;
/* Backstop for MAX_VIEWPORT_FRACTION, for when the window is resized after
the width was set. */
max-width: 40vw;
}
.top {
padding: 0 0.5rem;
height: 1.75rem;
.slot.resizing {
transition: none;
}
.sidebar {
/* Containing block for the window controls and the resize edge. Isolated so
both layer against the sidebar, not against portalled popovers. */
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-base);
transition: background-color 0.2s ease;
}
.sidebar.mini {
background-color: var(--color-surface-canvas);
}
/* Carries the square-up below. */
.sidebar :global(.button) {
transition: width 0.2s ease;
}
/* The collapse contract: `.label` anywhere in the sidebar is text that gives
up its space on the rail. Opted into by class alone, so a child that wants
its text to survive the collapse must not use it. Non-interactive, so the
part overflowing the collapsed square is not a phantom hover target. */
.sidebar :global(.label) {
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s ease;
}
/* A label still fading at full width would push its icon off the rail's
centre line, so it gives up the space too. */
.sidebar.mini :global(.label) {
opacity: 0;
width: 0;
min-width: 0;
overflow: hidden;
}
.drag-strip {
flex-shrink: 0;
}
.controls {
display: flex;
align-items: flex-start;
gap: 0.25rem;
padding: 0 0.5rem 0.25rem;
flex-shrink: 0;
/* Height comes from the content -- zero when expanded, the button column
when collapsed -- and is animated by the FLIP in the script, since a CSS
transition can't reach `auto`. Top-aligned in both states so the toggle
stays put while the rest reflow. */
}
.window-controls {
display: flex;
gap: 0.25rem;
}
/* Expanded, the controls lift into the drag strip beside the traffic lights.
They stay in the row's DOM so the same elements survive the toggle and can
glide into the rail's column. */
.sidebar:not(.mini) .window-controls {
position: absolute;
top: 0;
right: 0.5rem;
/* Centred on the traffic lights, not on the strip, which is taller and
would sit these too low. Their centre is ~16px down, matched here by a
2rem box; macOS draws them, so it can't be read from the page. */
height: 2rem;
align-items: center;
}
/* Nothing left in flow once they lift, so dropping the padding collapses the
row to nothing and gives the space back to the nav. */
.sidebar:not(.mini) .controls {
padding: 0;
}
.sidebar.mini .controls,
.sidebar.mini .window-controls {
flex-direction: column;
gap: 0.125rem;
align-items: center;
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.nav {
flex: 1;
overflow: visible;
@@ -128,6 +435,37 @@
flex-direction: column;
gap: 0.25rem;
}
/* The rail is one centred column. */
.sidebar.mini .nav {
align-items: center;
}
/* The rail carries navigation only. */
.sidebar.mini .bottom {
display: none;
}
/* One 2rem square per item, so the hover/selected highlight is a uniform
square rather than a full-width bar. */
.sidebar.mini :global(.nav-item),
.sidebar.mini :global(.button),
.sidebar.mini :global(.filter-button) {
width: 2rem;
min-width: 2rem;
height: 2rem;
box-sizing: border-box;
border-radius: var(--border-radius-sm);
/* Otherwise held open by the zero-width label. */
gap: 0;
}
/* Nav items are left out: their 0.5rem padding either side of a 1rem icon
already centres it in the 2rem square. */
.sidebar.mini :global(.button),
.sidebar.mini :global(.filter-button) {
justify-content: center;
}
/* `margin-left: auto` on a centred square drags the icon off centre. */
.sidebar.mini :global(.update-badge) {
display: none;
}
.nav-item {
display: flex;
align-items: center;
@@ -139,6 +477,8 @@
cursor: pointer;
width: 100%;
text-decoration: none;
white-space: nowrap;
transition: width 0.2s ease;
}
.nav-item:hover {
background-color: var(--color-surface-subtle);
@@ -151,6 +491,7 @@
}
.icon {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.update-badge {
margin-left: auto;
@@ -201,116 +542,183 @@
display: flex;
gap: 0.25rem;
}
/* Inside the sidebar rather than straddling the border, which put an
invisible grab target over the edge of the content pane. Drag resizes and a
double-press fits; collapsing is the toggle button's job. */
.edge {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
/* Above its siblings; the isolated stacking context keeps it below
popovers and modals. */
z-index: 1;
cursor: col-resize;
}
/* The pointer leaves the edge as soon as the drag starts. */
:global(body.resizing-sidebar),
:global(body.resizing-sidebar *) {
cursor: col-resize !important;
}
</style>
<div class="sidebar">
<div
class="top"
style:height={isMac() ? "2.75rem" : "1.75rem"}
data-tauri-drag-region>
<Button
variant="naked"
onclick={() => window.history.back()}
stylePadding="0 4px">
<span class="icon"><Icon name="arrow-left" /></span>
</Button>
<Button
variant="naked"
onclick={() => window.history.forward()}
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
bind:this={slotEl}
class="slot"
class:resizing={sidebarResizing.value && !snapping}
style:width={collapsed.value
? `${RAIL_WIDTH_REM + railStretch}rem`
: `${renderedSidebarWidth.value}rem`}>
<div class="sidebar" class:mini role="navigation">
<div
class="drag-strip"
style:height={dragStripHeight}
data-tauri-drag-region>
</div>
<div class="nav">
<IdentityButton config={sidebarData.config} />
<div class="controls" bind:this={controlsEl}>
<span class="window-controls">
<Button
variant="naked"
title="{collapsed.value
? 'Expand'
: 'Collapse'} sidebar ({toggleShortcut})"
keyShortcuts={isMac() ? "Meta+b" : "Control+b"}
onclick={toggleSidebar}
stylePadding="0 4px">
<span class="icon"><Icon name="sidebar-left" /></span>
</Button>
<Button
variant="naked"
onclick={() => window.history.back()}
stylePadding="0 4px">
<span class="icon"><Icon name="arrow-left" /></span>
</Button>
<Button
variant="naked"
onclick={() => window.history.forward()}
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>
</span>
</div>
<a
class="nav-item"
class:active={isInbox()}
href={router.routeToPath({ resource: "inbox" })}>
<span class="icon"><Icon name="inbox" /></span>
Inbox
{#if notificationCount.value > 0}
<span class="global-counter-badge">{notificationCount.value}</span>
{/if}
</a>
<div class="body">
<div class="nav">
{#if !mini}
<IdentityButton config={sidebarData.config} />
{/if}
<SidebarRepoList
initialRepos={sidebarData.repos}
initialSeededNotReplicated={sidebarData.seededNotReplicated}
config={sidebarData.config}
{activeRepo} />
</div>
<a
class="nav-item"
class:active={isInbox()}
title="Inbox"
href={router.routeToPath({ resource: "inbox" })}>
<span class="icon"><Icon name="inbox" /></span>
<span class="label">Inbox</span>
{#if !mini && notificationCount.value > 0}
<span
class="global-counter-badge"
in:scale={{ duration: 200, easing: backOut, start: 0 }}
out:fade={{ duration: 200 }}>
{notificationCount.value}
</span>
{/if}
</a>
<div class="bottom">
{#if !hints.isDismissed("guide")}
{#if confirmingGuideDismiss}
<div class="guide-confirm txt-body-s-regular">
<div>
Hide the Guide? You can bring it back from Hidden hints in Settings.
</div>
<div class="guide-confirm-actions">
<Button
variant="ghost"
styleHeight="1.75rem"
styleWidth="100%"
onclick={() => {
hints.dismiss("guide");
confirmingGuideDismiss = false;
}}>
Hide
</Button>
<Button
variant="outline"
styleHeight="1.75rem"
styleWidth="100%"
onclick={() => (confirmingGuideDismiss = false)}>
Cancel
</Button>
</div>
</div>
{:else}
<div class="guide-item">
<Button
variant="naked"
styleWidth="100%"
styleJustifyContent="flex-start"
active={isGuide()}
onclick={() => router.push({ resource: "guide" })}>
<span class="icon"><Icon name="guide" /></span>
Guide
</Button>
<button
type="button"
class="guide-dismiss"
title="Hide Guide"
aria-label="Hide Guide"
onclick={() => (confirmingGuideDismiss = true)}>
<Icon name="close" />
</button>
</div>
{/if}
{/if}
<Button
variant="naked"
styleWidth="100%"
styleJustifyContent="flex-start"
active={isSettings()}
onclick={() => show({ component: SettingsView, props: {} })}>
<span class="icon"><Icon name="settings" /></span>
Settings
{#if updateChecker.newVersion}
<span class="update-badge">New Update</span>
{/if}
</Button>
<NodeStatusButton />
<SidebarRepoList
initialRepos={sidebarData.repos}
initialSeededNotReplicated={sidebarData.seededNotReplicated}
config={sidebarData.config}
{activeRepo} />
</div>
<div class="bottom">
{#if !hints.isDismissed("guide")}
{#if confirmingGuideDismiss && !mini}
<div class="guide-confirm txt-body-s-regular">
<div>
Hide the Guide? You can bring it back from Hidden hints in
Settings.
</div>
<div class="guide-confirm-actions">
<Button
variant="ghost"
styleHeight="1.75rem"
styleWidth="100%"
onclick={() => {
hints.dismiss("guide");
confirmingGuideDismiss = false;
}}>
Hide
</Button>
<Button
variant="outline"
styleHeight="1.75rem"
styleWidth="100%"
onclick={() => (confirmingGuideDismiss = false)}>
Cancel
</Button>
</div>
</div>
{:else}
<div class="guide-item">
<Button
variant="naked"
title="Guide"
styleWidth={mini ? "2rem" : "100%"}
styleJustifyContent="flex-start"
active={isGuide()}
onclick={() => router.push({ resource: "guide" })}>
<span class="icon"><Icon name="guide" /></span>
<span class="label">Guide</span>
</Button>
{#if !mini}
<button
type="button"
class="guide-dismiss"
title="Hide Guide"
aria-label="Hide Guide"
onclick={() => (confirmingGuideDismiss = true)}>
<Icon name="close" />
</button>
{/if}
</div>
{/if}
{/if}
<Button
variant="naked"
title="Settings"
styleWidth={mini ? "2rem" : "100%"}
styleJustifyContent="flex-start"
active={isSettings()}
onclick={() => show({ component: SettingsView, props: {} })}>
<span class="icon"><Icon name="settings" /></span>
<span class="label">Settings</span>
{#if updateChecker.newVersion}
<span class="update-badge">New Update</span>
{/if}
</Button>
<NodeStatusButton />
</div>
</div>
<div
class="edge"
title="Drag to resize, double-click to fit"
onpointerdown={onEdgePointerDown}
role="separator"
aria-orientation="vertical"
tabindex="-1">
</div>
</div>
</div>
+8 -3
View File
@@ -33,10 +33,15 @@
<Popover placement="bottom-start" bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<Button variant="naked" active={popoverExpanded} {onclick}>
<Button
variant="naked"
styleWidth="100%"
styleJustifyContent="flex-start"
active={popoverExpanded}
{onclick}>
<UserAvatar nodeId={config.publicKey} styleWidth="1rem" />
{config.alias}
<span style:color="var(--color-text-tertiary)">
<span class="label">{config.alias}</span>
<span class="label" style:color="var(--color-text-tertiary)">
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
</span>
</Button>
+183 -48
View File
@@ -43,10 +43,12 @@
import { onMount } from "svelte";
import { flip } from "svelte/animate";
import { cubicOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
import { backOut, cubicOut } from "svelte/easing";
import { crossfade, fade, scale, slide } from "svelte/transition";
import { nodeRunning } from "@app/lib/events";
import type { FlipSnapshot } from "@app/lib/flip";
import { captureFlip, playFlip } from "@app/lib/flip";
import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
import {
cachedListReposSummary,
@@ -56,6 +58,7 @@
} from "@app/lib/invoke";
import { show } from "@app/lib/modal";
import * as router from "@app/lib/router";
import { sidebarCollapsed } from "@app/lib/sidebar.svelte";
import {
explorerHost,
explorerUrl,
@@ -116,6 +119,15 @@
}
});
// Collapsing discards the filter, so re-expanding comes back to a plain
// header rather than a stale query.
$effect(() => {
if (sidebarCollapsed.value) {
filterOpen = false;
filterQuery = "";
}
});
$effect(() => {
if (seededNotReplicated.length > 0) {
dynamicInterval("seededNotReplicated", reloadRepos, 5_000);
@@ -158,6 +170,29 @@
repos.filter(r => !pinnedRepoIds.value.includes(r.rid)).length,
);
// FLIP the "All Repos" header's icon and buttons, plus its own height, as it
// reflows on collapse. Measured relative to the header, so its downward shift
// (the controls growing above it) is left to the shared layout transition.
let allReposHeaderEl = $state<HTMLElement | undefined>();
let headerFirst: FlipSnapshot | undefined;
const HEADER_FLIP = { selector: "[data-flip]", animateHeight: true };
$effect.pre(() => {
// Measure before the DOM reflows for the new state.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
sidebarCollapsed.value;
headerFirst = captureFlip(allReposHeaderEl, HEADER_FLIP);
});
$effect(() => {
// ...and play once it has.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
sidebarCollapsed.value;
playFlip(allReposHeaderEl, headerFirst, HEADER_FLIP);
});
const ANIMATION_DURATION_MS = 220;
let animatingPinnedList = $state(false);
let animationTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -337,6 +372,7 @@
justify-content: space-between;
cursor: pointer;
user-select: none;
overflow: hidden;
}
.section-header-label {
display: flex;
@@ -350,6 +386,10 @@
align-items: center;
gap: 0.25rem;
}
/* No room on the rail; the repo rows below stand on their own there. */
.section-header.mini {
display: none;
}
.filter-button {
display: flex;
@@ -394,6 +434,19 @@
text-decoration: none;
user-select: none;
-webkit-user-select: none;
transition: width 0.2s ease;
}
.nav-item .txt-overflow {
min-width: 0;
}
/* No room on the rail, and reordering is off there, so rows stay plain
links. */
:global(.sidebar.mini) .row-actions {
display: none;
}
/* Centred on the rail. */
:global(.sidebar.mini) .repos-list {
align-items: center;
}
.nav-item :global(img),
.nav-item :global(svg) {
@@ -409,8 +462,20 @@
.nav-item .global-counter-badge {
margin-left: auto;
}
/* Rows unroll from behind the repo they belong to -- the `slide` transition
clips them while the height animates -- rather than appearing in place. */
.sub-items {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-top: 0.25rem;
}
.sub-item {
padding-left: 2rem;
transition: padding-left 0.2s ease;
}
.sub-item.mini {
padding-left: 0.5rem;
}
.pending-item {
@@ -436,12 +501,19 @@
background-color: var(--color-surface-mid);
}
/* Out of flow: hidden, they still reserved a button's width on every row.
They sit over the end of the name instead, on the row's own fill. */
.nav-item .row-actions {
visibility: hidden;
margin-left: auto;
position: absolute;
top: 0;
bottom: 0;
right: 0.5rem;
display: flex;
align-items: center;
gap: 0.125rem;
padding-left: 0.25rem;
background-color: var(--color-surface-subtle);
color: var(--color-text-tertiary);
}
.nav-item:hover .row-actions,
@@ -473,11 +545,13 @@
cursor: grab;
}
/* No gap: the sub-items carry their own top padding, so it animates open with
them. A gap here would appear and vanish with the mount, jumping either
side of the transition. */
.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 {
@@ -524,6 +598,31 @@
.icon {
color: var(--color-text-tertiary);
}
.repo-icon {
position: relative;
display: inline-flex;
}
/* Hover swaps the repo icon for arrows showing what a click will do. Keyed
off the label so the buttons beside it don't trigger it. */
.icon-stack {
display: grid;
}
.icon-default,
.icon-hover {
grid-area: 1 / 1;
transition: opacity 150ms ease;
}
.icon-hover {
opacity: 0;
}
.section-header-label:hover .icon-default,
.section-header:focus-visible .icon-default {
opacity: 0;
}
.section-header-label:hover .icon-hover,
.section-header:focus-visible .icon-hover {
opacity: 1;
}
.menu-item {
display: flex;
@@ -558,7 +657,7 @@
}
</style>
{#if seededNotReplicated.length > 0}
{#if seededNotReplicated.length > 0 && !sidebarCollapsed.value}
<div
class="section-header"
onclick={() => (fetchingExpanded.value = !fetchingExpanded.value)}
@@ -622,7 +721,9 @@
</div>
<div
bind:this={allReposHeaderEl}
class="section-header"
class:mini={sidebarCollapsed.value}
onclick={() => {
if (!filterOpen) {
reposExpanded.value = !reposExpanded.value;
@@ -671,7 +772,31 @@
</span>
{:else}
<span class="section-header-label">
<span onclick={e => e.stopPropagation()} role="none">
<span class="icon repo-icon" data-flip>
<span class="icon-stack">
<span class="icon-default"><Icon name="repository" /></span>
<span class="icon-hover">
<Icon
name={reposExpanded.value
? "collapse-vertical"
: "expand-vertical"} />
</span>
</span>
</span>
<span class="label">All Repos</span>
{#if !sidebarCollapsed.value && unpinnedReposCount > 1}
<span
class="global-counter-badge"
in:scale={{ duration: 200, easing: backOut, start: 0 }}
out:fade={{ duration: 200 }}>
{unpinnedReposCount}
</span>
{/if}
</span>
{/if}
<span class="section-header-actions">
{#if !filterOpen}
<span data-flip onclick={e => e.stopPropagation()} role="none">
<button
class="filter-button"
title="Filter repos"
@@ -680,20 +805,15 @@
filterOpen = true;
reposExpanded.value = true;
}}>
<span class="icon"><Icon name="filter" /></span>
<span class="icon"><Icon name="search" /></span>
</button>
</span>
All Repos
{#if unpinnedReposCount > 1}
<span class="global-counter-badge">{unpinnedReposCount}</span>
{/if}
<span class="icon">
<Icon name={reposExpanded.value ? "chevron-down" : "chevron-up"} />
</span>
</span>
{/if}
<span class="section-header-actions">
<span onclick={e => e.stopPropagation()} role="none">
{/if}
<span
class="add-repo-action"
data-flip
onclick={e => e.stopPropagation()}
role="none">
<AddRepoButton reload={reloadRepos} {repos} {seededNotReplicated} />
</span>
</span>
@@ -707,12 +827,14 @@
class:context-active={contextMenu?.repo.rid === 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}
onmousedown={pinned && !sidebarCollapsed.value
? e => drag.onMouseDown(e, repo.rid)
: undefined}
onclick={pinned && !sidebarCollapsed.value ? 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>
<span class="txt-overflow label">{repo.name}</span>
<span
class="row-actions"
role="none"
@@ -735,28 +857,32 @@
</a>
{#if activeRid() === repo.rid}
{@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
{@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,
)}
<div
class="sub-items"
transition:slide={{ duration: ANIMATION_DURATION_MS }}>
{@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,
)}
</div>
{/if}
{/snippet}
@@ -767,16 +893,25 @@
active: boolean,
count: number | undefined,
)}
<a class="nav-item sub-item" class:active {href}>
<a
class="nav-item sub-item"
class:active
class:mini={sidebarCollapsed.value}
{href}>
<span class="icon"><Icon name={icon} /></span>
{label}
{#if count !== undefined}
<span class="global-counter-badge">{count}</span>
<span class="label">{label}</span>
{#if !sidebarCollapsed.value && count !== undefined}
<span
class="global-counter-badge"
in:scale={{ duration: 200, easing: backOut, start: 0 }}
out:fade={{ duration: 200 }}>
{count}
</span>
{/if}
</a>
{/snippet}
{#if reposExpanded.value}
{#if reposExpanded.value && !sidebarCollapsed.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">
+86
View File
@@ -0,0 +1,86 @@
// FLIP: animate elements from where they were to where a layout change just put
// them. Capture from `$effect.pre` and play from `$effect`, both reading the
// state that drives the change so they land on the same flush.
//
// Positions are relative to the container, so only the reflow inside it is
// animated; the container's own shift is left to whatever already carries it.
export interface FlipOptions {
// Which descendants to animate. Defaults to the container's direct children.
selector?: string;
// Animate the container's height too. Needed when it lays out to `auto` in
// one state, which a CSS transition can't carry.
animateHeight?: boolean;
durationMs?: number;
}
export interface FlipSnapshot {
offsets: WeakMap<Element, { x: number; y: number }>;
height: number;
}
const DEFAULT_DURATION_MS = 200;
// Sub-pixel differences are rounding, not movement.
const EPSILON_PX = 0.5;
function targets(container: Element, selector: string | undefined): Element[] {
return Array.from(
selector ? container.querySelectorAll(selector) : container.children,
);
}
export function captureFlip(
container: Element | undefined,
options: FlipOptions = {},
): FlipSnapshot | undefined {
// First run, before the bind has landed. Returning nothing stops `playFlip`
// animating in from a zero-sized phantom.
if (!container) return undefined;
const base = container.getBoundingClientRect();
const offsets = new WeakMap<Element, { x: number; y: number }>();
for (const child of targets(container, options.selector)) {
const rect = child.getBoundingClientRect();
offsets.set(child, { x: rect.left - base.left, y: rect.top - base.top });
}
return { offsets, height: base.height };
}
export function playFlip(
container: Element | undefined,
first: FlipSnapshot | undefined,
options: FlipOptions = {},
): void {
if (!container || !first) return;
const duration = options.durationMs ?? DEFAULT_DURATION_MS;
const base = container.getBoundingClientRect();
if (
options.animateHeight &&
Math.abs(base.height - first.height) > EPSILON_PX
) {
container.animate(
[{ height: `${first.height}px` }, { height: `${base.height}px` }],
{ duration, easing: "ease" },
);
}
for (const child of targets(container, options.selector)) {
const from = first.offsets.get(child);
if (!from) continue;
const rect = child.getBoundingClientRect();
const dx = from.x - (rect.left - base.left);
const dy = from.y - (rect.top - base.top);
if (Math.abs(dx) < EPSILON_PX && Math.abs(dy) < EPSILON_PX) continue;
child.animate(
[
{ transform: `translate(${dx}px, ${dy}px)` },
{ transform: "translate(0px, 0px)" },
],
{ duration, easing: "ease" },
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import { boolean, number } from "zod";
import useLocalStorage from "@app/lib/useLocalStorage.svelte";
export const MIN_SIDEBAR_WIDTH = 12;
export const MAX_SIDEBAR_WIDTH = 30;
export const DEFAULT_SIDEBAR_WIDTH = 16.5;
// Wide enough to clear macOS's traffic lights (~70px in), which would otherwise
// spill onto the content pane. Not platform-gated, to keep one rail geometry.
export const RAIL_WIDTH_REM = 5;
// The widths above are rem and the user can scale the root font size, so cap
// the share of the window the sidebar may take. Mirrored by `max-width` on the
// slot, which also covers the window being resized afterwards.
const MAX_VIEWPORT_FRACTION = 0.4;
export function rootFontSize(): number {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
}
export function clampSidebarWidth(rem: number): number {
if (!Number.isFinite(rem)) return DEFAULT_SIDEBAR_WIDTH;
const viewportMax =
(window.innerWidth * MAX_VIEWPORT_FRACTION) / rootFontSize();
// Never below the minimum, even in a window too narrow to honour the share.
const max = Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, viewportMax),
);
return Math.min(max, Math.max(MIN_SIDEBAR_WIDTH, rem));
}
export const sidebarCollapsed = useLocalStorage(
"sidebarCollapsed",
boolean(),
false,
!window.localStorage,
);
export const sidebarWidth = useLocalStorage(
"sidebarWidth",
number(),
DEFAULT_SIDEBAR_WIDTH,
!window.localStorage,
);
// Writes are clamped; a value stored by a build with different bounds is not.
// Only written back when out of range, to keep startup off localStorage.
const restoredWidth = clampSidebarWidth(sidebarWidth.value);
if (sidebarWidth.value !== restoredWidth) {
sidebarWidth.value = restoredWidth;
}
// True while dragging the resize edge; suppresses the width transition so the
// sidebar tracks the pointer 1:1.
export const sidebarResizing = $state({ value: false });
// Held out of `sidebarWidth`, which writes to localStorage on every set.
const draft = $state<{ width: number | undefined }>({ width: undefined });
// The live drag value while resizing, the stored one otherwise.
export const renderedSidebarWidth = {
get value() {
return draft.width ?? sidebarWidth.value;
},
};
export function previewSidebarWidth(rem: number) {
draft.width = clampSidebarWidth(rem);
}
// Drops the drag's width without storing it, for a drag that ends up collapsing
// instead: the widths it passed through on the way down are not a width the
// user chose, so the stored one is left alone to reopen at.
export function discardSidebarWidthPreview() {
draft.width = undefined;
}
// A one-shot width change, for callers that aren't tracking a pointer.
export function setSidebarWidth(rem: number) {
draft.width = undefined;
sidebarWidth.value = clampSidebarWidth(rem);
}
// The single storage write for a whole drag.
export function commitSidebarWidth() {
if (draft.width !== undefined) {
sidebarWidth.value = draft.width;
draft.width = undefined;
}
}
export function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}