mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-04 03:00:32 +08:00
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:
+34
-24
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user