diff --git a/e2e/transfers.spec.ts b/e2e/transfers.spec.ts new file mode 100644 index 0000000..fb60f52 --- /dev/null +++ b/e2e/transfers.spec.ts @@ -0,0 +1,162 @@ +import { expect, test } from '@playwright/test' +import { getTestEnv } from './helpers' + +const file = (name: string, completed_at: string, error = '') => ({ + name, + completed_at, + error, + group: 'job/7', + srcFs: 'Source:', + dstFs: 'Destination:', + bytes: 512, + size: 1024, +}) + +test('compact activity keeps running files visible and sorts completions by file timestamp', async ({ + page, +}) => { + let active = true + let stoppedJob: number | undefined + await page.addInitScript(() => { + localStorage.setItem( + 'lite-auth-store', + JSON.stringify({ + state: { url: 'http://rc.test', user: 'fixture', pass: 'fixture' }, + version: 0, + }) + ) + }) + await page.route('http://rc.test/**', async (route) => { + const path = new URL(route.request().url()).pathname + if (path === '/job/stop') { + const requestUrl = new URL(route.request().url()) + const body = route.request().postData() + stoppedJob = Number( + requestUrl.searchParams.get('jobid') ?? (body ? JSON.parse(body).jobid : undefined) + ) + } + const responses: Record = { + '/core/stats': { + transferring: active + ? [ + { + ...file('Projects/2026/September/large-archive.bin', ''), + speed: 128, + percentage: 50, + }, + ] + : [], + }, + '/core/transferred': { + transferred: [ + file('old.bin', '2026-09-11T10:00:00Z'), + file('newest.bin', '2026-09-11T12:00:00Z'), + file('middle.bin', '2026-09-11T11:00:00Z'), + file('failed.bin', '2026-09-11T13:00:00Z', 'Fixture failure'), + file('unknown-time.bin', 'invalid'), + ], + }, + '/job/status': { id: 7, finished: false, startTime: '2026-09-11T09:00:00Z' }, + '/config/listremotes': { remotes: [] }, + '/config/dump': {}, + '/core/command': { result: '' }, + '/core/disks': { disks: [] }, + } + await route.fulfill({ json: responses[path] ?? {} }) + }) + await page.goto(getTestEnv().appUrl + '/transfers') + const rows = page.locator('tbody tr') + await expect(rows).toHaveCount(6) + for (const [index, name] of [ + 'Projects/2026/September/large-archive.bin', + 'failed.bin', + 'newest.bin', + 'middle.bin', + 'old.bin', + 'unknown-time.bin', + ].entries()) { + await expect(rows.nth(index)).toContainText(name) + } + await expect( + rows.first().getByTitle('Source:Projects/2026/September/large-archive.bin', { exact: true }) + ).toHaveAttribute('title', 'Source:Projects/2026/September/large-archive.bin') + expect((await rows.first().boundingBox())?.height).toBeLessThanOrEqual(42) + await page.screenshot({ path: 'test-results/transfers-desktop.png', fullPage: true }) + await rows.first().getByRole('button', { name: 'Stop', exact: true }).click() + await expect.poll(() => stoppedJob).toBe(7) + const toggle = page.getByRole('button', { name: /Transfer activity/ }) + await toggle.focus() + await page.keyboard.press('Enter') + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(rows).toHaveCount(1) + await expect(rows.first()).toContainText('Projects/2026/September/large-archive.bin') + active = false + await expect(page.getByText('No active transfers. Expand to see history.')).toBeVisible() + await toggle.click() + await expect(rows).toHaveCount(5) + await page.setViewportSize({ width: 390, height: 844 }) + await expect(toggle).toBeVisible() + await page.screenshot({ path: 'test-results/transfers-mobile.png', fullPage: true }) + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) +}) + +test('long paths preserve their beginning and filename ending as the table resizes', async ({ + page, +}) => { + const directory = 'Projects/Archive/2026/September/Originals/' + const names = [ + directory + 'report-final.pdf', + directory + 'a-very-long-filename-with-many-details-and-a-distinctive-ending.tar.gz', + 'short.txt', + ] + await page.addInitScript(() => + localStorage.setItem( + 'lite-auth-store', + JSON.stringify({ + state: { url: 'http://rc.test', user: 'fixture', pass: 'fixture' }, + version: 0, + }) + ) + ) + await page.route('http://rc.test/**', async (route) => { + const responses: Record = { + '/core/stats': { + transferring: names.map((name) => ({ + ...file(name, ''), + speed: 128, + percentage: 50, + })), + }, + '/core/transferred': { transferred: [] }, + '/job/status': { id: 7, finished: false, startTime: '2026-09-11T09:00:00Z' }, + '/config/listremotes': { remotes: ['Source'] }, + '/config/dump': {}, + } + await route.fulfill({ json: responses[new URL(route.request().url()).pathname] ?? {} }) + }) + await page.goto(getTestEnv().appUrl + '/transfers') + const labels = names.map((name) => + page.locator('tbody').getByTitle('Source:' + name, { exact: true }) + ) + const visible = labels.map((label) => label.locator('[aria-hidden="true"]')) + await expect(visible[0]).toHaveText(/^Source.*\.\.\.report-final\.pdf$/) + await expect(visible[1]).toHaveText(/^Source.*\.\.\..*ending\.tar\.gz$/) + await expect(visible[2]).toHaveText('Source:short.txt') + for (const label of labels) { + await label.hover() + expect(await label.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe( + true + ) + } + const narrow = await visible[1].textContent() + await page.setViewportSize({ width: 3000, height: 900 }) + await expect.poll(() => visible[1].textContent()).not.toBe(narrow) + await expect(visible[1]).toHaveText( + /a-very-long-filename-with-many-details-and-a-distinctive-ending\.tar\.gz$/ + ) + await page.setViewportSize({ width: 1280, height: 720 }) + await expect(visible[1]).toHaveText(narrow!) + await page.screenshot({ path: 'test-results/filename-ellipsis.png', fullPage: true }) +}) diff --git a/src/components/PathLabel.tsx b/src/components/PathLabel.tsx new file mode 100644 index 0000000..2fe39a4 --- /dev/null +++ b/src/components/PathLabel.tsx @@ -0,0 +1,69 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { cn } from '@/lib/ui' + +export function PathLabel({ value, className }: { value: string; className?: string }) { + const ref = useRef(null) + const [label, setLabel] = useState(value) + + useLayoutEffect(() => { + const element = ref.current + const context = document.createElement('canvas').getContext('2d') + if (!element || !context) return + + const update = () => { + const style = getComputedStyle(element) + context.font = style.font + const width = element.clientWidth + const measure = (text: string) => context.measureText(text).width + if (measure(value) <= width) { + setLabel(value) + return + } + + const characters = Array.from(value) + const ellipsis = '...' + const filename = value.slice( + Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'), value.lastIndexOf(':')) + + 1 + ) + const suffixBudget = + measure(ellipsis + filename) <= width + ? width - measure(ellipsis) + : Math.max(0, width * 0.8 - measure(ellipsis)) + let low = 0 + let high = characters.length + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (measure(characters.slice(-middle).join('')) <= suffixBudget) low = middle + else high = middle - 1 + } + const suffixLength = Math.min(low, Array.from(filename).length || low) + const suffix = suffixLength ? characters.slice(-suffixLength).join('') : '' + low = 0 + high = characters.length - suffixLength + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (measure(characters.slice(0, middle).join('') + ellipsis + suffix) <= width) + low = middle + else high = middle - 1 + } + setLabel(characters.slice(0, low).join('') + ellipsis + suffix) + } + + update() + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, [value]) + + return ( + + + {value || '—'} + + ) +} diff --git a/src/components/TransfersTable.tsx b/src/components/TransfersTable.tsx index 35a7b99..70c0186 100644 --- a/src/components/TransfersTable.tsx +++ b/src/components/TransfersTable.tsx @@ -1,4 +1,5 @@ import { CheckCircle2Icon, RefreshCwIcon, XCircleIcon } from 'lucide-react' +import { PathLabel } from '@/components/PathLabel' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -25,9 +26,7 @@ function getTransferredLabel(job: JobRow) { } function TransferLocationCell({ value }: { value: string }) { - return ( - {value || '—'} - ) + return } const statusUi: Record< @@ -60,14 +59,14 @@ const statusUi: Record< } const columnWidths = { - id: 'w-[92px]', - status: 'w-[168px]', - source: 'w-[320px]', - destination: 'w-[372px]', - progress: 'w-[284px]', - speed: 'w-[152px]', - eta: 'w-[152px]', - actions: 'w-[124px]', + id: 'w-[64px]', + status: 'w-[112px]', + source: 'w-[240px]', + destination: 'w-[240px]', + progress: 'w-[220px]', + speed: 'w-[100px]', + eta: 'w-[100px]', + actions: 'w-[80px]', } as const export function TransfersTable({ @@ -82,13 +81,13 @@ export function TransfersTable({ const t = useT() return (
- +
{t('transfersTable.group')} @@ -96,7 +95,7 @@ export function TransfersTable({ {t('transfersTable.status')} @@ -104,7 +103,7 @@ export function TransfersTable({ {t('transfersTable.source')} @@ -112,7 +111,7 @@ export function TransfersTable({ {t('transfersTable.destination')} @@ -120,7 +119,7 @@ export function TransfersTable({ {t('transfersTable.progress')} @@ -128,7 +127,7 @@ export function TransfersTable({ {t('transfersTable.speed')} @@ -136,7 +135,7 @@ export function TransfersTable({ {t('transfersTable.eta')} @@ -144,7 +143,7 @@ export function TransfersTable({ {t('transfersTable.actions')} @@ -158,7 +157,7 @@ export function TransfersTable({ const badge = ( #{job.id} @@ -187,7 +186,7 @@ export function TransfersTable({
@@ -211,7 +210,7 @@ export function TransfersTable({ @@ -220,7 +219,7 @@ export function TransfersTable({ @@ -229,17 +228,17 @@ export function TransfersTable({ -
-
+
+
{job.progress}% {getTransferredLabel(job)}
-
+
{job.speedLabel} @@ -263,7 +262,7 @@ export function TransfersTable({ {job.etaLabel} @@ -272,13 +271,13 @@ export function TransfersTable({ {job.canStop ? (
+
+ {visibleJobs.length > 0 ? ( + stopMutation.mutate(jobid)} + isStopping={stopMutation.isPending} + /> + ) : ( +

+ {t('transfers.noActive')} +

+ )} +
+ ) : null} diff --git a/src/rclone/jobs.ts b/src/rclone/jobs.ts index 5121064..6cb8811 100644 --- a/src/rclone/jobs.ts +++ b/src/rclone/jobs.ts @@ -14,6 +14,7 @@ export type JobRow = { id: number status: 'running' | 'completed' | 'failed' startTime: string + completedAt?: string source: string destination: string bytes: number @@ -357,6 +358,7 @@ export async function fetchJobsSnapshot() { id: jobid, status: isFailed ? 'failed' : 'completed', startTime: status.startTime, + completedAt: getText(item.completed_at), source: source || '—', destination: destination || '—', bytes, @@ -421,7 +423,9 @@ export async function fetchJobsSnapshot() { } satisfies Record return [...runningRows, ...failedRows, ...completedRows].sort( - (a, b) => statusOrder[a.status] - statusOrder[b.status] + (a, b) => + statusOrder[a.status] - statusOrder[b.status] || + (Date.parse(b.completedAt ?? '') || 0) - (Date.parse(a.completedAt ?? '') || 0) ) }