Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
],
"scripts": {
"dev": "npm run dev --workspace packages/ui",
"dev:electron": "npm run build --workspace packages/electron && electron packages/electron/dist/main.js",
"dev:electron": "npm run build --workspace packages/electron && electron packages/electron",
"build": "npm run build --workspace packages/ui && npm run build --workspace packages/electron",
"check": "npm run check --workspace packages/ui && npm run check --workspace packages/electron",
"test": "npm run test --workspace packages/electron && npm run test --workspace packages/ui",
Expand Down
38 changes: 37 additions & 1 deletion packages/ui/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,50 @@ test('the footer gear opens Settings; theme, interval, and update prefs work', a
await expect(page.getByText('Update available — v2.0.0')).toBeVisible();
});

test('startup update-available raises the banner; dismiss hides it', async ({ page }) => {
test('startup update-available raises the banner; closing it tucks it into the version badge', async ({ page }) => {
await boot(page, { fireUpdateOnBoot: true });

const banner = page.getByText('Update available — v2.0.0');
await expect(banner).toBeVisible();

await page.getByRole('button', { name: 'Dismiss update notice' }).click();
await expect(banner).toHaveCount(0);
// The version badge now offers it.
await expect(page.locator('#update-badge')).toHaveText('Update');
await expect(page.locator('#update-badge')).toHaveAttribute('title', 'BetterSshClient v2.0.0 is out — click to update');
await expect(page.getByText('v1.4.2', { exact: true })).toHaveCount(0);
});

test('the banner hides itself into the badge after a few seconds; the badge downloads, then restarts', async ({ page }) => {
await page.clock.install();
await boot(page, { fireUpdateOnBoot: true });
const banner = page.getByText('Update available — v2.0.0');
await expect(banner).toBeVisible();

// Not while the pointer rests on it…
await banner.hover();
await page.clock.runFor(10_000);
await expect(banner).toBeVisible();
// …but soon after it leaves.
await page.mouse.move(5, 5);
await page.clock.runFor(9_000);
await expect(banner).toHaveCount(0);

const badge = page.locator('#update-badge');
await expect(badge).toHaveText('Update');
await badge.click();
await expect(badge).toHaveText('Updating 42%');
await page.evaluate(() => (window as unknown as UpdateTestWindow).__finishDownload());
await expect(badge).toHaveText('Restart');
await badge.click();
await expect.poll(() => page.evaluate(() => (window as unknown as UpdateTestWindow).__restarted)).toBe(true);
});

test('where the app cannot update itself, the badge opens the release page', async ({ page }) => {
await boot(page, { fireUpdateOnBoot: true, canSelfUpdate: false });
await page.getByRole('button', { name: 'Dismiss update notice' }).click();
await page.locator('#update-badge').click();
await expect.poll(() => page.evaluate(() => (window as unknown as UpdateTestWindow).__opened)).toBe(UPDATE.url);
});

test('a settings toggle preserves a skipVersion the banner wrote out-of-band', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
:root[data-theme='dark'] {
--bg: #171717;
--surface: #212121;
--surface-raised: #2a2a2d;
--surface-raised: #2a2a2a;
--surface-inset: #1a1a1a;
--text: #ffffff;
--text-muted: #aeb7c2;
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import { get } from 'svelte/store';
import Logo from './Logo.svelte';
import ThemeToggle from './ThemeToggle.svelte';
import UpdateBadge from './UpdateBadge.svelte';
import { UPDATE_BADGE_ID } from './flyToBadge';
import { availableUpdate, updateMinimized } from '$lib/stores/update';
import { Button, Icon, StatusDot, type IconName } from '$lib/theme';
import { activeEntity } from '$lib/stores/activeEntity';
import {
Expand Down Expand Up @@ -269,8 +272,12 @@
>
<Icon name="settings" />
</button>
{#if appVersion && !$sidebarCollapsed}
{#if $availableUpdate && $updateMinimized}
<!-- The update banner has tucked itself in here: the badge offers the update. -->
<UpdateBadge collapsed={$sidebarCollapsed} />
{:else if appVersion && !$sidebarCollapsed}
<span
id={UPDATE_BADGE_ID}
class="ml-1 rounded-full border border-default px-2 py-0.5 font-mono text-[11px] text-faint"
title="BetterSshClient v{appVersion}"
>
Expand Down
75 changes: 75 additions & 0 deletions packages/ui/src/lib/components/UpdateBadge.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script lang="ts">
// The sidebar's version badge while an update is on offer and its banner has tucked
// itself away (UpdateBanner flies into it): highlighted, saying what's next, and one
// click does it — download, then restart to install; the release page where this copy
// can't update itself. Collapsed sidebar: a round button in its place.
import { scale } from 'svelte/transition';
import { backOut } from 'svelte/easing';
import { Icon } from '$lib/theme';
import { availableUpdate, updateDownload } from '$lib/stores/update';
import { updateFromBadge } from '$lib/stores/updateActions';
import { UPDATE_BADGE_ID } from './flyToBadge';

let { collapsed = false }: { collapsed?: boolean } = $props();

// Short: the badge shares the footer with three icons. The tooltip says the rest.
const label = $derived.by(() => {
const info = $availableUpdate;
const dl = $updateDownload;
if (!info) return '';
if (!info.canSelfUpdate) return 'Update';
if (dl.phase === 'downloading') return `Updating ${Math.round(dl.percent)}%`;
if (dl.phase === 'ready') return 'Restart';
if (dl.phase === 'failed') return 'Retry update';
return 'Update';
});

const title = $derived.by(() => {
const info = $availableUpdate;
const dl = $updateDownload;
if (!info) return '';
if (!info.canSelfUpdate) return `BetterSshClient v${info.version} is out — open the release page`;
if (dl.phase === 'downloading') return `Downloading v${info.version}…`;
if (dl.phase === 'ready') return `v${info.version} is downloaded — restart to install it`;
if (dl.phase === 'failed') return `The download failed: ${dl.error}`;
return `BetterSshClient v${info.version} is out — click to update`;
});

const busy = $derived($updateDownload.phase === 'downloading');
</script>

{#if collapsed}
<button
id={UPDATE_BADGE_ID}
type="button"
class="relative grid h-9 w-9 place-items-center rounded-full bg-accent text-accent-fg transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"
{title}
aria-label={label}
disabled={busy}
onclick={updateFromBadge}
in:scale={{ start: 0.4, duration: 350, delay: 380, easing: backOut }}
>
<Icon name="download" size={16} />
</button>
{:else}
<button
id={UPDATE_BADGE_ID}
type="button"
class="ml-1 inline-flex min-w-0 items-center gap-1.5 overflow-hidden rounded-full bg-accent px-2.5 py-0.5 text-[11px] font-medium text-accent-fg transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus disabled:cursor-default"
{title}
disabled={busy}
onclick={updateFromBadge}
in:scale={{ start: 0.4, duration: 350, delay: 380, easing: backOut }}
>
{#if busy}
<span class="inline-flex animate-spin"><Icon name="refresh" size={11} /></span>
{:else}
<span class="relative flex h-1.5 w-1.5 shrink-0">
<!-- Green, the app's "all good" colour: something new is ready. -->
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-ok opacity-75"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-status-ok"></span>
</span>
{/if}
<span class="truncate">{label}</span>
</button>
{/if}
67 changes: 36 additions & 31 deletions packages/ui/src/lib/components/UpdateBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,42 @@
// background with a progress bar, then "Restart to update" installs it. Everywhere
// else (macOS until builds are signed, the portable/zip builds, .deb/.rpm) it links to
// the release page instead. Skip persists the version to the shared config so it is
// never offered again; Dismiss hides it for this session only.
// never offered again. After a few seconds — or when closed — the banner flies into
// the sidebar's version badge, which then offers the update itself (UpdateBadge).
import { fly } from 'svelte/transition';
import { Icon } from '$lib/theme';
import {
availableUpdate,
beginUpdateDownload,
dismissUpdate,
updateDownload,
updateDownloadFailed
} from '$lib/stores/update';
import { installUpdate, loadUpdateConfig, restartToUpdate, saveUpdateConfig } from '$lib/ipc/commands';
import { openExternal } from '$lib/ipc/openExternal';
import { availableUpdate, dismissUpdate, minimizeUpdate, updateDownload, updateMinimized } from '$lib/stores/update';
import { openReleasePage, restartNow, startUpdate } from '$lib/stores/updateActions';
import { loadUpdateConfig, saveUpdateConfig } from '$lib/ipc/commands';
import { lastError } from '$lib/stores/notifications';
import { flyToBadge } from './flyToBadge';

const message = (e: unknown): string => (e instanceof Error ? e.message : String(e));
let busy = $state(false);

async function openReleasePage(url: string): Promise<void> {
try {
await openExternal(url);
} catch (e) {
lastError.set(message(e));
}
}

function updateNow(): void {
beginUpdateDownload();
// Progress and completion arrive as events; only a failure comes back here.
installUpdate().catch((e) => updateDownloadFailed(message(e)));
startUpdate();
}

async function restart(): Promise<void> {
busy = true;
try {
await restartToUpdate();
} catch (e) {
lastError.set(message(e));
busy = false;
}
await restartNow();
busy = false;
}

// How long the banner stays before tucking itself away. Not while the pointer or the
// keyboard is on it, and not while it has news to show (downloading, failed).
const AUTO_HIDE_MS = 8000;
let hovered = $state(false);
let focused = $state(false);
$effect(() => {
const phase = $updateDownload.phase;
if (!$availableUpdate || $updateMinimized || hovered || focused) return;
if (phase === 'downloading' || phase === 'failed') return;
const timer = setTimeout(minimizeUpdate, AUTO_HIDE_MS);
return () => clearTimeout(timer);
});

// Persist the skip against the current config so `check_on_startup` is preserved.
async function skip(version: string): Promise<void> {
busy = true;
Expand All @@ -67,14 +63,23 @@
const secondary = `${action} text-muted hover:bg-surface-inset hover:text-fg`;
</script>

{#if $availableUpdate}
{#if $availableUpdate && !$updateMinimized}
{@const info = $availableUpdate}
{@const dl = $updateDownload}
<div class="pointer-events-none fixed inset-x-0 bottom-14 z-40 flex justify-center px-4">
<div
class="pointer-events-none fixed inset-x-0 bottom-14 z-40 flex justify-center px-4"
in:fly={{ y: 16, duration: 250 }}
out:flyToBadge
>
<!-- svelte-ignore a11y_no_static_element_interactions -- hover only pauses the auto-hide -->
<div
class="pointer-events-auto w-full max-w-xl rounded-2xl border border-default bg-surface-raised px-4 py-3 shadow-soft"
role="status"
aria-label="Update"
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
onfocusin={() => (focused = true)}
onfocusout={() => (focused = false)}
>
<div class="flex items-center gap-3">
<span class="shrink-0 text-muted"><Icon name="download" size={18} /></span>
Expand Down Expand Up @@ -112,9 +117,9 @@
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-full text-muted transition hover:bg-surface-inset hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"
title="Dismiss"
title="Hide — it stays in the version badge"
aria-label="Dismiss update notice"
onclick={dismissUpdate}
onclick={minimizeUpdate}
>
<Icon name="close" size={15} />
</button>
Expand Down
99 changes: 99 additions & 0 deletions packages/ui/src/lib/components/flyToBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { TransitionConfig } from 'svelte/transition';
import { genieFrame, genieSlicing, type Rect } from './genie';

/** The element the update banner flies into: the sidebar's version badge (or, with the
* sidebar collapsed, the update button standing in for it). */
export const UPDATE_BADGE_ID = 'update-badge';

// Enough that the funnel's edge reads as a curve rather than steps.
const STRIPS = 40;

function rectOf(el: Element): Rect {
const r = el.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}

/**
* Outro for the update banner: the macOS genie into the version badge (genie.ts) — it
* narrows into a funnel towards the badge and is drawn in through it. While it runs the
* banner is hidden and drawn as strips in a layer above everything; the layer goes when
* it's done. Falls back to a plain fade when the badge isn't on screen or motion is
* reduced.
*/
export function flyToBadge(node: HTMLElement, { duration = 650 }: { duration?: number } = {}): TransitionConfig {
const target = document.getElementById(UPDATE_BADGE_ID);
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const card = (node.firstElementChild as HTMLElement | null) ?? node;
const fade: TransitionConfig = { duration: 150, css: (t) => `opacity: ${t}` };
if (!target || reduced) return fade;

const source = rectOf(card);
const dest = rectOf(target);
if (source.width === 0 || source.height === 0) return fade;
const { columns, reversed } = genieSlicing(source, dest);

// The strips: each a copy of the banner, clipped to its slice.
const layer = document.createElement('div');
layer.setAttribute('aria-hidden', 'true');
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:60;';
const strips: HTMLElement[] = [];
for (let i = 0; i < STRIPS; i += 1) {
const strip = document.createElement('div');
strip.style.cssText =
`position:fixed;left:${source.left}px;top:${source.top}px;width:${source.width}px;height:${source.height}px;` +
'transform-origin:0 0;will-change:transform;';
const copy = card.cloneNode(true) as HTMLElement;
copy.style.cssText = `width:${source.width}px;max-width:none;margin:0;`;
copy.removeAttribute('role');
strip.appendChild(copy);
layer.appendChild(strip);
strips.push(strip);
}

let removed = false;
const cleanup = (): void => {
if (removed) return;
removed = true;
layer.remove();
};
document.body.appendChild(layer);
node.style.visibility = 'hidden';
// However the transition ends (finished, or cut short), the layer goes.
setTimeout(cleanup, duration + 200);

// Local slice [a, b) of the banner (in px along the sliced axis) for f-range [from, to).
const size = columns ? source.width : source.height;
const slice = (from: number, to: number): [number, number] =>
reversed ? [(1 - to) * size, (1 - from) * size] : [from * size, to * size];

const draw = (p: number): void => {
const frames = genieFrame(source, dest, p, STRIPS);
const opacity = p > 0.85 ? Math.max(0, (1 - p) / 0.15) : 1;
frames.forEach((frame, i) => {
const [a, b] = slice(frame.from, frame.to);
const strip = strips[i];
// A hair of overlap so no seam shows between neighbours.
const clipA = Math.max(0, a - 0.5);
const clipB = Math.min(size, b + 0.5);
strip.style.clipPath = columns
? `inset(0 ${source.width - clipB}px 0 ${clipA}px)`
: `inset(${clipA}px 0 ${source.height - clipB}px 0)`;
const sx = columns ? frame.dest.width / (b - a) : frame.dest.width / source.width;
const sy = columns ? frame.dest.height / source.height : frame.dest.height / (b - a);
const tx = frame.dest.left - source.left - (columns ? sx * a : 0);
const ty = frame.dest.top - source.top - (columns ? 0 : sy * a);
strip.style.transform = `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})`;
strip.style.opacity = String(opacity);
});
};
draw(0);

return {
duration,
// `u` runs 0 → 1 as it leaves: 0 is the banner as it was, 1 is inside the badge.
tick: (_t, u) => {
if (u >= 1) cleanup();
else draw(u);
}
};
}
Loading
Loading