Files
radicle-explorer/src/components/AppSidebar.svelte
T
Brandon Oxendine ca80f13631 Disable Back and Forward when they lead nowhere
Fixes f18b808b76f9528e406f53f70221e1a39b4664c8
2026-09-29 16:34:31 +02:00

777 lines
25 KiB
Svelte

<script lang="ts">
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onDestroy, onMount, tick } from "svelte";
import { backOut, cubicOut } from "svelte/easing";
import { fade, scale } from "svelte/transition";
import { boolean } from "zod";
import { checkRadicleCLI } from "@app/lib/checkRadicleCLI.svelte";
import { hints } from "@app/lib/hints";
import { dynamicInterval } from "@app/lib/interval";
import { invoke } from "@app/lib/invoke";
import { modalStore, show } from "@app/lib/modal";
import { notificationCount } from "@app/lib/notificationCount.svelte";
import * as router from "@app/lib/router";
import type { SidebarData } from "@app/lib/router/definitions";
import { ariaKeyShortcuts, useShortcuts } from "@app/lib/shortcuts.svelte";
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";
import { badgeCounter } from "@app/components/BadgeCounterSwitch.svelte";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import IdentityButton from "@app/components/IdentityButton.svelte";
import NodeStatusButton from "@app/components/NodeStatusButton.svelte";
import SidebarRepoList from "@app/components/SidebarRepoList.svelte";
import SettingsView from "@app/modals/Settings.svelte";
interface Props {
sidebarData: SidebarData;
activeRepo?: RepoInfo;
}
const { sidebarData, activeRepo = undefined }: Props = $props();
const COLLAPSE_MS = 200;
// The rail and the expanded sidebar cross as whole views. Each is pinned to a
// width for the crossing -- the one leaving to what it had, the one arriving
// to where it lands -- so the frame moving underneath cannot drag it sideways.
const SWAP_SCALE_FROM = 0.9;
function swap(
node: Element,
{
duration,
rise = 0,
width,
}: { duration: number; rise?: number; width?: number },
) {
const pinned = width ?? (node as HTMLElement).offsetWidth;
const scale = (t: number) => SWAP_SCALE_FROM + (1 - SWAP_SCALE_FROM) * t;
return {
duration,
easing: cubicOut,
css: (t: number, u: number) =>
`opacity: ${t};
width: ${pinned}px;
transform: scale(${scale(t)}) translateY(${u * rise}px);
transform-origin: top left;`,
};
}
const toggleShortcut = isMac() ? "⌘B" : "Ctrl+B";
useShortcuts({
shortcut: "toggleSidebar",
allowInModal: true,
run: toggleSidebar,
});
const dragStripHeight = isMac() ? "2rem" : "1.75rem";
const mini = $derived(collapsed.value);
// 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);
// What `.slot` is animating towards.
const bodyTargetWidth = $derived(
(mini ? RAIL_WIDTH_REM + railStretch : renderedSidebarWidth.value) *
rootFontSize(),
);
// 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(),
true,
!window.localStorage,
);
onMount(async () => {
try {
await checkRadicleCLI();
} catch {
dynamicInterval("checkRadicleCLI", checkRadicleCLI, 1_000);
}
const isDefaultRoute =
window.location.pathname === "/" || window.location.pathname === "/inbox";
if (firstLaunchStorage.value === true && isDefaultRoute) {
await router.push({ resource: "guide" });
firstLaunchStorage.value = false;
}
await updateNotificationCount();
dynamicInterval("notificationCount", updateNotificationCount, 3_000);
});
async function updateNotificationCount() {
notificationCount.value = await invoke<number>("notification_count");
if (window.__TAURI_INTERNALS__ && $badgeCounter) {
await getCurrentWindow().setBadgeCount(
notificationCount.value === 0 ? undefined : notificationCount.value,
);
} else if (window.__TAURI_INTERNALS__) {
await getCurrentWindow().setBadgeCount(undefined);
}
}
$effect(() => {
if (window.__TAURI_INTERNALS__) {
void getCurrentWindow().setBadgeCount(
$badgeCounter && notificationCount.value > 0
? notificationCount.value
: undefined,
);
}
});
const activeRoute = router.activeRouteStore;
const canGoBack = router.canGoBack;
const canGoForward = router.canGoForward;
function isInbox(): boolean {
return $activeRoute.resource === "inbox";
}
function isGuide(): boolean {
return $activeRoute.resource === "guide";
}
function isSettings(): boolean {
return $modalStore?.component === SettingsView;
}
let confirmingGuideDismiss = $state(false);
</script>
<style>
.slot {
position: relative;
height: 100%;
min-height: 0;
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;
}
.slot.resizing {
transition: none;
}
.sidebar {
/* Containing block for the resize edge. Isolated so it layers against the
sidebar, not against portalled popovers. */
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
min-height: 0;
border-right: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-base);
transition: background-color 0.2s ease;
/* One cell the two views stack in. Tracks are sized from the sidebar, not
from its content: an `auto` track would hold both at the wider width. */
display: grid;
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
overflow: hidden;
}
.sidebar.mini {
background-color: var(--color-surface-canvas);
}
.view {
grid-area: 1 / 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
/* 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;
}
/* A label still fading at full width would push its icon off the rail's
centre line, so it gives up the space too. */
.view.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. */
.view: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. */
.view:not(.mini) .controls {
padding: 0;
}
.view.mini .controls,
.view.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;
/* No top padding: the 2rem drag strip above is already the gap. */
padding: 0 0.5rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 0;
}
.bottom {
/* Tighter on top: the nav's padding and the scroll area's fade runway
already sit above. */
padding: 0.25rem 0.5rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* The rail is one centred column. */
.view.mini .nav {
align-items: center;
}
/* The rail carries navigation only. */
.view.mini .bottom {
display: none;
}
/* One 2rem square per row, so the hover/selected highlight is a uniform
square rather than a full-width bar. Rows only: a `min-width` here would
outrank the 1.5rem squares the icon buttons set inline. */
.view.mini :global(.nav-item) {
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;
}
.view.mini :global(.button),
.view.mini :global(.filter-button) {
justify-content: center;
gap: 0;
}
/* `margin-left: auto` on a centred square drags the icon off centre. */
.view.mini :global(.update-badge) {
display: none;
}
.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;
white-space: nowrap;
}
.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;
}
.icon {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.window-controls :global(.button.disabled .icon) {
color: var(--color-text-quaternary);
}
.update-badge {
margin-left: auto;
font: var(--txt-body-s-regular);
color: var(--color-text-tertiary);
}
.guide-item {
position: relative;
display: flex;
}
.guide-dismiss {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: var(--border-radius-sm);
color: var(--color-text-tertiary);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease;
}
.guide-item:hover .guide-dismiss,
.guide-dismiss:focus-visible {
opacity: 1;
pointer-events: auto;
}
.guide-dismiss:hover {
color: var(--color-text-primary);
}
.guide-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-subtle);
color: var(--color-text-secondary);
}
.guide-confirm-actions {
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
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">
{#key mini}
<!-- Captured per view, so the outgoing one is not restyled mid-exit. -->
{@const isMini = mini}
<div
class="view"
class:mini={isMini}
in:swap={{ duration: COLLAPSE_MS, rise: 8, width: bodyTargetWidth }}
out:swap={{ duration: COLLAPSE_MS }}>
<div
class="drag-strip"
style:height={dragStripHeight}
data-tauri-drag-region>
</div>
<div class="controls">
<span class="window-controls">
<Button
variant="naked"
title="{collapsed.value
? 'Expand'
: 'Collapse'} sidebar ({toggleShortcut})"
ariaLabel="{collapsed.value ? 'Expand' : 'Collapse'} sidebar"
keyShortcuts={ariaKeyShortcuts("toggleSidebar")}
onclick={toggleSidebar}
styleWidth={isMini ? "2rem" : "1.5rem"}
styleHeight={isMini ? "2rem" : "1.5rem"}
stylePadding="0">
<span class="icon"><Icon name="sidebar-left" /></span>
</Button>
<Button
variant="naked"
title="Back"
ariaLabel="Back"
disabled={!$canGoBack}
onclick={() => window.history.back()}
styleWidth={isMini ? "2rem" : "1.5rem"}
styleHeight={isMini ? "2rem" : "1.5rem"}
stylePadding="0">
<span class="icon"><Icon name="arrow-left" /></span>
</Button>
<Button
variant="naked"
title="Forward"
ariaLabel="Forward"
disabled={!$canGoForward}
onclick={() => window.history.forward()}
styleWidth={isMini ? "2rem" : "1.5rem"}
styleHeight={isMini ? "2rem" : "1.5rem"}
stylePadding="0">
<span class="icon"><Icon name="arrow-right" /></span>
</Button>
<Button
variant="naked"
title="Reload"
ariaLabel="Reload"
onclick={() => window.location.reload()}
styleWidth={isMini ? "2rem" : "1.5rem"}
styleHeight={isMini ? "2rem" : "1.5rem"}
stylePadding="0">
<span class="icon"><Icon name="refresh" /></span>
</Button>
</span>
</div>
<div class="body">
<div class="nav">
{#if !mini}
<IdentityButton config={sidebarData.config} />
{/if}
<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>
<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>
{/key}
<div
class="edge"
title="Drag to resize, double-click to fit"
onpointerdown={onEdgePointerDown}
role="separator"
aria-orientation="vertical"
tabindex="-1">
</div>
</div>
</div>