From d93afc2bd5a9bca3a79703f4c0a996ae67d4409c Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:22:48 +0400 Subject: [PATCH] feat: audit UI and trash --- AGENTS.md | 5 +- _typos.toml | 3 + e2e/coverage-map.mjs | 19 +- e2e/tests/entities.test.mjs | 184 ++ e2e/tests/integrations.test.mjs | 35 + e2e/tests/motion.test.mjs | 19 +- e2e/tests/network.test.mjs | 10 +- e2e/tests/smoke.test.mjs | 32 +- e2e/tests/sync.test.mjs | 113 + e2e/tests/ui.test.mjs | 248 +- sdk/node/src/client.mts | 17 +- sdk/node/src/index.mts | 2 +- sdk/node/test/requests.test.mts | 4 +- sdk/python/src/donutbrowser/client.py | 13 +- sdk/python/tests/test_requests.py | 4 +- src/app/page.tsx | 1261 ++++++--- src/components/about-dialog.tsx | 6 +- src/components/account-page.tsx | 12 +- src/components/agent-page.tsx | 21 +- src/components/agent-recipes.tsx | 355 ++- src/components/bandwidth-mini-chart.tsx | 18 +- src/components/code-examples-dialog.tsx | 197 +- src/components/command-palette.tsx | 52 +- src/components/cookie-bot-enrol-dialog.tsx | 4 +- src/components/cookie-bot-overview.tsx | 18 +- src/components/cookie-bot-schedule.tsx | 33 +- src/components/cookie-bot-shared.tsx | 8 +- src/components/cookie-management-dialog.tsx | 31 +- src/components/create-profile-dialog.tsx | 248 +- src/components/custom-toast.tsx | 63 +- src/components/data-table-action-bar.tsx | 177 +- src/components/device-code-verify-dialog.tsx | 5 +- src/components/donut-snack.tsx | 8 +- src/components/entity-empty-state.tsx | 10 +- src/components/entity-page.tsx | 57 +- .../extension-management-dialog.tsx | 162 +- src/components/feedback-dialog.tsx | 8 +- src/components/group-management-dialog.tsx | 168 +- src/components/home-header.tsx | 186 +- src/components/import-profile-dialog.tsx | 173 +- src/components/integrations-dialog.tsx | 90 +- src/components/leave-prompt-dialog.tsx | 112 + src/components/log-viewer.tsx | 323 +++ src/components/managed-data-table.tsx | 96 +- src/components/move-profiles-dialog.tsx | 150 + src/components/network-requests-panel.tsx | 508 ++++ src/components/onboarding/scenes.tsx | 3 +- src/components/page-transition.tsx | 179 ++ src/components/paid-welcome-dialog.tsx | 5 +- src/components/permission-dialog.tsx | 6 +- src/components/pre-launch-gate-dialog.tsx | 26 +- src/components/profile-data-table.tsx | 2454 ++++++++++------- src/components/profile-handoff-status.tsx | 9 +- src/components/profile-info-dialog.tsx | 1435 ++++++---- .../profile-info/debug-endpoint.tsx | 243 ++ .../profile-info/draft-save-bar.tsx | 205 ++ .../profile-info/header-actions.tsx | 345 +++ src/components/profile-search-input.tsx | 136 +- .../profile-table/column-fit.test.mjs | 51 + src/components/profile-table/column-fit.ts | 63 + src/components/profile-table/empty-states.tsx | 210 ++ .../profile-table/launch-control.tsx | 283 ++ .../profile-table/row-context-menu.tsx | 181 ++ src/components/profile-table/row-presence.tsx | 83 + src/components/profile-table/traffic-cell.tsx | 221 ++ .../profile-table/traffic-snapshots.test.mjs | 93 + .../profile-table/traffic-snapshots.ts | 108 + src/components/profile-transfer.tsx | 17 +- src/components/proxy-management-dialog.tsx | 221 +- src/components/rail-nav.tsx | 487 ++-- src/components/settings-dialog.tsx | 295 +- .../settings/encryption-settings.tsx | 26 +- .../settings/settings-primitives.tsx | 105 +- src/components/settings/settings-save-bar.tsx | 7 +- src/components/settings/sound-settings.tsx | 57 +- src/components/settings/theme-settings.tsx | 8 +- src/components/shortcuts-page.tsx | 21 +- src/components/sync-all-dialog.tsx | 15 +- src/components/sync-config-dialog.tsx | 63 +- src/components/sync-health-indicator.tsx | 92 + src/components/synchronizer-panel.tsx | 438 +-- src/components/table-controls.tsx | 541 +++- src/components/thank-you-dialog.tsx | 16 +- src/components/tips-dialog.tsx | 25 +- src/components/traffic-details-dialog.tsx | 24 +- src/components/trash-page.tsx | 983 +++++-- src/components/ui/animated-disclosure.tsx | 9 +- src/components/ui/animated-switch.tsx | 66 +- src/components/ui/animated-tabs.tsx | 110 +- src/components/ui/auto-height.tsx | 71 +- src/components/ui/badge.tsx | 5 +- src/components/ui/button.tsx | 11 +- src/components/ui/checkbox.tsx | 4 +- src/components/ui/coach-mark.tsx | 116 +- src/components/ui/color-picker.tsx | 15 +- src/components/ui/command.tsx | 25 +- src/components/ui/confirmation-mark.tsx | 9 +- src/components/ui/context-menu.tsx | 217 ++ src/components/ui/dialog.tsx | 160 +- src/components/ui/dropdown-menu.tsx | 11 +- src/components/ui/focus-ring.ts | 10 + src/components/ui/highlight.tsx | 644 ----- src/components/ui/input.tsx | 6 +- src/components/ui/kbd.tsx | 2 +- src/components/ui/operation-flow.tsx | 26 +- src/components/ui/popover.tsx | 6 +- src/components/ui/popup-motion.ts | 63 + src/components/ui/pro-badge.tsx | 2 +- src/components/ui/radio-group.tsx | 4 +- src/components/ui/ripple.tsx | 24 +- src/components/ui/scroll-area.tsx | 3 +- src/components/ui/scroll-strip.tsx | 9 +- src/components/ui/select.tsx | 11 +- src/components/ui/step-dots.tsx | 22 +- src/components/ui/step-transition.tsx | 15 +- src/components/ui/tabs.tsx | 136 +- src/components/ui/textarea.tsx | 8 +- src/components/ui/tooltip.tsx | 6 +- src/components/wayfern-config-form.tsx | 39 +- src/components/welcome-dialog.tsx | 27 +- src/hooks/use-agent-console.ts | 33 +- src/hooks/use-auto-height.tsx | 94 - src/hooks/use-browser-state.ts | 63 +- src/hooks/use-commercial-trial.ts | 35 +- src/hooks/use-group-events.ts | 100 +- src/hooks/use-language.ts | 18 + src/hooks/use-launch-notices.ts | 46 + src/hooks/use-motion-allowed.ts | 14 + src/hooks/use-permissions.ts | 165 +- src/hooks/use-profile-events.ts | 152 +- src/hooks/use-profile-table-filters.ts | 3 +- src/hooks/use-proxy-events.ts | 152 +- src/hooks/use-proxy-health.ts | 47 + src/hooks/use-sync-health.ts | 102 + src/hooks/use-table-preferences.ts | 295 +- src/hooks/use-table-sorting.ts | 79 - src/hooks/use-table-tools.ts | 132 +- src/hooks/use-trash-events.ts | 170 +- src/hooks/use-unsaved-changes.ts | 67 + src/hooks/use-version-updater.ts | 29 +- src/hooks/use-vpn-events.ts | 123 +- src/i18n/locales/de.json | 419 ++- src/i18n/locales/en.json | 569 +++- src/i18n/locales/es.json | 480 +++- src/i18n/locales/fr.json | 444 ++- src/i18n/locales/ja.json | 415 ++- src/i18n/locales/ko.json | 415 ++- src/i18n/locales/pt.json | 476 +++- src/i18n/locales/ru.json | 437 ++- src/i18n/locales/tr.json | 505 +++- src/i18n/locales/vi.json | 427 ++- src/i18n/locales/zh.json | 415 ++- src/lib/backend-errors.ts | 113 +- src/lib/motion.test.mjs | 58 + src/lib/motion.ts | 62 + src/lib/profile-list-store.test.mjs | 185 ++ src/lib/profile-list-store.ts | 214 ++ src/lib/remote-sessions.ts | 21 + src/lib/shortcuts.test.mjs | 105 + src/lib/shortcuts.ts | 53 +- src/lib/system-notify.ts | 72 + src/lib/table-tools.ts | 3 +- src/lib/table-views.test.mjs | 139 + src/lib/table-views.ts | 192 ++ src/lib/toast-utils.ts | 41 + src/lib/ui-sounds.ts | 46 +- src/styles/globals.css | 66 +- src/types.ts | 52 +- 168 files changed, 19440 insertions(+), 5843 deletions(-) create mode 100644 src/components/leave-prompt-dialog.tsx create mode 100644 src/components/log-viewer.tsx create mode 100644 src/components/move-profiles-dialog.tsx create mode 100644 src/components/network-requests-panel.tsx create mode 100644 src/components/page-transition.tsx create mode 100644 src/components/profile-info/debug-endpoint.tsx create mode 100644 src/components/profile-info/draft-save-bar.tsx create mode 100644 src/components/profile-info/header-actions.tsx create mode 100644 src/components/profile-table/column-fit.test.mjs create mode 100644 src/components/profile-table/column-fit.ts create mode 100644 src/components/profile-table/empty-states.tsx create mode 100644 src/components/profile-table/launch-control.tsx create mode 100644 src/components/profile-table/row-context-menu.tsx create mode 100644 src/components/profile-table/row-presence.tsx create mode 100644 src/components/profile-table/traffic-cell.tsx create mode 100644 src/components/profile-table/traffic-snapshots.test.mjs create mode 100644 src/components/profile-table/traffic-snapshots.ts create mode 100644 src/components/sync-health-indicator.tsx create mode 100644 src/components/ui/context-menu.tsx create mode 100644 src/components/ui/focus-ring.ts delete mode 100644 src/components/ui/highlight.tsx create mode 100644 src/components/ui/popup-motion.ts delete mode 100644 src/hooks/use-auto-height.tsx create mode 100644 src/hooks/use-launch-notices.ts create mode 100644 src/hooks/use-motion-allowed.ts create mode 100644 src/hooks/use-proxy-health.ts create mode 100644 src/hooks/use-sync-health.ts delete mode 100644 src/hooks/use-table-sorting.ts create mode 100644 src/hooks/use-unsaved-changes.ts create mode 100644 src/lib/motion.test.mjs create mode 100644 src/lib/profile-list-store.test.mjs create mode 100644 src/lib/profile-list-store.ts create mode 100644 src/lib/shortcuts.test.mjs create mode 100644 src/lib/system-notify.ts create mode 100644 src/lib/table-views.test.mjs create mode 100644 src/lib/table-views.ts diff --git a/AGENTS.md b/AGENTS.md index 578d6b0d..d9a2493c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,13 +39,14 @@ donutbrowser/ │ │ ├── entity-page.tsx # Proxy/VPN/group/extension page parts: page and row actions, kind badges, sync and check cells │ │ ├── entity-empty-state.tsx # Empty states of those pages, with their scenes │ │ ├── profile-search-input.tsx # Profile search with ghost-text completion and suggestions +│ │ ├── profile-table/ # Profile table parts: launch control, traffic cell and snapshots, row menu, column fit │ │ ├── settings/ # Settings page parts: rows, choice cards, theme previews, sounds, save bar │ │ ├── onboarding/ # Welcome step scenes │ │ └── tips/ # Feature tips: scenes, icons and the day's coach mark │ ├── hooks/ # Event-driven React hooks, table preferences, filtering, and keyboard controls │ ├── i18n/locales/ # Translations (de, en, es, fr, ja, ko, pt, ru, tr, vi, zh) │ ├── generated/ # Build-generated third-party license inventory -│ ├── lib/ # Utilities (themes, toast, browser-utils, ui-sounds, schedules, team-activity, coach-mark placement) +│ ├── lib/ # Utilities (themes, toast, browser-utils, ui-sounds, schedules, team-activity, coach-mark placement, profile-list-store) │ └── types.ts # Shared TypeScript interfaces ├── src-tauri/ # Rust backend (Tauri) │ ├── src/ @@ -190,7 +191,7 @@ evidence to the owning suite. `e2e:smoke` fails if command registration and the Three log surfaces, in order of usefulness: - Donut Browser GUI: `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_.log` are rotated). The GUI, Tauri, `browser_runner`, `proxy_manager`, and `sync` all log here, and uncaught frontend errors arrive with the `webview` target. Search for `profile=` to follow a launch: `Proxy worker started`, then `Browser launched` (or `Browser launch failed` / `Browser launch blocked`), then `Browser stopped` or `Browser exited`. Dev builds write to `DonutBrowserDev.log` instead. -- donut-proxy worker: `$TMPDIR/donut-proxy-.log`. One file per worker process (each profile launch spawns a fresh one); same line shape as the app log, with UTC timestamps. Its first line is `Worker started kind=proxy|vpn id= pid= listen= upstream= ...` (never credentials) and, when it stops on its own, its last is `Worker exiting ... reason=`; a worker the app kills writes no last line, so read the app's `Proxy worker stopped id=` instead. Map a worker to its launch via `Proxy worker started id= ... profile=` and `Proxy worker stopped id= ... reason=...` (`browser_exited()`, `orphan_at_startup`, ...) in DonutBrowser.log. Upstream rejects (status lines like `HTTP/1.1 402 user reached limit`) and tunnel errors are at WARN as `CONNECT tunnel failed err="..." suppressed_30s=N`, at most once per 30s per message, with the destination host replaced by ``. Destinations (accepted CONNECT requests, per-request detail) are at DEBUG or TRACE on purpose, so the log is not a list of visited sites; set `RUST_LOG=donutbrowser_lib::proxy_server=debug` (or `=trace`) in the app's environment to see them; it overrides the INFO default. +- donut-proxy worker: `$TMPDIR/donut-proxy-.log`. One file per worker process (each profile launch spawns a fresh one); same line shape as the app log, with UTC timestamps. Its first line is `Worker started kind=proxy|vpn id= pid= listen= upstream= ...` (never credentials) and, when it stops on its own, its last is `Worker exiting ... reason=`; a worker the app kills writes no last line, so read the app's `Proxy worker stopped id=` instead. Map a worker to its launch via `Proxy worker started id= ... profile=` and `Proxy worker stopped id= ... reason=...` (`browser_exited()`, `orphan_at_startup`, ...) in DonutBrowser.log. Upstream rejects (status lines like `HTTP/1.1 402 user reached limit`) and tunnel errors are at WARN as `CONNECT tunnel failed err="..." suppressed_30s=N`, at most once per 30s per message, with the destination host replaced by ``. Destinations (accepted CONNECT requests, per-request detail) are at DEBUG or TRACE on purpose, so the log is not a list of visited sites; set `RUST_LOG=donutbrowser_lib::proxy_server=debug` (or `=trace`) in the app's environment to see them; it overrides the INFO default. The worker writes its traffic stats file at most once a minute, and again when it gets SIGTERM. Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir. diff --git a/_typos.toml b/_typos.toml index fc71a39a..1d2a5908 100644 --- a/_typos.toml +++ b/_typos.toml @@ -20,6 +20,9 @@ DBE = "DBE" nd = "nd" # Playwright's code for a BigUint64Array in an encoded IndexedDB value. bui = "bui" +# German and French words in the tray menu labels (tray.rs). +Alle = "Alle" +cours = "cours" [default.extend-identifiers] # Chrome Web Store extension name in the known-VPN list. diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 8083bde2..9258b046 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -15,14 +15,14 @@ export const commandCoverage = { commands: [ "confirm_quit", "hide_to_tray", - "update_tray_menu", + "tray::update_tray_menu", + "autostart::take_reopen_profiles", "get_app_settings", "save_app_settings", "read_log_files", + "log_viewer::read_app_log", "get_table_preferences", "save_table_preferences", - "get_table_sorting_settings", - "save_table_sorting_settings", "get_system_language", "get_system_info", "dismiss_window_resize_warning", @@ -81,6 +81,7 @@ export const commandCoverage = { "rename_profile", "detect_existing_profiles", "import_browser_profiles", + "cancel_profile_import", "scan_folder_for_profiles", "scan_profile_archive", "cleanup_profile_import_scratch", @@ -106,6 +107,10 @@ export const commandCoverage = { "restore_trashed_profile", "purge_trashed_profile", "empty_trash", + "entity_trash::trash_entity", + "entity_trash::list_trashed_entities", + "entity_trash::restore_trashed_entity", + "entity_trash::purge_trashed_entity", ], }, proxyEntities: { @@ -224,6 +229,11 @@ export const commandCoverage = { "get_browser_release_types", "check_browser_status", "kill_browser_profile", + "browser_runner::get_profile_debug_endpoint", + "network_capture::start_network_capture", + "network_capture::stop_network_capture", + "network_capture::list_network_requests", + "network_capture::get_network_request", "open_url_with_profile", "check_missing_binaries", "check_missing_geoip_database", @@ -287,6 +297,7 @@ export const commandCoverage = { "sync::account_server::get_sync_source", "sync::account_server::set_sync_source", "sync::account_server::set_account_sync_server", + "sync::health::get_sync_health", "cloud_auth::restart_sync_service", "set_profile_sync_mode", "cancel_profile_sync", @@ -339,6 +350,7 @@ export const commandCoverage = { "start_remote_session_events", "stop_remote_session_events", "get_remote_session_events_status", + "remote_session::start_profile_remote_session", ], }, cookieBot: { @@ -370,6 +382,7 @@ export const commandCoverage = { "recipes::create_agent_recipe", "recipes::update_agent_recipe", "recipes::delete_agent_recipe", + "recipes::run_agent_recipe_now", ], }, schedules: { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 56fbda79..accdfab1 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -745,6 +745,28 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a /IMPORT_SOURCE_NOT_CHROMIUM/, "a Firefox folder must be rejected by name, not imported empty", ); + // A cancel stops the import before its next profile; one asked before + // the import starts leaves every item not started. + await app.invoke("cancel_profile_import", { importId: "e2e-cancelled" }); + const cancelledBatch = await app.invoke("import_browser_profiles", { + items: [ + { + source_path: scanned[0].path, + browser_type: scanned[0].browser, + new_profile_name: "Cancelled Import", + proxy_id: null, + vpn_id: null, + }, + ], + groupId: null, + duplicateStrategy: "rename", + wayfernConfig: { fingerprint: "{}" }, + importId: "e2e-cancelled", + }); + assert.equal(cancelledBatch.cancelled, true); + assert.equal(cancelledBatch.imported_count, 0); + assert.equal(cancelledBatch.cancelled_count, 1); + assert.equal(cancelledBatch.results[0].status, "cancelled"); const archivePath = path.join(app.root, "profile-import-fixture.zip"); await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64")); const archiveScan = await app.invoke("scan_profile_archive", { @@ -2084,6 +2106,168 @@ test("deleted profiles land in the trash and come back intact on restore", async }); }); +test("proxies, VPNs, groups and templates go to the trash and come back as they were", async () => { + await withApp("entities-entity-trash", async (app) => { + const entityTrash = path.join(app.dataRoot, "data", "trash", "entities"); + const proxySettings = (port) => ({ + proxy_type: "socks5", + host: "127.0.0.1", + port, + username: "trash-user", + password: "trash-pass", + }); + assert.deepEqual(await app.invoke("list_trashed_entities"), []); + + const proxy = await app.invoke("create_stored_proxy", { + name: "Trash Proxy", + proxySettings: proxySettings(9), + }); + const profile = await createProfile(app, "Uses The Proxy"); + await app.invoke("update_profile_proxy", { + profileId: profile.id, + proxyId: proxy.id, + }); + // A launch through a proxy that is not there would go out direct. + assert.match( + await app.invokeError("trash_entity", { kind: "proxy", id: proxy.id }), + /ENTITY_IN_USE/, + ); + await app.invoke("update_profile_proxy", { + profileId: profile.id, + proxyId: null, + }); + assert.deepEqual( + await app.invoke("trash_entity", { kind: "proxy", id: proxy.id }), + { trashed: true }, + ); + assert.equal( + (await app.invoke("get_stored_proxies")).some( + (item) => item.id === proxy.id, + ), + false, + ); + assert.ok(existsSync(path.join(entityTrash, "proxy", proxy.id))); + + const group = await app.invoke("create_profile_group", { + name: "Trash Group", + }); + await app.invoke("assign_profiles_to_group", { + profileIds: [profile.id], + groupId: group.id, + }); + await app.invoke("trash_entity", { kind: "group", id: group.id }); + assert.equal( + (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + ).group_id, + group.id, + "a profile keeps its group while the group waits in the trash", + ); + + const vpn = await app.invoke("create_vpn_config_manual", { + name: "Trash VPN", + vpnType: "WireGuard", + configData: wireGuardFixture(), + }); + await app.invoke("trash_entity", { kind: "vpn", id: vpn.id }); + const vpnEntry = await readFile( + path.join(entityTrash, "vpn", vpn.id, "entry.json"), + "utf8", + ); + assert.equal( + vpnEntry.includes("PrivateKey ="), + false, + "a VPN stays sealed in the trash", + ); + + const template = await app.invoke("create_profile_template", { + name: "Trash Template", + settings: {}, + }); + await app.invoke("trash_entity", { kind: "template", id: template.id }); + + const listed = await app.invoke("list_trashed_entities"); + assert.deepEqual(listed.map((item) => item.kind).sort(), [ + "group", + "proxy", + "template", + "vpn", + ]); + const listedProxy = listed.find((item) => item.kind === "proxy"); + assert.equal(listedProxy.name, "Trash Proxy"); + assert.equal(listedProxy.detail, "socks5"); + assert.equal( + listedProxy.expires_at - listedProxy.deleted_at, + 30 * 24 * 60 * 60, + ); + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + + // A name taken meanwhile gets a suffix; the id and settings come back. + await app.invoke("create_stored_proxy", { + name: "Trash Proxy", + proxySettings: proxySettings(10), + }); + const restored = await app.invoke("restore_trashed_entity", { + kind: "proxy", + id: proxy.id, + }); + assert.equal(restored.name, "Trash Proxy (restored)"); + assert.equal(restored.sync_stayed_off, false); + assert.equal( + (await app.invoke("get_stored_proxies")).find( + (item) => item.id === proxy.id, + ).proxy_settings.password, + "trash-pass", + ); + assert.match( + await app.invokeError("restore_trashed_entity", { + kind: "proxy", + id: proxy.id, + }), + /TRASH_ENTRY_NOT_FOUND/, + ); + + await app.invoke("restore_trashed_entity", { kind: "group", id: group.id }); + assert.ok( + (await app.invoke("get_groups_with_profile_counts")).some( + (item) => item.id === group.id && item.count === 1, + ), + ); + await app.invoke("restore_trashed_entity", { kind: "vpn", id: vpn.id }); + assert.equal( + (await app.invoke("get_vpn_config", { vpnId: vpn.id })).config_data, + wireGuardFixture(), + ); + + await app.invoke("purge_trashed_entity", { + kind: "template", + id: template.id, + }); + assert.equal( + existsSync(path.join(entityTrash, "template", template.id)), + false, + ); + assert.match( + await app.invokeError("purge_trashed_entity", { + kind: "template", + id: template.id, + }), + /TRASH_ENTRY_NOT_FOUND/, + ); + assert.equal( + (await app.invoke("list_profile_templates")).some( + (item) => item.id === template.id, + ), + false, + ); + + // Emptying the trash takes entities with it. + await app.invoke("trash_entity", { kind: "group", id: group.id }); + assert.equal(await app.invoke("empty_trash"), 1); + assert.deepEqual(await app.invoke("list_trashed_entities"), []); + }); +}); + test("Delete at once erases a profile on the spot and keeps nothing in the trash", async () => { await withApp("entities-delete-at-once", async (app) => { const profilesDir = path.join(app.dataRoot, "data", "profiles"); diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index f5410933..37bafede 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -1475,6 +1475,12 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de }), notSignedIn, ); + assert.match( + await app.invokeError("start_profile_remote_session", { + profileId: missingProfileId, + }), + notSignedIn, + ); // The local-launch gate. Nothing has run remotely in this session, so it // is empty — but it must answer, because a UI that cannot read it shows // an enabled Run button over a profile the backend will refuse. @@ -1703,6 +1709,35 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de }), notSignedIn, ); + const run = { + recipeId: "00000000-0000-0000-0000-000000000000", + profileId: "00000000-0000-0000-0000-000000000001", + name: "E2E run", + }; + assert.match( + await app.invokeError("run_agent_recipe_now", { + ...run, + target: "desktop", + }), + notSignedIn, + ); + // A run that names no place to run, or a fleet run with no OS the fleet + // leases, is refused before any network call. + assert.match( + await app.invokeError("run_agent_recipe_now", { + ...run, + target: "moon", + }), + /"code":"AGENT_TARGET_INVALID"/, + ); + assert.match( + await app.invokeError("run_agent_recipe_now", { + ...run, + target: "fleet", + platform: "android", + }), + /"code":"AGENT_PLATFORM_INVALID"/, + ); assert.match( await app.invokeError("create_agent_recipe", { name: " ", diff --git a/e2e/tests/motion.test.mjs b/e2e/tests/motion.test.mjs index f673dce2..822e1a81 100644 --- a/e2e/tests/motion.test.mjs +++ b/e2e/tests/motion.test.mjs @@ -1176,6 +1176,9 @@ test("profile replay, inspector, group gestures, and remote handoff preserve con await resize(app, 720, 600); await waitForSelector(app, slot("profile-inspector"), false); await app.pressShortcut({ key: "Escape" }); + // The unsaved launch hook asks before the panel closes. + await waitForSelector(app, slot("leave-prompt-discard")); + await app.clickSelector(slot("leave-prompt-discard")); await waitForSelector(app, slot("profile-info-content"), false); await app.pressShortcut({ key: "k", ...modifier }); await waitForSelector(app, "[cmdk-input]"); @@ -1851,7 +1854,7 @@ test("connection diagnostics and extension impact expose real assigned profiles" profileId: profile.id, proxyId: proxy.id, }); - await app.pressShortcut({ ...modifier, key: "n" }); + await app.pressShortcut({ ...modifier, shift: true, key: "n" }); await waitForSelector(app, slot("profile-usage")); await app.clickSelector(slot("profile-usage")); await app.waitForText(profile.name); @@ -2190,7 +2193,19 @@ test("the Wayfern terms gate lifts when the backend announces acceptance", async test("the cheat code pays out sprinkles and never touches a profile", async () => { await withApp("motion-cheat-code", async (app) => { await resize(app, 1000, 700); - const code = ["", "", "", "", "", "", "", "", "b", "a"]; + // Up, up, down, down, left, right, left, right (WebDriver key codes), b, a. + const code = [ + "\uE013", + "\uE013", + "\uE015", + "\uE015", + "\uE012", + "\uE014", + "\uE012", + "\uE014", + "b", + "a", + ]; const toastVisible = () => app.execute( `return [...document.querySelectorAll('[data-sonner-toast]')].some(node => node.textContent.includes(arguments[0]));`, diff --git a/e2e/tests/network.test.mjs b/e2e/tests/network.test.mjs index d7b618bc..345995b1 100644 --- a/e2e/tests/network.test.mjs +++ b/e2e/tests/network.test.mjs @@ -210,12 +210,12 @@ async function createExtensionsThroughUi(app) { ); assert.ok(editButton, "Extension group edit control was not visible"); await app.session.click(editButton); - await app.waitForText("Edit Group"); + await app.waitForText("Edit group"); const memberToggle = await app.execute( ` const dialogs = [...document.querySelectorAll('[role="dialog"]')]; const dialog = dialogs.reverse().find( - (candidate) => (candidate.innerText || "").includes("Edit Group") + (candidate) => (candidate.innerText || "").includes("Edit group") ); const member = [ ...(dialog?.querySelectorAll('[data-slot="extension-group-members"] label') ?? []), @@ -253,7 +253,7 @@ async function createProfileThroughUi(app, groupName, proxy) { await app.clickSelector('[aria-label="Profiles"]'); await app.clickText(groupName, { exact: false, roles: ["button"] }); await app.clickText("New", { roles: ["button"] }); - await app.waitForText("Create New Profile"); + await app.waitForText("Create new profile"); await app.fillSelector("#profile-name", "Visible Network Profile"); await app.clickSelector("#profile-proxy"); await app.clickText(proxy.name, { roles: ["option"] }); @@ -264,7 +264,7 @@ async function createProfileThroughUi(app, groupName, proxy) { !(await app.execute(` return [...document.querySelectorAll('[role="dialog"]')].some( (dialog) => - (dialog.innerText || "").includes("Create New Profile") + (dialog.innerText || "").includes("Create new profile") ); `)), { description: "profile creation dialog to unmount" }, @@ -341,7 +341,7 @@ const assignNetworkThroughUi = (app, profileName, newName) => assignThroughUi(app, profileName, "Proxy / VPN", newName, "Network"); const assignExtensionGroupThroughUi = (app, profileName, newName) => - assignThroughUi(app, profileName, "EXT", newName, "Extension"); + assignThroughUi(app, profileName, "Extensions", newName, "Extension"); async function runProfile(_app, base, token, profileId, url) { const launched = await request(`${base}/v1/profiles/${profileId}/run`, { diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs index 70b10481..0028787f 100644 --- a/e2e/tests/smoke.test.mjs +++ b/e2e/tests/smoke.test.mjs @@ -1,14 +1,18 @@ import assert from "node:assert/strict"; -import { readdir, readFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { appFromEnvironment, withApp } from "../lib/app.mjs"; test("table views persist separately, migrate the old sort, and reject invalid settings", async () => { await withApp("smoke-table-views", async (app) => { - await app.invoke("save_table_sorting_settings", { - sorting: { column: "created_at", direction: "desc" }, - }); + // The sort an older version kept, before table views existed. + const settingsDir = path.join(app.dataRoot, "data", "settings"); + await mkdir(settingsDir, { recursive: true }); + await writeFile( + path.join(settingsDir, "table_sorting.json"), + JSON.stringify({ column: "created_at", direction: "desc" }), + ); assert.deepEqual( (await app.invoke("get_table_preferences", { tableId: "profiles" })) .sorting, @@ -130,17 +134,23 @@ test("fresh app renders, completes onboarding, persists settings, and never touc assert.equal(saved.theme, "dark"); assert.equal(saved.language, "en"); - await app.invoke("save_table_sorting_settings", { - sorting: { column: "browser", direction: "desc" }, - }); - assert.deepEqual(await app.invoke("get_table_sorting_settings"), { - column: "browser", - direction: "desc", - }); assert.ok((await app.invoke("get_system_language")).length >= 2); const system = await app.invoke("get_system_info"); assert.ok(system && typeof system === "object"); assert.equal(typeof (await app.invoke("read_log_files")), "string"); + const logTail = await app.invoke("read_app_log", { limit: 5 }); + assert.ok(Array.isArray(logTail) && logTail.length <= 5); + for (const line of logTail) { + assert.equal(typeof line.key, "string"); + assert.match(line.level, /^(trace|debug|info|warn|error)$/); + } + const errorsOnly = await app.invoke("read_app_log", { + level: "error", + profileId: "00000000-0000-0000-0000-0000000000ff", + }); + assert.deepEqual(errorsOnly, []); + // Reopen is off by default, so there is nothing to launch again. + assert.deepEqual(await app.invoke("take_reopen_profiles"), []); // Feature tips: what was read, the tip on the Profiles page, the day // the next one may come, and the decision behind the paid-plan welcome diff --git a/e2e/tests/sync.test.mjs b/e2e/tests/sync.test.mjs index f69acbcf..306f7ad4 100644 --- a/e2e/tests/sync.test.mjs +++ b/e2e/tests/sync.test.mjs @@ -56,6 +56,13 @@ async function configureSync(app) { assert.deepEqual(await app.invoke("get_sync_settings"), saved); await app.invoke("restart_sync_service"); await new Promise((resolve) => setTimeout(resolve, 750)); + // The header indicator reads this once; null until the first pass ran. + const health = await app.invoke("get_sync_health"); + assert.ok( + health === null || + ["idle", "syncing", "offline", "error"].includes(health.state), + `sync health must be a known state, got ${JSON.stringify(health)}`, + ); } async function createProfile(app, name) { @@ -1079,6 +1086,112 @@ test("a synced profile restored from the trash keeps its sync, and a delete made } }); +test("a synced group and proxy sent to the trash leave the cloud, and a restore brings them back synced", async () => { + assert.ok(syncUrl && syncToken, "Sync infrastructure was not started"); + const app = appFromEnvironment("sync-entity-trash"); + try { + await app.start(); + await configureSync(app); + + const remoteHas = async (key) => + (await listRemote(key)).some((object) => object.key === key); + const group = await app.invoke("create_profile_group", { + name: "Trashed Synced Group", + }); + const proxy = await app.invoke("create_stored_proxy", { + name: "Trashed Synced Proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 8090, + username: null, + password: null, + }, + }); + await app.invoke("set_group_sync_enabled", { + groupId: group.id, + enabled: true, + }); + await app.invoke("set_proxy_sync_enabled", { + proxyId: proxy.id, + enabled: true, + }); + const entities = [ + { kind: "group", id: group.id, folder: "groups" }, + { kind: "proxy", id: proxy.id, folder: "proxies" }, + ]; + for (const { id, folder } of entities) { + await waitFor( + app, + () => remoteHas(`${folder}/${id}.json`), + `${folder} entity uploaded`, + ); + } + + // From the cloud's side a trashed entity is deleted, as a permanent + // delete leaves it: no object, and a tombstone for the other devices. + for (const { kind, id } of entities) { + assert.deepEqual(await app.invoke("trash_entity", { kind, id }), { + trashed: true, + }); + } + for (const { id, folder } of entities) { + await waitFor( + app, + async () => + (await remoteHas(`tombstones/${folder}/${id}.json`)) && + !(await remoteHas(`${folder}/${id}.json`)), + `${folder} entity deleted in the cloud`, + ); + } + const trashed = await app.invoke("list_trashed_entities"); + for (const { kind, id } of entities) { + assert.equal( + trashed.find((entry) => entry.kind === kind && entry.id === id) + ?.sync_enabled, + true, + ); + } + + // The restore clears the tombstone before it turns sync back on, so the + // entity uploads again and no device erases it. + for (const { kind, id, folder } of entities) { + const restored = await app.invoke("restore_trashed_entity", { kind, id }); + assert.equal(restored.sync_stayed_off, false); + assert.equal( + await remoteHas(`tombstones/${folder}/${id}.json`), + false, + `the ${kind} restore clears its tombstone`, + ); + } + for (const { id, folder } of entities) { + await waitFor( + app, + () => remoteHas(`${folder}/${id}.json`), + `${folder} entity uploaded again`, + ); + } + assert.equal( + (await app.invoke("get_profile_groups")).find( + (item) => item.id === group.id, + )?.sync_enabled, + true, + ); + assert.equal( + (await app.invoke("get_stored_proxies")).find( + (item) => item.id === proxy.id, + )?.sync_enabled, + true, + ); + assert.deepEqual(await app.invoke("list_trashed_entities"), []); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + await app.close(); + } +}); + // A self-hosted server reaches its storage over an address only it can // resolve — the documented compose file points S3_ENDPOINT at // http://minio:9000, a Docker service name that exists on the compose network diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index ccc10ca1..f276215d 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -65,13 +65,16 @@ async function tableRows(app, tableId) { * more than half a pixel off the center of their cell. */ async function offCenterSelectionControls(app, tableSelector) { return app.execute( - `return [...document.querySelectorAll(arguments[0] + ' :is(th, td) :is([role="checkbox"], [role="checkbox"] + [aria-hidden="true"] > svg)')] + `return [...document.querySelectorAll(arguments[0] + ' :is(th, td) :is([data-slot="table-selection"], [role="checkbox"], [role="checkbox"] + [aria-hidden="true"] > svg)')] .map((node) => { const cell = node.closest("th, td").getBoundingClientRect(); const rect = node.getBoundingClientRect(); + // The checkbox and the row's icon sit side by side; their group is + // centered across the cell, and each of them down it. + const group = node.matches('[data-slot="table-selection"]'); return { - control: node.getAttribute("aria-label") ?? "icon", - x: rect.left + rect.width / 2 - (cell.left + cell.width / 2), + control: group ? "selection" : (node.getAttribute("aria-label") ?? "icon"), + x: group ? rect.left + rect.width / 2 - (cell.left + cell.width / 2) : 0, y: rect.top + rect.height / 2 - (cell.top + cell.height / 2), }; }) @@ -185,6 +188,8 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p options: [...dialog.querySelectorAll('#profile-proxy, [role="checkbox"]')].map(border), proxyName: document.querySelector('#profile-proxy').getAttribute('aria-labelledby') .split(' ').map((id) => document.getElementById(id)?.textContent.trim()), + groupName: document.querySelector('#profile-group').getAttribute('aria-labelledby') + .split(' ').map((id) => document.getElementById(id)?.textContent.trim()), }; `); assert.equal(chrome.name, "0px", "the name field has no box"); @@ -193,6 +198,11 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p en.createProfile.proxy.title, en.createProfile.proxy.noProxy, ]); + // The destination group shows as a pill: none while the list shows all. + assert.deepEqual(chrome.groupName, [ + en.profiles.table.group, + en.groups.noGroup, + ]); await app.capture("profile-create-compact-wide"); // The device's operating system is chosen on the dialog itself. Without // a plan that includes cross-OS fingerprints, only this computer's @@ -632,12 +642,28 @@ test("profile table filters, range selection, view settings, and keyboard edits JSON.stringify([profiles[1].id]), { description: "profile text search" }, ); - assert.equal( - await app.execute( - `return Boolean(document.querySelector('[data-slot="table-action-bar"]'));`, + // The selection outlives the search; the bar counts what it hides. + assert.ok( + ( + await app.execute( + `return document.querySelector('[data-slot="table-action-bar"]')?.textContent ?? "";`, + ) + ).includes( + en.dataTableActionBar.selectedHidden + .replace("{{selected}}", "4") + .replace("{{hidden}}", "3"), ), - false, - "search clears all action targets", + "the bar counts the selected rows the search hides", + ); + await app.clickSelector( + `[data-slot="table-action-bar"] [aria-label="${en.dataTableActionBar.clearSelection}"]`, + ); + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[data-slot="table-action-bar"]');`, + ), + { description: "clearing the selection removes the bar" }, ); const searchValue = () => app.execute("return document.querySelector(arguments[0]).value;", [ @@ -1010,8 +1036,10 @@ test("a proxy with a change-IP link offers Change IP on its row", async () => { password: null, }, }); + // Mod+Shift+N opens Network; plain Mod+N makes a new profile. await app.pressShortcut({ key: "n", + shift: true, ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), }); await app.waitFor( @@ -1058,8 +1086,10 @@ test("management tables share search, filters, selection, and saved views", asyn ); await app.invoke("create_profile_group", { name: "Table group Alpha" }); await app.invoke("create_profile_group", { name: "Table group Beta" }); + // Mod+Shift+N opens Network; plain Mod+N makes a new profile. await app.pressShortcut({ key: "n", + shift: true, ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), }); await app.waitFor( @@ -1437,7 +1467,7 @@ function checkedThemeCard(app, slotName) { } async function saveSettings(app) { - await app.clickText("Save Settings", { roles: ["button"] }); + await app.clickText("Save settings", { roles: ["button"] }); await app.waitFor( () => app.execute( @@ -1768,6 +1798,7 @@ test("MCP clients on this computer come first, and toolsets go to the connected ]); await stubCommand(app, "add_mcp_to_agent", null); await stubCommand(app, "set_mcp_toolsets", []); + await stubCommand(app, "forget_mcp_remote_credential", null); const calls = (command) => app.execute( "return (window.__donutStubbedCalls ?? []).filter((c) => c.command === arguments[0]).map((c) => c.payload);", @@ -1823,6 +1854,20 @@ test("MCP clients on this computer come first, and toolsets go to the connected "recipes", ]); await dismissSurface(app); + + // Forgetting the credential asks first, then revokes and removes it. + await app.clickSelector('[data-slot="mcp-credential-forget"]'); + await app.waitForText(en.integrations.remote.credentialForgetTitle); + await app.clickTextIn( + '[role="dialog"]', + en.integrations.remote.credentialForget, + { roles: ["button"] }, + ); + await app.waitFor( + async () => (await calls("forget_mcp_remote_credential")).length === 1, + { description: "the confirmed forget reached the backend" }, + ); + await app.waitForText(en.integrations.remote.credentialForgotten); await dismissSurface(app); }); }); @@ -1970,6 +2015,18 @@ test("the local API page shows a one-line example and opens runnable examples in assert.equal(printed.length, 1); assert.ok(Array.isArray(printed[0].profiles), JSON.stringify(printed[0])); + // "Try it" sends the listing to the running server with the saved token + // and shows the answer in the dialog. + await app.clickSelector('[data-slot="code-example-try"]'); + await app.waitFor( + () => + app.execute( + `const panel = document.querySelector('[data-slot="code-example-live-response"]'); + return panel?.dataset.status === "200" && panel.innerText.includes('"profiles"');`, + ), + { description: "the live answer of the local API" }, + ); + // The language is the reader's choice and stays as they move on. await app.clickSelector('[data-testid="code-example-createProxy"]'); await app.waitFor( @@ -2100,30 +2157,50 @@ test("every custom theme keeps tabs, counts, group pills, and rail states readab await app.waitFor( () => app.execute( - `return Boolean(document.querySelector('[data-slot="group-summary-pill"]'));`, + `return Boolean(document.querySelector('[data-summary="groups"] [data-slot="animated-tabs-indicator"]'));`, ), - { description: "Profile groups summary pill" }, + { description: "Profile groups summary tab" }, ); for (const theme of THEMES) { await applyThemeForContrastAudit(app, theme); const snapshot = await app.execute(` const rootStyle = getComputedStyle(document.documentElement); - const pill = document.querySelector( - '[data-slot="group-summary-pill"]', - ); + // The page title is the active tab of a one-tab list, as on the + // other sub-pages: its fill is the tab indicator. + const pill = document.querySelector('[data-summary="groups"]'); const count = pill?.querySelector( '[data-slot="group-summary-count"]', ); - const title = pill?.firstElementChild; + const title = pill?.querySelector('[data-slot="group-summary-title"]'); + const fill = pill?.querySelector( + '[data-slot="animated-tabs-indicator"]', + ); const rail = document.querySelector('nav [aria-current="page"]'); + // The active item's fill is a translucent pill over the rail. Paint + // both on a 1px canvas to read the colour under the icon. + const railFill = (() => { + if (!rail) return null; + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + const layers = [ + rail.closest("nav"), + rail.querySelector('[data-slot="rail-active-pill"]'), + ]; + for (const node of layers) { + if (!node) continue; + context.fillStyle = getComputedStyle(node).backgroundColor; + context.fillRect(0, 0, 1, 1); + } + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return "rgb(" + r + ", " + g + ", " + b + ")"; + })(); return { - pillBackground: pill ? getComputedStyle(pill).backgroundColor : null, + pillBackground: fill ? getComputedStyle(fill).backgroundColor : null, pillTitle: title ? getComputedStyle(title).color : null, pillCount: count ? getComputedStyle(count).color : null, - railBackground: rail - ? getComputedStyle(rail).backgroundColor - : null, + railBackground: railFill, railForeground: rail ? getComputedStyle(rail).color : null, accent: rootStyle.getPropertyValue("--accent").trim(), accentForeground: rootStyle @@ -2859,6 +2936,40 @@ test("interface sounds, their pack, volume and kinds stay as saved", async () => }); }); +test("system and bulk launch settings stay as saved", async () => { + await withApp("ui-system-launch-settings", async (app) => { + const before = await app.invoke("get_app_settings"); + assert.equal(before.launch_at_login, false); + assert.equal(before.reopen_running_profiles, false); + assert.equal(before.system_notifications, true); + assert.equal(before.bulk_launch_delay_ms, 800); + assert.equal(before.bulk_launch_concurrency, 3); + await app.clickSelector('[aria-label="Settings"]'); + await app.waitForText(en.settings.system.launchAtLoginDescription); + await app.clickSelector("#settings-launch-at-login"); + await app.clickSelector("#settings-reopen-running"); + await app.clickSelector("#settings-system-notifications"); + // Each slider steps through its stops: 800 ms -> 1 s, 3 -> 4 at once. + for (const slot of [ + "settings-bulk-launch-delay", + "settings-bulk-launch-concurrency", + ]) { + await app.execute( + `document.querySelector('[data-slot="' + arguments[0] + '"] [role="slider"]').focus();`, + [slot], + ); + await app.pressShortcut({ key: "\uE014" }); + } + await saveSettings(app); + const saved = await app.invoke("get_app_settings"); + assert.equal(saved.launch_at_login, true); + assert.equal(saved.reopen_running_profiles, true); + assert.equal(saved.system_notifications, false); + assert.equal(saved.bulk_launch_delay_ms, 1000); + assert.equal(saved.bulk_launch_concurrency, 4); + }); +}); + test("predefined theme remains rendered across navigation and restart", async () => { await withApp("ui-theme-predefined", async (app) => { await app.clickSelector('[aria-label="Settings"]'); @@ -3601,6 +3712,25 @@ test("scheduled recipes stay behind the plan, and a schedule is built from the f undefined, "only the weekly fields", ); + + // A recipe also runs once on demand: the profile and the place it runs + // go to the cloud, which replays it. + await stubCommand(app, "run_agent_recipe_now", { id: "replay-1" }); + await app.clickSelector('[data-testid="agent-tab-recipes"]'); + await app.clickSelector( + `[data-slot="agent-recipe-run"][aria-label="${en.agent.recipes.runAria.replace("{{name}}", "Check orders")}"]`, + ); + await app.clickSelector('[data-slot="agent-recipe-run-profile"]'); + await app.clickText("Scheduled Profile", { roles: ["option"] }); + await app.clickSelector('[data-slot="agent-recipe-run-start"]'); + await app.waitFor( + async () => (await calls("run_agent_recipe_now")).length === 1, + { description: "the run sent to the cloud" }, + ); + const [run] = await calls("run_agent_recipe_now"); + assert.equal(run.recipeId, "r1"); + assert.equal(run.profileId, profile.id); + assert.equal(run.target, "desktop", "a connected desktop runs it here"); }, { seedDownloadedBrowser: true }, ); @@ -4459,17 +4589,26 @@ test("templates save from the create dialog and the inspector, and several profi { description: "the template's name prefix filling an empty name" }, ); await app.clickSelector('[data-slot="template-picker"]'); + // With the trash on, the template goes to the trash at once: no + // confirmation, and the toast offers Undo. await app.clickSelector('[data-slot="template-delete"]'); - await app.clickSelector('[data-slot="template-delete-confirm"]'); await app.waitFor(async () => (await templates()).length === 0, { description: "the template deleted from the picker", }); + await app.waitForText(en.trash.moved.replace("{{name}}", "Quick")); + assert.ok( + (await app.invoke("list_trashed_entities")).some( + (entry) => entry.kind === "template" && entry.id === quick.id, + ), + "the template waits in the trash", + ); await dismissSurface(app); await dismissSurface(app); await app.clickSelector( `[data-slot="profile-inspect-trigger"][data-profile-id="${base.id}"]`, ); + await app.clickSelector('[data-slot="profile-info-more"]'); await app.clickSelector('[data-slot="profile-info-save-template"]'); await app.waitForText(en.templates.saveFromProfileDescription); await app.clickSelector('[data-slot="save-template-confirm"]'); @@ -4482,6 +4621,66 @@ test("templates save from the create dialog and the inspector, and several profi ); }); +test("the trash page lists deleted entities by kind and restores one", async () => { + await withApp("ui-trash-page", async (app) => { + const group = await app.invoke("create_profile_group", { + name: "Trash Page Group", + }); + const proxy = await app.invoke("create_stored_proxy", { + name: "Trash Page Proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 8091, + username: null, + password: null, + }, + }); + await app.invoke("trash_entity", { kind: "group", id: group.id }); + await app.invoke("trash_entity", { kind: "proxy", id: proxy.id }); + const hasRow = (id) => + app.execute( + `return Boolean(document.querySelector('[data-table-row="' + arguments[0] + '"]'));`, + [id], + ); + + const modifier = + process.platform === "darwin" ? { meta: true } : { ctrl: true }; + await app.pressShortcut({ ...modifier, shift: true, key: "t" }); + await app.waitFor(() => hasRow(`group:${group.id}`), { + description: "the trashed group on the trash page", + }); + assert.equal(await hasRow(`proxy:${proxy.id}`), true); + + // A kind tab shows that kind only. + await app.clickSelector('[role="tab"][data-kind="proxy"]'); + await app.waitFor(async () => !(await hasRow(`group:${group.id}`)), { + description: "the proxy tab hides the group", + }); + assert.equal(await hasRow(`proxy:${proxy.id}`), true); + + await app.clickSelector( + `[data-table-row="proxy:${proxy.id}"] [data-slot="trash-restore"]`, + ); + await app.waitFor( + async () => + (await app.invoke("get_stored_proxies")).some( + (item) => item.id === proxy.id, + ), + { description: "the proxy restored" }, + ); + // The emptied tab falls back to everything that is left. + await app.waitFor(() => hasRow(`group:${group.id}`), { + description: "the list falls back to all kinds", + }); + assert.deepEqual( + (await app.invoke("list_trashed_entities")).map((entry) => entry.id), + [group.id], + ); + await app.capture("trash-page"); + }); +}); + test("the group bookmark editor adds, reorders and removes a row", async () => { await withApp("ui-group-bookmarks", async (app) => { const group = await app.invoke("create_profile_group", { name: "Client" }); @@ -4810,7 +5009,10 @@ test("entity pages teach when empty, label protocols neutrally, and reach row ac return active?.matches('[data-slot="row-actions"] button') ? active.getAttribute("aria-label") : null;`, - )) === en.proxies.management.editProxy, + )) === + en.tables.rowAction + .replace("{{action}}", en.proxies.management.editProxy) + .replace("{{name}}", plain.name), { description: "the edit action focused" }, ); await app.execute(`document.activeElement.click();`); @@ -6255,7 +6457,7 @@ test("Pro users can edit and save launch arguments in each profile", async () => .launch_args?.length === 2, { description: "saved launch arguments" }, ); - await openEditor(); + // Saving keeps the panel open, with the saved value in the editor. assert.equal( await app.execute( 'return document.querySelector("#wayfern-launch-args").value;', @@ -6279,6 +6481,8 @@ test("Pro users can edit and save launch arguments in each profile", async () => input.split("\n"), ); await app.clickSelector('[data-slot="profile-info-close"]'); + // The refused draft is still unsaved, so closing asks first. + await app.clickSelector('[data-slot="leave-prompt-discard"]'); await restoreStubs(app); await app.invoke("plugin:event|emit", { event: "cloud-auth-changed", diff --git a/sdk/node/src/client.mts b/sdk/node/src/client.mts index 4f3600ba..b9bbda38 100644 --- a/sdk/node/src/client.mts +++ b/sdk/node/src/client.mts @@ -96,6 +96,13 @@ export interface RunProfileOptions { headless?: boolean; } +export interface BatchRunOptions extends RunProfileOptions { + /** Pause between the starts of two launches (0 to 60000). Defaults to the app's setting. */ + delayMs?: number; + /** How many profiles may be starting at once (1 to 20). Defaults to the app's setting. */ + concurrency?: number; +} + /** Drop every property the caller left `undefined`. */ function body(fields: Record): Record { const result: Record = {}; @@ -413,12 +420,18 @@ export class DonutClient { */ batchRunProfiles( profileIds: string[], - options: RunProfileOptions = {}, + options: BatchRunOptions = {}, ): Promise { return this.#request( "POST", "/v1/profiles/batch/run", - body({ profile_ids: profileIds, url: options.url, headless: options.headless }), + body({ + profile_ids: profileIds, + url: options.url, + headless: options.headless, + delay_ms: options.delayMs, + concurrency: options.concurrency, + }), ); } diff --git a/sdk/node/src/index.mts b/sdk/node/src/index.mts index 57128449..bc45ebfc 100644 --- a/sdk/node/src/index.mts +++ b/sdk/node/src/index.mts @@ -17,7 +17,7 @@ */ export { DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession } from "./client.mts"; -export type { DonutClientOptions, RunProfileOptions } from "./client.mts"; +export type { BatchRunOptions, DonutClientOptions, RunProfileOptions } from "./client.mts"; export { OMITTED, OPERATIONS } from "./coverage.mts"; export type { OperationKey } from "./coverage.mts"; export { diff --git a/sdk/node/test/requests.test.mts b/sdk/node/test/requests.test.mts index d2cc8242..ba974ea5 100644 --- a/sdk/node/test/requests.test.mts +++ b/sdk/node/test/requests.test.mts @@ -174,10 +174,10 @@ const CASES: Case[] = [ }, { method: "batchRunProfiles", - args: [["p1", "p2"], { headless: false }], + args: [["p1", "p2"], { headless: false, delayMs: 500, concurrency: 2 }], verb: "POST", path: "/v1/profiles/batch/run", - body: { profile_ids: ["p1", "p2"], headless: false }, + body: { profile_ids: ["p1", "p2"], headless: false, delay_ms: 500, concurrency: 2 }, operation: "POST /v1/profiles/batch/run", }, { diff --git a/sdk/python/src/donutbrowser/client.py b/sdk/python/src/donutbrowser/client.py index 4958dc8c..6f42fb79 100644 --- a/sdk/python/src/donutbrowser/client.py +++ b/sdk/python/src/donutbrowser/client.py @@ -461,15 +461,26 @@ class DonutClient: *, url: Optional[str] = None, headless: Optional[bool] = None, + delay_ms: Optional[int] = None, + concurrency: Optional[int] = None, ) -> BatchRunResponse: """POST /v1/profiles/batch/run Answers 200 even when some profiles failed; read ``results[].ok``. + ``delay_ms`` (0 to 60000) spaces the launches and ``concurrency`` + (1 to 20) caps how many start at once; both default to the app's + bulk launch settings. """ return self._request( "POST", "/v1/profiles/batch/run", - body=_body(profile_ids=list(profile_ids), url=url, headless=headless), + body=_body( + profile_ids=list(profile_ids), + url=url, + headless=headless, + delay_ms=delay_ms, + concurrency=concurrency, + ), ) def batch_stop_profiles(self, profile_ids: Sequence[str]) -> BatchStopResponse: diff --git a/sdk/python/tests/test_requests.py b/sdk/python/tests/test_requests.py index 0bb54b1f..b2394927 100644 --- a/sdk/python/tests/test_requests.py +++ b/sdk/python/tests/test_requests.py @@ -168,10 +168,10 @@ CASES: List[Case] = [ ( "batch_run_profiles", (["p1", "p2"],), - {"headless": False}, + {"headless": False, "delay_ms": 500, "concurrency": 2}, "POST", "/v1/profiles/batch/run", - {"profile_ids": ["p1", "p2"], "headless": False}, + {"profile_ids": ["p1", "p2"], "headless": False, "delay_ms": 500, "concurrency": 2}, {}, "/v1/profiles/batch/run", ), diff --git a/src/app/page.tsx b/src/app/page.tsx index e91fdb95..068385a2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -27,7 +27,10 @@ import { GroupManagementDialog } from "@/components/group-management-dialog"; import HomeHeader from "@/components/home-header"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { IntegrationsDialog } from "@/components/integrations-dialog"; +import { LeavePromptDialog } from "@/components/leave-prompt-dialog"; +import { MoveProfilesDialog } from "@/components/move-profiles-dialog"; import { OnboardingTour, type TourStep } from "@/components/onboarding-tour"; +import { PageTransition } from "@/components/page-transition"; import { PaidWelcomeDialog } from "@/components/paid-welcome-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { @@ -46,7 +49,7 @@ import { ProfileSyncDialog } from "@/components/profile-sync-dialog"; import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog"; import { ProxyDistributionDialog } from "@/components/proxy-distribution-dialog"; import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; -import { type AppPage, RailNav } from "@/components/rail-nav"; +import { type AppPage, pageDirection, RailNav } from "@/components/rail-nav"; import { SettingsDialog } from "@/components/settings-dialog"; import { ShortcutsPage } from "@/components/shortcuts-page"; import { SyncAllDialog } from "@/components/sync-all-dialog"; @@ -59,6 +62,7 @@ import { TipCoachMark } from "@/components/tips/tip-coach-mark"; import { TipsDialog } from "@/components/tips-dialog"; import { TotpToolDialog } from "@/components/totp-code"; import { TrashPage } from "@/components/trash-page"; +import { AutoHeight } from "@/components/ui/auto-height"; import { WayfernConfigDialog } from "@/components/wayfern-config-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; import { WelcomeDialog } from "@/components/welcome-dialog"; @@ -70,6 +74,8 @@ import { useCommercialTrial } from "@/hooks/use-commercial-trial"; import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; import { useGroupEvents } from "@/hooks/use-group-events"; import { useKonamiCode } from "@/hooks/use-konami-code"; +import { useDocumentLanguage } from "@/hooks/use-language"; +import { useLaunchNotices } from "@/hooks/use-launch-notices"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { useProfileEvents } from "@/hooks/use-profile-events"; @@ -78,6 +84,7 @@ import { useProfileTableFilters } from "@/hooks/use-profile-table-filters"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useSyncSessions } from "@/hooks/use-sync-session"; import { useTips } from "@/hooks/use-tips"; +import { confirmLeave, hasUnsavedChanges } from "@/hooks/use-unsaved-changes"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVpnEvents } from "@/hooks/use-vpn-events"; @@ -92,12 +99,14 @@ import { } from "@/lib/entitlements"; import { setOnboardingActive } from "@/lib/onboarding-signal"; import { type ProfileSearchContext } from "@/lib/profile-search"; +import { startRemoteSession } from "@/lib/remote-sessions"; import { matchesGroupDigit, matchesShortcut, SHORTCUTS, type ShortcutId, } from "@/lib/shortcuts"; +import { notifySystem } from "@/lib/system-notify"; import type { TipAction } from "@/lib/tips"; import { dismissToast, @@ -105,6 +114,7 @@ import { showSuccessToast, showSyncProgressToast, showToast, + showUndoToast, } from "@/lib/toast-utils"; import { playUiSound } from "@/lib/ui-sounds"; import type { @@ -160,6 +170,7 @@ export default function Home() { const reducedMotion = useReducedMotion(); // Mount global version update listener/toasts useVersionUpdater(); + useDocumentLanguage(); // Use the new profile events hook for centralized profile management const { @@ -169,6 +180,7 @@ export default function Home() { error: profilesError, loadProfiles, } = useProfileEvents(); + useLaunchNotices(profiles); // First-run product tour: coach marks on the real controls. const [tour, setTour] = useState<{ open: boolean; step: TourStep }>({ @@ -414,6 +426,19 @@ export default function Home() { !commercialTrialModalOpen; const [currentPage, setCurrentPage] = useState("profiles"); + const currentPageRef = useRef(currentPage); + currentPageRef.current = currentPage; + // Which way the page transition moves: down the rail or back up it. + const [pageMove, setPageMove] = useState<{ page: AppPage; dir: 1 | -1 }>({ + page: currentPage, + dir: 1, + }); + if (pageMove.page !== currentPage) { + setPageMove({ + page: currentPage, + dir: pageDirection(pageMove.page, currentPage), + }); + } const [accountDialogOpen, setAccountDialogOpen] = useState(false); // Tracks which tab inside the shared proxy-management page should be active. // The VPN rail item routes to the same page but pre-selects the VPN tab. @@ -572,7 +597,20 @@ export default function Home() { setSelectedProfiles([]); }, []); - const handleRailNavigate = useCallback((page: AppPage) => { + // Run a navigation that unmounts the current page, after the person chose + // what happens to unsaved edits on it. With nothing unsaved it runs at + // once, in the caller's batch. + const guardLeave = useCallback((go: () => void) => { + if (!hasUnsavedChanges()) { + go(); + return; + } + void confirmLeave().then((ok) => { + if (ok) go(); + }); + }, []); + + const navigateNow = useCallback((page: AppPage) => { // Always reset every sub-page-able dialog before opening the next one, // so navigating from one rail item to another doesn't stack two // sub-pages on top of each other. @@ -635,6 +673,47 @@ export default function Home() { } }, []); + const handleRailNavigate = useCallback( + (page: AppPage) => { + const from = currentPageRef.current; + const network = (p: AppPage) => p === "proxies" || p === "vpns"; + // Same page (or Proxies <-> VPNs, one page with two tabs): nothing + // unmounts, so nothing can be lost. + if (page === from || (network(page) && network(from))) { + navigateNow(page); + return; + } + guardLeave(() => navigateNow(page)); + }, + [guardLeave, navigateNow], + ); + + /** Go to Profiles, then run `then` there; asks first like any navigation. */ + const goToProfilesThen = useCallback( + (then: () => void) => { + if (currentPageRef.current === "profiles") { + then(); + return; + } + guardLeave(() => { + navigateNow("profiles"); + then(); + }); + }, + [guardLeave, navigateNow], + ); + + /** A sub-page's own back or close: leave it for Profiles. */ + const closeSubPage = useCallback( + (close: () => void) => { + guardLeave(() => { + close(); + setCurrentPage("profiles"); + }); + }, + [guardLeave], + ); + const openAgentConversation = useCallback(() => { setAgentInitialTab("conversation"); handleRailNavigate("agent"); @@ -667,38 +746,85 @@ export default function Home() { case "page": handleRailNavigate(action.page); break; - case "settings": + case "settings": { // The navigation clears the section; setting it afterwards in the // same batch is what makes it win. - handleRailNavigate("settings"); - setSettingsInitialSection(action.section); + const section = action.section; + const open = () => { + navigateNow("settings"); + setSettingsInitialSection(section); + }; + if (currentPageRef.current === "settings") open(); + else guardLeave(open); break; + } case "palette": setCommandPaletteOpen(true); break; case "feedback": setFeedbackDialogOpen(true); break; - case "search": - handleRailNavigate("profiles"); - setSelectedProfiles([]); - setSearchQuery(action.query); + case "search": { + const query = action.query; + goToProfilesThen(() => { + setSelectedProfiles([]); + setSearchQuery(query); + }); break; + } case "createProfile": - handleRailNavigate("profiles"); - openCreateProfile(); + goToProfilesThen(openCreateProfile); break; case "totp": setTotpToolOpen(true); break; } }, - [closeTips, handleRailNavigate, openCreateProfile], + [ + closeTips, + handleRailNavigate, + navigateNow, + guardLeave, + goToProfilesThen, + openCreateProfile, + ], ); + // Set once the launch and stop handlers below exist. + const stopAllRunningRef = useRef<() => void>(() => {}); + + const focusProfileSearch = useCallback(() => { + const focus = () => { + const input = document.getElementById("profile-search-input"); + if (!(input instanceof HTMLInputElement)) return; + if (document.activeElement === input) return; + input.focus(); + input.select(); + }; + goToProfilesThen(() => { + // The page may mount this frame, and a closing palette hands focus + // back to its trigger a moment later: keep it on the search box. + for (let delay = 0; delay <= 360; delay += 60) { + window.setTimeout(focus, delay); + } + }); + }, [goToProfilesThen]); + const runShortcut = useCallback( (id: ShortcutId) => { switch (id) { + case "newProfile": + openCreateProfile(); + break; + case "stopAllRunning": + stopAllRunningRef.current(); + break; + case "focusSearch": + focusProfileSearch(); + break; + case "goBack": + handleRailNavigate("profiles"); + break; case "openPalette": setCommandPaletteOpen(true); break; @@ -718,7 +844,7 @@ export default function Home() { handleRailNavigate("profiles"); break; case "goProxies": { - // Mod+N: navigate first time; flip proxies↔vpns on subsequent presses. + // Mod+Shift+N: navigate first time; flip proxies↔vpns on subsequent presses. // handleRailNavigate("proxies"|"vpns") already updates the dialog's // initialTab, so we just pick the right destination. if (currentPage === "proxies") { @@ -807,6 +933,8 @@ export default function Home() { cloudUser, openTips, openAgentConversation, + openCreateProfile, + focusProfileSearch, ], ); @@ -824,10 +952,9 @@ export default function Home() { (digit: number) => { const target = orderedGroupTargets[digit - 1]; if (!target) return; - handleRailNavigate("profiles"); - handleSelectGroup(target.id); + goToProfilesThen(() => handleSelectGroup(target.id)); }, - [orderedGroupTargets, handleRailNavigate, handleSelectGroup], + [orderedGroupTargets, goToProfilesThen, handleSelectGroup], ); useEffect(() => { @@ -857,6 +984,24 @@ export default function Home() { if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") { return; } + if (s.id === "goBack") { + // Escape leaves a sub-page only when nothing on it took the key: + // an open dialog, popover, menu or table selection goes first. + if ( + e.defaultPrevented || + e.repeat || + currentPageRef.current === "profiles" || + document.querySelector( + '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-radix-popper-content-wrapper], [data-slot="table-action-bar"]', + ) + ) { + return; + } + e.preventDefault(); + playUiSound("close"); + runShortcut(s.id); + return; + } e.preventDefault(); playUiSound("open"); runShortcut(s.id); @@ -1052,10 +1197,9 @@ export default function Home() { const checkNextPermission = useCallback( (justGranted?: PermissionType) => { try { - // Treat the just-granted permission as already granted even if our - // own usePermissions instance hasn't observed it yet — it polls on a - // 5 s cadence and would otherwise leave the dialog stuck on the - // permission the user just successfully granted. + // Treat the just-granted permission as granted even when this + // callback's state does not show it yet, so the dialog never stays + // on the permission the user just granted. const micGranted = isMicrophoneAccessGranted || justGranted === "microphone"; const camGranted = isCameraAccessGranted || justGranted === "camera"; @@ -1172,7 +1316,8 @@ export default function Home() { proxyId?: string; vpnId?: string; wayfernConfig?: WayfernConfig; - groupId?: string; + /** `null`: the person picked "No group" in the create dialog. */ + groupId?: string | null; extensionGroupId?: string; ephemeral?: boolean; dnsBlocklist?: string; @@ -1193,10 +1338,12 @@ export default function Home() { vpnId: profileData.vpnId, wayfernConfig: profileData.wayfernConfig, groupId: - profileData.groupId ?? - (selectedGroupId && selectedGroupId !== "__all__" - ? selectedGroupId - : undefined), + profileData.groupId === null + ? undefined + : (profileData.groupId ?? + (selectedGroupId && selectedGroupId !== "__all__" + ? selectedGroupId + : undefined)), ephemeral: profileData.ephemeral, dnsBlocklist: profileData.dnsBlocklist, dnsServer: profileData.dnsServer, @@ -1440,10 +1587,29 @@ export default function Home() { [], ); + // The raw error of each profile's last failed launch, shown by the table. + // An entry goes when that profile launches. + const [launchErrors, setLaunchErrors] = useState>({}); + const noteLaunchError = useCallback((profileId: string, err: unknown) => { + setLaunchErrors((current) => { + if (err === null) { + if (!(profileId in current)) return current; + const next = { ...current }; + delete next[profileId]; + return next; + } + return { + ...current, + [profileId]: err instanceof Error ? err.message : String(err), + }; + }); + }, []); + const launchProfile = useCallback( async ( profile: BrowserProfile, - opts?: { bulkRunId?: number }, + // `quiet`: a bulk run reports failures in one summary instead. + opts?: { bulkRunId?: number; quiet?: boolean }, ): Promise => { console.log("Starting launch for profile:", profile.name); @@ -1518,7 +1684,11 @@ export default function Home() { localChecks = checks; const blocked = checks.consistency.checked && !checks.consistency.consistent; - if (blocked || checks.vpn_extensions.length > 0) { + if ( + blocked || + checks.vpn_extensions.length > 0 || + checks.route_unreachable + ) { const decision = await requestGateDecision( { profile, @@ -1529,6 +1699,7 @@ export default function Home() { measurementUnreliable: checks.exit_measurement_unreliable, probePending: checks.exit_probe_pending, unverified: checks.exit_unverified, + routeUnreachable: checks.route_unreachable ?? null, }, }, opts?.bulkRunId, @@ -1554,6 +1725,7 @@ export default function Home() { }); console.log("Successfully launched profile:", result.name); playUiSound("launch", { causedAt, batch: opts?.bulkRunId }); + noteLaunchError(profile.id, null); return { status: "launched" }; } catch (err: unknown) { // Tier 2: the enforcing gate measured the exit mid-launch and stopped @@ -1593,26 +1765,36 @@ export default function Home() { causedAt: retriedAt, batch: opts?.bulkRunId, }); + noteLaunchError(profile.id, null); return { status: "launched" }; } catch (retryErr: unknown) { - showErrorToast( - t("errors.launchBrowserFailed", { - error: translateBackendError(t, retryErr), - }), - ); + noteLaunchError(profile.id, retryErr); + if (!opts?.quiet) { + showErrorToast( + t("errors.launchBrowserFailed", { + name: profile.name, + error: translateBackendError(t, retryErr), + }), + ); + } return { status: "blocked" }; } } console.error("Failed to launch browser:", err); - const errorMessage = translateBackendError(t, err); - showErrorToast( - t("errors.launchBrowserFailed", { error: errorMessage }), - ); + noteLaunchError(profile.id, err); + if (!opts?.quiet) { + showErrorToast( + t("errors.launchBrowserFailed", { + name: profile.name, + error: translateBackendError(t, err), + }), + ); + } throw err; } }, - [persistGateAcks, requestGateDecision, t], + [persistGateAcks, requestGateDecision, noteLaunchError, t], ); const handleCloneProfile = useCallback((profile: BrowserProfile) => { @@ -1637,10 +1819,52 @@ export default function Home() { setPasswordDialogProfile(profile); }, []); + // The Undo of a move to the trash. + const restoreFromTrash = useCallback( + async (profileIds: string[]) => { + const results = await Promise.allSettled( + profileIds.map((profileId) => + invoke("restore_trashed_profile", { profileId }), + ), + ); + const failure = results.find( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + if (failure) { + showErrorToast( + t("trash.restoreFailed", { + error: translateBackendError(t, failure.reason), + }), + ); + } + }, + [t], + ); + + /** + * A move to the trash needs no question: it is done, and the toast offers + * Undo. Ephemeral profiles have no trash; the table confirms those first. + */ + const announceTrashed = useCallback( + (trashed: BrowserProfile[]) => { + const recoverable = trashed.filter((profile) => !profile.ephemeral); + if (recoverable.length === 0) { + playUiSound("trash"); + return; + } + showUndoToast( + recoverable.length === 1 + ? t("profiles.trash.moved", { name: recoverable[0].name }) + : t("profiles.trash.movedMany", { count: recoverable.length }), + () => restoreFromTrash(recoverable.map((profile) => profile.id)), + ); + }, + [restoreFromTrash, t], + ); + const handleDeleteProfile = useCallback( async (profile: BrowserProfile) => { - console.log("Attempting to delete profile:", profile.name); - try { // First check if the browser is running for this profile const isRunning = await invoke("check_browser_status", { @@ -1652,13 +1876,9 @@ export default function Home() { return; } - // Attempt to delete the profile await invoke("delete_profile", { profileId: profile.id }); - playUiSound("trash"); - console.log("Profile deletion command completed successfully"); - - // No need to manually reload - useProfileEvents will handle the update - console.log("Profile deleted successfully"); + // useProfileEvents refreshes the list. + announceTrashed([profile]); } catch (err: unknown) { console.error("Failed to delete profile:", err); showErrorToast( @@ -1668,7 +1888,7 @@ export default function Home() { ); } }, - [t], + [announceTrashed, t], ); const handleRenameProfile = useCallback( @@ -1679,7 +1899,9 @@ export default function Home() { } catch (err: unknown) { console.error("Failed to rename profile:", err); showErrorToast( - t("errors.renameProfileFailed", { error: JSON.stringify(err) }), + t("errors.renameProfileFailed", { + error: translateBackendError(t, err), + }), ); throw err; } @@ -1698,8 +1920,11 @@ export default function Home() { // No need to manually reload - useProfileEvents will handle the update } catch (err: unknown) { console.error("Failed to kill browser:", err); - const errorMessage = err instanceof Error ? err.message : String(err); - showErrorToast(t("errors.killBrowserFailed", { error: errorMessage })); + showErrorToast( + t("errors.killBrowserFailed", { + error: translateBackendError(t, err), + }), + ); // Re-throw the error so the table component can handle loading state cleanup throw err; } @@ -1709,20 +1934,23 @@ export default function Home() { const handleDeleteSelectedProfiles = useCallback( async (profileIds: string[]) => { + const targets = profiles.filter((profile) => + profileIds.includes(profile.id), + ); try { await invoke("delete_selected_profiles", { profileIds }); - playUiSound("trash"); - // No need to manually reload - useProfileEvents will handle the update + // useProfileEvents refreshes the list. + announceTrashed(targets); } catch (err: unknown) { console.error("Failed to delete selected profiles:", err); showErrorToast( t("errors.deleteSelectedProfilesFailed", { - error: JSON.stringify(err), + error: translateBackendError(t, err), }), ); } }, - [t], + [profiles, announceTrashed, t], ); const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => { @@ -1773,34 +2001,46 @@ export default function Home() { [profiles, runningProfiles, groupsData, loadProfiles, loadGroups, t], ); - const handleBulkDelete = useCallback(() => { - if (selectedProfiles.length === 0) return; - setShowBulkDeleteConfirmation(true); - }, [selectedProfiles]); - const confirmBulkDelete = useCallback(async () => { if (selectedProfiles.length === 0) return; + const targets = profiles.filter((profile) => + selectedProfiles.includes(profile.id), + ); setIsBulkDeleting(true); try { await invoke("delete_selected_profiles", { profileIds: selectedProfiles, }); - playUiSound("trash"); - // No need to manually reload - useProfileEvents will handle the update + // useProfileEvents refreshes the list. setSelectedProfiles([]); setShowBulkDeleteConfirmation(false); + announceTrashed(targets); } catch (error) { console.error("Failed to delete selected profiles:", error); showErrorToast( t("errors.deleteSelectedProfilesFailed", { - error: JSON.stringify(error), + error: translateBackendError(t, error), }), ); } finally { setIsBulkDeleting(false); } - }, [selectedProfiles, t]); + }, [selectedProfiles, profiles, announceTrashed, t]); + + // To the trash at once, with Undo. Only an ephemeral profile, which is + // destroyed for good, still asks first. + const handleBulkDelete = useCallback(() => { + if (selectedProfiles.length === 0) return; + const permanent = profiles.some( + (profile) => selectedProfiles.includes(profile.id) && profile.ephemeral, + ); + if (permanent) { + setShowBulkDeleteConfirmation(true); + return; + } + void confirmBulkDelete(); + }, [selectedProfiles, profiles, confirmBulkDelete]); const handleBulkGroupAssignment = useCallback(() => { if (selectedProfiles.length === 0) return; @@ -1860,35 +2100,149 @@ export default function Home() { } | null>(null); const [isBulkActing, setIsBulkActing] = useState(false); + // Set once the table filters exist: shows and selects the given profiles. + const showProfilesRef = useRef<(profileIds: string[]) => void>(() => {}); + + /** + * Launch many profiles: at most `bulk_launch_concurrency` at a time, with + * `bulk_launch_delay_ms` between starts, behind a progress toast whose + * Cancel drops the launches not started yet. Failures are reported once, + * in a summary that can show the failed profiles. + */ const executeBulkRun = useCallback( async (targets: BrowserProfile[]) => { setIsBulkActing(true); blanketGateDecisionRef.current = null; bulkRunIdRef.current += 1; const runId = bulkRunIdRef.current; + const total = targets.length; + const toastId = `bulk-launch-${runId}`; + const startedAt = Date.now(); + let cancelled = false; + let delayMs = 800; + let concurrency = 3; try { - const results = await Promise.allSettled( - targets.map((p) => launchProfile(p, { bulkRunId: runId })), + const settings = await invoke<{ + bulk_launch_delay_ms?: number; + bulk_launch_concurrency?: number; + }>("get_app_settings"); + delayMs = Math.min( + Math.max(settings.bulk_launch_delay_ms ?? delayMs, 0), + 60000, ); - const stopped = results.filter( - (r) => r.status === "fulfilled" && r.value.status !== "launched", - ).length; - if (stopped > 0) { - // Previously a declined launch resolved to undefined, so allSettled - // reported success and the user was told nothing. - showErrorToast( - t("prelaunchGate.cancelledSummary", { - cancelled: stopped, - total: targets.length, - }), - ); + concurrency = Math.min( + Math.max(settings.bulk_launch_concurrency ?? concurrency, 1), + 20, + ); + } catch (err) { + console.warn("Bulk launch settings unavailable, using defaults:", err); + } + + let started = 0; + let done = 0; + let declined = 0; + const failed: BrowserProfile[] = []; + const showProgress = () => { + if (total < 2 || cancelled) return; + showToast({ + id: toastId, + type: "loading", + title: t("profiles.bulkRun.progress", { done, total }), + duration: Number.POSITIVE_INFINITY, + onCancel: () => { + cancelled = true; + dismissToast(toastId); + }, + }); + }; + + let next = 0; + let nextStartAt = 0; + const worker = async () => { + while (next < total && !cancelled) { + const profile = targets[next]; + next += 1; + // Starts are spaced across all workers, not per worker. + const startAt = Math.max(Date.now(), nextStartAt); + nextStartAt = startAt + delayMs; + const wait = startAt - Date.now(); + if (wait > 0) { + await new Promise((resolve) => window.setTimeout(resolve, wait)); + } + if (cancelled) return; + started += 1; + try { + const result = await launchProfile(profile, { + bulkRunId: runId, + quiet: true, + }); + if (result.status === "blocked") failed.push(profile); + else if (result.status === "cancelled") declined += 1; + } catch { + failed.push(profile); + } + done += 1; + showProgress(); } - setSelectedProfiles([]); + }; + + try { + showProgress(); + await Promise.all( + Array.from({ length: Math.min(concurrency, total) }, worker), + ); } finally { + dismissToast(toastId); blanketGateDecisionRef.current = null; setIsBulkActing(false); setPendingBulkAction(null); } + + if (failed.length > 0) { + const failedIds = failed.map((profile) => profile.id); + const names = failed.slice(0, 3).map((profile) => profile.name); + showErrorToast( + t("profiles.bulkRun.failedSummary", { + failed: failed.length, + total, + }), + { + id: `bulk-launch-failed-${runId}`, + description: + failed.length > names.length + ? `${names.join(", ")}, …` + : names.join(", "), + action: { + label: t("profiles.bulkRun.showFailed"), + onClick: () => showProfilesRef.current(failedIds), + }, + }, + ); + } + if (declined > 0) { + // A declined launch is no failure, but the person is told about it. + showErrorToast( + t("prelaunchGate.cancelledSummary", { cancelled: declined, total }), + ); + } + if (cancelled && started < total) { + showToast({ + type: "info", + title: t("profiles.bulkRun.notStarted", { + count: total - started, + total, + }), + }); + } + if (Date.now() - startedAt >= 10_000) { + void notifySystem({ + title: t("profiles.bulkRun.finishedTitle"), + body: t("profiles.bulkRun.finishedBody", { + launched: started - failed.length - declined, + total, + }), + }); + } }, [launchProfile, t], ); @@ -1901,7 +2255,6 @@ export default function Home() { await Promise.allSettled( targets.map((p) => handleKillProfile(p, { batch })), ); - setSelectedProfiles([]); } finally { setIsBulkActing(false); setPendingBulkAction(null); @@ -1910,6 +2263,29 @@ export default function Home() { [handleKillProfile], ); + const stopAllRunning = useCallback(() => { + const targets = profiles.filter((p) => runningProfiles.has(p.id)); + if (targets.length === 0) { + showToast({ type: "info", title: t("profiles.stopAll.none") }); + return; + } + void executeBulkStop(targets); + }, [profiles, runningProfiles, executeBulkStop, t]); + stopAllRunningRef.current = stopAllRunning; + + // A profile that runs, however it was started, has no launch error left. + useEffect(() => { + setLaunchErrors((current) => { + const cleared = Object.keys(current).filter((id) => + runningProfiles.has(id), + ); + if (cleared.length === 0) return current; + const next = { ...current }; + for (const id of cleared) delete next[id]; + return next; + }); + }, [runningProfiles]); + // Bulk run/stop only touch eligible profiles (run: not already running; // stop: currently running). An empty result shows a toast instead of a silent // no-op (guard), and 10+ targets require confirmation before launching/stopping. @@ -1994,12 +2370,109 @@ export default function Home() { ); } catch (error) { console.error("Failed to toggle sync:", error); - showErrorToast(t("errors.updateSyncSettingsFailed")); + showErrorToast(t("errors.updateSyncSettingsFailed"), { + description: translateBackendError(t, error), + }); } }, [t], ); + // The per-profile toggle, for every selected profile whose sync is off. + const handleBulkEnableSync = useCallback(async () => { + const targets = profiles.filter( + (profile) => + selectedProfiles.includes(profile.id) && + !profile.ephemeral && + (!profile.sync_mode || profile.sync_mode === "Disabled"), + ); + if (targets.length === 0) { + showToast({ type: "info", title: t("sync.bulkEnableNone") }); + return; + } + const results = await Promise.allSettled( + targets.map((profile) => + invoke("set_profile_sync_mode", { + profileId: profile.id, + syncMode: "Regular", + }), + ), + ); + const failure = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + const enabled = results.filter((r) => r.status === "fulfilled").length; + if (enabled > 0) { + showSuccessToast(t("sync.bulkEnabledToast", { count: enabled }), { + description: t("sync.enabledDescription"), + }); + } + if (failure) { + showErrorToast(t("errors.updateSyncSettingsFailed"), { + description: translateBackendError(t, failure.reason), + }); + } + }, [profiles, selectedProfiles, t]); + + // A copy with the same settings and a new device, named by the backend. + const handleDuplicateProfile = useCallback( + async (profile: BrowserProfile) => { + try { + const copy = await invoke("clone_profile", { + profileId: profile.id, + name: null, + }); + showSuccessToast(t("profiles.duplicated", { name: copy.name })); + } catch (error) { + showErrorToast( + t("errors.cloneProfileFailed", { + error: translateBackendError(t, error), + }), + ); + } + }, + [t], + ); + + const handleLockProfile = useCallback( + async (profile: BrowserProfile) => { + try { + await invoke("lock_profile", { profileId: profile.id }); + showSuccessToast(t("profiles.lockedToast", { name: profile.name })); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } + }, + [t], + ); + + // Open the profile on a leased host; the session stream reports the rest. + const handleRunRemote = useCallback( + async (profile: BrowserProfile) => { + try { + await startRemoteSession(profile.id); + showSuccessToast(t("profiles.remote.starting", { name: profile.name })); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } + }, + [t], + ); + + // The empty group's "Move profiles here": a picker of the other profiles. + const [moveTargetGroup, setMoveTargetGroup] = useState<{ + id: string; + name: string; + } | null>(null); + const handleMoveProfilesHere = useCallback(() => { + const group = groupsData.find((g) => g.id === selectedGroupId); + if (group) setMoveTargetGroup({ id: group.id, name: group.name }); + }, [groupsData, selectedGroupId]); + + // Read through a ref so the sync listeners below subscribe once, and keep + // which profiles are transferring across profile list reloads. + const syncProfilesRef = useRef(profiles); + syncProfilesRef.current = profiles; useEffect(() => { let disposed = false; let unlistenStatus: (() => void) | undefined; @@ -2015,7 +2488,9 @@ export default function Home() { }>("profile-sync-status", (event) => { const { profile_id, status, error, profile_name } = event.payload; const toastId = `sync-${profile_id}`; - const profile = profiles.find((p) => p.id === profile_id); + const profile = syncProfilesRef.current.find( + (p) => p.id === profile_id, + ); const name = profile_name || profile?.name || t("common.labels.unknownProfile"); @@ -2052,7 +2527,9 @@ export default function Home() { }>("profile-sync-progress", (event) => { const payload = event.payload; const toastId = `sync-${payload.profile_id}`; - const profile = profiles.find((p) => p.id === payload.profile_id); + const profile = syncProfilesRef.current.find( + (p) => p.id === payload.profile_id, + ); const name = payload.profile_name || profile?.name || @@ -2096,7 +2573,7 @@ export default function Home() { if (unlistenStatus) unlistenStatus(); if (unlistenProgress) unlistenProgress(); }; - }, [profiles, t]); + }, [t]); useEffect(() => { // Listen for URL open events. Guard against the effect tearing down (or @@ -2173,8 +2650,7 @@ export default function Home() { action: { label: t("encryption.required.openSettings"), onClick: () => { - setSettingsDialogOpen(true); - setCurrentPage("settings"); + handleRailNavigate("settings"); }, }, }); @@ -2265,7 +2741,7 @@ export default function Home() { unlistenWayfernBlocked?.(); unlistenGenerationLimit?.(); }; - }, [t]); + }, [t, handleRailNavigate]); // Re-check Wayfern terms when a browser download completes useEffect(() => { @@ -2309,6 +2785,95 @@ export default function Home() { }; }, [checkSelfHostedSync]); + // The tray menu acts through the same handlers as the window. The latest + // handlers and lists are read through a ref, so one listener serves all. + const trayRef = useRef({ + profiles, + runningProfiles, + launchProfile, + handleKillProfile, + stopAllRunning, + goToProfilesThen, + }); + trayRef.current = { + profiles, + runningProfiles, + launchProfile, + handleKillProfile, + stopAllRunning, + goToProfilesThen, + }; + useEffect(() => { + // The backend brings the window forward itself for Show, Open and a + // launch that asks for a password; a plain launch or stop stays behind. + const unlisten = listen<{ + action: + | "show" + | "stop-all" + | "stop-profile" + | "launch-profile" + | "open-profile"; + profile_id?: string; + }>("tray-action", (event) => { + const tray = trayRef.current; + const { action, profile_id } = event.payload; + const profile = profile_id + ? tray.profiles.find((p) => p.id === profile_id) + : undefined; + switch (action) { + case "show": + break; + case "stop-all": + tray.stopAllRunning(); + break; + case "stop-profile": + if (profile && tray.runningProfiles.has(profile.id)) { + void tray.handleKillProfile(profile).catch(() => {}); + } + break; + case "launch-profile": + if (profile && !tray.runningProfiles.has(profile.id)) { + void tray.launchProfile(profile).catch(() => {}); + } + break; + case "open-profile": + if (profile) { + tray.goToProfilesThen(() => { + setProfileInfoOpenMethod("pointer"); + setProfileInfoDialog(profile); + }); + } + break; + } + }); + return () => { + void unlisten.then((stop) => { + stop(); + }); + }; + }, []); + + // Profiles that were running when the app last quit open again, through + // the bulk launch path, once the list is loaded and launches are allowed. + const reopenCheckedRef = useRef(false); + useEffect(() => { + if (reopenCheckedRef.current) return; + if (profilesLoading || termsAccepted !== true) return; + reopenCheckedRef.current = true; + void invoke("take_reopen_profiles") + .then((ids) => { + const targets = trayRef.current.profiles.filter( + (profile) => + ids.includes(profile.id) && + !trayRef.current.runningProfiles.has(profile.id), + ); + if (targets.length > 0) void executeBulkRun(targets); + }) + .catch(() => { + // An older backend has no such command; nothing to reopen then. + }); + }, [profilesLoading, termsAccepted, executeBulkRun]); + // A profile stores ids, and the query asks about names, so the matcher is // handed the resolution up front. Built off the entity lists rather than off // `profiles`, because the alternative — a .find() per row per term — is @@ -2332,7 +2897,6 @@ export default function Home() { ], ); - const clearTableSelection = useCallback(() => setSelectedProfiles([]), []); const profilesInGroup = useMemo( () => !selectedGroupId || selectedGroupId === "__all__" @@ -2344,16 +2908,26 @@ export default function Home() { profilesInGroup, searchQuery, searchContext, - clearTableSelection, ); const changeProfileSearch = (query: string) => { - setSelectedProfiles([]); setSearchQuery(query); }; const clearProfileFilters = () => { profileFilters.reset(); setSearchQuery(""); }; + // The bulk launch summary's "Show": every filter off, then the failed + // profiles selected, so they are all on screen together. + showProfilesRef.current = (profileIds: string[]) => { + goToProfilesThen(() => { + setSelectedGroupId("__all__"); + profileFilters.reset(); + setSearchQuery(""); + setSelectedProfiles( + profileIds.filter((id) => profiles.some((p) => p.id === id)), + ); + }); + }; // Update loading states const isLoading = profilesLoading || groupsLoading || proxiesLoading; @@ -2364,6 +2938,8 @@ export default function Home() { : currentPage === "import" ? t("pageTitle.import") : t(`pageTitle.${currentPage}`); + // Proxies and VPNs are one page with two tabs: switching tabs is no move. + const transitionKey = currentPage === "vpns" ? "proxies" : currentPage; return (
0} agentRequests={agentConsole.openRequests.length} /> -
- {currentPage === "profiles" && ( -
- + + {currentPage === "profiles" && ( +
+ + + {teamReadOnly && ( +

+ {t("teamRoles.readOnlyNotice")} +

+ )} +
+ + } + onClearFilters={clearProfileFilters} + allProfiles={profiles} + infoDialogProfile={profileInfoDialog} + infoOpenMethod={profileInfoOpenMethod} + onInfoOpenMethodChange={setProfileInfoOpenMethod} + onInfoDialogProfileChange={setProfileInfoDialog} + onLaunchProfile={launchProfile} + onKillProfile={handleKillProfile} + onCloneProfile={handleCloneProfile} + onSetPassword={handleSetPassword} + onChangePassword={handleChangePassword} + onRemovePassword={handleRemovePassword} + onDeleteProfile={handleDeleteProfile} + onRenameProfile={handleRenameProfile} + onConfigureWayfern={handleConfigureWayfern} + onCopyCookiesToProfile={handleCopyCookiesToProfile} + onOpenCookieManagement={handleOpenCookieManagement} + runningProfiles={runningProfiles} + extensionGroups={extensionGroups} + isUpdating={isUpdating} + onDeleteSelectedProfiles={handleDeleteSelectedProfiles} + onAssignProfilesToGroup={handleAssignProfilesToGroup} + onAssignProfilesToProxy={handleAssignProfilesToProxy} + selectedGroupId={selectedGroupId} + selectedProfiles={selectedProfiles} + onSelectedProfilesChange={setSelectedProfiles} + onBulkDelete={handleBulkDelete} + onBulkGroupAssignment={handleBulkGroupAssignment} + onBulkProxyAssignment={handleBulkProxyAssignment} + onBulkProxyDistribution={handleBulkProxyDistribution} + onBulkCopyCookies={handleBulkCopyCookies} + onBulkRun={handleBulkRun} + onBulkStop={handleBulkStop} + bulkActionsUnlocked={automationUnlocked} + onBulkExtensionGroupAssignment={ + handleBulkExtensionGroupAssignment + } + onAssignExtensionGroup={handleAssignExtensionGroup} + onOpenProfileSyncDialog={handleOpenProfileSyncDialog} + onToggleProfileSync={handleToggleProfileSync} + crossOsUnlocked={crossOsUnlocked} + syncUnlocked={syncUnlocked} + getProfileSyncInfo={getProfileSyncInfo} + onLaunchWithSync={(profile) => { + setSyncLeaderProfile(profile); + }} + onCreateProfile={openCreateProfile} + onImportProfiles={() => { + handleRailNavigate("import"); + }} + launchErrors={launchErrors} + onCreateProfileInGroup={openCreateProfile} + onMoveProfilesHere={handleMoveProfilesHere} + onBulkEnableSync={() => void handleBulkEnableSync()} + onDuplicateProfile={(profile) => + void handleDuplicateProfile(profile) + } + onLockProfile={(profile) => void handleLockProfile(profile)} + onRunRemote={(profile) => void handleRunRemote(profile)} + /> + +
+ )} + + {currentPage === "shortcuts" && ( +
+ { + setCommandPaletteOpen(true); + }} + /> +
+ )} + + {settingsDialogOpen && ( + { + closeSubPage(() => setSettingsDialogOpen(false)); + }} + onIntegrationsOpen={() => { + handleRailNavigate("integrations"); + }} + subPage={currentPage === "settings"} + initialSection={settingsInitialSection} /> - {teamReadOnly && ( -

- {t("teamRoles.readOnlyNotice")} -

- )} - - } - onClearFilters={clearProfileFilters} - allProfiles={profiles} - infoDialogProfile={profileInfoDialog} - infoOpenMethod={profileInfoOpenMethod} - onInfoOpenMethodChange={setProfileInfoOpenMethod} - onInfoDialogProfileChange={setProfileInfoDialog} - onLaunchProfile={launchProfile} - onKillProfile={handleKillProfile} - onCloneProfile={handleCloneProfile} - onSetPassword={handleSetPassword} - onChangePassword={handleChangePassword} - onRemovePassword={handleRemovePassword} - onDeleteProfile={handleDeleteProfile} - onRenameProfile={handleRenameProfile} - onConfigureWayfern={handleConfigureWayfern} - onCopyCookiesToProfile={handleCopyCookiesToProfile} - onOpenCookieManagement={handleOpenCookieManagement} - runningProfiles={runningProfiles} - extensionGroups={extensionGroups} - isUpdating={isUpdating} - onDeleteSelectedProfiles={handleDeleteSelectedProfiles} - onAssignProfilesToGroup={handleAssignProfilesToGroup} - onAssignProfilesToProxy={handleAssignProfilesToProxy} - selectedGroupId={selectedGroupId} - selectedProfiles={selectedProfiles} - onSelectedProfilesChange={setSelectedProfiles} - onBulkDelete={handleBulkDelete} - onBulkGroupAssignment={handleBulkGroupAssignment} - onBulkProxyAssignment={handleBulkProxyAssignment} - onBulkProxyDistribution={handleBulkProxyDistribution} - onBulkCopyCookies={handleBulkCopyCookies} - onBulkRun={handleBulkRun} - onBulkStop={handleBulkStop} - bulkActionsUnlocked={automationUnlocked} - onBulkExtensionGroupAssignment={ - handleBulkExtensionGroupAssignment - } - onAssignExtensionGroup={handleAssignExtensionGroup} - onOpenProfileSyncDialog={handleOpenProfileSyncDialog} - onToggleProfileSync={handleToggleProfileSync} + )} + + {integrationsDialogOpen && ( + { + closeSubPage(() => setIntegrationsDialogOpen(false)); + }} + subPage={currentPage === "integrations"} + initialTab={integrationsInitialTab} + /> + )} + + {proxyManagementDialogOpen && ( + { + closeSubPage(() => setProxyManagementDialogOpen(false)); + }} + subPage={currentPage === "proxies" || currentPage === "vpns"} + initialTab={proxyManagementInitialTab} + /> + )} + + {groupManagementDialogOpen && ( + { + closeSubPage(() => setGroupManagementDialogOpen(false)); + }} + onGroupManagementComplete={handleGroupManagementComplete} + subPage={currentPage === "groups"} + /> + )} + + {extensionManagementDialogOpen && ( + { + closeSubPage(() => setExtensionManagementDialogOpen(false)); + }} + limitedMode={false} + subPage={currentPage === "extensions"} + initialTab={extensionManagementInitialTab} + /> + )} + + {importProfileDialogOpen && ( + { + closeSubPage(() => setImportProfileDialogOpen(false)); + }} crossOsUnlocked={crossOsUnlocked} - syncUnlocked={syncUnlocked} - getProfileSyncInfo={getProfileSyncInfo} - onLaunchWithSync={(profile) => { - setSyncLeaderProfile(profile); + subPage={currentPage === "import"} + /> + )} + + {cookieBotDialogOpen && ( + { + closeSubPage(() => setCookieBotDialogOpen(false)); }} - onCreateProfile={openCreateProfile} - onImportProfiles={() => { - handleRailNavigate("import"); + subPage={currentPage === "cookieBot"} + initialTab={cookieBotInitialTab} + profiles={profiles} + cloudUser={cloudUser} + onOpenProfileSync={handleOpenProfileSyncDialog} + onAssignProxy={handleAssignProfilesToProxy} + /> + )} + + {agentDialogOpen && ( + { + closeSubPage(() => setAgentDialogOpen(false)); + }} + subPage={currentPage === "agent"} + initialTab={agentInitialTab} + onTabChange={setAgentInitialTab} + profiles={profiles} + cloudUser={cloudUser} + agentConsole={agentConsole} + onConnectAgent={() => { + setIntegrationsInitialTab("remote"); + handleRailNavigate("integrations"); + }} + onOpenAccount={() => { + handleRailNavigate("account"); }} /> - -
- )} + )} - {currentPage === "shortcuts" && ( -
- { - setCommandPaletteOpen(true); + {accountDialogOpen && ( + { + closeSubPage(() => setAccountDialogOpen(false)); + }} + subPage={currentPage === "account"} + onOpenSignIn={() => { + closeSubPage(() => { + setAccountDialogOpen(false); + setDeviceCodeDialogOpen(true); + }); }} /> -
- )} + )} - {settingsDialogOpen && ( - { - setSettingsDialogOpen(false); - setCurrentPage("profiles"); - }} - onIntegrationsOpen={() => { - setSettingsDialogOpen(false); - setIntegrationsDialogOpen(true); - setCurrentPage("integrations"); - }} - subPage={currentPage === "settings"} - initialSection={settingsInitialSection} - /> - )} - - {integrationsDialogOpen && ( - { - setIntegrationsDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "integrations"} - initialTab={integrationsInitialTab} - /> - )} - - {proxyManagementDialogOpen && ( - { - setProxyManagementDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "proxies" || currentPage === "vpns"} - initialTab={proxyManagementInitialTab} - /> - )} - - {groupManagementDialogOpen && ( - { - setGroupManagementDialogOpen(false); - setCurrentPage("profiles"); - }} - onGroupManagementComplete={handleGroupManagementComplete} - subPage={currentPage === "groups"} - /> - )} - - {extensionManagementDialogOpen && ( - { - setExtensionManagementDialogOpen(false); - setCurrentPage("profiles"); - }} - limitedMode={false} - subPage={currentPage === "extensions"} - initialTab={extensionManagementInitialTab} - /> - )} - - {importProfileDialogOpen && ( - { - setImportProfileDialogOpen(false); - setCurrentPage("profiles"); - }} - crossOsUnlocked={crossOsUnlocked} - subPage={currentPage === "import"} - /> - )} + {trashPageOpen && ( + { + closeSubPage(() => setTrashPageOpen(false)); + }} + subPage={currentPage === "trash"} + /> + )} + + {/* A modal, so it stays out of the page transition: a page that + fades out must not take a second copy of it along. */} setTotpToolOpen(false)} /> - - {cookieBotDialogOpen && ( - { - setCookieBotDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "cookieBot"} - initialTab={cookieBotInitialTab} - profiles={profiles} - cloudUser={cloudUser} - onOpenProfileSync={handleOpenProfileSyncDialog} - onAssignProxy={handleAssignProfilesToProxy} - /> - )} - - {agentDialogOpen && ( - { - setAgentDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "agent"} - initialTab={agentInitialTab} - onTabChange={setAgentInitialTab} - profiles={profiles} - cloudUser={cloudUser} - agentConsole={agentConsole} - onConnectAgent={() => { - setIntegrationsInitialTab("remote"); - handleRailNavigate("integrations"); - }} - onOpenAccount={() => { - handleRailNavigate("account"); - }} - /> - )} - - {accountDialogOpen && ( - { - setAccountDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "account"} - onOpenSignIn={() => { - setAccountDialogOpen(false); - setCurrentPage("profiles"); - setDeviceCodeDialogOpen(true); - }} - /> - )} - - {trashPageOpen && ( - { - setTrashPageOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "trash"} - /> - )}
@@ -2672,8 +3258,7 @@ export default function Home() { onAction={runShortcut} groupTargets={orderedGroupTargets} onSelectGroup={(id) => { - handleRailNavigate("profiles"); - handleSelectGroup(id); + goToProfilesThen(() => handleSelectGroup(id)); }} profiles={profiles} runningProfileIds={runningProfiles} @@ -2684,9 +3269,10 @@ export default function Home() { void handleKillProfile(profile); }} onShowProfileInfo={(profile) => { - handleRailNavigate("profiles"); - setProfileInfoOpenMethod("keyboard"); - setProfileInfoDialog(profile); + goToProfilesThen(() => { + setProfileInfoOpenMethod("keyboard"); + setProfileInfoDialog(profile); + }); }} onCreateProfile={openCreateProfile} onOpenAbout={() => { @@ -2694,6 +3280,16 @@ export default function Home() { }} /> + + + setMoveTargetGroup(null)} + profiles={profiles} + runningProfiles={runningProfiles} + onMove={handleDropProfilesToGroup} + /> + { @@ -2772,8 +3368,7 @@ export default function Home() { onAutoShowChange={(enabled) => void tipsFlow.setAutoShow(enabled)} onAction={runTipAction} onShowMe={(id) => { - handleRailNavigate("profiles"); - tipsFlow.showOnPage(id); + goToProfilesThen(() => tipsFlow.showOnPage(id)); }} /> + {t("about.portableBadge")} )} @@ -387,7 +387,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { role="region" tabIndex={0} aria-label={t("about.licenses")} - className="-mx-2 min-h-0 flex-1 rounded-md px-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" + className="-mx-2 min-h-0 flex-1 rounded-md px-2 outline-none focus-visible:ring-2 focus-visible:ring-ring" > {filteredLicenses.length > 0 ? (
    @@ -402,7 +402,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { onClick={() => void openUrl(xraySource.sourceUrl)} aria-label={t("about.openSource", { name })} title={t("about.openSource", { name })} - className="flex min-w-0 items-center gap-1.5 rounded-sm text-left text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.98] motion-reduce:transform-none" + className="flex min-w-0 items-center gap-1.5 rounded-sm text-left text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] motion-reduce:transform-none" > {name} -
    +
    {detail.label}
    diff --git a/src/components/agent-page.tsx b/src/components/agent-page.tsx index fb172e67..a7c254f5 100644 --- a/src/components/agent-page.tsx +++ b/src/components/agent-page.tsx @@ -518,19 +518,16 @@ export function AgentPage({
    {subPage ? ( - <> -

    - {title} -

    -

    - {description} -

    - + // The shell's header already names the page. +

    {description}

    ) : ( <> {title} @@ -637,7 +634,11 @@ export function AgentPage({ > {blockerPanel ?? (automation || scheduled ? ( - + ) : ( ([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -96,7 +126,7 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) { setEditing("new"); }} > - + {t("agent.recipes.new")}
    @@ -149,43 +179,78 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) { {t("agent.recipes.empty")}

    ) : ( - recipes.map((recipe) => ( -
    -
    - - {recipe.name} - - - {t("agent.recipes.stepCount", { - steps: recipe.steps.length, + + {recipes.map((recipe, index) => ( + +
    + + {recipe.name} + + + {t("agent.recipes.stepCount", { + steps: recipe.steps.length, + })} + +
    + +
    - - -
    - )) + onClick={() => { + setEditing(recipe); + }} + > + {t("common.buttons.edit")} + + + + ))} + )}
    @@ -309,7 +374,7 @@ function RecipeEditor({ setSteps([...steps, emptyStep("navigate")]); }} > - + {t("agent.recipes.addStep")}
    @@ -336,3 +401,211 @@ function RecipeEditor({ ); } + +/** + * Replays a saved recipe once on a profile the person picks. Donut cloud runs + * it, on this computer through remote control or on the remote fleet. + */ +function RecipeRunButton({ + recipe, + profiles, + canRunOnDesktop, + desktopConnected, +}: { + recipe: AgentRecipe; + profiles: BrowserProfile[]; + canRunOnDesktop: boolean; + desktopConnected: boolean; +}) { + const { t } = useTranslation(); + const animate = useMotionAllowed(); + const formId = useId(); + const [open, setOpen] = useState(false); + const [profileId, setProfileId] = useState(""); + const [target, setTarget] = useState( + canRunOnDesktop && desktopConnected ? "desktop" : "fleet", + ); + const [isRunning, setIsRunning] = useState(false); + const shownTarget: ScheduleTarget = canRunOnDesktop ? target : "fleet"; + const profile = profiles.find((item) => item.id === profileId) ?? null; + const os = profile ? resolvedOs(profile) : null; + const platform = os !== null && isCookieBotPlatform(os) ? os : null; + const blocker = + profile === null + ? null + : shownTarget === "fleet" + ? platform + ? null + : t("agent.recipes.runPlatformUnsupported") + : desktopConnected + ? null + : t("agent.recipes.runDesktopOffline"); + + const run = async () => { + if (!profile || blocker !== null) return; + setIsRunning(true); + try { + await invoke("run_agent_recipe_now", { + recipeId: recipe.id, + profileId: profile.id, + target: shownTarget, + platform: shownTarget === "fleet" ? platform : null, + name: t("agent.recipes.oneOffName", { name: recipe.name }), + }); + showSuccessToast(t("agent.schedules.runStarted"), { + description: t("agent.recipes.runStartedOn", { + name: recipe.name, + profile: profile.name, + }), + }); + setOpen(false); + } catch (runError) { + showErrorToast(translateBackendError(t, runError)); + } finally { + setIsRunning(false); + } + }; + + return ( + + + + + +
    { + event.preventDefault(); + void run(); + }} + > +
    +

    + {t("agent.recipes.runTitle", { name: recipe.name })} +

    +

    + {t("agent.recipes.runDescription")} +

    +
    +
    + + +
    +
    + + +
    + + {blocker !== null && ( + + {blocker} + + )} + +
    + + + {t("agent.schedules.runNow")} + +
    +
    +
    +
    + ); +} diff --git a/src/components/bandwidth-mini-chart.tsx b/src/components/bandwidth-mini-chart.tsx index 2336b2f9..e242527e 100644 --- a/src/components/bandwidth-mini-chart.tsx +++ b/src/components/bandwidth-mini-chart.tsx @@ -10,6 +10,7 @@ interface BandwidthMiniChartProps { currentBandwidth?: number; onClick?: () => void; className?: string; + "aria-label"?: string; } export function BandwidthMiniChart({ @@ -17,7 +18,11 @@ export function BandwidthMiniChart({ currentBandwidth: externalBandwidth, onClick, className, + "aria-label": ariaLabel, }: BandwidthMiniChartProps) { + // One gradient per chart: rows that share an id all paint with the first + // row's gradient, and lose it when that row unmounts. + const gradientId = `bandwidth-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; // Transform data for the chart - combine sent and received for total bandwidth const chartData = React.useMemo(() => { // Fill in missing seconds with zeros for smooth chart @@ -62,8 +67,9 @@ export function BandwidthMiniChart({ + + +
    + {shown.length === 0 ? ( +

    + {emptyText} +

    + ) : ( + shown.map((line, index) => { + const content = ( + <> + + {line.time.slice(11)} + + + {line.level} + + + {line.target} + + {line.message} + + + + ); + const rowClass = + "flex gap-2 px-3 py-px hover:bg-foreground/5 select-text"; + const animate = + motionAllowed && + fresh.has(line.key) && + index >= shown.length - ANIMATED_TAIL; + return animate ? ( + + {content} + + ) : ( +
    + {content} +
    + ); + }) + )} +
    + {lines.length >= LIMIT && ( +

    + {t("logViewer.capped", { count: LIMIT })} +

    + )} + + ); +} diff --git a/src/components/managed-data-table.tsx b/src/components/managed-data-table.tsx index 7f287d97..f7e25a17 100644 --- a/src/components/managed-data-table.tsx +++ b/src/components/managed-data-table.tsx @@ -16,7 +16,8 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { useEntityMotion } from "@/components/entity-page"; +import { tableSelectionCounts } from "@/components/data-table-action-bar"; +import { RowNameProvider } from "@/components/entity-page"; import { DATA_TABLE_CLASSES, TableColumnResizer, @@ -36,9 +37,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useMotionAllowed } from "@/hooks/use-motion-allowed"; import { useTablePreferences } from "@/hooks/use-table-preferences"; import { useTableFilters, useTableKeyboard } from "@/hooks/use-table-tools"; -import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion"; +import { MOTION_DURATION, MOTION_EASE_OUT, MOTION_SPRING } from "@/lib/motion"; import { proxyProtocolToken } from "@/lib/proxy-string"; import { canonicalProxyType } from "@/lib/proxy-type"; import { @@ -61,7 +63,7 @@ type Entity = | GroupWithCount | StoredProxy | VpnConfig; -type EntityTableId = Exclude; +type EntityTableId = Exclude; const LABELS: Record> = { proxies: { name: "common.labels.name", @@ -234,24 +236,19 @@ export function useEntityTableTools( } return result; }, [id, data, t]); - const clearSelection = useCallback(() => setSelection({}), [setSelection]); - const filters = useTableFilters( - data, - entitySearch, - definitions, - clearSelection, - ); + const filters = useTableFilters(data, entitySearch, definitions, id); + // Rows a filter hides stay selected; rows that no longer exist do not. useLayoutEffect(() => { - const visible = new Set(filters.rows.map((row) => row.id)); + const existing = new Set(data.map((row) => row.id)); setSelection((prev) => { const ids = Object.keys(prev).filter((id) => prev[id]); - return ids.every((id) => visible.has(id)) + return ids.every((id) => existing.has(id)) ? prev : Object.fromEntries( - ids.filter((id) => visible.has(id)).map((id) => [id, true]), + ids.filter((id) => existing.has(id)).map((id) => [id, true]), ); }); - }, [filters.rows, setSelection]); + }, [data, setSelection]); const visibility = resolveTableVisibility( id === "proxies" ? { hostPort: width >= 672, protocol: width >= 672 } @@ -264,8 +261,8 @@ export function useEntityTableTools( return { view, labels, filters, visibility, setWidth }; } -/** Above this many rows, rows enter and leave without animating. */ -const ANIMATED_ROW_LIMIT = 150; +/** Above this many rows, rows enter, leave and move without animating. */ +const ANIMATED_ROW_LIMIT = 60; export function ManagedDataTable({ table, @@ -284,7 +281,7 @@ export function ManagedDataTable({ const { t } = useTranslation(); const { view, labels, filters, setWidth } = tools; const keyboard = useTableKeyboard(table); - const motionAllowed = useEntityMotion(); + const motionAllowed = useMotionAllowed(); const resizeRef = useCallback( (element: HTMLDivElement | null) => { if (!element) return; @@ -340,7 +337,7 @@ export function ManagedDataTable({ 0 && + tableSelectionCounts(table).selected > 0 && "pb-[calc(var(--table-action-bar-height,56px)+32px)]", )} style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties} @@ -435,41 +432,48 @@ export function ManagedDataTable({ className={DATA_TABLE_CLASSES.row} style={{ height: rowHeight }} layout={animateRows ? "position" : false} - initial={animateRows ? { opacity: 0 } : false} + initial={animateRows ? { opacity: 0.5 } : false} animate={{ opacity: 1 }} exit={{ opacity: 0, - transition: { duration: animateRows ? 0.12 : 0 }, + transition: { + duration: animateRows ? MOTION_DURATION.fast : 0, + }, }} transition={{ - opacity: { duration: 0.18, ease: MOTION_EASE_OUT }, - layout: MOTION_SPRING_POSITION, + opacity: { + duration: MOTION_DURATION.base, + ease: MOTION_EASE_OUT, + }, + layout: MOTION_SPRING.position, }} > - {row.getVisibleCells().map((cell, columnIndex) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} + + {row.getVisibleCells().map((cell, columnIndex) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + ))} diff --git a/src/components/move-profiles-dialog.tsx b/src/components/move-profiles-dialog.tsx new file mode 100644 index 00000000..b1d4ff35 --- /dev/null +++ b/src/components/move-profiles-dialog.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LoadingButton } from "@/components/loading-button"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { paletteScore } from "@/lib/palette-search"; +import type { BrowserProfile } from "@/types"; + +interface MoveProfilesDialogProps { + /** The group the profiles move into; null closes the dialog. */ + group: { id: string; name: string } | null; + onClose: () => void; + profiles: BrowserProfile[]; + runningProfiles: Set; + /** Resolves true when the move landed. */ + onMove: (profileIds: string[], groupId: string) => Promise; +} + +/** Picks profiles from other groups and moves them into one group. */ +export function MoveProfilesDialog({ + group, + onClose, + profiles, + runningProfiles, + onMove, +}: MoveProfilesDialogProps) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const [picked, setPicked] = useState>(new Set()); + const [moving, setMoving] = useState(false); + + const candidates = useMemo( + () => + group + ? profiles + .filter((profile) => profile.group_id !== group.id) + .filter((profile) => paletteScore(profile.name, [], search) > 0) + : [], + [group, profiles, search], + ); + + const close = () => { + setSearch(""); + setPicked(new Set()); + onClose(); + }; + + const toggle = (id: string) => { + setPicked((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const move = async () => { + if (!group || picked.size === 0) return; + setMoving(true); + try { + if (await onMove([...picked], group.id)) close(); + } finally { + setMoving(false); + } + }; + + return ( + { + if (!open) close(); + }} + > + + + + {t("moveProfiles.title", { group: group?.name ?? "" })} + + {t("moveProfiles.description")} + + setSearch(event.target.value)} + placeholder={t("header.searchPlaceholder")} + aria-label={t("header.searchPlaceholder")} + /> +
      + {candidates.length === 0 && ( +
    • + {search ? t("common.noResults") : t("moveProfiles.empty")} +
    • + )} + {candidates.map((profile) => { + const running = runningProfiles.has(profile.id); + const id = `move-profile-${profile.id}`; + return ( +
    • + +
    • + ); + })} +
    + + + void move()} + > + {t("moveProfiles.confirm", { count: picked.size })} + + +
    +
    + ); +} diff --git a/src/components/network-requests-panel.tsx b/src/components/network-requests-panel.tsx new file mode 100644 index 00000000..27aee94d --- /dev/null +++ b/src/components/network-requests-panel.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { ArrowLeft, Circle, Eraser, Radio, Square } from "lucide-react"; +import { motion } from "motion/react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { FOCUS_RING } from "@/components/ui/focus-ring"; +import { useMotionAllowed } from "@/hooks/use-motion-allowed"; +import { translateBackendError } from "@/lib/backend-errors"; +import { MOTION_DURATION, MOTION_EASE_OUT } from "@/lib/motion"; +import { showErrorToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; + +interface RequestSummary { + id: number; + method: string; + url: string; + type: string; + state: "pending" | "done" | "failed" | "redirected" | "incomplete"; + status: number | null; + mime: string | null; + bytes: number | null; + ms: number | null; +} + +interface RequestList { + capturing: boolean; + requests?: RequestSummary[]; + last_id?: number; + withheld?: number; +} + +interface RequestDetail extends RequestSummary { + status_text: string | null; + protocol: string | null; + from_cache: boolean; + error: string | null; + request_headers: Record; + response_headers: Record; + post_data: string | null; + post_data_truncated: boolean; +} + +const POLL_MS = 1000; +const LIST_LIMIT = 500; +const ANIMATED_TAIL = 30; +const STICK_DISTANCE_PX = 24; +const GRID = + "grid grid-cols-[3.5rem_3rem_minmax(0,1fr)_minmax(0,1.4fr)_4.5rem_4rem_4rem] gap-2"; + +function splitUrl(url: string): { host: string; path: string } { + try { + const parsed = new URL(url.replace(/…$/, "")); + return { host: parsed.host, path: `${parsed.pathname}${parsed.search}` }; + } catch { + return { host: "", path: url }; + } +} + +function formatBytes(bytes: number | null): string { + if (bytes === null || bytes === undefined) return "–"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatMs(ms: number | null): string { + if (ms === null || ms === undefined) return "–"; + return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`; +} + +function statusClass(request: RequestSummary): string { + if (request.state === "failed" || (request.status ?? 0) >= 400) { + return "text-destructive"; + } + if (request.state === "pending") return "text-muted-foreground"; + if ((request.status ?? 0) >= 300) return "text-warning"; + return "text-foreground"; +} + +function usePageVisible(): boolean { + const [visible, setVisible] = useState( + typeof document === "undefined" || document.visibilityState === "visible", + ); + useEffect(() => { + const onChange = () => setVisible(document.visibilityState === "visible"); + document.addEventListener("visibilitychange", onChange); + return () => document.removeEventListener("visibilitychange", onChange); + }, []); + return visible; +} + +function Headers({ + title, + headers, +}: { + title: string; + headers: Record; +}) { + const { t } = useTranslation(); + const entries = Object.entries(headers); + return ( +
    +

    {title}

    + {entries.length === 0 ? ( +

    + {t("networkPanel.noHeaders")} +

    + ) : ( +
    + {entries.map(([name, value]) => ( +
    +
    {name}:
    +
    + {String(value)} +
    +
    + ))} +
    + )} +
    + ); +} + +export function NetworkRequestsPanel({ + profileId, + running, +}: { + profileId: string; + running: boolean; +}) { + const { t } = useTranslation(); + const motionAllowed = useMotionAllowed(); + const pageVisible = usePageVisible(); + const [capturing, setCapturing] = useState(false); + const [busy, setBusy] = useState(false); + const [requests, setRequests] = useState([]); + const [withheld, setWithheld] = useState(0); + const [fresh, setFresh] = useState>(new Set()); + const [selected, setSelected] = useState(null); + const [detail, setDetail] = useState(null); + const clearedAfter = useRef(0); + const lastId = useRef(0); + const known = useRef | null>(null); + const scrollRef = useRef(null); + const stickToEnd = useRef(true); + + const reset = useCallback(() => { + setRequests([]); + setFresh(new Set()); + setSelected(null); + setDetail(null); + setWithheld(0); + known.current = null; + clearedAfter.current = 0; + }, []); + + const poll = useCallback(async () => { + try { + const list = await invoke("list_network_requests", { + profileId, + after: clearedAfter.current, + limit: LIST_LIMIT, + }); + if (!list.capturing) { + setCapturing(false); + return; + } + setCapturing(true); + const next = list.requests ?? []; + lastId.current = list.last_id ?? lastId.current; + const previous = known.current; + known.current = new Set(next.map((request) => request.id)); + setFresh( + previous + ? new Set( + next + .filter((request) => !previous.has(request.id)) + .map((request) => request.id), + ) + : new Set(), + ); + setRequests(next); + setWithheld(list.withheld ?? 0); + } catch (error) { + console.warn("Failed to read the network capture:", error); + } + }, [profileId]); + + useEffect(() => { + reset(); + if (running) void poll(); + else setCapturing(false); + }, [running, poll, reset]); + + useEffect(() => { + if (!running || !capturing || !pageVisible) return; + const id = window.setInterval(() => void poll(), POLL_MS); + return () => window.clearInterval(id); + }, [running, capturing, pageVisible, poll]); + + const selectedSummary = requests.find((request) => request.id === selected); + const selectedState = selectedSummary + ? `${selectedSummary.state}:${selectedSummary.status}` + : null; + + useEffect(() => { + if (selected === null || selectedState === null) return; + let cancelled = false; + void invoke("get_network_request", { + profileId, + requestId: selected, + }) + .then((value) => { + if (!cancelled) setDetail(value); + }) + .catch(() => { + if (!cancelled) setDetail(null); + }); + return () => { + cancelled = true; + }; + }, [profileId, selected, selectedState]); + + useLayoutEffect(() => { + const element = scrollRef.current; + if (element && stickToEnd.current && requests.length > 0) { + element.scrollTop = element.scrollHeight; + } + }, [requests]); + + const start = async () => { + setBusy(true); + try { + await invoke("start_network_capture", { profileId }); + reset(); + setCapturing(true); + stickToEnd.current = true; + await poll(); + } catch (error) { + showErrorToast(t("networkPanel.startFailed"), { + description: translateBackendError(t, error), + }); + } finally { + setBusy(false); + } + }; + + const stop = async () => { + setBusy(true); + try { + await invoke("stop_network_capture", { profileId }); + setCapturing(false); + reset(); + } catch (error) { + showErrorToast(t("networkPanel.stopFailed"), { + description: translateBackendError(t, error), + }); + } finally { + setBusy(false); + } + }; + + const clear = () => { + const after = lastId.current; + reset(); + clearedAfter.current = after; + }; + + if (selected !== null && detail) { + const { host } = splitUrl(detail.url); + return ( +
    +
    + +
    +
    +

    + {detail.method} + + {detail.status ?? t(`networkPanel.states.${detail.state}`)} + {detail.status_text ? ` ${detail.status_text}` : ""} + + {host} +

    +

    + {detail.url} +

    +

    + {[ + detail.type, + detail.protocol, + detail.mime, + formatBytes(detail.bytes), + formatMs(detail.ms), + detail.from_cache ? t("networkPanel.fromCache") : null, + ] + .filter(Boolean) + .join(" · ")} +

    + {detail.error && ( +

    {detail.error}

    + )} +
    + + + {detail.post_data && ( +
    +

    + {t("networkPanel.requestBody")} +

    +
    +              {detail.post_data}
    +              {detail.post_data_truncated ? "…" : ""}
    +            
    +
    + )} +

    + {t("networkPanel.redactedNote")} +

    +
    + ); + } + + return ( +
    +
    + {capturing ? ( + + ) : ( + + )} + {capturing && ( + <> + + + + {t("networkPanel.capturing", { count: requests.length })} + + + )} +
    + + {!capturing ? ( +
    +

    + {running + ? t("networkPanel.emptyStopped") + : t("networkPanel.emptyNotRunning")} +

    +

    + {t("networkPanel.privacyNote")} +

    +
    + ) : ( +
    +
    + {t("networkPanel.columns.method")} + {t("networkPanel.columns.status")} + {t("networkPanel.columns.host")} + {t("networkPanel.columns.path")} + {t("networkPanel.columns.type")} + {t("networkPanel.columns.size")} + {t("networkPanel.columns.time")} +
    +
    { + const element = scrollRef.current; + if (!element) return; + stickToEnd.current = + element.scrollHeight - + element.scrollTop - + element.clientHeight < + STICK_DISTANCE_PX; + }} + className="h-72 overflow-y-auto overflow-x-hidden py-1" + > + {requests.length === 0 ? ( +

    + {t("networkPanel.waiting")} +

    + ) : ( + requests.map((request, index) => { + const { host, path } = splitUrl(request.url); + const animate = + motionAllowed && + fresh.has(request.id) && + index >= requests.length - ANIMATED_TAIL; + return ( + + + + ); + }) + )} +
    + {withheld > 0 && ( +

    + {t("networkPanel.withheld", { count: withheld })} +

    + )} +
    + )} +
    + ); +} diff --git a/src/components/onboarding/scenes.tsx b/src/components/onboarding/scenes.tsx index 1f748c41..d342e5e3 100644 --- a/src/components/onboarding/scenes.tsx +++ b/src/components/onboarding/scenes.tsx @@ -160,7 +160,8 @@ export function IntroScene() { ; + as: LayerTag; + /** Where the entering layer starts. Entering content stays readable. */ + from: TargetAndTransition; + transition: Transition; + animate: boolean; + className?: string; + children: ReactNode; +} + +function Layer({ + ref, + as, + from, + transition, + animate, + className, + children, +}: LayerProps) { + const [isPresent, safeToRemove] = usePresence(); + // AnimatePresence initial={false}: what is there at mount does not enter. + const mountedWithParent = useContext(PresenceContext)?.initial === false; + const removed = useRef(false); + const remove = useRef(safeToRemove); + remove.current = safeToRemove; + + const finish = useCallback(() => { + if (removed.current) return; + removed.current = true; + remove.current?.(); + }, []); + + useEffect(() => { + if (isPresent) { + removed.current = false; + return; + } + if (!animate) { + finish(); + return; + } + const timer = window.setTimeout(finish, EXIT_FALLBACK_MS); + return () => window.clearTimeout(timer); + }, [isPresent, animate, finish]); + + const MotionTag = as === "span" ? motion.span : motion.div; + return ( + // The layer alone answers to AnimatePresence: motion components inside + // the page never hold an exit open. + + { + if (!isPresent) finish(); + }} + > + {children} + + + ); +} + +interface SwapProps { + /** A new key swaps the content: the new one enters, the old one fades. */ + swapKey: Key; + from: TargetAndTransition; + transition: Transition; + as?: LayerTag; + className?: string; + children: ReactNode; +} + +/** + * Swaps content with an entrance and a short fade-out that never blocks: + * the new content is in the DOM at once (mode="popLayout"), and the old one + * leaves within 200 ms whether or not its fade ran. + */ +export function Swap({ + swapKey, + from, + transition, + as = "div", + className, + children, +}: SwapProps) { + const animate = useMotionAllowed(); + return ( + + + {children} + + + ); +} + +interface PageTransitionProps { + pageKey: Key; + /** +1 when the new page is further down the rail, -1 when it is above. */ + direction: 1 | -1; + className?: string; + children: ReactNode; +} + +/** + * Moves between app pages: the new page slides 16px in from the side it + * lies on in the rail while the old one fades out. The parent must be + * positioned (`relative`) so the outgoing page can pop out of the flow. + */ +export function PageTransition({ + pageKey, + direction, + className, + children, +}: PageTransitionProps) { + return ( + + {children} + + ); +} diff --git a/src/components/paid-welcome-dialog.tsx b/src/components/paid-welcome-dialog.tsx index 33eef07f..5973ac5b 100644 --- a/src/components/paid-welcome-dialog.tsx +++ b/src/components/paid-welcome-dialog.tsx @@ -9,7 +9,7 @@ import { Logo } from "@/components/icons/logo"; import { TipIcon } from "@/components/tips/tip-icon"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; -import { useInputModality } from "@/hooks/use-input-modality"; +import { useMotionAllowed } from "@/hooks/use-motion-allowed"; import { MOTION_EASE_OUT } from "@/lib/motion"; import { isMacOS } from "@/lib/platform"; import { type TipDefinition, type TipId, tipTextKeys } from "@/lib/tips"; @@ -41,8 +41,7 @@ export function PaidWelcomeDialog({ }) { const { t } = useTranslation(); const reduceMotion = useReducedMotion(); - const modality = useInputModality(); - const animate = !reduceMotion && modality === "pointer"; + const animate = useMotionAllowed(); const mod = isMacOS() ? "⌘" : "Ctrl"; useEffect(() => { diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx index 8e1dfc8b..5c96bd73 100644 --- a/src/components/permission-dialog.tsx +++ b/src/components/permission-dialog.tsx @@ -23,10 +23,8 @@ interface PermissionDialogProps { permissionType: PermissionType; /** * Fired when the displayed permission becomes granted. The just-granted - * type is passed through so the parent can act optimistically — its own - * usePermissions instance polls on a 5 s cadence and would otherwise be - * stale right after the macOS system prompt is accepted, leaving the - * dialog open in a confusing state. + * type is passed through so the parent can move on before its own state + * shows the grant. */ onPermissionGranted?: (justGranted: PermissionType) => void; } diff --git a/src/components/pre-launch-gate-dialog.tsx b/src/components/pre-launch-gate-dialog.tsx index f055c7e2..d4356e75 100644 --- a/src/components/pre-launch-gate-dialog.tsx +++ b/src/components/pre-launch-gate-dialog.tsx @@ -19,6 +19,7 @@ import type { ConsistencyResult, DetectedVpnExtension, ExtensionScanState, + RouteUnreachable, } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -37,6 +38,9 @@ export interface GateFindings { /// of its own. Reported so a launch that verified nothing does not read as a /// launch that verified everything. Never a block. unverified: string[]; + /// The proxy or VPN did not answer a check just now. A warning: the user + /// may launch anyway. + routeUnreachable?: RouteUnreachable | null; } export interface GateDecision { @@ -180,6 +184,7 @@ export function PreLaunchGateDialog({ const fingerprint = findings?.fingerprint ?? null; const extensions = findings?.vpnExtensions ?? []; + const route = findings?.routeUnreachable ?? null; // Two different claims, kept visually apart. The first names extensions as // VPN/proxy tools; the second says only that an extension holds Chromium's // proxy permission, which a download manager needs to route its own @@ -324,6 +329,25 @@ export function PreLaunchGateDialog({ )} + {route && ( +
    +

    + {route.kind === "vpn" + ? t("prelaunchGate.routeVpnHeading") + : t("prelaunchGate.routeProxyHeading")} +

    +

    + {translateBackendError(t, JSON.stringify({ code: route.code }))} +

    +

    + {t("prelaunchGate.routeExplainer")} +

    +
    + )} + {vpnExtensions.length > 0 && (

    @@ -344,7 +368,7 @@ export function PreLaunchGateDialog({ )} {proxyCapableExtensions.length > 0 && ( -

    +

    {t("prelaunchGate.proxyCapableHeading")}

    diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index aa436833..2843a74b 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -11,34 +11,37 @@ import { type LegacyColumnDef as ColumnDef, getCoreRowModel, getSortedRowModel, + type LegacyRow as Row, useLegacyTable as useReactTable, } from "@tanstack/react-table/legacy"; import { useVirtualizer } from "@tanstack/react-virtual"; import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; +import { AnimatePresence, motion } from "motion/react"; import type { Dispatch, SetStateAction } from "react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { FiWifi } from "react-icons/fi"; import { LuArrowDown, LuArrowUp, LuArrowUpDown, LuCheck, LuChevronDown, + LuCircleAlert, LuCookie, LuInfo, LuLock, LuMoon, LuPlay, LuPuzzle, + LuRefreshCw, LuShuffle, LuSlidersHorizontal, LuSquare, LuTrash2, LuTriangleAlert, - LuUserSearch, LuUsers, + LuWifi, } from "react-icons/lu"; import { BulkEditDialog } from "@/components/bulk-edit-dialog"; import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog"; @@ -73,6 +76,25 @@ import { ProfileStatusChip, ProfileStatusesDialog, } from "@/components/profile-statuses-dialog"; +import { + type FitColumn, + fitColumns, +} from "@/components/profile-table/column-fit"; +import { + type ProfilesEmptyKind, + ProfilesEmptyState, +} from "@/components/profile-table/empty-states"; +import { + LaunchControl, + LiveDot, +} from "@/components/profile-table/launch-control"; +import { RowContextMenuContent } from "@/components/profile-table/row-context-menu"; +import { RowPresence } from "@/components/profile-table/row-presence"; +import { + RunningTrafficCell, + useTrafficPolling, +} from "@/components/profile-table/traffic-cell"; +import { TrafficSnapshotStore } from "@/components/profile-table/traffic-snapshots"; import { SaveTemplateDialog } from "@/components/save-template-dialog"; import { DATA_TABLE_CLASSES, @@ -92,6 +114,7 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { CopyToClipboard } from "@/components/ui/copy-to-clipboard"; import { DropdownMenu, @@ -101,6 +124,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { FOCUS_RING } from "@/components/ui/focus-ring"; import { Popover, PopoverContent, @@ -123,13 +147,15 @@ import { import { useBrowserState } from "@/hooks/use-browser-state"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; -import { useLaunchActivity } from "@/hooks/use-launch-activity"; +import { useMotionAllowed } from "@/hooks/use-motion-allowed"; import { useProxyEvents } from "@/hooks/use-proxy-events"; +import { type ProxyHealth, useProxyHealth } from "@/hooks/use-proxy-health"; import { useRemoteHandoff } from "@/hooks/use-remote-handoff"; import { useScrollFade } from "@/hooks/use-scroll-fade"; import { useTablePreferences } from "@/hooks/use-table-preferences"; import { useTableKeyboard } from "@/hooks/use-table-tools"; import { useTeamLocks } from "@/hooks/use-team-locks"; +import { confirmLeave } from "@/hooks/use-unsaved-changes"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { @@ -149,6 +175,13 @@ import { import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { canUseCookieBot } from "@/lib/entitlements"; import { formatRelativeTime } from "@/lib/flag-utils"; +import { + MOTION_EASE_IN, + MOTION_EASE_OUT, + MOTION_SPRING, + MOTION_SPRING_POSITION, +} from "@/lib/motion"; +import { isMacOS } from "@/lib/platform"; import type { RemoteHandoffState } from "@/lib/remote-sessions"; import { resolveTableVisibility } from "@/lib/table-tools"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; @@ -161,21 +194,19 @@ import type { ProfileStatus, StoredProxy, SyncSessionInfo, - TrafficSnapshot, VpnConfig, } from "@/types"; -import { BandwidthMiniChart } from "./bandwidth-mini-chart"; import { + ACTION_BAR_BUTTON_CLASS, DataTableActionBar, DataTableActionBarAction, DataTableActionBarSelection, + tableSelectionCounts, } from "./data-table-action-bar"; -import { Logo } from "./icons/logo"; import MultipleSelector, { type Option } from "./multiple-selector"; import { ProxyCheckButton } from "./proxy-check-button"; import { TrafficDetailsDialog } from "./traffic-details-dialog"; import { Input } from "./ui/input"; -import { RippleButton } from "./ui/ripple"; import { Skeleton } from "./ui/skeleton"; declare module "@tanstack/table-core" { @@ -193,47 +224,21 @@ declare module "@tanstack/table-core" { // Stable table meta type to pass volatile state/handlers into TanStack Table without // causing column definitions to be recreated on every render. -/** - * The launch control's tooltip. While a launch is in flight it opens by - * itself and names the stage the backend is in, so a multi-second launch - * reads as progress rather than a bare spinner. It is click-through for - * that time, so it can never sit between the pointer and a neighbouring row. - */ -function LaunchControlTooltip({ - profileId, - launching, - content, - children, -}: { - profileId: string; - launching: boolean; - content: React.ReactNode; - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const stage = useLaunchActivity(profileId).at(-1)?.stage; - const live = - launching && - stage !== undefined && - stage !== "running" && - stage !== "failed"; - return ( - - {children} - {(live || content) && ( - - {live ? t(`appFeedback.launch.${stage}`) : content} - - )} - - ); -} - interface TableMeta { t: (key: string, options?: Record) => string; + /** Decorative motion may run (pointer user, no reduced motion). */ + motionAllowed: boolean; + /** Profile id -> raw error of its last failed launch. */ + launchErrors: Record; + /** Measured route health of running profiles. */ + proxyHealth: Map; + openDetails: ( + profile: BrowserProfile, + method: "pointer" | "keyboard", + ) => void; + startRename: (profile: BrowserProfile) => void; + launchRow: (profile: BrowserProfile) => void; + stopRow: (profile: BrowserProfile) => void; selectedProfiles: string[]; selectedDragProfiles: BrowserProfile[]; canDragProfiles: (profiles: BrowserProfile[]) => boolean; @@ -267,6 +272,8 @@ interface TableMeta { setOpenProxySelectorFor: React.Dispatch>; proxyOverrides: Record; storedProxies: StoredProxy[]; + /** The proxies a row's picker lists: the user's own, not cloud ones. */ + pickableProxies: StoredProxy[]; handleProxySelection: ( profileId: string, proxyId: string | null, @@ -306,12 +313,6 @@ interface TableMeta { isRenamingSaving: boolean; renameError: string | null; - // Launch/stop helpers - setLaunchingProfiles: React.Dispatch>>; - setStoppingProfiles: React.Dispatch>>; - onKillProfile: (profile: BrowserProfile) => void | Promise; - onLaunchProfile: (profile: BrowserProfile) => void | Promise; - // Overflow actions onAssignProfilesToGroup?: (profileIds: string[]) => void; onConfigureWayfern?: (profile: BrowserProfile) => void; @@ -319,8 +320,9 @@ interface TableMeta { onCopyCookiesToProfile?: (profile: BrowserProfile) => void; onOpenCookieManagement?: (profile: BrowserProfile) => void; - // Traffic snapshots (lightweight real-time data) - trafficSnapshots: Record; + // Live traffic of running rows. A store, not state: a poll re-renders + // only the traffic cells whose numbers changed. + trafficStore: TrafficSnapshotStore; onOpenTrafficDialog?: (profileId: string) => void; // Sync @@ -330,8 +332,9 @@ interface TableMeta { crossOsUnlocked?: boolean; syncUnlocked?: boolean; - // Country proxy creation (inline in proxy dropdown) - countries: LocationItem[]; + // Country proxy creation (inline in proxy dropdown): the countries that + // have no proxy yet. + creatableCountries: LocationItem[]; canCreateLocationProxy: boolean; loadCountries: () => Promise; handleCreateCountryProxy: ( @@ -384,6 +387,68 @@ interface TableMeta { onBotFix: ((profile: BrowserProfile, fix: PreflightFix) => void) | null; } +/** + * What a row can do right now. The launch button, the row menu, a + * double-click and Mod+Enter all read this, so they can never disagree. + */ +function getRowRuntime(profile: BrowserProfile, meta: TableMeta) { + const handoff = meta.getRemoteHandoff(profile.id); + // A profile open on the fleet IS running, and the control has to say so: + // it is the control that stops it, and stopping now reaches the remote + // browser rather than looking for a local process that was never there. + const isRunningRemotely = handoff === "running"; + const isPendingRemotePull = handoff === "pending_sync"; + const isRunning = + (meta.isClient && meta.runningProfiles.has(profile.id)) || + isRunningRemotely; + const isLaunching = meta.launchingProfiles.has(profile.id); + const isStopping = meta.stoppingProfiles.has(profile.id); + const isLockedByAnother = meta.isProfileLockedByAnother(profile.id); + const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing"; + // A remote session holds the profile lock under a different holder than + // this device, so `isLockedByAnother` is true for the user's OWN remote + // session. That must not disable the control that stops it. + const canLaunch = isRunningRemotely + ? true + : meta.browserState.canLaunchProfile(profile) && + !isPendingRemotePull && + !isLockedByAnother && + !isSyncing; + return { + isRunningRemotely, + isPendingRemotePull, + isRunning, + isLaunching, + isStopping, + isLockedByAnother, + canLaunch, + canToggle: canLaunch && !isLaunching && !isStopping, + isRuntimeLocked: + (meta.isClient && meta.runningProfiles.has(profile.id)) || + isLaunching || + isStopping, + }; +} + +/** Launches a stopped row or stops a running one, when it can. */ +function toggleRowRun(profile: BrowserProfile, meta: TableMeta) { + const runtime = getRowRuntime(profile, meta); + if (!runtime.canToggle) return; + if (runtime.isRunning) meta.stopRow(profile); + else meta.launchRow(profile); +} + +/** A backend error as the person reads it; a plain string gets `fallback`. */ +function readableError( + t: TableMeta["t"], + error: unknown, + fallback: string, +): string { + return parseBackendError(error) + ? translateBackendError(t as never, error) + : fallback; +} + /** * Below this container width the bot column keeps its state mark but drops the * "next run" label: an operator still sees at a glance which rows are enrolled @@ -392,15 +457,36 @@ interface TableMeta { */ const BOT_LABEL_WIDTH = 880; -/** Below this the bot column leaves entirely, like the other low-priority ones. */ -const BOT_COLUMN_MIN_WIDTH = 400; +/** The name column keeps at least this much; other columns leave first. */ +const NAME_MIN_WIDTH = 220; /** - * Above this the table has room for the profile id. Below it the name column, - * which takes whatever the fixed columns leave over, needs those 100px more - * than a value that is already one click away in the info dialog. + * The columns that always show: select (wide enough to show the browser or + * OS icon beside the checkbox), launch, sync and details. */ -const PROFILE_ID_MIN_WIDTH = 1152; +const SELECT_COLUMN_WIDTH = 56; +const FIXED_COLUMNS_WIDTH = SELECT_COLUMN_WIDTH + 44 + 28 + 32; + +/** + * Optional columns, most expendable first, with their floor and their share + * of the container. The id, the note and the tags leave first: the details + * panel and the search reach them. DNS and status stay longest: they are the + * controls the onboarding tour and the tips point at. The proxy column stays: + * it is where a running row shows its traffic. + */ +const FIT_COLUMNS: FitColumn[] = [ + { id: "profileId", floor: 100, pct: 0 }, + { id: "note", floor: 80, pct: 0.1 }, + { id: "tags", floor: 100, pct: 0.12 }, + { id: "ext", floor: 95, pct: 0.11 }, + { id: "dns", floor: 95, pct: 0.11 }, + { id: "state", floor: 90, pct: 0.1 }, + { id: "bot", floor: 84, pct: 0.09 }, + { id: "proxy", floor: 110, pct: 0.13, pinned: true }, +]; + +/** Rows animate on sort, filter and group changes only while in view. */ +const MAX_ANIMATED_ROWS = 30; /** Bulk enrolments of this size or larger are confirmed, as run and stop are. */ const BULK_ENROL_CONFIRM_THRESHOLD = 10; @@ -421,6 +507,19 @@ interface SyncStatusDot { encrypted: boolean; } +/** Formatted last-sync times: formatting a date per row per render is slow. */ +const syncTimeLabels = new Map(); + +function formatSyncTime(seconds: number): string { + let label = syncTimeLabels.get(seconds); + if (label === undefined) { + if (syncTimeLabels.size > 1000) syncTimeLabels.clear(); + label = new Date(seconds * 1000).toLocaleString(); + syncTimeLabels.set(seconds, label); + } + return label; +} + function getProfileSyncStatusDot( profile: BrowserProfile, liveStatus: @@ -460,18 +559,23 @@ function getProfileSyncStatusDot( color: "bg-success", tooltip: profile.last_sync ? t("profileTable.syncTooltipSyncedAt", { - time: new Date(profile.last_sync * 1000).toLocaleString(), + time: formatSyncTime(profile.last_sync), }) : t("profileTable.syncTooltipSynced"), animate: false, encrypted, }; case "error": + // The backend now sends error codes; an older plain English string + // stays out of the UI and reads as the generic line. return { color: "bg-destructive", - tooltip: errorMessage - ? t("profileTable.syncTooltipErrorWith", { error: errorMessage }) - : t("profileTable.syncTooltipError"), + tooltip: + errorMessage && parseBackendError(errorMessage) + ? t("profileTable.syncTooltipErrorWith", { + error: translateBackendError(t as never, errorMessage), + }) + : t("profileTable.syncTooltipError"), animate: false, encrypted, }; @@ -525,6 +629,11 @@ function EmptyCellValue({ ); } +/** Names an editable cell's control for screen readers: "Tags for X: ". */ +function CellLabel({ text }: { text: string }) { + return {`${text}: `}; +} + // Inline extension-group dropdown for the Ext column. Matches the // proxy column's Popover-style picker — no nested dialog. function ExtCell({ @@ -550,7 +659,7 @@ function ExtCell({ extensionGroupId: nextId, }); } catch (err) { - console.error("Failed to assign extension group:", err); + showErrorToast(translateBackendError(meta.t as never, err)); } finally { setIsSaving(false); setOpen(false); @@ -563,8 +672,16 @@ function ExtCell({ {hasAssignment && isOverflowing && ( @@ -1296,6 +1550,8 @@ const NoteCell = React.memo<{ const onNoteChange = React.useCallback( async (newNote: string | null) => { const trimmedNote = newNote?.trim() ?? null; + const before = effectiveNote; + if (trimmedNote === (before?.trim() ?? null)) return; setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote })); try { await invoke("update_profile_note", { @@ -1303,10 +1559,11 @@ const NoteCell = React.memo<{ note: trimmedNote, }); } catch (error) { - console.error("Failed to update note:", error); + setNoteOverrides((prev) => ({ ...prev, [profile.id]: before })); + showErrorToast(translateBackendError(t as never, error)); } }, - [profile.id, setNoteOverrides], + [profile.id, effectiveNote, setNoteOverrides, t], ); const editorRef = React.useRef(null); @@ -1382,6 +1639,7 @@ const NoteCell = React.memo<{ type="button" className={cn( "group/cell flex min-h-6 w-full min-w-0 items-center rounded-md border-none bg-transparent px-2 py-1 text-left transition-colors duration-100", + FOCUS_RING, isDisabled ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:bg-foreground/6", @@ -1393,6 +1651,9 @@ const NoteCell = React.memo<{ } }} > + {effectiveNote ? ( {displayNote} @@ -1446,6 +1707,7 @@ const NoteCell = React.memo<{ setOpenNoteEditorFor(null); }} placeholder={t("profiles.note.placeholder")} + aria-label={t("profileTable.aria.note", { name: profile.name })} className="max-h-[200px] min-h-6 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm focus:ring-0 focus:outline-none" style={{ overflow: "auto", @@ -1650,6 +1912,164 @@ const BotCell = React.memo<{ BotCell.displayName = "BotCell"; +type CellProps = ( + rowId: string, + columnId: string, +) => { + "data-table-column": string; + tabIndex: number; + onFocus: () => void; +}; + +/** + * One table row. Memoized: it re-renders when its profile, the table meta, + * its selection, focus, motion or the column layout change, and never for + * a parent render that changed none of them. + */ +const ProfileTableRow = React.memo(function ProfileTableRow({ + ref, + row, + meta, + rowIndex, + rowHeight, + selected, + inspected, + moving, + crossOsTitle, + cellPropsRef, + columnWidth, + sizing, + layoutDependency, + riseKind, + riseIndex, + flashAt, + fades, +}: { + ref?: React.Ref; + row: Row; + meta: TableMeta; + rowIndex: number; + rowHeight: number; + selected: boolean; + inspected: boolean; + moving: boolean; + crossOsTitle: string | undefined; + /** + * The column of the table's one tab stop, when it is in this row. Read by + * the memo only: the cells take their tab stop from `cellPropsRef`. + */ + activeColumn: string | null; + cellPropsRef: React.RefObject; + /** The visible column ids, read by the memo: a change redraws the cells. */ + columnsKey: string; + columnWidth: (id: string, sizePx: number) => string; + sizing: Record; + layoutDependency: string; + /** Set only in the moment rows enter after a load or an order change. */ + riseKind: "load" | "change" | null; + riseIndex: number; + /** Set while a new profile's row flashes once. */ + flashAt: number | null; + fades: (id: string) => boolean; +}) { + const rise = + riseKind === "load" + ? riseIndex < 8 + ? { + initial: { opacity: 0.6, y: 4 }, + duration: 0.2, + delay: riseIndex * 0.02, + } + : null + : riseKind === "change" + ? { + initial: { opacity: 0.5, y: 4 }, + duration: 0.14, + delay: Math.min(riseIndex * 0.015, 0.15), + } + : null; + const flashing = flashAt !== null; + return ( + fades(row.id)}> + {(rowRef) => ( + { + const target = event.target as HTMLElement; + const control = target.closest( + 'button, a, input, textarea, [role="checkbox"], [role="combobox"], [role="button"]', + ); + if (control && !control.hasAttribute("data-row-name")) return; + window.getSelection()?.removeAllRanges(); + toggleRowRun(row.original, meta); + }} + > + {row.getVisibleCells().map((cell, columnIndex) => ( + + {flashing && ( + + ))} + + )} + + ); +}); + interface ProfilesDataTableProps { filterControls?: React.ReactNode; onClearFilters?: () => void; @@ -1719,8 +2139,20 @@ interface ProfilesDataTableProps { showOnboardingEmptyState?: boolean; onCreateProfile?: () => void; onImportProfiles?: () => void; + /** Profile id -> raw error of its last failed launch. */ + launchErrors?: Record; + /** Opens profile creation with the selected group preset. */ + onCreateProfileInGroup?: () => void; + /** Opens a picker that moves existing profiles into the selected group. */ + onMoveProfilesHere?: () => void; + onBulkEnableSync?: () => void; + onDuplicateProfile?: (profile: BrowserProfile) => void; + onLockProfile?: (profile: BrowserProfile) => void; + onRunRemote?: (profile: BrowserProfile) => void; } +const EMPTY_LAUNCH_ERRORS: Record = {}; + const EMPTY_STATUSES: ProfileStatus[] = []; export function ProfilesDataTable({ @@ -1771,8 +2203,19 @@ export function ProfilesDataTable({ showOnboardingEmptyState = false, onCreateProfile, onImportProfiles, + launchErrors = EMPTY_LAUNCH_ERRORS, + onCreateProfileInGroup, + onMoveProfilesHere, + onBulkEnableSync, + onDuplicateProfile, + onLockProfile, + onRunRemote, + onDeleteSelectedProfiles, + selectedGroupId, }: ProfilesDataTableProps) { const { t } = useTranslation(); + const motionAllowed = useMotionAllowed(); + const proxyHealth = useProxyHealth(); const profileDrag = useProfileGroupDrag(); const [internalInfoOpenMethod, setInternalInfoOpenMethod] = React.useState< "pointer" | "keyboard" @@ -1908,9 +2351,7 @@ export function ProfilesDataTable({ const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState< string | null >(null); - const [trafficSnapshots, setTrafficSnapshots] = React.useState< - Record - >({}); + const [trafficStore] = React.useState(() => new TrafficSnapshotStore()); const [trafficDialogProfile, setTrafficDialogProfile] = React.useState<{ id: string; name?: string; @@ -1945,42 +2386,54 @@ export function ProfilesDataTable({ } }, []); - const handleProxySelection = React.useCallback( - async (profileId: string, proxyId: string | null) => { + /** + * Shows a new proxy or VPN at once and puts the old one back when the + * backend refuses, with the reason in a toast. + */ + const applyRouteChange = React.useCallback( + async ( + profileId: string, + next: { proxyId: string | null; vpnId: string | null }, + save: () => Promise, + ) => { + const profile = allProfiles.find((item) => item.id === profileId); + const before = { + proxy: Object.hasOwn(proxyOverrides, profileId) + ? proxyOverrides[profileId] + : (profile?.proxy_id ?? null), + vpn: Object.hasOwn(vpnOverrides, profileId) + ? vpnOverrides[profileId] + : (profile?.vpn_id ?? null), + }; + setProxyOverrides((prev) => ({ ...prev, [profileId]: next.proxyId })); + setVpnOverrides((prev) => ({ ...prev, [profileId]: next.vpnId })); + setOpenProxySelectorFor(null); try { - await invoke("update_profile_proxy", { - profileId, - proxyId, - }); - setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId })); - setVpnOverrides((prev) => ({ ...prev, [profileId]: null })); + await save(); await emit("profile-updated"); } catch (error) { - console.error("Failed to update proxy settings:", error); - } finally { - setOpenProxySelectorFor(null); + setProxyOverrides((prev) => ({ ...prev, [profileId]: before.proxy })); + setVpnOverrides((prev) => ({ ...prev, [profileId]: before.vpn })); + showErrorToast(translateBackendError(t as never, error)); } }, - [], + [allProfiles, proxyOverrides, vpnOverrides, t], + ); + + const handleProxySelection = React.useCallback( + (profileId: string, proxyId: string | null) => + applyRouteChange(profileId, { proxyId, vpnId: null }, () => + invoke("update_profile_proxy", { profileId, proxyId }), + ), + [applyRouteChange], ); const handleVpnSelection = React.useCallback( - async (profileId: string, vpnId: string | null) => { - try { - await invoke("update_profile_vpn", { - profileId, - vpnId, - }); - setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId })); - setProxyOverrides((prev) => ({ ...prev, [profileId]: null })); - await emit("profile-updated"); - } catch (error) { - console.error("Failed to update VPN settings:", error); - } finally { - setOpenProxySelectorFor(null); - } - }, - [], + (profileId: string, vpnId: string | null) => + applyRouteChange(profileId, { proxyId: null, vpnId }, () => + invoke("update_profile_vpn", { profileId, vpnId }), + ), + [applyRouteChange], ); const handleCreateCountryProxy = React.useCallback( @@ -2007,10 +2460,10 @@ export function ProfilesDataTable({ } setOpenProxySelectorFor(null); } catch (error) { - console.error("Failed to create country proxy:", error); + showErrorToast(translateBackendError(t as never, error)); } }, - [handleProxySelection], + [handleProxySelection, t], ); const getProfileBotState = React.useCallback( @@ -2170,10 +2623,12 @@ export function ProfilesDataTable({ error?: string; }>("profile-sync-status", (event) => { const { profile_id, status, error } = event.payload; - setSyncStatuses((prev) => ({ - ...prev, - [profile_id]: { status, error }, - })); + setSyncStatuses((prev) => + prev[profile_id]?.status === status && + prev[profile_id]?.error === error + ? prev + : { ...prev, [profile_id]: { status, error } }, + ); }); } catch (error) { console.error("Failed to listen for sync status events:", error); @@ -2184,76 +2639,13 @@ export function ProfilesDataTable({ }; }, [browserState.isClient]); - // Fetch traffic snapshots for running profiles (lightweight, real-time data) - // Convert Set to sorted array to avoid Set reference comparison issues in dependencies + // Live traffic of running rows, polled into a store the traffic cells + // read: a tick never re-renders the table. const runningProfileIds = React.useMemo( () => Array.from(runningProfiles).sort(), [runningProfiles], ); - const runningCount = runningProfileIds.length; - React.useEffect(() => { - if (!browserState.isClient) return; - - if (runningCount === 0) { - setTrafficSnapshots({}); - return; - } - - const fetchTrafficSnapshots = async () => { - try { - const allSnapshots = await invoke( - "get_all_traffic_snapshots", - ); - const newSnapshots: Record = {}; - // O(1) membership; runningProfileIds.includes() in this loop would be - // O(snapshots * runningProfiles). - const runningSet = new Set(runningProfileIds); - for (const snapshot of allSnapshots) { - if (snapshot.profile_id) { - // Only keep snapshots for profiles that are currently running - if (runningSet.has(snapshot.profile_id)) { - const existing = newSnapshots[snapshot.profile_id]; - if (!existing || snapshot.last_update > existing.last_update) { - newSnapshots[snapshot.profile_id] = snapshot; - } - } - } - } - setTrafficSnapshots(newSnapshots); - } catch (error) { - console.error("Failed to fetch traffic snapshots:", error); - } - }; - - void fetchTrafficSnapshots(); - const interval = setInterval(() => { - void fetchTrafficSnapshots(); - }, 1000); - return () => { - clearInterval(interval); - }; - }, [browserState.isClient, runningCount, runningProfileIds]); - - // Clean up snapshots for profiles that are no longer running - React.useEffect(() => { - if (!browserState.isClient) return; - - setTrafficSnapshots((prev) => { - const cleaned: Record = {}; - const runningSet = new Set(runningProfileIds); - for (const [profileId, snapshot] of Object.entries(prev)) { - // Only keep snapshots for profiles that are currently running - if (runningSet.has(profileId)) { - cleaned[profileId] = snapshot; - } - } - // Only update if something was removed - if (Object.keys(cleaned).length !== Object.keys(prev).length) { - return cleaned; - } - return prev; - }); - }, [browserState.isClient, runningProfileIds]); + useTrafficPolling(trafficStore, runningProfileIds, browserState.isClient); // Clear launching/stopping spinners when backend reports running status changes React.useEffect(() => { @@ -2333,47 +2725,96 @@ export function ProfilesDataTable({ browserState.isClient, ]); + // A search keeps the selection; only profiles that no longer exist leave. React.useLayoutEffect(() => { - const visible = new Set(profiles.map((profile) => profile.id)); + const existing = new Set(allProfiles.map((profile) => profile.id)); onSelectedProfilesChange((prev) => - prev.every((id) => visible.has(id)) + prev.every((id) => existing.has(id)) ? prev - : prev.filter((id) => visible.has(id)), + : prev.filter((id) => existing.has(id)), ); - }, [profiles, onSelectedProfilesChange]); + }, [allProfiles, onSelectedProfilesChange]); - // Automatically deselect profiles that become running, updating, launching, or stopping - React.useEffect(() => { - const newSet = new Set(selectedProfiles); - let hasChanges = false; - - for (const profileId of selectedProfiles) { - const profile = profiles.find((p) => p.id === profileId); - if (profile) { - const isRunning = - browserState.isClient && runningProfiles.has(profile.id); - const isLaunching = launchingProfiles.has(profile.id); - const isStopping = stoppingProfiles.has(profile.id); - - if (isRunning || isLaunching || isStopping) { - newSet.delete(profileId); - hasChanges = true; - } + const launchRow = React.useCallback( + async (profile: BrowserProfile) => { + setLaunchingProfiles((prev) => new Set(prev).add(profile.id)); + try { + await onLaunchProfile(profile); + } catch { + // The page reports a failed launch; the row only drops its ring. + } finally { + // The running state is tracked separately via profile-running-changed. + setLaunchingProfiles((prev) => { + const next = new Set(prev); + next.delete(profile.id); + return next; + }); } - } + }, + [onLaunchProfile], + ); - if (hasChanges) { - onSelectedProfilesChange(Array.from(newSet)); - } - }, [ - profiles, - runningProfiles, - launchingProfiles, - stoppingProfiles, - browserState.isClient, - onSelectedProfilesChange, - selectedProfiles, - ]); + const stopRow = React.useCallback( + async (profile: BrowserProfile) => { + const syncInfo = getProfileSyncInfo?.(profile.id); + if (syncInfo) { + try { + if (syncInfo.isLeader) { + // Stopping the leader stops every follower with it. + await invoke("stop_sync_session", { + sessionId: syncInfo.session.id, + }); + } else { + await invoke("remove_sync_follower", { + sessionId: syncInfo.session.id, + followerProfileId: profile.id, + }); + } + } catch (error) { + showErrorToast(translateBackendError(t as never, error)); + } + return; + } + setStoppingProfiles((prev) => new Set(prev).add(profile.id)); + try { + await onKillProfile(profile); + } catch { + // The page reports a failed stop; the row goes back to running. + setStoppingProfiles((prev) => { + const next = new Set(prev); + next.delete(profile.id); + return next; + }); + } + }, + [getProfileSyncInfo, onKillProfile, t], + ); + + const startRename = React.useCallback((profile: BrowserProfile) => { + setProfileToRename(profile); + setNewProfileName(profile.name); + setRenameError(null); + }, []); + + // Read through a ref so `openDetails`, and with it the table meta, stays + // the same while the panel switches profiles. + const shownInfoProfileRef = React.useRef(profileForInfoDialog); + shownInfoProfileRef.current = profileForInfoDialog; + const openDetails = React.useCallback( + (profile: BrowserProfile, method: "pointer" | "keyboard") => { + void (async () => { + const shown = shownInfoProfileRef.current; + // Switching rows unmounts the panel's draft: ask first. + if (shown && shown.id !== profile.id && !(await confirmLeave())) { + return; + } + setInternalInfoOpenMethod(method); + onInfoOpenMethodChange?.(method); + setProfileForInfoDialog(profile); + })(); + }, + [onInfoOpenMethodChange, setProfileForInfoDialog], + ); const handleRename = React.useCallback(async () => { if (!profileToRename || !newProfileName.trim()) return; @@ -2477,9 +2918,52 @@ export function ProfilesDataTable({ handoffFor, ], ); + // Computed once per list, not per row. + const pickableProxies = React.useMemo( + () => + storedProxies.filter( + (proxy) => !proxy.is_cloud_managed && !proxy.is_cloud_derived, + ), + [storedProxies], + ); + const creatableCountries = React.useMemo( + () => + countries.filter( + (country) => + !storedProxies.some( + (proxy) => + proxy.is_cloud_derived && proxy.geo_country === country.code, + ), + ), + [countries, storedProxies], + ); + // Callbacks in the meta that read props through a ref: a parent that + // passes a new function on each render must not re-render every row. + const latestRef = React.useRef({ profiles, onLaunchWithSync }); + latestRef.current = { profiles, onLaunchWithSync }; + const openTrafficDialog = React.useCallback((profileId: string) => { + const profile = latestRef.current.profiles.find((p) => p.id === profileId); + setTrafficDialogProfile({ id: profileId, name: profile?.name }); + }, []); + const launchWithSync = React.useCallback((profile: BrowserProfile) => { + latestRef.current.onLaunchWithSync?.(profile); + }, []); + const cookieBotCompact = + containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH; const tableMeta = React.useMemo( () => ({ t, + motionAllowed, + launchErrors, + proxyHealth, + openDetails, + startRename, + launchRow: (profile: BrowserProfile) => { + void launchRow(profile); + }, + stopRow: (profile: BrowserProfile) => { + void stopRow(profile); + }, selectedProfiles, selectedDragProfiles, canDragProfiles, @@ -2509,6 +2993,7 @@ export function ProfilesDataTable({ setOpenProxySelectorFor, proxyOverrides, storedProxies, + pickableProxies, handleProxySelection, // VPN selector state @@ -2534,12 +3019,6 @@ export function ProfilesDataTable({ isRenamingSaving, renameError, - // Launch/stop helpers - setLaunchingProfiles, - setStoppingProfiles, - onKillProfile, - onLaunchProfile, - // Overflow actions onAssignProfilesToGroup, onCloneProfile: onCloneProfile @@ -2551,12 +3030,8 @@ export function ProfilesDataTable({ onCopyCookiesToProfile, onOpenCookieManagement, - // Traffic snapshots (lightweight real-time data) - trafficSnapshots, - onOpenTrafficDialog: (profileId: string) => { - const profile = profiles.find((p) => p.id === profileId); - setTrafficDialogProfile({ id: profileId, name: profile?.name }); - }, + trafficStore, + onOpenTrafficDialog: openTrafficDialog, // Sync syncStatuses, @@ -2566,7 +3041,7 @@ export function ProfilesDataTable({ syncUnlocked, // Country proxy creation - countries, + creatableCountries, canCreateLocationProxy, loadCountries, handleCreateCountryProxy, @@ -2581,15 +3056,11 @@ export function ProfilesDataTable({ // Synchronizer getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined), - onLaunchWithSync: - onLaunchWithSync ?? - (() => { - /* empty */ - }), + onLaunchWithSync: launchWithSync, // Cookie Bot cookieBotUnlocked, - cookieBotCompact: containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH, + cookieBotCompact, getProfileBotState, botPendingProfiles, onBotEnrol: handleBotEnrol, @@ -2606,6 +3077,13 @@ export function ProfilesDataTable({ }), [ t, + motionAllowed, + launchErrors, + proxyHealth, + openDetails, + startRename, + launchRow, + stopRow, selectedProfiles, selectedDragProfiles, canDragProfiles, @@ -2623,6 +3101,7 @@ export function ProfilesDataTable({ openProxySelectorFor, proxyOverrides, storedProxies, + pickableProxies, handleProxySelection, vpnConfigs, vpnOverrides, @@ -2633,11 +3112,9 @@ export function ProfilesDataTable({ profileToRename, newProfileName, isRenamingSaving, - trafficSnapshots, - profiles, + trafficStore, + openTrafficDialog, renameError, - onKillProfile, - onLaunchProfile, onAssignProfilesToGroup, onCloneProfile, onConfigureWayfern, @@ -2648,16 +3125,16 @@ export function ProfilesDataTable({ onToggleProfileSync, crossOsUnlocked, syncUnlocked, - countries, + creatableCountries, loadCountries, handleCreateCountryProxy, isProfileLocked, getLockInfo, handoffFor, getProfileSyncInfo, - onLaunchWithSync, + launchWithSync, cookieBotUnlocked, - containerWidth, + cookieBotCompact, getProfileBotState, botPendingProfiles, handleBotEnrol, @@ -2676,20 +3153,13 @@ export function ProfilesDataTable({ id: "select", header: ({ table }) => , cell: ({ row, table }) => { - const meta = table.options.meta as TableMeta; const profile = row.original; const crossOs = isCrossOsProfile(profile); const os = profile.host_os || profile.wayfern_config?.os || ""; const Icon = crossOs ? getOSIcon(os) : getProfileIcon(profile); const message = crossOs ? t("crossOs.viewOnly", { os: getOSDisplayName(os) }) - : meta.runningProfiles.has(profile.id) - ? t("profiles.table.cantModifyRunning") - : meta.launchingProfiles.has(profile.id) - ? t("profiles.table.cantModifyLaunching") - : meta.stoppingProfiles.has(profile.id) - ? t("profiles.table.cantModifyStopping") - : getBrowserDisplayName(profile.browser); + : getBrowserDisplayName(profile.browser); return ( {message}

    } @@ -2700,16 +3170,7 @@ export function ProfilesDataTable({ - ) : undefined - } + icon={Icon ? : undefined} />
    @@ -2718,7 +3179,7 @@ export function ProfilesDataTable({ enableSorting: false, enableHiding: false, enableResizing: false, - size: 28, + size: SELECT_COLUMN_WIDTH, }, { id: "actions", @@ -2726,71 +3187,16 @@ export function ProfilesDataTable({ cell: ({ row, table }) => { const meta = table.options.meta as TableMeta; const profile = row.original; - const handoff = meta.getRemoteHandoff(profile.id); - // A profile open on the fleet IS running, and the button has to say - // so: it is the control that stops it, and stopping now reaches the - // remote browser rather than looking for a local process that was - // never there. - const isRunningRemotely = handoff === "running"; - const isPendingRemotePull = handoff === "pending_sync"; - const isRunning = - (meta.isClient && meta.runningProfiles.has(profile.id)) || - isRunningRemotely; - const isLaunching = meta.launchingProfiles.has(profile.id); - const isStopping = meta.stoppingProfiles.has(profile.id); - const isLockedByAnother = meta.isProfileLockedByAnother(profile.id); - const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing"; - // A remote session holds the profile lock under a different holder - // than this device, so `isLockedByAnother` is true for the user's OWN - // remote session. That must not disable the control that stops it. - const canLaunch = isRunningRemotely - ? true - : meta.browserState.canLaunchProfile(profile) && - !isPendingRemotePull && - !isLockedByAnother && - !isSyncing; + const runtime = getRowRuntime(profile, meta); const lockEmail = meta.getProfileLockEmail(profile.id); - const tooltipContent = isRunningRemotely + const tooltipContent = runtime.isRunningRemotely ? meta.t("profiles.remote.runningTooltip") - : isPendingRemotePull + : runtime.isPendingRemotePull ? meta.t("profiles.remote.pendingSyncTooltip") - : isLockedByAnother + : runtime.isLockedByAnother ? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail }) : meta.browserState.getLaunchTooltipContent(profile); - const handleProfileStop = async (profile: BrowserProfile) => { - meta.setStoppingProfiles((prev: Set) => - new Set(prev).add(profile.id), - ); - try { - await meta.onKillProfile(profile); - } catch (error) { - meta.setStoppingProfiles((prev: Set) => { - const next = new Set(prev); - next.delete(profile.id); - return next; - }); - throw error; - } - }; - - const handleProfileLaunch = async (profile: BrowserProfile) => { - meta.setLaunchingProfiles((prev: Set) => - new Set(prev).add(profile.id), - ); - try { - await meta.onLaunchProfile(profile); - } finally { - // Always clear launching state — the running state is tracked - // separately via profile-running-changed events - meta.setLaunchingProfiles((prev: Set) => { - const next = new Set(prev); - next.delete(profile.id); - return next; - }); - } - }; - const syncInfo = meta.getProfileSyncInfo(profile.id); const isLeader = syncInfo?.isLeader === true; const isFollower = syncInfo?.isLeader === false; @@ -2803,31 +3209,6 @@ export function ProfilesDataTable({ }) : tooltipContent; - const handleStop = async () => { - if (isLeader && syncInfo) { - // Stop leader: invoke stop_sync_session which kills leader + all followers - try { - await invoke("stop_sync_session", { - sessionId: syncInfo.session.id, - }); - } catch (error) { - console.error("Failed to stop sync session:", error); - } - } else if (isFollower && syncInfo) { - // Stop follower: remove from session - try { - await invoke("remove_sync_follower", { - sessionId: syncInfo.session.id, - followerProfileId: profile.id, - }); - } catch (error) { - console.error("Failed to remove sync follower:", error); - } - } else { - await handleProfileStop(profile); - } - }; - return (
    {isDesynced && ( @@ -2844,50 +3225,18 @@ export function ProfilesDataTable({ )} - - - - isRunning - ? void handleStop() - : void handleProfileLaunch(profile) - } - > - - {isLaunching || isStopping ? ( - - ) : isRunning ? ( - - ) : ( - - )} - - - - + profileName={profile.name} + isRunning={runtime.isRunning} + isLaunching={runtime.isLaunching} + isStopping={runtime.isStopping} + canLaunch={runtime.canLaunch} + isFollower={isFollower} + tooltip={runtime.isRunning ? stopTooltip : tooltipContent} + onLaunch={() => meta.launchRow(profile)} + onStop={() => meta.stopRow(profile)} + />
    ); }, @@ -2984,14 +3333,11 @@ export function ProfilesDataTable({ const profile = row.original as BrowserProfile; const rawName: string = row.getValue("name"); const name = getBrowserDisplayName(rawName); - const isRuntimeLocked = - (meta.isClient && meta.runningProfiles.has(profile.id)) || - meta.launchingProfiles.has(profile.id) || - meta.stoppingProfiles.has(profile.id); + const runtime = getRowRuntime(profile, meta); const isCrossOsBlocked = isCrossOsProfile(profile); const isEditing = meta.profileToRename?.id === profile.id && - !isRuntimeLocked && + !runtime.isRuntimeLocked && !isCrossOsBlocked; if (isEditing) { @@ -3002,15 +3348,16 @@ export function ProfilesDataTable({ > { meta.setNewProfileName(e.target.value); if (meta.renameError) meta.setRenameError(null); }} onKeyDown={(e) => { - if (e.key === "Enter" && !(e.metaKey || e.ctrlKey)) { - void meta.handleRename(); - } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === "Enter") { void meta.handleRename(); } else if (e.key === "Escape") { meta.setProfileToRename(null); @@ -3036,51 +3383,20 @@ export function ProfilesDataTable({ ); } - const display = ( - - ); - const lockedEmail = meta.getProfileLockEmail(profile.id); const isLocked = meta.isProfileLockedByAnother(profile.id); const dragProfiles = meta.selectedProfiles.includes(profile.id) ? meta.selectedDragProfiles : [profile]; const canDrag = () => meta.canDragProfiles(dragProfiles); - const nameControl = isRuntimeLocked ? ( -
    - {display} -
    - ) : ( - - ); + const launchError = meta.launchErrors[profile.id]; + const launchErrorText = launchError + ? readableError( + meta.t, + launchError, + meta.t("appFeedback.launch.failed"), + ) + : null; return (
    @@ -3095,7 +3411,61 @@ export function ProfilesDataTable({ ) } /> - {nameControl} + + {launchErrorText && ( + + + + + + {launchErrorText} + + + )} - meta.onOpenTrafficDialog?.(profile.id)} - /> -
    + meta.onOpenTrafficDialog?.(profile.id)} + /> ); } @@ -3278,6 +3642,7 @@ export function ProfilesDataTable({ }} > - - { - if (meta.canCreateLocationProxy) - void meta.loadCountries(); - }} - /> - - - {t("createProfile.proxy.notFound")} - - - - void meta.handleProxySelection(profile.id, null) - } - > - - {t("common.labels.none")} - - {meta.storedProxies - .filter( - (proxy: StoredProxy) => - !proxy.is_cloud_managed && - !proxy.is_cloud_derived, - ) - .map((proxy: StoredProxy) => ( - - void meta.handleProxySelection( - profile.id, - proxy.id, - ) - } - > - - {proxy.name} - - ))} - - {meta.vpnConfigs.length > 0 && ( - - {meta.vpnConfigs.map((vpn) => ( - - void meta.handleVpnSelection( - profile.id, - vpn.id, - ) - } - > - - - WG - - {vpn.name} - - ))} - - )} - {meta.canCreateLocationProxy && - meta.countries.length > 0 && ( - - {meta.countries - .filter( - (c) => - !meta.storedProxies.some( - (p) => - p.is_cloud_derived && - p.geo_country === c.code, - ), - ) - .map((country) => ( - - void meta.handleCreateCountryProxy( - profile.id, - country, - ) - } - > - +{" "} - {country.name} - - ))} - - )} - - + )} @@ -3492,22 +3736,46 @@ export function ProfilesDataTable({ if (!dot) return null; return ( - - - - {dot.encrypted ? ( - - ) : ( - - )} - - - {dot.tooltip} - +
    + + + + + {dot.tooltip} + +
    ); }, }, @@ -3527,15 +3795,15 @@ export function ProfilesDataTable({ data-profile-id={profile.id} {...tipAnchor("row-inspect")} disabled={!meta.isClient} - onClick={(event) => { - const method = event.detail === 0 ? "keyboard" : "pointer"; - setInternalInfoOpenMethod(method); - onInfoOpenMethodChange?.(method); - setProfileForInfoDialog(profile); - }} + onClick={(event) => + meta.openDetails( + profile, + event.detail === 0 ? "keyboard" : "pointer", + ) + } > - {t("profiles.aria.profileInfo")} + {meta.t("profileTable.aria.details", { name: profile.name })} @@ -3544,22 +3812,56 @@ export function ProfilesDataTable({ }, }, ], - [t, setProfileForInfoDialog, onInfoOpenMethodChange], + [t], ); - // Low-priority columns leave the table as the container narrows (most - // expendable first); their data stays reachable via the profile info - // dialog. Visibility (not CSS hiding) so table-fixed reclaims the width. - // `bot` and `profileId` start hidden and are switched on by the resize effect - // below. An unentitled account must never see a paid column, not even for the - // frame before the observer's first measurement lands, and the id must not - // flash into a narrow table for that same frame. - const [columnVisibility, setColumnVisibility] = - React.useState({ + // Optional columns leave the table, most expendable first, until the name + // keeps NAME_MIN_WIDTH; their data stays reachable in the details panel. + // Visibility (not CSS hiding) so table-fixed reclaims the width. Before the + // first measurement `bot` and `profileId` stay hidden: an unentitled account + // must never see a paid column, and the id must not flash into a narrow + // table, not even for one frame. + const columnFit = React.useMemo(() => { + if (containerWidth <= 0) return null; + const choices = view.preferences.visibility; + const sizing = view.preferences.sizing; + const compactBot = containerWidth < BOT_LABEL_WIDTH; + const candidates: FitColumn[] = []; + for (const column of FIT_COLUMNS) { + if (column.id === "bot" && !cookieBotUnlocked) continue; + if (choices[column.id] === false) continue; + // The bot column has two shapes: a labelled state at full width, a + // bare 28px mark when the table is narrow. + const shaped = + column.id === "bot" && compactBot + ? { ...column, floor: 28, pct: 0 } + : column; + const resized = sizing[column.id]; + candidates.push({ + ...shaped, + ...(resized ? { floor: resized, pct: 0 } : {}), + pinned: shaped.pinned || choices[column.id] === true, + }); + } + return fitColumns( + containerWidth, + FIXED_COLUMNS_WIDTH, + candidates, + NAME_MIN_WIDTH, + ); + }, [ + containerWidth, + cookieBotUnlocked, + view.preferences.visibility, + view.preferences.sizing, + ]); + const columnVisibility = React.useMemo( + () => ({ created_at: false, - bot: false, - profileId: false, - }); + ...(columnFit ? columnFit.visible : { bot: false, profileId: false }), + }), + [columnFit], + ); const columnLabels = React.useMemo( () => [ @@ -3655,14 +3957,9 @@ export function ProfilesDataTable({ columnResizeMode: "onChange", onRowSelectionChange: handleRowSelectionChange, onColumnVisibilityChange: view.setVisibility, - enableRowSelection: (row) => { - const profile = row.original; - const isRunning = - browserState.isClient && runningProfiles.has(profile.id); - const isLaunching = launchingProfiles.has(profile.id); - const isStopping = stoppingProfiles.has(profile.id); - return !isRunning && !isLaunching && !isStopping; - }, + // Every row is selectable: bulk stop acts on running rows, bulk run on + // stopped ones, and each action counts the rows it applies to. + enableRowSelection: true, getSortedRowModel: getSortedRowModel(), getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, @@ -3681,31 +3978,33 @@ export function ProfilesDataTable({ observer.observe(element); return () => observer.disconnect(); }, []); + // The closed inspector slides out for 120ms. Motion may decorate that exit, + // never perform it: when a stalled clock never ends it, a fresh presence + // drops the ghost after 200ms. + const [inspectorPresence, setInspectorPresence] = React.useState(0); + const inspectorWasOpen = React.useRef(false); + React.useEffect(() => { + if (profileForInfoDialog) { + inspectorWasOpen.current = true; + return; + } + if (!inspectorWasOpen.current) return; + inspectorWasOpen.current = false; + const timer = window.setTimeout( + () => setInspectorPresence((value) => value + 1), + 200, + ); + return () => window.clearTimeout(timer); + }, [profileForInfoDialog]); const columnWidth = React.useCallback( (id: string, sizePx: number) => { - if (view.preferences.sizing[id]) - return `${view.preferences.sizing[id]}px`; - // The bot column is the one column with two shapes: a labelled state at - // full width, a bare mark when the table is narrow. Taking a proportion - // in the compact shape would waste the space the name column needs. - if (id === "bot") { - return containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH - ? "28px" - : `${Math.max(84, Math.round(containerWidth * 0.09))}px`; - } - const proportions: Record = { - state: { pct: 0.1, floor: 90 }, - tags: { pct: 0.12, floor: 100 }, - note: { pct: 0.1, floor: 80 }, - proxy: { pct: 0.13, floor: 110 }, - ext: { pct: 0.11, floor: 95 }, - dns: { pct: 0.11, floor: 95 }, - }; - const p = proportions[id]; - if (!p) return `${sizePx}px`; - return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`; + const resized = view.preferences.sizing[id]; + if (resized) return `${resized}px`; + const fitted = columnFit?.widths[id]; + if (fitted) return `${fitted}px`; + return `${FIT_COLUMNS.find((column) => column.id === id)?.floor ?? sizePx}px`; }, - [containerWidth, view.preferences.sizing], + [columnFit, view.preferences.sizing], ); const sortedRows = table.getRowModel().rows; useScrollFade(scrollParentRef); @@ -3714,28 +4013,7 @@ export function ProfilesDataTable({ const el = scrollParentRef.current; if (!el) return; const update = () => { - const w = el.clientWidth; - setContainerWidth(Math.round(w / 8) * 8); - setColumnVisibility((prev) => { - const next: VisibilityState = { - // Always hidden — sort-only column. - created_at: false, - // First to leave: pure metadata, and the info dialog still has it. - profileId: w >= PROFILE_ID_MIN_WIDTH, - dns: w >= 768, - ext: w >= 672, - state: w >= 560, - note: w >= 576, - tags: w >= 512, - // Bot state survives further down than the other content columns: - // by then it is a 28px mark, and it is the only place a row's - // enrolment and its actions can be reached. - bot: cookieBotUnlocked && w >= BOT_COLUMN_MIN_WIDTH, - }; - return Object.keys(next).every((k) => prev[k] === next[k]) - ? prev - : next; - }); + setContainerWidth(Math.round(el.clientWidth / 8) * 8); }; update(); const ro = new ResizeObserver(update); @@ -3743,7 +4021,7 @@ export function ProfilesDataTable({ return () => { ro.disconnect(); }; - }, [cookieBotUnlocked]); + }, []); // The virtual row estimate must match the selected row height. const ROW_HEIGHT = view.preferences.density === "comfortable" ? 48 : 36; @@ -3768,21 +4046,277 @@ export function ProfilesDataTable({ rowVirtualizer.scrollToIndex(index, { align: "auto" }), edit: (rowId, columnId) => { if (columnId !== "name") return false; - const profile = profiles.find((item) => item.id === rowId); + const profile = profilesById.get(rowId); if ( !profile || isCrossOsProfile(profile) || - runningProfiles.has(rowId) || - launchingProfiles.has(rowId) || - stoppingProfiles.has(rowId) + getRowRuntime(profile, tableMeta).isRuntimeLocked ) return true; - setProfileToRename(profile); - setNewProfileName(profile.name); - setRenameError(null); + startRename(profile); return true; }, }); + + /** + * Moves profiles to the Trash: the selection when there is one, else the + * given row. Running rows stay; the page refuses them anyway. + */ + const trashProfiles = React.useCallback( + (focused: BrowserProfile | undefined, onlyFocused = false) => { + const ids = + !onlyFocused && selectedProfiles.length > 0 + ? selectedProfiles + : focused + ? [focused.id] + : []; + const targets = ids + .map((id) => profilesById.get(id)) + .filter((profile): profile is BrowserProfile => { + if (!profile) return false; + const runtime = getRowRuntime(profile, tableMeta); + return !runtime.isRuntimeLocked && !runtime.isRunningRemotely; + }); + if (targets.length === 0) return; + // The Trash takes a profile back with Undo; an ephemeral profile is + // deleted for good, so it alone still asks first. + if (targets.length === 1) { + if (targets[0].ephemeral) setProfileToDelete(targets[0]); + else void onDeleteProfile(targets[0]); + } else if (targets.some((profile) => profile.ephemeral) && onBulkDelete) { + onBulkDelete(); + return; + } else { + void onDeleteSelectedProfiles(targets.map((profile) => profile.id)); + } + if (ids === selectedProfiles) onSelectedProfilesChange([]); + }, + [ + selectedProfiles, + profilesById, + tableMeta, + onDeleteProfile, + onDeleteSelectedProfiles, + onSelectedProfilesChange, + onBulkDelete, + ], + ); + + const menuOpenedByKeyboard = React.useRef(false); + + // Row keys on top of the cell navigation of useTableKeyboard: Space + // selects, Delete trashes, Mod+Enter launches or stops, Mod+A selects all, + // Enter on the name opens details, Shift+F10 or the Menu key opens the + // row menu. + const handleTableKeyDown = (event: React.KeyboardEvent) => { + const target = event.target as HTMLElement; + const editable = target.matches( + 'input, textarea, select, [contenteditable="true"]', + ); + if (editable || event.altKey) { + keyboard.onKeyDown(event); + return; + } + const rowId = + target.closest("[data-table-row]")?.dataset.tableRow; + const profile = rowId ? profilesById.get(rowId) : undefined; + const cell = target.closest("[data-table-column]"); + const onCell = cell !== null && cell === target; + const mod = isMacOS() ? event.metaKey : event.ctrlKey; + + if ( + profile && + (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) + ) { + event.preventDefault(); + menuOpenedByKeyboard.current = true; + const rect = target.getBoundingClientRect(); + target.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect.left + Math.min(24, rect.width / 2), + clientY: rect.bottom, + }), + ); + return; + } + if (mod && !event.shiftKey && event.key.toLowerCase() === "a") { + event.preventDefault(); + onSelectedProfilesChange(sortedRows.map((row) => row.id)); + return; + } + if (mod && event.key === "Enter" && profile) { + event.preventDefault(); + toggleRowRun(profile, tableMeta); + return; + } + if (profile && !mod && !event.shiftKey) { + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + trashProfiles(profile); + return; + } + if (onCell && event.key === " ") { + event.preventDefault(); + onSelectedProfilesChange((prev) => + prev.includes(profile.id) + ? prev.filter((id) => id !== profile.id) + : [...prev, profile.id], + ); + return; + } + if ( + onCell && + event.key === "Enter" && + cell?.dataset.tableColumn === "name" + ) { + event.preventDefault(); + openDetails(profile, "keyboard"); + return; + } + } + keyboard.onKeyDown(event); + }; + + // The row menu. One menu serves every row: the right-clicked row names + // the profile it acts on. + const [menuProfileId, setMenuProfileId] = React.useState(null); + const menuProfile = menuProfileId + ? (profilesById.get(menuProfileId) ?? null) + : null; + const menuKeepsFocus = React.useRef(false); + const inlineEditorOpen = Boolean( + profileToRename || openNoteEditorFor || openTagsEditorFor, + ); + const duplicateProfile = + onDuplicateProfile ?? + (onCloneProfile + ? (profile: BrowserProfile) => { + void onCloneProfile(profile); + } + : undefined); + const menuActions = (() => { + if (!menuProfile) return null; + const runtime = getRowRuntime(menuProfile, tableMeta); + const crossOs = isCrossOsProfile(menuProfile); + return { + isRunning: runtime.isRunning, + canToggleRun: runtime.canToggle, + canRename: !runtime.isRuntimeLocked && !crossOs, + canTrash: !runtime.isRuntimeLocked && !runtime.isRunningRemotely, + onToggleRun: () => toggleRowRun(menuProfile, tableMeta), + onOpenDetails: () => + openDetails( + menuProfile, + menuOpenedByKeyboard.current ? "keyboard" : "pointer", + ), + onRename: () => { + menuKeepsFocus.current = true; + startRename(menuProfile); + }, + onDuplicate: duplicateProfile + ? () => duplicateProfile(menuProfile) + : undefined, + onMoveToGroup: onAssignProfilesToGroup + ? () => onAssignProfilesToGroup([menuProfile.id]) + : undefined, + onRunRemote: onRunRemote ? () => onRunRemote(menuProfile) : undefined, + onLockNow: onLockProfile ? () => onLockProfile(menuProfile) : undefined, + onTrash: () => trashProfiles(menuProfile, true), + }; + })(); + + const groupIsEmpty = + Boolean(selectedGroupId) && + selectedGroupId !== "__all__" && + !allProfiles.some((profile) => profile.group_id === selectedGroupId); + const emptyKind: ProfilesEmptyKind = showOnboardingEmptyState + ? "onboarding" + : groupIsEmpty + ? "group" + : "filtered"; + + // How many selected rows each bulk action applies to. + const bulkCounts = React.useMemo(() => { + let run = 0; + let stop = 0; + for (const profile of selectedDragProfiles) { + const runtime = getRowRuntime(profile, tableMeta); + if (runtime.isLaunching || runtime.isStopping) continue; + if (runtime.isRunning) stop += 1; + else run += 1; + } + return { run, stop }; + }, [selectedDragProfiles, tableMeta]); + + // Row motion. Rows slide to their new place when the order changes (sort, + // filter, group, a row added or removed), never on scroll; rows that come + // into view with the change rise in. The first rows after the skeletons + // grow out of them. + const rowOrderKey = React.useMemo( + () => sortedRows.map((row) => row.id).join(","), + [sortedRows], + ); + const sawSkeletonRef = React.useRef(false); + const hadRowsRef = React.useRef(false); + if (isLoading && sortedRows.length === 0) sawSkeletonRef.current = true; + const entrance = React.useMemo(() => { + const kind: "load" | "change" | null = !rowOrderKey + ? null + : hadRowsRef.current + ? "change" + : sawSkeletonRef.current + ? "load" + : null; + return { kind, at: performance.now() }; + }, [rowOrderKey]); + React.useEffect(() => { + if (rowOrderKey) hadRowsRef.current = true; + }, [rowOrderKey]); + const entranceKind = + motionAllowed && + entrance.kind !== null && + performance.now() - entrance.at < 300 + ? entrance.kind + : null; + const allIds = React.useMemo( + () => new Set(allProfiles.map((profile) => profile.id)), + [allProfiles], + ); + const allIdsRef = React.useRef(allIds); + allIdsRef.current = allIds; + const motionAllowedRef = React.useRef(motionAllowed); + motionAllowedRef.current = motionAllowed; + + // A profile that appears after the first load (created, imported, back + // from the Trash) is scrolled to and flashes once. + const knownIdsRef = React.useRef | null>(null); + const [flash, setFlash] = React.useState<{ id: string; at: number } | null>( + null, + ); + React.useEffect(() => { + if (isLoading) return; + const known = knownIdsRef.current; + if (!known) { + knownIdsRef.current = new Set(allProfiles.map((profile) => profile.id)); + return; + } + const fresh = allProfiles.filter((profile) => !known.has(profile.id)); + for (const profile of fresh) known.add(profile.id); + if (fresh.length > 0) { + setFlash({ id: fresh[fresh.length - 1].id, at: Date.now() }); + } + }, [allProfiles, isLoading]); + // biome-ignore lint/correctness/useExhaustiveDependencies: runs once per new profile + React.useEffect(() => { + if (!flash) return; + const index = sortedRows.findIndex((row) => row.id === flash.id); + if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "auto" }); + const timer = window.setTimeout(() => setFlash(null), 1000); + return () => window.clearTimeout(timer); + }, [flash]); + const minimumWidth = Object.keys(view.preferences.sizing).length || Object.values(view.preferences.visibility).some(Boolean) @@ -3792,12 +4326,25 @@ export function ProfilesDataTable({ (total, column) => total + (column.id === "name" && !view.preferences.sizing.name - ? 220 + ? NAME_MIN_WIDTH : parseFloat(columnWidth(column.id, column.getSize()))), 0, ) : undefined; + // What the memoized rows read. A row re-renders only when one of its own + // props changes: a parent render with the same data leaves it alone. + const cellPropsRef = React.useRef(keyboard.cellProps); + cellPropsRef.current = keyboard.cellProps; + const visibleColumnIds = table + .getVisibleLeafColumns() + .map((column) => column.id); + const visibleColumnsKey = visibleColumnIds.join(","); + const rowFades = React.useCallback( + (id: string) => motionAllowedRef.current && !allIdsRef.current.has(id), + [], + ); + const virtualRows = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; @@ -3821,7 +4368,7 @@ export function ProfilesDataTable({ ref={scrollParentRef} className={cn( "scroll-fade relative min-h-0 min-w-0 flex-1 overflow-auto", - table.getFilteredSelectedRowModel().rows.length > 0 && + tableSelectionCounts(table).selected > 0 && "pb-[calc(var(--table-action-bar-height,56px)+32px)]", )} style={ @@ -3839,7 +4386,7 @@ export function ProfilesDataTable({ aria-label={t("profiles.title")} aria-rowcount={sortedRows.length + 1} aria-colcount={table.getVisibleLeafColumns().length} - onKeyDown={keyboard.onKeyDown} + onKeyDown={handleTableKeyDown} onKeyDownCapture={keyboard.onKeyDownCapture} className={DATA_TABLE_CLASSES.table} style={{ minWidth: minimumWidth, width: minimumWidth }} @@ -3902,191 +4449,169 @@ export function ProfilesDataTable({ ))} - - {sortedRows.length === 0 ? ( - isLoading ? ( - Array.from({ length: 8 }, (_, i) => ( - - -
    - - -
    - - -
    - - - )) - ) : showOnboardingEmptyState ? ( - - -
    - -
    -

    - {t("profiles.table.emptyTitle")} -

    -

    - {t("profiles.table.emptyHint")} -

    -
    -
    - {onCreateProfile && ( - - {t("profiles.table.emptyCreate")} - - )} - {onImportProfiles && ( - - {t("profiles.table.emptyImport")} - - )} -
    -
    -
    -
    - ) : ( - - -
    -
    - -
    -
    -

    - {t("profiles.table.emptyFilteredTitle")} -

    -

    - {t("profiles.table.emptyFilteredHint")} -

    -
    - {onClearFilters && ( - - {t("tables.clearFilters")} - - )} -
    -
    -
    - ) - ) : ( - <> - {paddingTop > 0 && ( - - - - )} - {virtualRows.map((virtualRow) => { - const row = sortedRows[virtualRow.index]; - const rowIsCrossOs = isCrossOsProfile(row.original); - const crossOsTitle = rowIsCrossOs - ? t("crossOs.viewOnly", { - os: getOSDisplayName( - row.original.host_os || - row.original.wayfern_config?.os || - "", - ), - }) - : undefined; - return ( - - {row.getVisibleCells().map((cell, columnIndex) => ( + { + // The profile stays set so the menu can play its exit. + if (!open) menuOpenedByKeyboard.current = false; + }} + > + + { + const rowId = ( + event.target as HTMLElement + ).closest("[data-table-row]")?.dataset + .tableRow; + if (!rowId) { + event.preventDefault(); + return; + } + if (event.isTrusted) menuOpenedByKeyboard.current = false; + setMenuProfileId(rowId); + }} + > + {sortedRows.length === 0 ? ( + isLoading ? ( + Array.from({ length: 8 }, (_, i) => ( + - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} +
    + + +
    + + +
    - ))} + + )) + ) : ( + + + + - ); - })} - {paddingBottom > 0 && ( - - - + ) + ) : ( + <> + {paddingTop > 0 && ( + + + + )} + + {virtualRows.map((virtualRow) => { + const row = sortedRows[virtualRow.index]; + const rowIsCrossOs = isCrossOsProfile(row.original); + const crossOsTitle = rowIsCrossOs + ? t("crossOs.viewOnly", { + os: getOSDisplayName( + row.original.host_os || + row.original.wayfern_config?.os || + "", + ), + }) + : undefined; + const range = rowVirtualizer.range; + const viewIndex = + virtualRow.index - (range?.startIndex ?? 0); + const animated = + motionAllowed && + range !== null && + virtualRow.index >= range.startIndex && + virtualRow.index <= range.endIndex && + viewIndex < MAX_ANIMATED_ROWS; + const riseKind = animated ? entranceKind : null; + return ( + + keyboard.cellProps(row.id, id).tabIndex === + 0, + ) ?? null + } + cellPropsRef={cellPropsRef} + columnsKey={visibleColumnsKey} + columnWidth={columnWidth} + sizing={view.preferences.sizing} + layoutDependency={ + animated ? rowOrderKey : "still" + } + riseKind={riseKind} + riseIndex={riseKind ? viewIndex : 0} + flashAt={ + motionAllowed && flash?.id === row.id + ? flash.at + : null + } + fades={rowFades} + /> + ); + })} + + {paddingBottom > 0 && ( + + + + )} + )} - + + + {menuProfile && menuActions && ( + { + if (menuKeepsFocus.current) { + event.preventDefault(); + menuKeepsFocus.current = false; + } + }} + /> )} - +
    - {profileForInfoDialog && - (() => { - const infoProfile = - allProfiles.find((p) => p.id === profileForInfoDialog.id) ?? - profileForInfoDialog; - const infoIsRunning = - browserState.isClient && runningProfiles.has(infoProfile.id); - const infoIsLaunching = launchingProfiles.has(infoProfile.id); - const infoIsStopping = stoppingProfiles.has(infoProfile.id); - const infoIsCrossOs = isCrossOsProfile(infoProfile); - const infoIsDisabled = - infoIsRunning || - infoIsLaunching || - infoIsStopping || - infoIsCrossOs; - return ( - { - setProfileForInfoDialog(null); - }} - profile={infoProfile} - storedProxies={storedProxies} - vpnConfigs={vpnConfigs} - onOpenTrafficDialog={(profileId) => { - const profile = profiles.find((p) => p.id === profileId); - setTrafficDialogProfile({ - id: profileId, - name: profile?.name, - }); - }} - onOpenProfileSyncDialog={onOpenProfileSyncDialog} - onAssignProfilesToGroup={onAssignProfilesToGroup} - onConfigureWayfern={onConfigureWayfern} - onCopyCookiesToProfile={onCopyCookiesToProfile} - onOpenCookieManagement={onOpenCookieManagement} - onAssignExtensionGroup={onAssignExtensionGroup} - onOpenBypassRules={(profile) => { - setBypassRulesProfile(profile); - }} - onOpenDnsBlocklist={(profile) => { - setDnsBlocklistProfile(profile); - }} - onOpenLaunchHook={(profile) => { - setLaunchHookProfile(profile); - }} - onCloneProfile={onCloneProfile} - onSaveAsTemplate={setTemplateProfile} - onLaunchWithSync={onLaunchWithSync} - onSetPassword={onSetPassword} - onChangePassword={onChangePassword} - onRemovePassword={onRemovePassword} - onDeleteProfile={(profile) => { - setProfileForInfoDialog(null); - setProfileToDelete(profile); - }} - crossOsUnlocked={crossOsUnlocked} - isRunning={infoIsRunning} - isDisabled={infoIsDisabled} - isCrossOs={infoIsCrossOs} - syncStatuses={syncStatuses} - /> - ); - })()} + + {profileForInfoDialog && + (() => { + const infoProfile = + allProfiles.find((p) => p.id === profileForInfoDialog.id) ?? + profileForInfoDialog; + const infoIsRunning = + browserState.isClient && runningProfiles.has(infoProfile.id); + const infoIsLaunching = launchingProfiles.has(infoProfile.id); + const infoIsStopping = stoppingProfiles.has(infoProfile.id); + const infoIsCrossOs = isCrossOsProfile(infoProfile); + const infoIsDisabled = + infoIsRunning || + infoIsLaunching || + infoIsStopping || + infoIsCrossOs; + const slides = inspectorLayout && motionAllowed; + return ( + + { + setProfileForInfoDialog(null); + }} + profile={infoProfile} + storedProxies={storedProxies} + vpnConfigs={vpnConfigs} + onOpenTrafficDialog={(profileId) => { + const profile = profiles.find((p) => p.id === profileId); + setTrafficDialogProfile({ + id: profileId, + name: profile?.name, + }); + }} + onOpenProfileSyncDialog={onOpenProfileSyncDialog} + onAssignProfilesToGroup={onAssignProfilesToGroup} + onConfigureWayfern={onConfigureWayfern} + onCopyCookiesToProfile={onCopyCookiesToProfile} + onOpenCookieManagement={onOpenCookieManagement} + onAssignExtensionGroup={onAssignExtensionGroup} + onOpenBypassRules={(profile) => { + setBypassRulesProfile(profile); + }} + onOpenDnsBlocklist={(profile) => { + setDnsBlocklistProfile(profile); + }} + onOpenLaunchHook={(profile) => { + setLaunchHookProfile(profile); + }} + onCloneProfile={onCloneProfile} + onSaveAsTemplate={setTemplateProfile} + onLaunchWithSync={onLaunchWithSync} + onSetPassword={onSetPassword} + onChangePassword={onChangePassword} + onRemovePassword={onRemovePassword} + onDeleteProfile={(profile) => { + setProfileForInfoDialog(null); + if (profile.ephemeral) setProfileToDelete(profile); + else void onDeleteProfile(profile); + }} + crossOsUnlocked={crossOsUnlocked} + isRunning={infoIsRunning} + isDisabled={infoIsDisabled} + isCrossOs={infoIsCrossOs} + syncStatuses={syncStatuses} + onToggleRun={() => toggleRowRun(infoProfile, tableMeta)} + canToggleRun={ + getRowRuntime(infoProfile, tableMeta).canToggle + } + isLaunching={infoIsLaunching} + isStopping={infoIsStopping} + /> + + ); + })()} +
    {onBulkRun && ( @@ -4194,14 +4757,18 @@ export function ProfilesDataTable({ {onBulkStop && ( @@ -4229,12 +4796,16 @@ export function ProfilesDataTable({ tooltip={t("profiles.actionBar.assignProxy")} onClick={onBulkProxyAssignment} > - + )} - @@ -4258,6 +4829,12 @@ export function ProfilesDataTable({ {t("profiles.actionBar.copyCookies")} )} + {onBulkEnableSync && ( + + + {t("profiles.actionBar.enableSync")} + + )} { @@ -4271,12 +4848,9 @@ export function ProfilesDataTable({ {onBulkDelete && ( <> - + - {t("common.buttons.delete")} + {t("profileTable.menu.moveToTrash")} )} diff --git a/src/components/profile-handoff-status.tsx b/src/components/profile-handoff-status.tsx index 0def2aeb..f7b1cd11 100644 --- a/src/components/profile-handoff-status.tsx +++ b/src/components/profile-handoff-status.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -10,7 +10,7 @@ import { LuMonitor, LuTriangleAlert, } from "react-icons/lu"; -import { useInputModality } from "@/hooks/use-input-modality"; +import { useMotionAllowed } from "@/hooks/use-motion-allowed"; import { MOTION_EASE_OUT } from "@/lib/motion"; import type { RemoteHandoffState } from "@/lib/remote-sessions"; import { cn } from "@/lib/utils"; @@ -32,8 +32,7 @@ export function ProfileHandoffStatus({ onOpenSync, }: ProfileHandoffStatusProps) { const { t } = useTranslation(); - const reduceMotion = useReducedMotion(); - const inputModality = useInputModality(); + const motionAllowed = useMotionAllowed(); const [observed, setObserved] = useState({ profileId, state, @@ -150,7 +149,7 @@ export function ProfileHandoffStatus({ : "100%", }} transition={{ - duration: reduceMotion || inputModality === "keyboard" ? 0 : 0.2, + duration: motionAllowed ? 0.2 : 0, ease: MOTION_EASE_OUT, }} className="absolute top-0 left-1/4 flex w-1/2 justify-start" diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index e14dadd1..a24f8650 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -6,14 +6,13 @@ import { open as openDialog, save } from "@tauri-apps/plugin-dialog"; import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; import { hideOthers } from "aria-hidden"; import Color from "color"; -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { DismissableLayer, FocusGuards, FocusScope } from "radix-ui/internal"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; import { - LuChevronRight, + LuActivity, LuClipboard, LuClipboardCheck, LuCookie, @@ -34,6 +33,7 @@ import { LuPuzzle, LuRefreshCcwDot, LuRefreshCw, + LuScrollText, LuSettings, LuShield, LuShieldCheck, @@ -44,7 +44,25 @@ import { LuX, } from "react-icons/lu"; import { DnsServerField } from "@/components/dns-server-field"; +import { LogViewer } from "@/components/log-viewer"; +import { NetworkRequestsPanel } from "@/components/network-requests-panel"; import { ProfileHandoffStatus } from "@/components/profile-handoff-status"; +import { DebugEndpointBlock } from "@/components/profile-info/debug-endpoint"; +import { + DraftSaveBar, + SavedNote, + SectionHeader, + useSavedFlash, +} from "@/components/profile-info/draft-save-bar"; +import { + lockProfileNow, + ProfileMoreMenu, + RunInCloudButton, + RunningBanner, + RunToggleButton, + useCanRunRemote, + useProfileUnlocked, +} from "@/components/profile-info/header-actions"; import { ProfileLaunchActivity } from "@/components/profile-launch-activity"; import { ProfileMetadataCard } from "@/components/profile-metadata-card"; import { ExportProfileSection } from "@/components/profile-transfer"; @@ -77,6 +95,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { SoftFields } from "@/components/ui/field-variant"; +import { FOCUS_RING } from "@/components/ui/focus-ring"; import { Input } from "@/components/ui/input"; import { Popover, @@ -95,11 +114,13 @@ import { Textarea } from "@/components/ui/textarea"; import { LaunchArgumentsEditor } from "@/components/wayfern-config-form"; import { WindowDragArea } from "@/components/window-drag-area"; import { useInputModality } from "@/hooks/use-input-modality"; +import { useMotionAllowed } from "@/hooks/use-motion-allowed"; +import { confirmLeave, useUnsavedChanges } from "@/hooks/use-unsaved-changes"; import { translateBackendError } from "@/lib/backend-errors"; import { getProfileIcon, getProfileNativeOs } from "@/lib/browser-utils"; import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { formatRelativeTime } from "@/lib/flag-utils"; -import { MOTION_EASE_OUT } from "@/lib/motion"; +import { MOTION_DURATION, MOTION_EASE_OUT, MOTION_SPRING } from "@/lib/motion"; import { rotateProxyIps } from "@/lib/proxy-rotation"; import type { RemoteHandoffState } from "@/lib/remote-sessions"; import { startPagesFromText } from "@/lib/start-pages"; @@ -145,19 +166,11 @@ interface ProfileInfoDialogProps { isDisabled?: boolean; isCrossOs?: boolean; syncStatuses: Record; -} - -function _OSIcon({ os }: { os: string }) { - switch (os) { - case "macos": - return ; - case "windows": - return ; - case "linux": - return ; - default: - return null; - } + /** Launches a stopped profile or stops a running one, as the row does. */ + onToggleRun?: () => void | Promise; + canToggleRun?: boolean; + isLaunching?: boolean; + isStopping?: boolean; } function ClearOnCloseToggle({ @@ -170,6 +183,7 @@ function ClearOnCloseToggle({ const { t } = useTranslation(); const [enabled, setEnabled] = React.useState(profile.clear_on_close === true); const [saving, setSaving] = React.useState(false); + const { saved, flash } = useSavedFlash(); React.useEffect(() => { setEnabled(profile.clear_on_close === true); @@ -183,6 +197,7 @@ function ClearOnCloseToggle({ profileId: profile.id, clearOnClose: next, }); + flash(); } catch (error) { setEnabled(!next); showErrorToast(translateBackendError(t, error)); @@ -195,7 +210,10 @@ function ClearOnCloseToggle({
    -

    {t("clearOnClose.label")}

    +

    + {t("clearOnClose.label")} + +

    {t("clearOnClose.description")}

    @@ -227,7 +245,10 @@ function InfoCard({ type="button" disabled={disabled} onClick={onClick} - className="min-w-0 rounded-lg bg-foreground/4 px-3 py-2.5 text-left transition-colors hover:bg-foreground/7 focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50" + className={cn( + "min-w-0 rounded-lg bg-foreground/4 px-3 py-2.5 text-left transition-colors hover:bg-foreground/7 disabled:opacity-50", + FOCUS_RING, + )} > {label} {value} @@ -308,6 +329,7 @@ const DEFAULT_SWATCH_COLOR = "#94a3b8"; function WindowColorSwatch({ profile }: { profile: BrowserProfile }) { const { t } = useTranslation(); const [color, setColor] = React.useState(profile.window_color); + const { saved, flash } = useSavedFlash(); React.useEffect(() => { setColor(profile.window_color); @@ -320,49 +342,63 @@ function WindowColorSwatch({ profile }: { profile: BrowserProfile }) { profileId: profile.id, windowColor: hex, }); - } catch { + flash(); + } catch (error) { setColor(profile.window_color); + showErrorToast(translateBackendError(t, error)); } }, - [profile.id, profile.window_color], + [profile.id, profile.window_color, flash, t], ); return ( - { - if (!open && color && color !== profile.window_color) - void persist(color); - }} - > - -
    ); } @@ -388,7 +424,7 @@ function profileInfoFocusSurfaces(surface: HTMLElement): HTMLElement[] { export function ProfileInfoDialog(props: ProfileInfoDialogProps) { const { t } = useTranslation(); - const reduceMotion = useReducedMotion(); + const motionAllowed = useMotionAllowed(); const inputModality = useInputModality(); const { isOpen, @@ -398,6 +434,18 @@ export function ProfileInfoDialog(props: ProfileInfoDialogProps) { openMethod = inputModality, } = props; const [section, setSection] = React.useState("overview"); + // Every way out of the panel, and every section switch, asks first when a + // section keeps a draft that is not saved. + const requestClose = React.useCallback(async () => { + if (await confirmLeave()) onClose(); + }, [onClose]); + const requestSection = React.useCallback( + async (next: ProfileSection) => { + if (next === section) return; + if (await confirmLeave()) setSection(next); + }, + [section], + ); const surfaceRef = React.useRef(null); const returnFocusRef = React.useRef(null); const titleId = React.useId(); @@ -481,7 +529,7 @@ export function ProfileInfoDialog(props: ProfileInfoDialogProps) { open={isOpen} modal={isModal} onOpenChange={(open) => { - if (!open) onClose(); + if (!open) void requestClose(); }} > {isModal && ( @@ -516,7 +564,7 @@ export function ProfileInfoDialog(props: ProfileInfoDialogProps) { asChild disableOutsidePointerEvents={isModal} deferPointerDownOutside - onDismiss={onClose} + onDismiss={() => void requestClose()} onFocusOutside={(event) => event.preventDefault()} onPointerDownOutside={(event) => { const original = event.detail.originalEvent; @@ -548,13 +596,16 @@ export function ProfileInfoDialog(props: ProfileInfoDialogProps) { aria-labelledby={titleId} data-slot={isModal ? "dialog-content" : "profile-inspector"} data-profile-id={profile.id} + // The table's inspector slot slides in from x 24 on the same + // spring, so only the modal enters by itself: it grows from its + // center. initial={ - isModal || reduceMotion || openMethod === "keyboard" - ? false - : { x: 6 } + motionAllowed && openMethod !== "keyboard" && isModal + ? { scale: 0.97 } + : false } - animate={{ x: 0 }} - transition={{ duration: 0.18, ease: MOTION_EASE_OUT }} + animate={{ scale: 1 }} + transition={MOTION_SPRING.page} className={cn( "@container/profile-info flex min-h-0 min-w-0 shrink-0 flex-col overflow-hidden rounded-lg bg-card text-card-foreground", isModal @@ -568,8 +619,9 @@ export function ProfileInfoDialog(props: ProfileInfoDialogProps) { void requestSection(next)} /> @@ -607,9 +659,15 @@ function ProfileInfoContents({ isDisabled = false, isCrossOs = false, syncStatuses, + onToggleRun, + canToggleRun = true, + isLaunching = false, + isStopping = false, + onRequestClose, section, onSectionChange, }: ProfileInfoDialogProps & { + onRequestClose: () => Promise; section: ProfileSection; onSectionChange: (section: ProfileSection) => void; }) { @@ -698,13 +756,18 @@ function ProfileInfoContents({ setCopied(false); }, 2000); } catch { - // ignore + // The clipboard refused; the id stays on screen to select by hand. } }; + // An action that opens another surface closes the panel, so a draft gets + // its Save, Discard or Stay first. const handleAction = (action: () => void) => { - onClose(); - action(); + void (async () => { + if (!(await confirmLeave())) return; + onClose(); + action(); + })(); }; // Items in the settings tab `actions` list MUST only open another dialog @@ -935,9 +998,14 @@ function ProfileInfoContents({ vpnConfigs={vpnConfigs} copied={copied} handleCopyId={handleCopyId} - onClose={onClose} + onClose={() => void onRequestClose()} onCloneProfile={onCloneProfile} - onKillProfile={undefined} + handoffState={handoffState ?? null} + crossOsUnlocked={crossOsUnlocked} + onToggleRun={onToggleRun} + canToggleRun={canToggleRun} + isLaunching={isLaunching} + isStopping={isStopping} visibleActions={visibleActions} section={section} onSectionChange={onSectionChange} @@ -965,7 +1033,12 @@ interface ProfileInfoLayoutProps { handleCopyId: () => Promise; onClose: () => void; onCloneProfile?: (profile: BrowserProfile) => void; - onKillProfile?: (profile: BrowserProfile) => void; + handoffState: RemoteHandoffState | null; + crossOsUnlocked: boolean; + onToggleRun?: () => void | Promise; + canToggleRun: boolean; + isLaunching: boolean; + isStopping: boolean; visibleActions: { id?: string; icon: React.ReactNode; @@ -988,10 +1061,11 @@ type ProfileSection = | "extensions" | "sync" | "automation" + | "requests" + | "logs" | "transfer" | "security" - | "twoFactor" - | "delete"; + | "twoFactor"; function ProfileInfoLayout({ section: requestedSection, @@ -1012,10 +1086,23 @@ function ProfileInfoLayout({ handleCopyId, onClose, onCloneProfile, + handoffState, + crossOsUnlocked, + onToggleRun, + canToggleRun, + isLaunching, + isStopping, visibleActions, t, }: ProfileInfoLayoutProps) { const contentId = React.useId(); + const motionAllowed = useMotionAllowed(); + const [unlocked, recheckUnlocked] = useProfileUnlocked(profile, isRunning); + const canLock = unlocked && !isRunning && !isLaunching && !isStopping; + const lockNow = async () => { + if (await lockProfileNow(profile, t as never)) recheckUnlocked(); + }; + const canRunRemote = useCanRunRemote(profile, isRunning, handoffState); // Map sidebar items to existing actions by their stable, language-independent // `id`, so clicking a section triggers the existing dialog handler. Matching @@ -1042,10 +1129,6 @@ function ProfileInfoLayout({ const saveTemplateAction = findAction("saveTemplate"); const syncAction = findAction("sync"); const synchronizerAction = findAction("synchronizer"); - const _launchHookAction = findAction("hook"); - const _networkAction = findAction("network"); - // Password actions are no longer routed via the legacy action handlers — - // SecuritySectionInline writes directly to the backend instead. // Cookie count is fetched at the layout level so the sidebar badge can // surface it without waiting for the user to open the Cookies section. @@ -1143,6 +1226,17 @@ function ProfileInfoLayout({ label: t("profileInfo.sections.launchHook"), badge: profile.launch_hook ? t("profileInfo.badges.active") : undefined, }, + { + id: "requests", + icon: , + label: t("profileInfo.sections.requests"), + hidden: !isWayfernProfile, + }, + { + id: "logs", + icon: , + label: t("profileInfo.sections.logs"), + }, { id: "transfer", icon: , @@ -1168,10 +1262,26 @@ function ProfileInfoLayout({ ? requestedSection : "overview"; + // A section switch slides in from the side of the section it came from. + const sectionOrder = sidebarItems + .filter((it) => !it.hidden) + .map((it) => it.id); + const lastSection = React.useRef(section); + const slideFrom = React.useRef(0); + if (lastSection.current !== section) { + slideFrom.current = + sectionOrder.indexOf(section) >= sectionOrder.indexOf(lastSection.current) + ? 1 + : -1; + lastSection.current = section; + } + + const runningHere = isRunning && handoffState !== "running"; + return ( {/* Top bar */} -
    +
    @@ -1182,55 +1292,52 @@ function ProfileInfoLayout({ {profile.name}
    - {onCloneProfile && ( - - )} - {saveTemplateAction && ( - - )} - {deleteAction && ( - + {canRunRemote && } + {onToggleRun && ( + )} + onCloneProfile(profile) : undefined + } + duplicateDisabled={isDisabled} + onSaveTemplate={saveTemplateAction?.onClick} + saveTemplateLabel={saveTemplateAction?.label} + onLock={canLock ? () => void lockNow() : undefined} + onDelete={deleteAction?.onClick} + deleteDisabled={deleteAction?.disabled} + />
    + {runningHere && ( + + )} + {/* Body */}
    {/* Sidebar */} @@ -1253,19 +1360,32 @@ function ProfileInfoLayout({ aria-controls={contentId} onClick={() => onSectionChange(it.id)} className={cn( - "flex min-h-8 max-w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors duration-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", + "relative isolate flex min-h-8 max-w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors duration-100", + FOCUS_RING, active - ? "bg-foreground/8 text-foreground" + ? "text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", )} > + {active && ( +