diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9de5680..6ed7efb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,9 @@ jobs: with: go-version-file: tests/fixture/go.mod cache: false + - uses: jdx/mise-action@9149ea85001c7435d5a66bb127d6a1b6227cb0a5 - run: npm ci - - run: npm test + - run: npm run check - name: Package committed source shell: bash run: | diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..4e27668 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +shellcheck = "0.11.0" +shfmt = "3.14.1" diff --git a/.prettierignore b/.prettierignore index 825d5f4..660e075 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ playwright-report/ TODO/external/webUI-original/ static/vendor/ integration/omarchy-theme.css.in +scripts/*.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..395d2b6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing + +## Setup + +Install the JavaScript dependencies and the pinned shell tools: + +```sh +npm ci +mise install +``` + +Run `npm run format` before committing and `npm run check` before handing work +off. The check covers Prettier, shfmt, ESLint, ShellCheck, and unit tests. +Application changes also require `npm run build`. Browser, real-daemon, and +installed-theme behavior is covered by: + +```sh +npm run test:integration +``` + +## Dependency direction + +Keep dependencies pointing toward stable policy and shared primitives: + +```text +composition + |-- features + |-- shared UI + |-- core + `-- client adapters + +features ------> shared UI, core, client adapters +shared UI -----> core and presentation-only client helpers +core ----------> core +client --------> client +``` + +The directories have these responsibilities: + +- `app/core/` owns configuration, locale, session, event, and state policy. It + must not import feature views, shared UI, or `client/` modules. Lifecycle + hooks may use Preact; domain functions should remain independent of it. +- `client/` owns the remaining Syncthing, desktop, formatting, identity, and DOM + adapters. It must not import from `app/`. +- `app/ui/` owns reusable interaction and accessibility behavior. It may use + core context and presentation-only client helpers, but it must not know about + a feature. +- `app/features//` keeps a workflow's views, domain rules, hooks, and + styles together. Pure `.mjs` rules must not import JSX or browser adapters. +- `app/App.jsx`, `app/DialogHost.jsx`, and nearby entry components compose the + slices. Cross-feature imports belong here unless two domains have a real, + named relationship. + +Keep HTTP, event streams, timers, DOM listeners, and desktop bridge calls in +adapters or lifecycle hooks. Pass their results into pure reducers, selectors, +normalizers, and validators. Do not introduce a global state framework, barrel +files, or wrappers that merely hide an import path. + +## Styles + +Feature and shared-component CSS lives beside its owner. `app/styles/` contains +only the global token, layout, shared-component, utility, and import contracts. +Use semantic tokens and logical properties. Preserve the declared cascade order +in `static/assets/css/layers.css` and keep light, dark, and Omarchy themes on +the same color-token contract. + +## Tests + +Name tests after a user-visible rule. Prefer role, label, and accessible-state +assertions over implementation classes or exact pixels. Use pure unit tests for +domain rules, Playwright for browser interaction, and the real-daemon suite only +for the Syncthing boundary. Add visual snapshots only for deliberate visual +contracts and keep the matrix small. diff --git a/README.md b/README.md index e5c8bcc..b2157b7 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,5 @@ Omarchy palette values to the included template. Its Go bridge provides optional local file actions. Without a plugin grant, ordinary Syncthing operations remain available and local file actions report unavailable access. -See [RELEASES.md](RELEASES.md) for packaging and automated plugin updates. This -is a third-party Syncthing interface. Original authors and dependency licenses -remain credited in About and in the release. +This is a third-party Syncthing interface. Original authors and dependency +licenses remain credited in About and in the release. diff --git a/RELEASES.md b/RELEASES.md deleted file mode 100644 index 1603ccd..0000000 --- a/RELEASES.md +++ /dev/null @@ -1,111 +0,0 @@ -# Releases and plugin handoff - -## Local packaging - -Update `package.json` and `package-lock.json`, then commit the source. From a -clean checkout: - -```sh -npm ci -npm test -npm run release -- 0.1.3 -bash scripts/test-release.sh "$PWD/release/syncshell-webui-v0.1.3.tar.gz" -``` - -The release command rebuilds from committed source. The archive records its -version, Git commit, tested Syncthing versions and integration format. It -contains `gui/syncshell-modern/`, `integration/`, `install.sh`, `manifest.json` -and asset checksums. Source, tests and build dependencies are excluded. The -archive has a separate SHA-256 file. Packaging is reproducible for the same -source and toolchain. - -The archive test installs and updates the release, preserves an unrelated theme, -rejects corrupted assets and runs the browser suite against installed files. -Only add a Syncthing version to the manifest after testing it. - -## GitHub configuration - -Expected Web repository: `syncshell/syncshell-webui`. Current plugin repository: -`omarchy-QOL/syncshell`. - -Create a private GitHub App owned by `omarchy-QOL` with repository Contents and -Pull requests write permissions. Disable webhooks and install it only on -`omarchy-QOL/syncshell`. Store its private key as `SYNCSHELL_APP_PRIVATE_KEY` -and its ID as repository variable `SYNCSHELL_APP_ID` in both repositories. The -private key does not expire; only replace it after a compromise or deliberate -rotation. - -Configure both repositories from the machine holding the downloaded PEM: - -```sh -app_id=REPLACE_WITH_APP_ID -app_key_file=/absolute/path/to/syncshell-release.pem -for repo in syncshell/syncshell-webui omarchy-QOL/syncshell; do - gh variable set SYNCSHELL_APP_ID --repo "$repo" --body "$app_id" - gh secret set SYNCSHELL_APP_PRIVATE_KEY --repo "$repo" \ - <"$app_key_file" -done -``` - -Keep the PEM secure until both credential checks pass. GitHub stores only the -public half of an App key, so a lost private key must be replaced rather than -downloaded again. - -The Web workflow requests a one-hour plugin installation token solely to send -the release notification. The plugin requests a separate one-hour token to push -the update branch and open the pull request. Each workflow narrows its token to -the permissions needed by that job. - -The plugin's `update-webui.yml` must exist on its default branch for -`repository_dispatch` delivery, even though updates target `dev`. Landing that -workflow is an activation prerequisite. Repository Actions policies must allow -the pinned actions and the GitHub App's operations. - -Before the first release, manually run the Web repository's `webui` workflow -with `verify_handoff` enabled. Then run the plugin repository's `update webui` -workflow with `verify_only` enabled. These checks validate both copies of the -App ID and private key without publishing a release or changing either -repository. - -On Web pull requests and branch pushes, CI builds, packages and tests. A -`vX.Y.Z` tag matching `package.json` publishes the tested archive through GitHub -Releases. Then it sends the exact version and checksum to the plugin. The plugin -imports it, runs installation, palette refresh and desktop bridge checks, and -opens `build-webui-X.Y.Z` against `dev`. The full frontend suite runs only here; -the plugin runs its own integration checks. - -The plugin importer is `go -C core run ./cmd/import-webui`, using its existing -Go module. It validates the archive and atomically exchanges the bundle -directory on Linux. No second importer implementation is supported. - -No workflow merges the update pull request or publishes a plugin release. The -pull request remains open against `dev` until a maintainer merges or closes it. -A failed handoff leaves the Web release available. Retry the notification job or -run the plugin update workflow manually with the published version and checksum. -Published tags and archives must remain unchanged; fixes use a new version. - -## Release procedure - -1. Merge the plugin receiver workflow into its default branch. -2. Configure and verify the GitHub App in both repositories. -3. Merge the tested Web changes into `main` and set the stable package version. -4. Run the local packaging and archive test from a clean checkout. -5. Push the matching annotated tag, for example `v0.1.3`. -6. Confirm the Web release succeeds and the plugin update pull request opens - against `dev`. -7. Review the plugin pull request checks and leave it open until approved. - -## Theme contract 1 - -The plugin retains `syncshell-modern` and `syncthing-omarchy` as theme names. It -copies the compiled Modern tree, then generates the Omarchy stylesheet from -`integration/omarchy-theme.css.in`. The required substitutions are `background`, -`foreground`, `accent`, `muted`, `selection`, `surface`, `surface_dark`, -`foreground_dark`, `foreground_light`, `red`, `yellow`, `green`, `cyan`, `blue`, -`magenta`, and `orange`. - -The existing plugin maps Omarchy's resolved palette to these values. The -included refresh helper watches the installed theme generation and swaps -stylesheets without reloading the application. Selector changes belong here. An -incompatible template or bridge change requires a new integration format and a -corresponding plugin importer update. diff --git a/UPSTREAM.md b/UPSTREAM.md deleted file mode 100644 index 99b4b5a..0000000 --- a/UPSTREAM.md +++ /dev/null @@ -1,16 +0,0 @@ -# Source and attribution - -Syncshell Web was extracted from `omarchy-QOL/syncshell` at commit -`0016be98bae080138fa5ffd4d17b7f0a9a72baeb`. Its relevant frontend, test, logo -and theme history is retained in this repository through path filtering. - -The Preact port is based on Syncthing v2.1.3. Tests describe the current -application contract; old framework implementations remain only in Git history. - -Original Syncthing authors and license notices remain credited in About. -`LICENSE.syncthing`, `licenses/` and vendor license files travel with compiled -releases. Bootstrap remains a complete customized upstream stylesheet. Lucide -icons are imported as Preact components and selected by the frontend build. - -See `README.md` for standalone installation and `RELEASES.md` for the artifact -and optional plugin integration contract. diff --git a/app/About.jsx b/app/About.jsx deleted file mode 100644 index 0f0de4f..0000000 --- a/app/About.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useContext, useEffect, useState } from 'preact/hooks'; -import { LocaleContext } from './locale-context.jsx'; -import { Dialog } from './Dialog.jsx'; -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(''); - useEffect(() => { - const controller = new AbortController(); - if (window.metadata?.authenticated) - api - .get('system/paths', undefined, controller.signal) - .then(setPaths) - .catch((value) => { - if (!controller.signal.aborted) setError(value.message); - }); - return () => controller.abort(); - }, [api]); - return ( - -

- - Syncshell - -

-

Modern / Omarchy Web UI, based on Syncthing.

-

- Syncthing {version.version || ''} {version.codename || ''} -

- {version.date && ( -

- Build {version.date.slice(0, 10)}{' '} - {Array.isArray(version.tags) ? version.tags.join(', ') : ''} -

- )} - {!version.version && ( -

{t('Log in to see version information.')}

- )} -

- {t('Syncthing is Free and Open Source Software licensed as MPL v2.0.')}{' '} - MPL 2.0 -

- - {error && ( - - )} - {tab === 'Authors' ? ( - <> -

{t('The Syncthing Authors')}

-

{data.authors}

- - ) : tab === 'Included Software' ? ( - <> -

- Preact · MIT license -

-

- {t( - 'Syncthing includes the following software or portions thereof:', - )} -

- - - ) : ( - - - - {aboutPaths.map(([label, keys]) => ( - - - - - ))} - -
{t('Internally used paths:')}
{t(label)} - {keys.map((key) => ( -
- {paths[key] || ''} -
- ))} -
- )} -
- ); -} diff --git a/app/ActionDialog.jsx b/app/ActionDialog.jsx deleted file mode 100644 index ae49686..0000000 --- a/app/ActionDialog.jsx +++ /dev/null @@ -1,256 +0,0 @@ -import { useContext } from 'preact/hooks'; -import { LocaleContext } from './locale-context.jsx'; -import { Dialog } from './Dialog.jsx'; -import { About } from './About.jsx'; -import { IdentityControls } from './IdentityControls.jsx'; -import { ServiceDialog } from './ServiceDialog.jsx'; -import { Logs } from './Logs.jsx'; -import { Editor } from './Editor.jsx'; -import { Settings } from './Settings.jsx'; -import { ConfirmAction } from './ConfirmAction.jsx'; -import { RemoteFiles } from './RemoteFiles.jsx'; -import { RestoreVersions } from './RestoreVersions.jsx'; -import { - deviceName, - sharedFolders, - serviceHealth, -} from '../client/devices.mjs'; -import { timestamp } from '../client/format.mjs'; -import { Tooltip } from './Tooltip.jsx'; -export function ActionDialog({ action, state, api, session, onClose }) { - const { t } = useContext(LocaleContext); - const friendly = (id) => - deviceName( - state.config.devices.find((device) => - device.deviceID.startsWith(id || '\0'), - ), - ) || - id || - t('Unknown'); - if (['restart', 'shutdown', 'upgrade'].includes(action.type)) - return ( - - ); - if (action.type === 'logs') return ; - if (action.type === 'settings' || action.type === 'advanced') - return ( - - ); - if (action.type.startsWith('edit-') || action.type.startsWith('add-')) - return ( - - ); - if (['override', 'revert'].includes(action.type)) - return ( - - ); - if (action.type === 'versions') - return ( - - ); - if (action.type === 'about') - return ; - if (action.type === 'identification') - return ( - -
-
- {action.device.deviceID} -
- {t('QR - -
-
- ); - if (action.type === 'listeners' || action.type === 'discovery') { - const health = serviceHealth( - action.type === 'listeners' - ? state.system.connectionServiceStatus - : state.system.discoveryStatus, - ); - return ( - - {action.type === 'listeners' ? ( -

- {t( - health.running - ? 'Syncthing is listening on the following network addresses for connection attempts from other devices:' - : 'Syncthing is not listening for connection attempts from other devices on any address. Only outgoing connections from this device may work.', - )} -

- ) : ( - <> -

- {t( - health.running - ? 'The following methods are used to discover other devices on the network and announce this device to be found by others:' - : 'This device cannot automatically discover other devices or announce its own address to be found by others. Only devices with statically configured addresses can connect.', - )} -

-

- {t( - 'Failure to connect to IPv6 servers is expected if there is no IPv6 connectivity.', - )} -

- - )} - {health.entries.map(([name, value]) => ( -
-
{name}
-
- {Object.entries(value || {}).map(([key, item]) => ( -
-
{key}
-
- {Array.isArray(item) - ? item.join(', ') - : typeof item === 'object' - ? JSON.stringify(item) - : item} -
-
- ))} -
-
- ))} -
- ); - } - if (action.type === 'remote-needed') { - const folders = sharedFolders(state.config, action.device.deviceID).filter( - (folder) => { - const completion = - state.completion[action.device.deviceID]?.[folder.id]; - return !completion || completion.needItems + completion.needDeletes > 0; - }, - ); - return ( - - {folders.map((folder) => ( - - ))} - - ); - } - if (action.type === 'changes') - return ( - -
- - - - - - - - - - - - {['Device', 'Action', 'Type', 'Folder', 'Path', 'Time'].map( - (label) => ( - - ), - )} - - - - {state.globalChanges.map((event) => { - const folder = - state.config.folders.find( - (item) => item.id === event.data.folder, - )?.label || event.data.folder; - return ( - - - - - - - - - ); - })} - -
{t(label)}
- {friendly(event.data.modifiedBy)} - {t(event.data.action)}{t(event.data.type)}{folder} - - {timestamp(event.time)}
-
-
- ); - return null; -} diff --git a/app/App.jsx b/app/App.jsx new file mode 100644 index 0000000..d70d9a5 --- /dev/null +++ b/app/App.jsx @@ -0,0 +1,375 @@ +import { desktopActions } from '../client/desktop.mjs'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { createSyncthingApi } from './core/http/syncthingApi.mjs'; +import { runReportedSessionAction } from './core/session/createSession.mjs'; +import { useSyncthingSession } from './core/session/useSyncthingSession.mjs'; +import { loadEnglishCatalog, translator } from './core/locale/translate.mjs'; +import { groupAndSortItems } from '../client/grouping.mjs'; +import { deviceName } from './features/devices/device-status.mjs'; +import { UsageReport } from './features/reports/UsageReport.jsx'; +import { needsUsageConsent } from './features/reports/reports.mjs'; +import { prepareFolderEditorAction } from './features/folders/folder-editor.mjs'; +import { prepareDeviceEditorAction } from './features/devices/device-editor.mjs'; +import { notices } from './features/notifications/notices.mjs'; +import { FolderCard } from './features/folders/FolderCard.jsx'; +import { DeviceCard } from './features/devices/DeviceCard.jsx'; +import { Login } from './features/authentication/Login.jsx'; +import { Notifications } from './features/notifications/Notifications.jsx'; +import { DialogHost } from './shell/DialogHost.jsx'; +import { Conflicts } from './features/conflicts/Conflicts.jsx'; +import { LocaleContext } from './core/locale/LocaleContext.jsx'; +import { Icon } from './ui/Icon.jsx'; +import { DashboardTabs } from './shell/DashboardTabs.jsx'; +import { MainNavigation } from './shell/MainNavigation.jsx'; +const desktop = desktopActions(); + +export function App() { + const [api] = useState(() => createSyncthingApi()); + const authenticated = Boolean(window.metadata?.authenticated); + const [state, session] = useSyncthingSession(api, { + active: authenticated, + onAuthExpired: () => location.reload(), + }); + const [locale, setLocale] = useState({ t: translator({}) }); + const [activeTab, setActiveTab] = useState('overview'); + const [action, setAction] = useState(null); + const [usesMetricRates, setUsesMetricRates] = useState(false); + const self = state.config.devices.find( + (device) => device.deviceID === state.system.myID, + ); + const others = state.config.devices.filter( + (device) => device.deviceID !== state.system.myID, + ); + 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; + function toggleUnits() { + setUsesMetricRates((value) => { + try { + localStorage.setItem('metricRates', String(!value)); + } catch { + // Storage can be unavailable in restricted browser contexts. + } + return !value; + }); + } + const openAction = useCallback( + async (next) => { + try { + if (next.type === 'add-device') { + next = await prepareDeviceEditorAction(api, next); + } + if (next.type === 'add-folder') { + next = await prepareFolderEditorAction(api, state, next); + } + if (next.type === 'changes') await session.refreshGlobalChanges(); + setAction(next); + } catch (error) { + session.reportError(error); + } + }, + [api, session, state], + ); + 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 }); + }, [openAction, state.ready, state.config.devices]); + useEffect(() => { + loadEnglishCatalog() + .then(setLocale) + .catch((error) => session.reportError(error)); + try { + setUsesMetricRates(localStorage.getItem('metricRates') === 'true'); + } catch { + // Storage can be unavailable in restricted browser contexts. + } + }, [session]); + useEffect(() => { + document.title = name + ' | Syncshell'; + }, [name]); + return ( + + +
+ {!authenticated ? ( + + ) : ( + <> + {state.error && + !['restart', 'shutdown', 'upgrade'].includes(action?.type) && ( + + )} + {!state.configInSync && ( +
+ {t('Restart Needed')}{' '} + +
+ )} + {!state.ready &&

{t('Loading data...')}

} +
+ +
+
+
+

+ {t('Folders')} + {state.config.folders.length > 1 + ? ' (' + state.config.folders.length + ')' + : ''} +

+ {folderGroups.map(([group, folders]) => ( +
+ {group && ( +

+ {group} + {folders.length > 1 + ? ' (' + folders.length + ')' + : ''} +

+ )} +
+ {folders.map((folder) => ( + session.rescan(folder.id)} + onAction={openAction} + /> + ))} +
+
+ ))} +
+ {state.config.folders.some( + (folder) => !folder.paused, + ) && ( + + )} + {state.config.folders.some((folder) => folder.paused) && ( + + )} + {state.config.folders.length > 0 && ( + + )} + +
+
+
+
+

{t('Devices')}

+ +
+ {self && ( + + )} +
+ {deviceGroups.map(([group, devices]) => ( +
+ {group && ( +

+ {group} + {devices.length > 1 + ? ' (' + devices.length + ')' + : ''} +

+ )} +
+ {devices.map((device) => ( + + ))} +
+
+ ))} +
+ {others.some((device) => !device.paused) && ( + + )} + {others.some((device) => device.paused) && ( + + )} + +
+
+
+
+
+ +
+
+ +
+
+
+ + )} +
+ {action && ( + setAction(null)} + /> + )} + {needsUsageConsent(state) && ( + {}} + /> + )} +
+ ); +} diff --git a/app/Conflicts.jsx b/app/Conflicts.jsx deleted file mode 100644 index f5a1d1e..0000000 --- a/app/Conflicts.jsx +++ /dev/null @@ -1,282 +0,0 @@ -import { ConflictRow } from './ConflictRow.jsx'; -import { useContext, useEffect, useRef, useState } from 'preact/hooks'; -import { LocaleContext } from './locale-context.jsx'; -import { - listConflicts, - recheckConflicts, - replaceDirectory, -} from '../client/conflicts.mjs'; -import { Dialog } from './Dialog.jsx'; -import '../client/conflicts.css'; -import { desktopHelp } from '../client/desktop.mjs'; -import { Icon } from './Icon.jsx'; - -export function Conflicts({ - api, - folders, - active, - ready, - hostActions = null, - device, -}) { - const { t } = useContext(LocaleContext); - const [groups, setGroups] = useState([]), - [search, setSearch] = useState(''); - const [selected, setSelected] = useState({}), - [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 controller = useRef(); - const folderKey = folders.map((folder) => folder.id).join('|'); - useEffect(() => { - if (!active || !ready) return; - const request = new AbortController(); - controller.current = request; - load(false, null, request); - return () => request.abort(); - }, [active, ready, folderKey]); - const visible = groups.filter((group) => - [ - group.folderName, - group.path, - ...group.copies.map((file) => file.path), - ].some((value) => - value.toLocaleLowerCase().includes(search.toLocaleLowerCase()), - ), - ); - async function load( - scan = false, - group = null, - request = controller.current, - ) { - setLoading(true); - setScanning(scan ? group?.id || 'all' : null); - setErrors([]); - setMessage(''); - try { - const result = scan - ? await recheckConflicts(api, folders, group, request.signal) - : await listConflicts(api, folders, request.signal); - if (request.signal.aborted) return; - setGroups((previous) => - group - ? replaceDirectory(previous, group, result.groups) - : result.groups, - ); - setErrors(result.errors); - if (hostActions?.status) - await refreshAccess(result.groups, request.signal); - if (scan && !result.errors.length) - setMessage('Syncthing scan finished; file list updated.'); - } catch (error) { - if (!request.signal.aborted) setErrors([error.message]); - } finally { - if (!request.signal.aborted) { - setLoading(false); - setScanning(null); - } - } - } - async function checkFileAccess(group, file) { - const key = JSON.stringify([group.id, file.path]); - try { - return [key, await hostActions.check(device, group, file)]; - } catch (error) { - return [key, { reason: error.message }]; - } - } - async function refreshAccess(groups, signal) { - try { - await hostActions.status(device); - setDesktopError(''); - const pending = groups.flatMap((group) => - [group.current, ...group.copies] - .filter(Boolean) - .map((file) => checkFileAccess(group, file)), - ); - const entries = await Promise.all(pending); - if (!signal.aborted) { - setAccess((previous) => ({ - ...previous, - ...Object.fromEntries(entries), - })); - } - } catch (error) { - setDesktopError(error.message); - setAccess({}); - } - } - async function open(group, file) { - try { - await hostActions.open(group, file, device); - } catch (error) { - setErrors([error.message]); - } - } - async function restore() { - setLoading(true); - setErrors([]); - try { - await hostActions.rename(rename.group, rename.file, device); - const group = rename.group; - setRename(null); - await load(true, group); - } catch (error) { - setErrors([error.message]); - } finally { - setLoading(false); - } - } - function permission(group, file) { - if (!hostActions) return { reason: desktopHelp }; - if (desktopError) return { reason: desktopError }; - if (!hostActions.check) return { open: true, rename: true }; - return ( - access[JSON.stringify([group.id, file.path])] || { - reason: 'Checking local file access...', - } - ); - } - return ( - <> -
-

{t('Review in your file manager')}

- {(!hostActions || desktopError) && ( -

{t(desktopError || desktopHelp)}

- )} -
- setSearch(event.currentTarget.value)} - /> - -
- {loading &&

{t('Loading data...')}

} - {message && ( -

- {t(message)} -

- )} - {errors.map((error) => ( - - ))} - - - - {['Location', 'Current file', 'Conflict files', 'Actions'].map( - (heading) => ( - - ), - )} - - - - {visible.map((group) => ( - - setSelected({ ...selected, [group.id]: path }) - } - onOpen={open} - onRename={(file) => setRename({ group, file })} - onRecheck={() => load(true, group)} - /> - ))} - -
- {t(heading)} -
- {!loading && !visible.length && !errors.length && ( -

- {t(groups.length ? 'No matches' : 'No conflict files remain')} -

- )} -
- {rename && ( - setRename(null)} - onCancel={() => { - if (!loading) setRename(null); - }} - footer={ - <> - - - - } - > -

- {t( - 'The selected file keeps its contents and takes the original name shown below. Its conflict filename disappears; other conflict files remain.', - )} -

-

- {t( - 'If a file already exists at the destination, nothing is renamed or replaced.', - )} -

- {t('From')}: -

- {rename.group.root}/{rename.file.path} -

- {t('To')}: -

- {rename.group.root}/{rename.group.path} -

- {errors.map((error) => ( - - ))} -
- )} - - ); -} diff --git a/app/Device.jsx b/app/Device.jsx deleted file mode 100644 index c5633a6..0000000 --- a/app/Device.jsx +++ /dev/null @@ -1,561 +0,0 @@ -import { ShareStatus } from './ShareStatus.jsx'; -import { useContext, useEffect, useRef, useState } from 'preact/hooks'; -import { LocaleContext } from './locale-context.jsx'; -import { - deviceName, - sharedFolders, - deviceStatus, - deviceLabels, - deviceIcons, - deviceColor, - connectionType, - connectionLabels, - connectionIcons, - lastSeenDays, - addressError, - remoteGui, - serviceHealth, -} from '../client/devices.mjs'; -import { localStateTotal } from '../client/folders.mjs'; -import { - unitPrefixed, - compactNumber, - duration, - timestamp, -} from '../client/format.mjs'; -import { stripeSections } from '../client/stripes.mjs'; -import { Field } from './Field.jsx'; -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'; - -export function Device({ - device, - state, - session, - local = false, - metric, - toggleUnits, - onAction, -}) { - const { t } = useContext(LocaleContext); - const [open, setOpen] = useState(local); - const [foldersOpen, setFoldersOpen] = useState(false); - const panel = useRef(); - const foldersMenu = useRef(); - useDismissableMenu(foldersMenu, foldersOpen, setFoldersOpen); - useEffect(() => { - if (open) return stripeSections(panel.current); - }, [open]); - const conn = local - ? state.connectionsTotal - : state.connections[device.deviceID] || {}; - const completion = state.completion[device.deviceID] || {}; - const folders = local - ? state.config.folders - : sharedFolders(state.config, device.deviceID); - const status = deviceStatus(device, state); - const type = connectionType(conn); - const age = lastSeenDays(state.deviceStats[device.deviceID]?.lastSeen); - const totals = localStateTotal(state.model); - const listeners = serviceHealth(state.system.connectionServiceStatus); - const discovery = serviceHealth(state.system.discoveryStatus); - const gui = remoteGui(device, conn); - const addresses = [ - ...(device.addresses || []).map((address) => ({ - address, - source: 'Configured', - })), - ...(state.discoveryCache[device.deviceID]?.addresses || []).map( - (address) => ({ address, source: 'Discovered' }), - ), - ]; - const perform = (promise) => promise.catch(() => {}); - const openAction = (type, extra = {}) => onAction({ type, device, ...extra }); - const rate = (bytes) => - unitPrefixed(metric ? bytes * 8 : bytes, !metric) + - (metric ? 'bps' : 'B/s'); - const link = (event, action) => { - event.preventDefault(); - openAction(action); - }; - return ( -
- - {open && ( -
-
- {!local && ( - - - - {t(deviceLabels[status])} - - -
- )} - {(local || - conn.connected || - folders.length > 0 || - completion._needItems > 0) && ( -
- {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) && - ['in', 'out'].map((direction) => ( - - { - event.preventDefault(); - toggleUnits(); - }} - > - {rate(conn[direction + 'bps'] || 0)} - {(local ? state.config.options : device)[ - direction === 'in' ? 'maxRecvKbps' : 'maxSendKbps' - ] > 0 && ( - -
- - {t('Limit')}:{' '} - {rate( - (local ? state.config.options : device)[ - direction === 'in' - ? 'maxRecvKbps' - : 'maxSendKbps' - ] * 1024, - )} - {local && - state.config.options.limitBandwidthInLan && - ` (${t('Applied to LAN')})`} - -
- )} -
-
- ))} - {local && ( - - - - )} - {!local && completion._needItems > 0 && ( - - link(event, 'remote-needed')} - > - - {compactNumber(completion._needItems)} {t('items')}, ~ - {unitPrefixed(completion._needBytes, true)}B - - - )} - -
-
- )} -
- {t('Connectivity')} - - - {local ? ( - <> - - link(event, 'listeners')} - > - {listeners.running}/{listeners.total} - - - {state.system.discoveryEnabled && ( - - link(event, 'discovery')} - > - {discovery.running}/{discovery.total} - - - )} - - ) : ( - <> - - {conn.connected - ? conn.address - : addresses.map((item, index) => ( - - - {item.address} - - {state.system.lastDialStatus?.[item.address] - ?.error && - !device.paused && ( - - {addressError( - state.system.lastDialStatus[ - item.address - ], - )} - - )} - - ))} - - {!conn.connected ? ( - - {!age ? ( - t('Never') - ) : ( - <> - {timestamp( - state.deviceStats[device.deviceID].lastSeen, - )} - {age >= 7 && ( - <> -
- = 365 - ? 'text-danger' - : age >= 30 - ? 'text-warning' - : '' - } - > - {t( - age >= 365 - ? 'More than a year ago' - : age >= 30 - ? 'More than a month ago' - : 'More than a week ago', - )} - - - )} - - )} -
- ) : ( - <> - - {t(connectionLabels[type] || 'Disconnected')} - - - 1 - {conn.secondary?.length - ? ' + ' + conn.secondary.length - : ''} - - - )} - - )} - -
-
-
- {t('Device information')} - - - {local ? ( - <> - - {duration(state.system.uptime, 'm')} - - - {state.version.version} ({state.version.os}{' '} - {state.version.arch}) - - - ) : ( - <> - {conn.clientVersion && ( - {conn.clientVersion} - )} - {device.introducedBy && ( - - {deviceName( - state.config.devices.find( - (item) => item.deviceID === device.introducedBy, - ), - ) || device.introducedBy.slice(0, 7)} - - )} - - {t( - { - always: 'All Data', - metadata: 'Metadata Only', - never: 'Off', - }[device.compression] || '', - )} - - {device.allowedNetworks?.length > 0 && ( - - {device.allowedNetworks.join(', ')} - - )} - {[ - ['introducer', 'Introducer'], - ['autoAcceptFolders', 'Auto Accept'], - ['untrusted', 'Untrusted'], - ].map( - ([property, label]) => - device[property] && ( - - {t('Yes')} - - ), - )} - - )} - -
-
-
- -
- )} -
- ); -} diff --git a/app/Editor.jsx b/app/Editor.jsx deleted file mode 100644 index 5a4883b..0000000 --- a/app/Editor.jsx +++ /dev/null @@ -1,807 +0,0 @@ -import { useContext, useEffect, useRef, useState } from 'preact/hooks'; -import { LocaleContext } from './locale-context.jsx'; -import { Dialog } from './Dialog.jsx'; -import { ConfirmAction } from './ConfirmAction.jsx'; -import { - copy, - getValue, - editorFields, - inputValue, - changedValue, - saveEditor, - ignoreLines, -} from '../client/edit.mjs'; -import { - folderPath, - updateEditor, - editorFieldState, - newXattrEntry, - xattrDefault, - xattrHint, - overlappingPath, -} from '../client/editor-behavior.mjs'; -import { fieldHelp } from '../client/field-help.mjs'; -import { Tooltip } from './Tooltip.jsx'; -import { IdentityControls } from './IdentityControls.jsx'; -import { SharingEntry } from './SharingEntry.jsx'; -import { deviceName } from '../client/devices.mjs'; -import { Icon } from './Icon.jsx'; - -export function Editor({ action, state, api, session, onClose, onSaved }) { - const { t } = useContext(LocaleContext); - 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 [tab, setTab] = useState( - action.tab === 'sharing' - ? 'Sharing' - : action.tab === 'ignores' - ? 'Ignore Patterns' - : 'General', - ); - const [removing, setRemoving] = useState(false); - const [error, setError] = useState(''); - const [busy, setBusy] = useState(false); - const [addIgnores, setAddIgnores] = useState(false); - const autoPath = useRef(true); - const [directories, setDirectories] = useState([]); - const [stage, setStage] = useState('edit'); - const [ignores, setIgnores] = useState(''); - const originalIgnores = useRef([]); - const [loadedIgnores, setLoadedIgnores] = useState(false); - const saved = useRef(false); - const form = useRef(); - const [passwords, setPasswords] = useState(() => - Object.fromEntries( - (draft.devices || []).map((member) => [ - member.deviceID, - member.encryptionPassword || '', - ]), - ), - ); - const [shares, setShares] = useState(() => - Object.fromEntries( - state.config.folders.map((folder) => { - const member = folder.devices.find( - (device) => device.deviceID === draft.deviceID, - ); - return [ - folder.id, - { selected: !!member, password: member?.encryptionPassword || '' }, - ]; - }), - ), - ); - const tabs = ( - kind === 'folder' - ? ['General', 'Sharing', 'File Versioning', 'Ignore Patterns', 'Advanced'] - : ['General', 'Sharing', 'Advanced'] - ).filter((name) => !defaults || name !== 'Sharing'); - const fields = editorFields(kind, tab, state.config, state.system.myID) - .map((field) => - editorFieldState(field, draft, { - kind, - isNew, - defaults, - myID: state.system.myID, - }), - ) - .filter( - (field) => - !field.hidden && - (!defaults || !['id', 'deviceID'].includes(field.path)), - ); - const title = defaults - ? 'Edit ' + (kind === 'folder' ? 'Folder' : 'Device') + ' Defaults' - : (isNew ? 'Add ' : 'Edit ') + (kind === 'folder' ? 'Folder' : 'Device'); - const overlap = - kind === 'folder' - ? overlappingPath(draft, state.config, state.system) - : null; - useEffect(() => { - if (kind !== 'folder' || (!isNew && !defaults) || !draft.path) return; - const controller = new AbortController(); - api - .get('system/browse', { current: draft.path }, controller.signal) - .then(setDirectories) - .catch((value) => { - if (!controller.signal.aborted) setError(value.message); - }); - return () => controller.abort(); - }, [api, kind, isNew, defaults, draft.path]); - useEffect(() => { - if (kind === 'folder' && isNew && state.config.defaults.folder.path) - setDraft((previous) => ({ - ...previous, - path: folderPath( - state.config.defaults.folder.path, - previous.label || previous.id, - state.system.pathSeparator, - ), - })); - if (defaults && kind === 'folder') { - originalIgnores.current = state.config.defaults.ignores.lines; - setIgnores(originalIgnores.current.join('\n')); - setLoadedIgnores(true); - return; - } - if (kind === 'folder' && !isNew && draft.type !== 'receiveencrypted') { - api - .get('db/ignores', { folder: draft.id }) - .then((data) => { - originalIgnores.current = data.ignore || []; - setIgnores(originalIgnores.current.join('\n')); - setLoadedIgnores(true); - if (data.error) setError(data.error); - }) - .catch((failure) => setError(failure.message)); - } - }, []); - function tabDisabled(name) { - return ( - (stage === 'ignores' && name !== 'Ignore Patterns') || - (kind === 'folder' && - draft.type === 'receiveencrypted' && - name === 'Ignore Patterns') - ); - } - function update(path, value) { - if (path === 'path') autoPath.current = false; - setDraft((previous) => - updateEditor(previous, path, value, { - kind, - isNew, - defaults, - autoPath: autoPath.current, - config: state.config, - system: state.system, - }), - ); - } - async function loadAddedIgnores() { - setLoadedIgnores(false); - setBusy(true); - setError(''); - try { - const data = await api.get('db/ignores', { folder: draft.id }); - originalIgnores.current = - data.ignore?.length || data.error - ? data.ignore || [] - : state.config.defaults?.ignores?.lines || []; - setIgnores(originalIgnores.current.join('\n')); - setLoadedIgnores(true); - if (data.error) setError(data.error); - } catch (failure) { - setError(failure.message); - } finally { - setBusy(false); - } - } - function sharePassword(id, value) { - setPasswords((previous) => ({ ...previous, [id]: value })); - setDraft((previous) => ({ - ...previous, - devices: previous.devices.map((member) => - member.deviceID === id - ? { ...member, encryptionPassword: value } - : member, - ), - })); - } - function shareDevice(id, selected) { - setDraft((draft) => ({ - ...draft, - devices: selected - ? [ - ...draft.devices, - { deviceID: id, encryptionPassword: passwords[id] || '' }, - ] - : draft.devices.filter((device) => device.deviceID !== id), - })); - } - function shareFolder(id, property, value) { - setShares((shares) => ({ - ...shares, - [id]: { ...shares[id], [property]: value }, - })); - } - async function save() { - if (!form.current.reportValidity()) return; - setBusy(true); - setError(''); - try { - if (defaults) { - await saveEditor({ - session, - api, - state, - kind, - draft, - isNew, - shares, - defaults, - ignores: ignoreLines(ignores), - }); - } else if (stage === 'ignores') { - if (!loadedIgnores) return; - await api.post( - 'db/ignores', - { ignore: ignoreLines(ignores) }, - { folder: draft.id }, - ); - await session.setPaused('folders', draft.id, !!draft.paused); - } else if ( - kind === 'folder' && - isNew && - addIgnores && - draft.type !== 'receiveencrypted' - ) { - await saveEditor({ - session, - api, - state, - kind, - draft: { ...copy(draft), paused: true }, - isNew, - shares, - }); - setStage('ignores'); - setTab('Ignore Patterns'); - await loadAddedIgnores(); - return; - } else { - if ( - kind === 'folder' && - loadedIgnores && - ignores !== originalIgnores.current.join('\n') - ) - await api.post( - 'db/ignores', - { ignore: ignoreLines(ignores) }, - { folder: draft.id }, - ); - await saveEditor({ session, api, state, kind, draft, isNew, shares }); - } - saved.current = true; - onSaved?.(copy(draft), ignoreLines(ignores)); - onClose(); - } catch (failure) { - setError(failure.message); - } finally { - setBusy(false); - } - } - async function cancel() { - if (busy) return; - if (!saved.current && stage === 'ignores' && loadedIgnores) { - saved.current = true; - try { - await api.post( - 'db/ignores', - { ignore: originalIgnores.current }, - { folder: draft.id }, - ); - await session.setPaused('folders', draft.id, !!draft.paused); - } catch (failure) { - session.reportError(failure); - } - } - onClose(); - } - const footer = ( - <> - {!defaults && - !isNew && - stage === 'edit' && - draft.deviceID !== state.system.myID && ( - - )} - - - - ); - return ( - <> - -
{ - event.preventDefault(); - save(); - }} - > - - {error && ( - - )} - - {directories.map((directory) => ( - - - {[ - ...new Set( - state.config[kind === 'folder' ? 'folders' : 'devices'] - .map((item) => item.group) - .filter(Boolean), - ), - ].map((group) => ( - -
- {tab === 'Sharing' ? ( - <> -
- {[true, false].map((select) => ( - - ))} -
-

- {t( - kind === 'folder' - ? 'Select additional devices to share this folder with.' - : 'Select the folders to share with this device.', - )} -

- {kind === 'folder' - ? state.config.devices - .filter((device) => device.deviceID !== state.system.myID) - .map((device) => { - const member = draft.devices.find( - (item) => item.deviceID === device.deviceID, - ); - return ( - - shareDevice(device.deviceID, value) - } - onPassword={(value) => - sharePassword(device.deviceID, value) - } - /> - ); - }) - : state.config.folders.map((folder) => ( - - shareFolder(folder.id, 'selected', value) - } - onPassword={(value) => - shareFolder(folder.id, 'password', value) - } - /> - ))} - - ) : tab === 'Ignore Patterns' ? ( - <> -

- {t('Enter ignore patterns, one per line.')}{' '} - - {t('full documentation')} - -

- {stage === 'ignores' && ( - <> -

- {t('Set Ignores on Added Folder')} ·{' '} - {draft.label || draft.id} -

- {!loadedIgnores && ( - - )} - - )} - {isNew && stage !== 'ignores' ? ( - <> - -

- {t( - 'Patterns are applied before the folder starts synchronizing.', - )} -

- - ) : ( -