From eebb4ee165b868c59bb25e577b7e4909abc18843 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Wed, 16 Sep 2026 02:41:08 +0200 Subject: [PATCH 001/317] test: characterize dashboard tabs --- tests/dashboard-tabs.spec.mjs | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/dashboard-tabs.spec.mjs diff --git a/tests/dashboard-tabs.spec.mjs b/tests/dashboard-tabs.spec.mjs new file mode 100644 index 0000000..18872e9 --- /dev/null +++ b/tests/dashboard-tabs.spec.mjs @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { folderFixture } from './folder-fixture.mjs'; + +test('dashboard tabs move focus, wrap and activate from the keyboard', async ({ + page, +}) => { + await folderFixture(page); + await page.goto('/'); + await expect(page.locator('.dashboard-folders .panel-heading')).toBeVisible(); + + const tablist = page.getByRole('tablist'); + const tabs = [ + { + control: tablist.getByRole('tab', { name: 'Overview', exact: true }), + panel: page.locator('#dashboard-overview'), + }, + { + control: tablist.getByRole('tab', { + name: 'Resolve sync conflicts (beta)', + exact: true, + }), + panel: page.locator('#dashboard-conflicts'), + }, + { + control: tablist.getByRole('tab', { name: /Notifications/ }), + panel: page.locator('#dashboard-notifications'), + }, + ]; + + async function expectActive(index) { + for (const [position, tab] of tabs.entries()) { + const active = position === index; + await expect(tab.control).toHaveAttribute( + 'aria-selected', + String(active), + ); + await expect(tab.control).toHaveAttribute( + 'tabindex', + active ? '0' : '-1', + ); + if (active) { + await expect(tab.control).toBeFocused(); + await expect(tab.panel).toHaveClass(/\bactive\b/); + } else { + await expect(tab.panel).not.toHaveClass(/\bactive\b/); + } + } + } + + await tabs[0].control.focus(); + await expectActive(0); + + await page.keyboard.press('ArrowLeft'); + await expectActive(2); + + await page.keyboard.press('ArrowRight'); + await expectActive(0); + + await page.keyboard.press('End'); + await expectActive(2); + + await page.keyboard.press('Home'); + await expectActive(0); + + await page.keyboard.press('ArrowRight'); + await expectActive(1); +}); From c98cc9ed5a6bde022a892af8b1efdd2a02200d80 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Wed, 16 Sep 2026 04:20:39 +0200 Subject: [PATCH 002/317] test: characterize dialog focus --- tests/dialog-focus.spec.mjs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/dialog-focus.spec.mjs diff --git a/tests/dialog-focus.spec.mjs b/tests/dialog-focus.spec.mjs new file mode 100644 index 0000000..ea55623 --- /dev/null +++ b/tests/dialog-focus.spec.mjs @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { folderFixture } from './folder-fixture.mjs'; + +test('dialogs take initial focus and return it after Escape', async ({ + page, +}) => { + await folderFixture(page); + await page.route('**/rest/events/disk?*', (route) => + route.fulfill({ json: [] }), + ); + await page.goto('/'); + + const opener = page.getByRole('button', { + name: 'Recent Changes', + exact: true, + }); + await expect(opener).toBeVisible(); + await opener.click(); + + const dialog = page.getByRole('dialog', { + name: 'Recent Changes', + exact: true, + }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Full View', exact: true }), + ).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); +}); From 703c590ca7080fe3b9237340d4965c7122cfdb83 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Wed, 16 Sep 2026 11:50:21 +0200 Subject: [PATCH 003/317] test: characterize menu focus --- tests/menu-focus.spec.mjs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/menu-focus.spec.mjs diff --git a/tests/menu-focus.spec.mjs b/tests/menu-focus.spec.mjs new file mode 100644 index 0000000..f8e965e --- /dev/null +++ b/tests/menu-focus.spec.mjs @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import { folderFixture } from './folder-fixture.mjs'; + +test('Escape closes a folder menu and returns focus to its trigger', async ({ + page, +}) => { + await folderFixture(page); + await page.goto('/'); + + const localDevice = page.locator('.dashboard-devices > .panel'); + const trigger = localDevice.getByRole('button', { name: /Folders/ }); + const menu = trigger.locator('xpath=..'); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect(menu).toHaveClass(/\bopen\b/); + + const item = menu.getByRole('link', { + name: 'Folder under test', + exact: true, + }); + await item.focus(); + await expect(item).toBeFocused(); + await page.keyboard.press('Escape'); + + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).not.toHaveClass(/\bopen\b/); + await expect(trigger).toBeFocused(); +}); From 40ae448745f2315f72f8aa687646a8ace4b9a6ae Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Wed, 30 Sep 2026 20:25:38 +0200 Subject: [PATCH 004/317] feat: open selected device from desktop --- app/main.jsx | 9 +++++++++ client/desktop.mjs | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/main.jsx b/app/main.jsx index d4167ce..6affe29 100644 --- a/app/main.jsx +++ b/app/main.jsx @@ -120,6 +120,15 @@ function App() { session.reportError(error); } } + useEffect(() => { + if (!state.ready) return; + const launch = desktop?.takeLaunchAction?.(); + if (launch?.type !== 'edit-device') return; + const device = state.config.devices.find( + (candidate) => candidate.deviceID === launch.device, + ); + if (device) openAction({ type: 'edit-device', device }); + }, [state.ready, state.config.devices]); function tabKey(event) { let index = tabs.findIndex(([id]) => id === activeTab); if (event.key === 'ArrowRight') index = (index + 1) % tabs.length; diff --git a/client/desktop.mjs b/client/desktop.mjs index d763da0..ef11fe7 100644 --- a/client/desktop.mjs +++ b/client/desktop.mjs @@ -1,14 +1,23 @@ const storageKey = 'syncshell-desktop'; +const launchKey = 'syncshell-launch-action'; export const desktopHelp = 'Local file actions are unavailable. Open this UI from Syncshell on the desktop running Syncthing, under the same user.'; // Only the plugin launch grants access; the address bar and HTTP requests retain no token. export function desktopActions(browser = window) { let grant; + let launch = null; try { - const fragment = browser.location.hash.slice(1); - if (fragment.startsWith('syncshell-desktop=')) { - grant = fragment.slice('syncshell-desktop='.length); + const fragment = new URLSearchParams(browser.location.hash.slice(1)); + if (fragment.has('syncshell-desktop')) { + grant = fragment.get('syncshell-desktop'); + const action = fragment.get('syncshell-action'); + const device = fragment.get('device'); + if ( + action === 'edit-device' && + /^[A-Z2-7]{7}(-[A-Z2-7]{7}){7}$/.test(device || '') + ) + launch = { type: action, device }; browser.history.replaceState( null, '', @@ -21,6 +30,13 @@ export function desktopActions(browser = window) { } const match = /^127\.0\.0\.1:([0-9]{1,5})\/([a-f0-9]{64})$/.exec(grant || ''); if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535) return null; + if (launch) { + try { + browser.sessionStorage.setItem(launchKey, JSON.stringify(launch)); + } catch { + return null; + } + } async function request(action, body) { let response; try { @@ -62,6 +78,18 @@ export function desktopActions(browser = window) { modified: file.modified, }); return { + takeLaunchAction() { + let action; + try { + action = JSON.parse( + browser.sessionStorage.getItem(launchKey) || 'null', + ); + browser.sessionStorage.setItem(launchKey, ''); + } catch { + return null; + } + return action; + }, status: (device) => request('status', { device }), check: (device, group, file) => request('check', fileRequest(device, group, file)), From e669edea7c269d7f282f876f89c57a44f55bb6bb Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Wed, 30 Sep 2026 20:25:44 +0200 Subject: [PATCH 005/317] build: prepare web ui 0.1.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f68f602..6687505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "syncshell-webui", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "syncshell-webui", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "lucide-preact": "1.43.0", "preact": "10.29.8" diff --git a/package.json b/package.json index 9659979..48ac85e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "syncshell-webui", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "scripts": { From 3c186770305cc042b2313a49e4e2bd89e1fe40d6 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Wed, 30 Sep 2026 23:55:15 +0200 Subject: [PATCH 006/317] fix: align responsive notification cards --- app/Notifications.jsx | 150 ++++++++++++------------ static/assets/css/modern.css | 44 ++++++-- tests/notifications-layout.spec.mjs | 169 ++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 83 deletions(-) create mode 100644 tests/notifications-layout.spec.mjs diff --git a/app/Notifications.jsx b/app/Notifications.jsx index 774c66f..c64a2c6 100644 --- a/app/Notifications.jsx +++ b/app/Notifications.jsx @@ -34,86 +34,86 @@ export function Notifications({ cards, session, onAction }) { {!cards.length && (

{t('No pending notifications.')}

)} - {cards.map((card) => ( -
-
-
-
-

- {card.kind === 'device' ? ( - - ) : ( - - - - )} - {t(card.title)} - {card.time && ( - {timestamp(card.time)} - )} -

-
-
- {(card.paragraphs || []).map((paragraph, index) => ( -

{t(paragraph, card.params)}

- ))} - {(card.errors || []).map((error, index) => ( -

- {timestamp(error.when)}: {error.message} -

- ))} - {card.watchers && ( - - - {card.watchers.map((watcher) => ( - - - - - ))} - -
{watcher.name}: {watcher.error}
+
+ {cards.map((card) => ( +
+
+

+ {card.kind === 'device' ? ( + + ) : ( + + + )} - {card.link && ( -

- - -   - {t(card.id === 'watchers' ? 'Support' : 'Learn more')} - -

+ {t(card.title)} + {card.time && ( + )} -

- {card.actions?.length > 0 && ( - + +
+
+ {(card.paragraphs || []).map((paragraph, index) => ( +

{t(paragraph, card.params)}

+ ))} + {(card.errors || []).map((error, index) => ( +

+ {timestamp(error.when)}: {error.message} +

+ ))} + {card.watchers && ( + + + {card.watchers.map((watcher) => ( + + + + + ))} + +
{watcher.name}: {watcher.error}
+ )} + {card.link && ( +

+ + +   + {t(card.id === 'watchers' ? 'Support' : 'Learn more')} + +

)}
+ {card.actions?.length > 0 && ( + + )}
-
- ))} + ))} +
); } diff --git a/static/assets/css/modern.css b/static/assets/css/modern.css index 2b343bf..493f48f 100644 --- a/static/assets/css/modern.css +++ b/static/assets/css/modern.css @@ -54,12 +54,30 @@ display: none; } +.notification-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.notification-grid > .panel { + min-width: 0; + margin-bottom: 0; +} + .dashboard-folders, .dashboard-devices, .notifications { min-width: 0; } +@media (min-width: 700px) { + .notification-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (min-width: 1000px) { .dashboard-primary.active { display: grid; @@ -67,6 +85,10 @@ gap: 24px; align-items: start; } + + .notification-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (min-width: 1400px) { @@ -75,8 +97,9 @@ minmax(320px, calc((100% - 48px) * 0.25)) minmax(320px, calc((100% - 48px) * 0.25)); } - .notifications { - width: max(280px, calc((100% - 48px) * 0.1875)); + + .notification-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); } } @@ -127,21 +150,26 @@ font-size: 16px; display: flex; flex-wrap: wrap; - align-items: center; + align-items: flex-start; gap: 0.4em; } -.notifications .panel-title > .panel-icon, -.notifications .panel-title > .syncshell-icon { - flex-shrink: 0; +.notifications .panel-title > .panel-icon { + flex: 0 0 auto; + margin: 0.125em 0; +} + +.notification-title-text { + flex: 1 1 0; + min-width: 0; } .notifications .panel-body { font-size: 14px; } -.notifications .panel-title .pull-right { - flex-basis: 100%; +.notification-time { + flex: 1 0 100%; font-size: 14px; text-align: right; } diff --git a/tests/notifications-layout.spec.mjs b/tests/notifications-layout.spec.mjs new file mode 100644 index 0000000..e428277 --- /dev/null +++ b/tests/notifications-layout.spec.mjs @@ -0,0 +1,169 @@ +import { test, expect } from '@playwright/test'; +import { notificationCards } from '../client/notification-cards.mjs'; +import { folderFixture } from './folder-fixture.mjs'; + +const peer = 'PEER-DEVICE-IDENTIFIER'; + +async function expectColumns(panels, count) { + const boxes = await panels.evaluateAll((nodes) => + nodes.map((node) => { + const box = node.getBoundingClientRect(); + return { left: box.left, top: box.top }; + }), + ); + const first = boxes[0]; + for (const box of boxes.slice(0, count)) + expect(Math.abs(box.top - first.top)).toBeLessThan(1); + if (boxes.length > count) { + expect(boxes[count].top).toBeGreaterThan(first.top + 10); + expect(Math.abs(boxes[count].left - first.left)).toBeLessThan(1); + } +} + +test('notification cards form a responsive grid with aligned headers', async ({ + page, +}, testInfo) => { + await folderFixture(page, { + folder: { fsWatcherEnabled: true, paused: false }, + model: { watchError: 'watch failed' }, + }); + await page.route('**/rest/config', async (route) => { + const config = await (await route.fetch()).json(); + config.gui = { + ...config.gui, + authMode: '', + user: '', + password: '', + insecureAdminAccess: false, + }; + config.options.unackedNotificationIDs = Object.keys(notificationCards); + config.folders = [ + { + ...config.folders[0], + label: 'Folder under test', + path: '/a/b', + fsWatcherEnabled: true, + paused: false, + }, + ]; + await route.fulfill({ json: config }); + }); + await page.route('**/rest/system/status', async (route) => + route.fulfill({ + json: { + ...(await (await route.fetch()).json()), + guiAddressUsed: '0.0.0.0:8384', + }, + }), + ); + await page.route('**/rest/config/insync', (route) => + route.fulfill({ json: { configInSync: false } }), + ); + await page.route('**/rest/cluster/pending/devices', (route) => + route.fulfill({ + json: { + [peer]: { + name: 'Peer device', + address: 'tcp://192.0.2.1:22000', + time: '2026-09-30T20:44:16Z', + }, + }, + }), + ); + await page.route('**/rest/cluster/pending/folders', (route) => + route.fulfill({ + json: { + 'port-verification': { + offeredBy: { + [peer]: { + label: 'Existing folder', + time: '2026-09-30T20:44:16Z', + }, + }, + }, + 'incoming-folder': { + offeredBy: { + [peer]: { + label: 'Incoming folder', + time: '2026-09-30T20:44:16Z', + }, + }, + }, + }, + }), + ); + await page.route('**/rest/system/error', (route) => + route.fulfill({ + json: { + errors: [ + { + when: '2026-09-30T20:44:16Z', + message: 'Temporary notification error', + }, + ], + }, + }), + ); + + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto('/'); + await page.getByRole('tab', { name: /Notifications/ }).click(); + + const panels = page.locator('.notification-grid > .panel'); + await expect(panels).toHaveCount(12); + await expect(panels.locator('.notification-title-text')).toHaveText([ + 'Danger!', + 'Restart Needed', + ...Object.values(notificationCards).map((card) => card.title), + 'New Device', + 'Share Folder', + 'New Folder', + 'Notice', + 'Filesystem Watcher Errors', + ]); + + const headers = await panels.locator('.panel-title').evaluateAll((nodes) => + nodes.map((node) => { + const icon = node.querySelector('.panel-icon').getBoundingClientRect(); + const title = node + .querySelector('.notification-title-text') + .getBoundingClientRect(); + return { + iconRight: icon.right, + iconTop: icon.top, + titleLeft: title.left, + titleTop: title.top, + titleWidth: title.width, + }; + }), + ); + for (const header of headers) { + expect(Math.abs(header.iconTop - header.titleTop)).toBeLessThan(4); + expect(header.titleLeft).toBeGreaterThan(header.iconRight); + expect(header.titleWidth).toBeGreaterThan(0); + } + + const authenticationTitle = panels + .filter({ hasText: 'GUI Authentication: Set User and Password' }) + .locator('.notification-title-text'); + const titleMetrics = await authenticationTitle.evaluate((node) => ({ + height: node.getBoundingClientRect().height, + lineHeight: Number.parseFloat(getComputedStyle(node).lineHeight), + })); + expect(titleMetrics.height).toBeGreaterThan(titleMetrics.lineHeight * 1.5); + + await expectColumns(panels, 4); + await page.screenshot({ + path: testInfo.outputPath('notifications-four-columns.png'), + fullPage: true, + }); + + for (const [width, columns] of [ + [1100, 3], + [800, 2], + [600, 1], + ]) { + await page.setViewportSize({ width, height: 900 }); + await expectColumns(panels, columns); + } +}); From a3e274f84cdb7e47d352a02072fccb633ed1e683 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:27:48 +0200 Subject: [PATCH 007/317] test: characterize tooltip focus --- tests/tooltip-focus.spec.mjs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/tooltip-focus.spec.mjs diff --git a/tests/tooltip-focus.spec.mjs b/tests/tooltip-focus.spec.mjs new file mode 100644 index 0000000..a255bd8 --- /dev/null +++ b/tests/tooltip-focus.spec.mjs @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; +import { folderFixture } from './folder-fixture.mjs'; + +test('focused tooltips describe their trigger and hide on Escape', async ({ + page, +}) => { + await folderFixture(page); + await page.goto('/'); + await page.getByRole('button', { name: /Folder under test/ }).click(); + + const trigger = page + .locator('.folder-state-summary') + .getByRole('img', { name: 'Files', exact: true }); + await trigger.focus(); + await expect(trigger).toBeFocused(); + await expect(trigger).toHaveAttribute( + 'aria-describedby', + /^syncshell-tooltip-\d+$/, + ); + + const tooltipId = await trigger.getAttribute('aria-describedby'); + const tooltip = page.locator(`#${tooltipId}`); + await expect(tooltip).toHaveAttribute('role', 'tooltip'); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText('109,274'); + + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + await expect(trigger).not.toHaveAttribute('aria-describedby', tooltipId); + await expect(trigger).toBeFocused(); +}); From a01113ed3183e783fa6c3ff5122cc2e083249cf1 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:29:50 +0200 Subject: [PATCH 008/317] refactor: clarify initial session state --- app/main.jsx | 4 ++-- client/session.mjs | 4 ++-- tests/session.test.mjs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/main.jsx b/app/main.jsx index 6affe29..3f61599 100644 --- a/app/main.jsx +++ b/app/main.jsx @@ -3,7 +3,7 @@ import { desktopActions } from '../client/desktop.mjs'; import { render } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import { createApi } from '../client/api.mjs'; -import { createSession, initialState } from '../client/session.mjs'; +import { createInitialState, createSession } from '../client/session.mjs'; import { loadEnglish, translator } from '../client/locale.mjs'; import { grouped, deviceName } from '../client/devices.mjs'; import { UsageReport } from './UsageReport.jsx'; @@ -38,7 +38,7 @@ const helpLinks = [ const desktop = desktopActions(); function App() { - const [state, setState] = useState(initialState); + const [state, setState] = useState(createInitialState); const [api] = useState(() => createApi()); const [locale, setLocale] = useState({ t: translator({}) }); const [session] = useState(() => diff --git a/client/session.mjs b/client/session.mjs index 8f8c1fd..9e8400d 100644 --- a/client/session.mjs +++ b/client/session.mjs @@ -5,7 +5,7 @@ import { createEvents } from './events.mjs'; import { transferProgress, endedTransfers } from './transfer.mjs'; import { completionTotal, connectionRates } from './devices.mjs'; -export function initialState() { +export function createInitialState() { return { online: false, ready: false, @@ -97,7 +97,7 @@ export function createSession( createEventStream = createEvents, } = {}, ) { - let state = initialState(); + let state = createInitialState(); let controller; let interval; let hydrating; diff --git a/tests/session.test.mjs b/tests/session.test.mjs index c1a4973..22460cb 100644 --- a/tests/session.test.mjs +++ b/tests/session.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createSession, - initialState, + createInitialState, folderEvent, } from '../client/session.mjs'; @@ -122,7 +122,7 @@ async function createSessionFixture(testContext) { test('folder event updates preserve unrelated folders and clear obsolete scan data', () => { const original = { - ...initialState(), + ...createInitialState(), model: { a: { state: 'idle' }, b: { state: 'idle' } }, scanProgress: { a: { current: 50 }, b: { current: 20 } }, }; From d122b05fa3f88fc16d6dc649495f7098aea0a4e1 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:31:58 +0200 Subject: [PATCH 009/317] refactor: clarify config cloning --- app/Editor.jsx | 8 ++++---- app/Settings.jsx | 8 ++++---- client/edit.mjs | 4 ++-- client/settings.mjs | 6 +++--- tests/editor.test.mjs | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/Editor.jsx b/app/Editor.jsx index 5a4883b..3debcdc 100644 --- a/app/Editor.jsx +++ b/app/Editor.jsx @@ -3,7 +3,7 @@ import { LocaleContext } from './locale-context.jsx'; import { Dialog } from './Dialog.jsx'; import { ConfirmAction } from './ConfirmAction.jsx'; import { - copy, + cloneConfig, getValue, editorFields, inputValue, @@ -32,7 +32,7 @@ export function Editor({ action, state, api, session, onClose, onSaved }) { const kind = action.type.includes('device') ? 'device' : 'folder'; const defaults = !!action.defaults; const isNew = action.type.startsWith('add'); - const [draft, setDraft] = useState(() => copy(action[kind])); + const [draft, setDraft] = useState(() => cloneConfig(action[kind])); const [tab, setTab] = useState( action.tab === 'sharing' ? 'Sharing' @@ -242,7 +242,7 @@ export function Editor({ action, state, api, session, onClose, onSaved }) { api, state, kind, - draft: { ...copy(draft), paused: true }, + draft: { ...cloneConfig(draft), paused: true }, isNew, shares, }); @@ -264,7 +264,7 @@ export function Editor({ action, state, api, session, onClose, onSaved }) { await saveEditor({ session, api, state, kind, draft, isNew, shares }); } saved.current = true; - onSaved?.(copy(draft), ignoreLines(ignores)); + onSaved?.(cloneConfig(draft), ignoreLines(ignores)); onClose(); } catch (failure) { setError(failure.message); diff --git a/app/Settings.jsx b/app/Settings.jsx index 2070fb4..bab5d07 100644 --- a/app/Settings.jsx +++ b/app/Settings.jsx @@ -4,7 +4,7 @@ import { Dialog } from './Dialog.jsx'; import { UsageReport } from './UsageReport.jsx'; import { Editor } from './Editor.jsx'; import { FormFields } from './FormFields.jsx'; -import { copy, setValue } from '../client/edit.mjs'; +import { cloneConfig, setValue } from '../client/edit.mjs'; import { settingsTabs, settingsFields, @@ -19,8 +19,8 @@ import { timestamp } from '../client/format.mjs'; export function Settings({ state, api, session, onClose, advanced = false }) { const { t } = useContext(LocaleContext), form = useRef(); - const [initial] = useState(() => copy(state.config)); - const [draft, setDraft] = useState(() => copy(initial)), + const [initial] = useState(() => cloneConfig(state.config)); + const [draft, setDraft] = useState(() => cloneConfig(initial)), [mode, setMode] = useState(upgradeMode(initial)); const [tab, setTab] = useState('General'), [options, setOptions] = useState({ themes: [], upgrade: null }); @@ -47,7 +47,7 @@ export function Settings({ state, api, session, onClose, advanced = false }) { setError(''); try { const config = advanced - ? copy(draft) + ? cloneConfig(draft) : settingsConfig( draft, mode, diff --git a/client/edit.mjs b/client/edit.mjs index f2c35e8..5b53339 100644 --- a/client/edit.mjs +++ b/client/edit.mjs @@ -1,8 +1,8 @@ -export const copy = (value) => JSON.parse(JSON.stringify(value)); +export const cloneConfig = (value) => JSON.parse(JSON.stringify(value)); export const getValue = (object, path) => path.split('.').reduce((value, key) => value?.[key], object); export function setValue(object, path, value) { - const result = copy(object), + const result = cloneConfig(object), keys = path.split('.'); let target = result; for (const key of keys.slice(0, -1)) target = target[key] ||= {}; diff --git a/client/settings.mjs b/client/settings.mjs index 501fe32..e4ac9cc 100644 --- a/client/settings.mjs +++ b/client/settings.mjs @@ -1,4 +1,4 @@ -import { copy, editorFields } from './edit.mjs'; +import { cloneConfig, editorFields } from './edit.mjs'; const isUnixAddress = (address) => address.startsWith('/') || address.startsWith('unix://') || @@ -51,7 +51,7 @@ export function settingsFields(tab, draft, myID, themes) { return fields; } export function settingsConfig(draft, mode, system, version, supported) { - const config = copy(draft), + const config = cloneConfig(draft), options = config.options; if (supported) { options.autoUpgradeIntervalH = @@ -90,7 +90,7 @@ export const ignoredFolders = (config) => (device.ignoredFolders || []).map((folder) => ({ device, folder })), ); export function unignore(config, deviceID, folderID) { - const next = copy(config); + const next = cloneConfig(config); if (folderID !== undefined) { const device = next.devices.find((device) => device.deviceID === deviceID); device.ignoredFolders = device.ignoredFolders.filter( diff --git a/tests/editor.test.mjs b/tests/editor.test.mjs index 79116dd..486cc21 100644 --- a/tests/editor.test.mjs +++ b/tests/editor.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { changedValue, - copy, + cloneConfig, getValue, ignoreLines, inputValue, @@ -88,7 +88,7 @@ function createSaveFixture({ }; const session = { async changeConfig(edit) { - const next = copy(config); + const next = cloneConfig(config); edit(next); savedConfig = next; return next; @@ -139,7 +139,7 @@ test('configuration values are cloned before a dotted path is changed', () => { devices: [{ deviceID: 'local' }], }; - const cloned = copy(original); + const cloned = cloneConfig(original); cloned.devices[0].deviceID = 'changed'; assert.equal(original.devices[0].deviceID, 'local'); assert.equal(getValue(original, 'versioning.params.keep'), '5'); From b957be15c57d8cfa864c660b5fc92c181d097c14 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:32:57 +0200 Subject: [PATCH 010/317] refactor: clarify grouped sorting --- app/main.jsx | 6 +++--- client/devices.mjs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/main.jsx b/app/main.jsx index 3f61599..48be19f 100644 --- a/app/main.jsx +++ b/app/main.jsx @@ -5,7 +5,7 @@ import { useEffect, useState } from 'preact/hooks'; import { createApi } from '../client/api.mjs'; import { createInitialState, createSession } from '../client/session.mjs'; import { loadEnglish, translator } from '../client/locale.mjs'; -import { grouped, deviceName } from '../client/devices.mjs'; +import { deviceName, groupAndSortItems } from '../client/devices.mjs'; import { UsageReport } from './UsageReport.jsx'; import { needsUsageConsent } from '../client/reports.mjs'; import { updateEditor } from '../client/editor-behavior.mjs'; @@ -58,8 +58,8 @@ function App() { const others = state.config.devices.filter( (device) => device.deviceID !== state.system.myID, ); - const folderGroups = grouped(state.config.folders, 'label', 'id'); - const deviceGroups = grouped(others, 'name', 'deviceID'); + const folderGroups = groupAndSortItems(state.config.folders, 'label', 'id'); + const deviceGroups = groupAndSortItems(others, 'name', 'deviceID'); const cards = notices(state); const name = deviceName(self) || 'Syncthing'; const { t } = locale; diff --git a/client/devices.mjs b/client/devices.mjs index 7e03e66..ebbd4e4 100644 --- a/client/devices.mjs +++ b/client/devices.mjs @@ -10,7 +10,7 @@ export const sharedFolders = (config, id) => folder.devices.some((device) => device.deviceID === id), ); -export function grouped(items, name, id) { +export function groupAndSortItems(items, name, id) { const groups = {}; for (const item of items) (groups[item.group || ''] ||= []).push(item); return Object.keys(groups) From 785f5da7135ff9c3f28e32e21867e914efdb031c Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:33:49 +0200 Subject: [PATCH 011/317] refactor: clarify folder event reduction --- client/session.mjs | 4 ++-- tests/session.test.mjs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/session.mjs b/client/session.mjs index 9e8400d..3a95dc0 100644 --- a/client/session.mjs +++ b/client/session.mjs @@ -33,7 +33,7 @@ export function createInitialState() { }; } -export function folderEvent(state, event) { +export function reduceFolderEvent(state, event) { const data = event.data; switch (event.type) { case 'FolderSummary': @@ -314,7 +314,7 @@ export function createSession( update({ ...state, online: false, error }); }, onEvent(event) { - update(folderEvent(state, event)); + update(reduceFolderEvent(state, event)); const data = event.data; if (event.type === 'DownloadProgress') { const progress = transferProgress(data), diff --git a/tests/session.test.mjs b/tests/session.test.mjs index 22460cb..e964180 100644 --- a/tests/session.test.mjs +++ b/tests/session.test.mjs @@ -3,7 +3,7 @@ import { test } from 'node:test'; import { createSession, createInitialState, - folderEvent, + reduceFolderEvent, } from '../client/session.mjs'; async function createSessionFixture(testContext) { @@ -126,7 +126,7 @@ test('folder event updates preserve unrelated folders and clear obsolete scan da model: { a: { state: 'idle' }, b: { state: 'idle' } }, scanProgress: { a: { current: 50 }, b: { current: 20 } }, }; - const scanning = folderEvent(original, { + const scanning = reduceFolderEvent(original, { type: 'StateChanged', data: { folder: 'a', from: 'idle', to: 'scanning' }, }); @@ -136,14 +136,14 @@ test('folder event updates preserve unrelated folders and clear obsolete scan da assert.equal(original.model.a.state, 'idle'); const summary = { state: 'idle', localFiles: 3 }; assert.equal( - folderEvent(scanning, { + reduceFolderEvent(scanning, { type: 'FolderSummary', data: { folder: 'a', summary }, }).model.a, summary, ); assert.equal( - folderEvent(scanning, { + reduceFolderEvent(scanning, { type: 'FolderErrors', data: { folder: 'unknown', errors: ['bad'] }, }), From cf6f8857d6e8d18da1cd335b01446c71f0667ab8 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:35:37 +0200 Subject: [PATCH 012/317] refactor: clarify settings preparation --- app/Settings.jsx | 4 ++-- client/settings.mjs | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/Settings.jsx b/app/Settings.jsx index bab5d07..ec84642 100644 --- a/app/Settings.jsx +++ b/app/Settings.jsx @@ -8,7 +8,7 @@ import { cloneConfig, setValue } from '../client/edit.mjs'; import { settingsTabs, settingsFields, - settingsConfig, + prepareSettingsForSave, upgradeMode, ignoredFolders, unignore, @@ -48,7 +48,7 @@ export function Settings({ state, api, session, onClose, advanced = false }) { try { const config = advanced ? cloneConfig(draft) - : settingsConfig( + : prepareSettingsForSave( draft, mode, state.system, diff --git a/client/settings.mjs b/client/settings.mjs index e4ac9cc..4165675 100644 --- a/client/settings.mjs +++ b/client/settings.mjs @@ -50,7 +50,13 @@ export function settingsFields(tab, draft, myID, themes) { ]); return fields; } -export function settingsConfig(draft, mode, system, version, supported) { +export function prepareSettingsForSave( + draft, + mode, + system, + version, + supported, +) { const config = cloneConfig(draft), options = config.options; if (supported) { From a8ce698e295c36fe9f9da07fe25763cbd81a172a Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:36:32 +0200 Subject: [PATCH 013/317] refactor: clarify logging updates --- app/Logs.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Logs.jsx b/app/Logs.jsx index adfd7da..b5344cd 100644 --- a/app/Logs.jsx +++ b/app/Logs.jsx @@ -60,7 +60,7 @@ export function Logs({ api, onClose }) { if (!pausedRef.current && area.current) area.current.scrollTop = area.current.scrollHeight; }, [entries, tab]); - async function level(key, value) { + async function setLoggingLevel(key, value) { setBusy(true); try { await api.post('system/loglevels', { @@ -144,7 +144,7 @@ export function Logs({ api, onClose }) { disabled={busy} value={value} onChange={(event) => - level(key, event.currentTarget.value) + setLoggingLevel(key, event.currentTarget.value) } > {[ From b040d7813c2efe24dbb2dd8061c5691778e3fd3a Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:37:27 +0200 Subject: [PATCH 014/317] refactor: correct dismissible menu naming --- app/Device.jsx | 4 ++-- app/Folder.jsx | 4 ++-- app/{useDismissableMenu.mjs => useDismissibleMenu.mjs} | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename app/{useDismissableMenu.mjs => useDismissibleMenu.mjs} (91%) diff --git a/app/Device.jsx b/app/Device.jsx index c5633a6..0d382bb 100644 --- a/app/Device.jsx +++ b/app/Device.jsx @@ -29,7 +29,7 @@ import { Counts } from './Counts.jsx'; import { Tooltip } from './Tooltip.jsx'; import { Identicon } from './Identicon.jsx'; import { Icon } from './Icon.jsx'; -import { useDismissableMenu } from './useDismissableMenu.mjs'; +import { useDismissibleMenu } from './useDismissibleMenu.mjs'; export function Device({ device, @@ -45,7 +45,7 @@ export function Device({ const [foldersOpen, setFoldersOpen] = useState(false); const panel = useRef(); const foldersMenu = useRef(); - useDismissableMenu(foldersMenu, foldersOpen, setFoldersOpen); + useDismissibleMenu(foldersMenu, foldersOpen, setFoldersOpen); useEffect(() => { if (open) return stripeSections(panel.current); }, [open]); diff --git a/app/Folder.jsx b/app/Folder.jsx index 729575f..858b515 100644 --- a/app/Folder.jsx +++ b/app/Folder.jsx @@ -31,7 +31,7 @@ import { Field } from './Field.jsx'; import { Versioning } from './Versioning.jsx'; import { ItemsDialog } from './ItemsDialog.jsx'; import { Icon } from './Icon.jsx'; -import { useDismissableMenu } from './useDismissableMenu.mjs'; +import { useDismissibleMenu } from './useDismissibleMenu.mjs'; export function Folder({ folder, @@ -51,7 +51,7 @@ export function Folder({ const [sharingOpen, setSharingOpen] = useState(false); const panel = useRef(); const sharingMenu = useRef(); - useDismissableMenu(sharingMenu, sharingOpen, setSharingOpen); + useDismissibleMenu(sharingMenu, sharingOpen, setSharingOpen); useEffect(() => { if (open) return stripeSections(panel.current); }, [open]); diff --git a/app/useDismissableMenu.mjs b/app/useDismissibleMenu.mjs similarity index 91% rename from app/useDismissableMenu.mjs rename to app/useDismissibleMenu.mjs index 5e525e5..6a83370 100644 --- a/app/useDismissableMenu.mjs +++ b/app/useDismissibleMenu.mjs @@ -1,6 +1,6 @@ import { useEffect } from 'preact/hooks'; -export function useDismissableMenu(ref, open, setOpen) { +export function useDismissibleMenu(ref, open, setOpen) { useEffect(() => { if (!open) return; function pointerdown(event) { From ecd055d0e07872d011469dff9c61a9345c9b860e Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:39:59 +0200 Subject: [PATCH 015/317] refactor: clarify local device flag --- app/Device.jsx | 73 +++++++++++++++++++++++++++----------------------- app/main.jsx | 2 +- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/app/Device.jsx b/app/Device.jsx index 0d382bb..0af3b6d 100644 --- a/app/Device.jsx +++ b/app/Device.jsx @@ -35,13 +35,13 @@ export function Device({ device, state, session, - local = false, + isLocalDevice = false, metric, toggleUnits, onAction, }) { const { t } = useContext(LocaleContext); - const [open, setOpen] = useState(local); + const [open, setOpen] = useState(isLocalDevice); const [foldersOpen, setFoldersOpen] = useState(false); const panel = useRef(); const foldersMenu = useRef(); @@ -49,11 +49,11 @@ export function Device({ useEffect(() => { if (open) return stripeSections(panel.current); }, [open]); - const conn = local + const conn = isLocalDevice ? state.connectionsTotal : state.connections[device.deviceID] || {}; const completion = state.completion[device.deviceID] || {}; - const folders = local + const folders = isLocalDevice ? state.config.folders : sharedFolders(state.config, device.deviceID); const status = deviceStatus(device, state); @@ -88,7 +88,7 @@ export function Device({ aria-expanded={open} onClick={() => setOpen(!open)} > - {!local && status === 'syncing' && ( + {!isLocalDevice && status === 'syncing' && ( - {!local && ( + {!isLocalDevice && ( @@ -120,7 +120,7 @@ export function Device({ {deviceName(device)} - ({t(local ? 'This Device' : 'Remote')}) + ({t(isLocalDevice ? 'This Device' : 'Remote')}) @@ -128,7 +128,7 @@ export function Device({ {open && (
- {!local && ( + {!isLocalDevice && ( @@ -137,7 +137,7 @@ export function Device({
)} - {(local || + {(isLocalDevice || conn.connected || folders.length > 0 || completion._needItems > 0) && ( @@ -145,16 +145,21 @@ export function Device({ {t('Current activity')} - {!local && !conn.connected && folders.length > 0 && ( - - {completion._total === 100 - ? t('Up to Date') - : completion._total < 100 - ? t('Out of Sync') + ' (' + completion._total + '%)' - : ''} - - )} - {(local || conn.connected) && + {!isLocalDevice && + !conn.connected && + folders.length > 0 && ( + + {completion._total === 100 + ? t('Up to Date') + : completion._total < 100 + ? t('Out of Sync') + + ' (' + + completion._total + + '%)' + : ''} + + )} + {(isLocalDevice || conn.connected) && ['in', 'out'].map((direction) => ( {rate(conn[direction + 'bps'] || 0)} - {(local ? state.config.options : device)[ + {(isLocalDevice ? state.config.options : device)[ direction === 'in' ? 'maxRecvKbps' : 'maxSendKbps' ] > 0 && ( @@ -189,13 +194,15 @@ export function Device({ {t('Limit')}:{' '} {rate( - (local ? state.config.options : device)[ + (isLocalDevice + ? state.config.options + : device)[ direction === 'in' ? 'maxRecvKbps' : 'maxSendKbps' ] * 1024, )} - {local && + {isLocalDevice && state.config.options.limitBandwidthInLan && ` (${t('Applied to LAN')})`} @@ -204,7 +211,7 @@ export function Device({ ))} - {local && ( + {isLocalDevice && ( )} - {!local && completion._needItems > 0 && ( + {!isLocalDevice && completion._needItems > 0 && ( {t('Connectivity')}
- {local ? ( + {isLocalDevice ? ( <> {t('Device information')}
- {local ? ( + {isLocalDevice ? ( <> {duration(state.system.uptime, 'm')} @@ -441,19 +448,19 @@ export function Device({ {folders.map((folder) => (
  • { event.preventDefault(); setFoldersOpen(false); onAction({ type: 'edit-folder', folder, - ...(local ? {} : { tab: 'sharing' }), + ...(isLocalDevice ? {} : { tab: 'sharing' }), }); }} > {folder.label || folder.id} - {!local && ( + {!isLocalDevice && ( <> {' '} )} - {local ? ( + {isLocalDevice ? ( state.config.folders.length > 0 && (
  • - {aboutPaths.map(([label, keys]) => ( + {aboutPaths.map(({ label, keys }) => ( )} - - + + ))} From 6d11cf1b9a0a5bf5becb9c6da5c9aa929ced8471 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 01:35:47 +0200 Subject: [PATCH 042/317] refactor: separate component declarations --- app/About.jsx | 6 +++--- app/ConfirmAction.jsx | 8 ++++---- app/Conflicts.jsx | 18 +++++++++--------- app/IdentityControls.jsx | 8 ++++---- app/Logs.jsx | 16 ++++++++-------- app/RemoteFiles.jsx | 8 ++++---- app/RestoreVersions.jsx | 22 +++++++++++----------- app/ServiceDialog.jsx | 8 ++++---- app/Settings.jsx | 22 +++++++++++----------- app/SharingEntry.jsx | 4 ++-- app/UsageReport.jsx | 16 ++++++++-------- app/main.jsx | 8 ++++---- 12 files changed, 72 insertions(+), 72 deletions(-) diff --git a/app/About.jsx b/app/About.jsx index 83a3044..0148147 100644 --- a/app/About.jsx +++ b/app/About.jsx @@ -5,9 +5,9 @@ import data from '../client/about-data.json'; import { aboutPaths } from '../client/about.mjs'; export function About({ api, version, onClose }) { const { t } = useContext(LocaleContext); - const [tab, setTab] = useState('Authors'), - [paths, setPaths] = useState({}), - [error, setError] = useState(''); + const [tab, setTab] = useState('Authors'); + const [paths, setPaths] = useState({}); + const [error, setError] = useState(''); useEffect(() => { const controller = new AbortController(); if (window.metadata?.authenticated) diff --git a/app/ConfirmAction.jsx b/app/ConfirmAction.jsx index 303e467..bf3591f 100644 --- a/app/ConfirmAction.jsx +++ b/app/ConfirmAction.jsx @@ -10,8 +10,8 @@ export function ConfirmAction({ onDone, devices = [], }) { - const { t } = useContext(LocaleContext), - definition = managementActions[action.type]; + const { t } = useContext(LocaleContext); + const definition = managementActions[action.type]; const name = action.folder?.label || action.folder?.id || @@ -21,8 +21,8 @@ export function ConfirmAction({ (device) => device.deviceID === action.device?.introducedBy && device.introducer, ); - const [busy, setBusy] = useState(false), - [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); async function apply() { setBusy(true); setError(''); diff --git a/app/Conflicts.jsx b/app/Conflicts.jsx index 899a365..cfe1ba8 100644 --- a/app/Conflicts.jsx +++ b/app/Conflicts.jsx @@ -20,16 +20,16 @@ export function Conflicts({ device, }) { const { t } = useContext(LocaleContext); - const [groups, setGroups] = useState([]), - [search, setSearch] = useState(''); - const [selected, setSelected] = useState({}), - [loading, setLoading] = useState(false); + const [groups, setGroups] = useState([]); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState({}); + const [loading, setLoading] = useState(false); const [scanning, setScanning] = useState(null); - const [errors, setErrors] = useState([]), - [message, setMessage] = useState(''), - [rename, setRename] = useState(null); - const [access, setAccess] = useState({}), - [desktopError, setDesktopError] = useState(''); + const [errors, setErrors] = useState([]); + const [message, setMessage] = useState(''); + const [rename, setRename] = useState(null); + const [access, setAccess] = useState({}); + const [desktopError, setDesktopError] = useState(''); const controller = useRef(); const folderKey = folders.map((folder) => folder.id).join('|'); useEffect(() => { diff --git a/app/IdentityControls.jsx b/app/IdentityControls.jsx index bae3a29..287cd5d 100644 --- a/app/IdentityControls.jsx +++ b/app/IdentityControls.jsx @@ -5,10 +5,10 @@ import { identityMessage } from '../client/identity.mjs'; import { Icon } from './Icon.jsx'; export function IdentityControls({ device, api }) { const { t } = useContext(LocaleContext); - const [method, setMethod] = useState(''), - [validated, setValidated] = useState(null); - const [copied, setCopied] = useState(false), - [error, setError] = useState(''); + const [method, setMethod] = useState(''); + const [validated, setValidated] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(''); const message = validated ? identityMessage(validated, method, t) : null; async function use(action) { setError(''); diff --git a/app/Logs.jsx b/app/Logs.jsx index b5344cd..5548e81 100644 --- a/app/Logs.jsx +++ b/app/Logs.jsx @@ -2,15 +2,15 @@ import { useContext, useEffect, useRef, useState } from 'preact/hooks'; import { LocaleContext } from './locale-context.jsx'; import { Dialog } from './Dialog.jsx'; export function Logs({ api, onClose }) { - const { t } = useContext(LocaleContext), - area = useRef(), - pausedRef = useRef(false); - const [tab, setTab] = useState('Log'), - [entries, setEntries] = useState([]); + const { t } = useContext(LocaleContext); + const area = useRef(); + const pausedRef = useRef(false); + const [tab, setTab] = useState('Log'); + const [entries, setEntries] = useState([]); const [facilities, setFacilities] = useState({ levels: {}, packages: {} }); - const [error, setError] = useState(''), - [busy, setBusy] = useState(false), - [paused, setPaused] = useState(false); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [paused, setPaused] = useState(false); const content = entries .map( (entry) => diff --git a/app/RemoteFiles.jsx b/app/RemoteFiles.jsx index c1f2c82..b08135a 100644 --- a/app/RemoteFiles.jsx +++ b/app/RemoteFiles.jsx @@ -5,10 +5,10 @@ import { unitPrefixed, timestamp } from '../client/format.mjs'; import { Pagination } from './Pagination.jsx'; export function RemoteFiles({ api, folder, device, state, single }) { const { t } = useContext(LocaleContext); - const [page, setPage] = useState(1), - [perpage, setPerpage] = useState(10); - const [files, setFiles] = useState([]), - [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [perpage, setPerpage] = useState(10); + const [files, setFiles] = useState([]); + const [error, setError] = useState(''); const revision = state.completion[device.deviceID]?.[folder.id]?.needItems + ':' + diff --git a/app/RestoreVersions.jsx b/app/RestoreVersions.jsx index c7c087d..405f0c8 100644 --- a/app/RestoreVersions.jsx +++ b/app/RestoreVersions.jsx @@ -12,17 +12,17 @@ import { Icon } from './Icon.jsx'; export function RestoreVersions({ api, folder, onClose }) { const { t } = useContext(LocaleContext); - const [versions, setVersions] = useState(null), - [selections, setSelections] = useState({}); - const [errors, setErrors] = useState({}), - [error, setError] = useState(''); - const [busy, setBusy] = useState(false), - [confirm, setConfirm] = useState(false); - const [search, setSearch] = useState(''), - [start, setStart] = useState(''), - [end, setEnd] = useState(''); - const groups = versionGroups(versions, search, start, end), - chosen = selectedVersions(selections); + const [versions, setVersions] = useState(null); + const [selections, setSelections] = useState({}); + const [errors, setErrors] = useState({}); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [confirm, setConfirm] = useState(false); + const [search, setSearch] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const groups = versionGroups(versions, search, start, end); + const chosen = selectedVersions(selections); const count = Object.keys(chosen).length; useEffect(() => { const controller = new AbortController(); diff --git a/app/ServiceDialog.jsx b/app/ServiceDialog.jsx index 9caf422..5b3e5b2 100644 --- a/app/ServiceDialog.jsx +++ b/app/ServiceDialog.jsx @@ -2,10 +2,10 @@ import { useContext, useEffect, useState } from 'preact/hooks'; import { LocaleContext } from './locale-context.jsx'; import { Dialog } from './Dialog.jsx'; export function ServiceDialog({ kind, state, session, onClose }) { - const { t } = useContext(LocaleContext), - [started] = useState(state.system.startTime); - const [phase, setPhase] = useState('confirm'), - [error, setError] = useState(''); + const { t } = useContext(LocaleContext); + const [started] = useState(state.system.startTime); + const [phase, setPhase] = useState('confirm'); + const [error, setError] = useState(''); const major = state.upgradeInfo?.majorNewer; const title = error ? 'Error' diff --git a/app/Settings.jsx b/app/Settings.jsx index ec84642..6336352 100644 --- a/app/Settings.jsx +++ b/app/Settings.jsx @@ -17,18 +17,18 @@ import { } from '../client/settings.mjs'; import { timestamp } from '../client/format.mjs'; export function Settings({ state, api, session, onClose, advanced = false }) { - const { t } = useContext(LocaleContext), - form = useRef(); + const { t } = useContext(LocaleContext); + const form = useRef(); const [initial] = useState(() => cloneConfig(state.config)); - const [draft, setDraft] = useState(() => cloneConfig(initial)), - [mode, setMode] = useState(upgradeMode(initial)); - const [tab, setTab] = useState('General'), - [options, setOptions] = useState({ themes: [], upgrade: null }); - const [busy, setBusy] = useState(false), - [error, setError] = useState(''); - const [nested, setNested] = useState(null), - [report, setReport] = useState(null), - [discard, setDiscard] = useState(false); + const [draft, setDraft] = useState(() => cloneConfig(initial)); + const [mode, setMode] = useState(upgradeMode(initial)); + const [tab, setTab] = useState('General'); + const [options, setOptions] = useState({ themes: [], upgrade: null }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [nested, setNested] = useState(null); + const [report, setReport] = useState(null); + const [discard, setDiscard] = useState(false); const fields = settingsFields(tab, draft, state.system.myID, options.themes); const ignored = ignoredFolders(draft); useEffect(() => { diff --git a/app/SharingEntry.jsx b/app/SharingEntry.jsx index 63278e7..3e94646 100644 --- a/app/SharingEntry.jsx +++ b/app/SharingEntry.jsx @@ -13,8 +13,8 @@ export function SharingEntry({ onSelected, onPassword, }) { - const { t } = useContext(LocaleContext), - [plain, setPlain] = useState(false); + const { t } = useContext(LocaleContext); + const [plain, setPlain] = useState(false); return (

    @@ -113,7 +88,9 @@ export function ServiceDialog({ kind, state, session, onClose }) {

    - ) : kind === 'shutdown' ? ( + ); + if (kind === 'shutdown') + return (

    {t( phase === 'working' @@ -121,11 +98,25 @@ export function ServiceDialog({ kind, state, session, onClose }) { : 'Syncthing has been shut down.', )}

    - ) : ( -

    - {t('Syncthing is restarting.')} {t('Please wait')}... -

    - )} + ); + return ( +

    + {t('Syncthing is restarting.')} {t('Please wait')}... +

    + ); + } + return ( + { + if (phase === 'confirm' || error) onClose(); + }} + footer={renderFooter()} + > + {renderBody()} ); } diff --git a/client/service.mjs b/client/service.mjs new file mode 100644 index 0000000..1b1a00a --- /dev/null +++ b/client/service.mjs @@ -0,0 +1,16 @@ +export function servicePresentation(kind, phase, error, isMajorUpgrade) { + if (error) return { title: 'Error', status: 'danger', icon: 'hourglass' }; + if (phase === 'confirm') + return { + title: isMajorUpgrade ? 'Major Upgrade' : 'Upgrade', + status: isMajorUpgrade ? 'danger' : 'warning', + icon: 'hourglass', + }; + if (kind === 'shutdown') + return { + title: 'Shutdown Complete', + status: 'success', + icon: phase === 'waiting' ? 'power' : 'hourglass', + }; + return { title: 'Restarting', status: 'info', icon: 'hourglass' }; +} diff --git a/tests/service-dialog.test.mjs b/tests/service-dialog.test.mjs new file mode 100644 index 0000000..fb69ee2 --- /dev/null +++ b/tests/service-dialog.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { servicePresentation } from '../client/service.mjs'; + +test('service phases select their title, tone and icon', () => { + for (const [input, expected] of [ + [ + ['upgrade', 'confirm', '', false], + { title: 'Upgrade', status: 'warning', icon: 'hourglass' }, + ], + [ + ['upgrade', 'confirm', '', true], + { title: 'Major Upgrade', status: 'danger', icon: 'hourglass' }, + ], + [ + ['restart', 'working', '', false], + { title: 'Restarting', status: 'info', icon: 'hourglass' }, + ], + [ + ['shutdown', 'working', '', false], + { title: 'Shutdown Complete', status: 'success', icon: 'hourglass' }, + ], + [ + ['shutdown', 'waiting', '', false], + { title: 'Shutdown Complete', status: 'success', icon: 'power' }, + ], + [ + ['restart', 'waiting', 'service unavailable', false], + { title: 'Error', status: 'danger', icon: 'hourglass' }, + ], + ]) + assert.deepEqual(servicePresentation(...input), expected); +}); From ecec6e986e2aa70bd8bd4ef288dc153466e40a10 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 01:45:07 +0200 Subject: [PATCH 045/317] refactor: name theme polling policy --- integration/omarchy-theme-refresh.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/integration/omarchy-theme-refresh.js b/integration/omarchy-theme-refresh.js index 4c791b7..b2dfe84 100644 --- a/integration/omarchy-theme-refresh.js +++ b/integration/omarchy-theme-refresh.js @@ -44,6 +44,10 @@ current.parentNode.insertBefore(replacement, current.nextSibling); } + function ignoreThemeCheckFailure() { + // Theme refresh is best effort; the next poll retries transient failures. + } + function checkTheme() { if (document.hidden) return; @@ -59,7 +63,7 @@ var version = value.trim(); if (/^[A-Za-z0-9._-]+$/.test(version)) applyTheme(version); }) - .catch(function () {}); + .catch(ignoreThemeCheckFailure); } document.addEventListener('visibilitychange', checkTheme); From 9eb50725e42120bf635c964da3417ba22c413eef Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 01:49:34 +0200 Subject: [PATCH 046/317] refactor: name reported session actions --- app/Device.jsx | 6 +++--- app/Folder.jsx | 11 +++++------ app/Notifications.jsx | 5 +++-- app/main.jsx | 19 ++++++++++++------- client/session.mjs | 8 ++++++++ tests/session.test.mjs | 13 +++++++++++++ 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/app/Device.jsx b/app/Device.jsx index 6f87772..cadc28a 100644 --- a/app/Device.jsx +++ b/app/Device.jsx @@ -30,6 +30,7 @@ import { Tooltip } from './Tooltip.jsx'; import { Identicon } from './Identicon.jsx'; import { Icon } from './Icon.jsx'; import { useDismissibleMenu } from './useDismissibleMenu.mjs'; +import { runReportedSessionAction } from '../client/session.mjs'; export function Device({ device, @@ -72,7 +73,6 @@ export function Device({ (address) => ({ address, source: 'Discovered' }), ), ]; - const perform = (promise) => promise.catch(() => {}); const openAction = (type, extra = {}) => onAction({ type, device, ...extra }); const rate = (bytes) => unitPrefixed(usesMetricRates ? bytes * 8 : bytes, !usesMetricRates) + @@ -490,7 +490,7 @@ export function Device({ @@ -565,7 +570,7 @@ function App() {
    {t('Internally used paths:')}
    {t(label)} diff --git a/client/about.mjs b/client/about.mjs index 8af0962..41232fc 100644 --- a/client/about.mjs +++ b/client/about.mjs @@ -1,10 +1,13 @@ export const aboutPaths = [ - ['User Home', ['baseDir-userHome']], - ['Configuration Directory', ['baseDir-config']], - ['Configuration File', ['config']], - ['Device Certificate', ['certFile', 'keyFile']], - ['GUI / API HTTPS Certificate', ['httpsCertFile', 'httpsKeyFile']], - ['Database Location', ['database']], - ['Log File', ['logFile']], - ['GUI Override Directory', ['guiAssets']], + { label: 'User Home', keys: ['baseDir-userHome'] }, + { label: 'Configuration Directory', keys: ['baseDir-config'] }, + { label: 'Configuration File', keys: ['config'] }, + { label: 'Device Certificate', keys: ['certFile', 'keyFile'] }, + { + label: 'GUI / API HTTPS Certificate', + keys: ['httpsCertFile', 'httpsKeyFile'], + }, + { label: 'Database Location', keys: ['database'] }, + { label: 'Log File', keys: ['logFile'] }, + { label: 'GUI Override Directory', keys: ['guiAssets'] }, ]; From d5100d7c8c487f1a78d1254fe160b51c6310c597 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:47:55 +0200 Subject: [PATCH 020/317] refactor: name transfer segment fields --- app/TransferProgress.jsx | 2 +- client/transfer.mjs | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/TransferProgress.jsx b/app/TransferProgress.jsx index 8db1f8d..a5857ba 100644 --- a/app/TransferProgress.jsx +++ b/app/TransferProgress.jsx @@ -13,7 +13,7 @@ export function TransferProgress({ progress, legend = false }) { aria-valuemax={legend ? undefined : progress.bytesTotal} aria-valuenow={legend ? undefined : progress.bytesDone} > - {transferSegments.map(([key, label, color]) => ( + {transferSegments.map(({ key, label, color }) => (
    { const parts = Object.fromEntries( - transferSegments.map(([key]) => [ + transferSegments.map(({ key }) => [ key, (100 * value[key]) / value.total, ]), From 205d7bf8abc8fddb29757cbe73fe1b87d6e6a876 Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 1 Oct 2026 00:49:40 +0200 Subject: [PATCH 021/317] refactor: name version action fields --- app/RestoreVersions.jsx | 2 +- client/versions.mjs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/RestoreVersions.jsx b/app/RestoreVersions.jsx index 3947cfd..a764c3b 100644 --- a/app/RestoreVersions.jsx +++ b/app/RestoreVersions.jsx @@ -59,7 +59,7 @@ export function RestoreVersions({ api, folder, onClose }) { } } const massActions = (files) => - versionActions.map(([action, label]) => ( + versionActions.map(({ action, label }) => ( + )} + + + ); + } + function renderItemDetail(file) { + if ( + kind === 'need' && + file.type === 'progress' && + file.action === 'Sync' && + progress[file.name] + ) + return ; + if (kind === 'failed') return file.error; + if (kind === 'local') + return ['DIRECTORY', 'FILE_INFO_TYPE_DIRECTORY'].includes(file.type) + ? '' + : unitPrefixed(file.size, true) + 'B'; + return file.size > 0 ? unitPrefixed(file.size, true) + 'B' : ''; + } return ( {t(file.action)}
    - {kind === 'need' ? ( - <> - {file.type === 'queued' && ( - - )} - - - ) : ( - file.path || file.name - )} - - {kind === 'need' && - file.type === 'progress' && - file.action === 'Sync' && - progress[file.name] ? ( - - ) : kind === 'failed' ? ( - file.error - ) : kind === 'local' ? ( - ['DIRECTORY', 'FILE_INFO_TYPE_DIRECTORY'].includes( - file.type, - ) ? ( - '' - ) : ( - unitPrefixed(file.size, true) + 'B' - ) - ) : file.size > 0 ? ( - unitPrefixed(file.size, true) + 'B' - ) : ( - '' - )} - {renderItemPath(file)}{renderItemDetail(file)}