From 1304a22f1f7e1bca1296e43bc644fe1523f652a0 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 22:14:43 +0300 Subject: [PATCH] Add enabled local routing rules and gateway version reporting --- .codex/skills/design-vpn-client-ui/SKILL.md | 2 + .../references/motion-and-interaction.md | 8 + .../references/visual-language.md | 1 + .gitea/workflows/gateway-build.yml | 2 +- README.md | 6 +- docs/recovery/state-recovery.md | 8 +- src/server/index.js | 10 +- src/server/services/stateStore.js | 8 +- src/server/singbox.js | 9 +- src/server/version.js | 10 + src/shared/contracts/state.js | 17 +- src/shared/routingRules.js | 27 +- src/shared/versions.js | 6 +- src/web/components/ClientOverviewPage.jsx | 215 +++++++++-- src/web/styles.css | 357 ++++++++++++++---- test/server/deploy-split.test.js | 3 + test/server/singbox-client-mode.test.js | 11 +- test/server/singbox-gateway-mode.test.js | 4 +- test/server/state-contract.test.js | 27 +- test/server/state-store.test.js | 9 +- test/server/version.test.js | 20 +- test/shared/routing-rules.test.js | 13 +- 22 files changed, 585 insertions(+), 188 deletions(-) diff --git a/.codex/skills/design-vpn-client-ui/SKILL.md b/.codex/skills/design-vpn-client-ui/SKILL.md index f97e66e..42febc4 100644 --- a/.codex/skills/design-vpn-client-ui/SKILL.md +++ b/.codex/skills/design-vpn-client-ui/SKILL.md @@ -27,12 +27,14 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a - Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties. - Make live behavior visibly alive: running processes, changing values, mode changes, and interactive affordances should communicate through restrained motion instead of abrupt static replacement. - Let every visible cycle finish and return to its resting coordinates before stopping. Never cancel a hover animation, spinner, or list exit at an arbitrary frame. +- Animate dynamic rows through complete enter and exit phases; keep a departing row mounted until its exit finishes, with immediate removal under reduced motion. - Animate only what changed. Keep unchanged digits, labels, icons, and surrounding geometry stable. - Keep tooltips outside transformed, rotating, glowing, or filtered controls. Show them quickly above the control as independent translucent cloud surfaces. - Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply. - Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density. - Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen. - Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state. +- In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete. ## Acceptance pass diff --git a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md index 1b5f401..c4e76e4 100644 --- a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md +++ b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md @@ -69,6 +69,14 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri - Disable pointer interaction during exit. - Do not replay on ping updates or unrelated renders. +## Dynamic editors + +- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable. +- Give removal its own exit state and keep the row mounted until `animationend`; under reduced motion, remove it immediately. +- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot. +- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus. +- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation. + ## Subscription input - Show the public domain while retaining the full URL internally. diff --git a/.codex/skills/design-vpn-client-ui/references/visual-language.md b/.codex/skills/design-vpn-client-ui/references/visual-language.md index 833f240..dc4795a 100644 --- a/.codex/skills/design-vpn-client-ui/references/visual-language.md +++ b/.codex/skills/design-vpn-client-ui/references/visual-language.md @@ -50,6 +50,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt - Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control. - Use inline confirmation for destructive subscription removal. Explain that VPN stops and proxy-dependent applications lose connectivity until a new subscription is configured. - Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed. +- Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy. ## Data presentation diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index d834784..438a1a0 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -111,7 +111,7 @@ jobs: DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}" UPDATE_DATAPLANE=false if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \ - '^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|dataplaneClient|gatewayRouting|singboxRuntime)\.js)$'; then + '^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/shared/errors\.js)$'; then UPDATE_DATAPLANE=true fi diff --git a/README.md b/README.md index 1191292..664990a 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \ ## Локальные правила маршрутизации -После добавления подписки откройте «Локальные правила» справа от основного экрана. Встроенное правило `*.ru` всегда отправляет российские домены напрямую. Дополнительно можно добавить точный домен, suffix домена или фрагмент имени; эти правила также обходят VPN, а остальной трафик идёт через выбранный сервер. +После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер. Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются. @@ -221,7 +221,7 @@ cd ~/.vpn-proxy-client ### Версии -Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает одну строку `M`, Gateway — строки `C` (client) и `B` (backend). Наведите курсор или переведите клавиатурный фокус на любую цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у backend там же указана фактическая версия `sing-box` из dataplane. +Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`. Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`. @@ -304,7 +304,7 @@ docker compose -f docker-compose.client.yml config | `GATEWAY_IMAGE=<образ> ./scripts/deploy-gateway.sh` | Развернуть уже собранный образ в `/opt/vpn-proxy` | | `./scripts/harbor-network-monitor.sh` | Один раз записать текущий Gateway macOS; обычно этот скрипт запускает установленный LaunchAgent | -Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Обычный деплой пересоздаёт только `vpn-proxy-control`; процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`. Dataplane обновляется отдельно, только когда изменены его runtime-файлы. +Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Каждый деплой пересоздаёт `vpn-proxy-control`, поэтому строка `B` соответствует текущему коду API. Процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`; он пересоздаётся только при изменении его runtime-зависимостей, а его фактическая версия показывается отдельно как `D`. ## Хранение данных diff --git a/docs/recovery/state-recovery.md b/docs/recovery/state-recovery.md index 38ae312..a171d62 100644 --- a/docs/recovery/state-recovery.md +++ b/docs/recovery/state-recovery.md @@ -1,6 +1,6 @@ # Harbor state recovery -Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 2`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 adds locally managed domain routing rules; an absent field is migrated to an empty custom list while the built-in `.ru` rule remains in code. +Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 3`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 adds the `enabled` state and migrates the former code-owned `.ru` exception into the first ordinary enabled rule. ## Atomic writes @@ -8,13 +8,13 @@ Persistent files are written to a unique temporary file in the same directory, f ## Migration -On startup, a legacy `state.json` without `schemaVersion`, or a v1 state without local routing rules, is normalized and migrated to the current schema. Before replacement Harbor saves the original beside it: +On startup, a legacy `state.json` without `schemaVersion`, or any v1/v2 state, is normalized and migrated to the current schema. Existing custom rules are preserved, default to `enabled: true`, and follow the new ordinary `.ru` rule. A v3 state may keep, disable, or delete that rule without Harbor recreating it. Before replacement Harbor saves the original beside it: ```text state.json.backup-v0-2026-07-11T12-00-00-000Z ``` -The v1 migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. Older Harbor builds ignore the additional `schemaVersion` field, but the backup is the safest rollback source. +The migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. The backup is the safest rollback source because builds that only understand schema v2 do not know the per-rule `enabled` state. ## Corrupt JSON @@ -24,7 +24,7 @@ If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to: state.json.corrupt-2026-07-11T12-00-00-000Z ``` -It then creates a valid empty v1 state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs. +It then creates a valid empty current-schema state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs. Recovery should be performed while Harbor is stopped: diff --git a/src/server/index.js b/src/server/index.js index 9370111..0ef1b19 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -32,7 +32,7 @@ import { import { HarborError, normalizeHarborError } from '../shared/errors.js'; import { normalizeRouteRules } from '../shared/routingRules.js'; import { createJsonStore, createStateStore } from './services/stateStore.js'; -import { buildVersionInfo } from './version.js'; +import { buildGatewayVersionInfo, buildVersionInfo } from './version.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; @@ -90,7 +90,7 @@ let revision = normalizeStoredState(initialStoredState).revision; function updateStoredState(update) { return stateStore.update((stored) => { const current = normalizeStoredState(stored); - const next = normalizeStoredState(update(current)); + const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) }); revision = Math.max(revision, current.revision) + 1; next.revision = revision; return next; @@ -505,11 +505,7 @@ async function handleApi(req, res) { if (req.method === 'GET' && req.url === '/api/version') { if (!remoteDataplane) return sendJson(res, 200, versionInfo); const runtime = await singboxRuntime.refresh(); - return sendJson(res, 200, { - ...versionInfo, - components: { gatewayBackend: runtime.gatewayBackendVersion || null }, - runtime: { singBox: runtime.singBoxVersion || null }, - }); + return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime)); } if (req.method === 'GET' && req.url === '/api/shared-proxy') { diff --git a/src/server/services/stateStore.js b/src/server/services/stateStore.js index 1127649..96034b6 100644 --- a/src/server/services/stateStore.js +++ b/src/server/services/stateStore.js @@ -2,8 +2,9 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { normalizeStoredState } from '../../shared/contracts/state.js'; +import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js'; -export const STATE_SCHEMA_VERSION = 2; +export const STATE_SCHEMA_VERSION = 3; const clone = (value) => structuredClone(value); const stamp = (value) => value.toISOString().replace(/[:.]/g, '-'); @@ -55,8 +56,11 @@ export function migrateStoredState(value) { if (version < 0 || version > STATE_SCHEMA_VERSION) { throw new Error(`Unsupported Harbor state schemaVersion: ${version}`); } + const routeRules = version < 3 + ? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])] + : stored.routeRules; return { - ...normalizeStoredState(stored), + ...normalizeStoredState({ ...stored, routeRules }), schemaVersion: STATE_SCHEMA_VERSION, }; } diff --git a/src/server/singbox.js b/src/server/singbox.js index f89e9e9..e5bf1b5 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { settings } from './config.js'; import { HarborError } from '../shared/errors.js'; -import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../shared/routingRules.js'; +import { normalizeRouteRules } from '../shared/routingRules.js'; import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); @@ -52,10 +52,9 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { set_system_proxy: false, }, ]; - const directRules = [...BUILT_IN_DIRECT_RULES, ...normalizeRouteRules(routeRules)].map((rule) => ({ - [rule.type]: [rule.value], - outbound: 'direct', - })); + const directRules = normalizeRouteRules(routeRules) + .filter((rule) => rule.enabled) + .map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' })); const rules = clientMode ? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }] : [ diff --git a/src/server/version.js b/src/server/version.js index 1a1c51d..35f0dfc 100644 --- a/src/server/version.js +++ b/src/server/version.js @@ -18,3 +18,13 @@ export function buildVersionInfo(appMode, run = spawnSync) { runtime: { singBox: detectSingBoxVersion(run) }, }; } + +export function buildGatewayVersionInfo(controlInfo, dataplaneState) { + return { + ...controlInfo, + runtime: { + dataplaneVersion: dataplaneState?.gatewayBackendVersion || null, + singBox: dataplaneState?.singBoxVersion || null, + }, + }; +} diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js index f00e816..bd47f67 100644 --- a/src/shared/contracts/state.js +++ b/src/shared/contracts/state.js @@ -1,4 +1,4 @@ -import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../routingRules.js'; +import { normalizeRouteRules } from '../routingRules.js'; const MODES = new Set(['client', 'gateway']); const CONNECTION_STATES = new Set(['running', 'stopped']); @@ -83,10 +83,7 @@ export function createStateSnapshot({ gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, lastVerifiedAt: null, reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', - localRules: { - builtIn: BUILT_IN_DIRECT_RULES.map((rule) => ({ ...rule })), - custom: stored.routeRules, - }, + localRules: stored.routeRules, }, operation: { kind: nullableText(operation.kind), @@ -147,7 +144,8 @@ export function assertStateSnapshot(snapshot) { rule && ['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) && typeof rule.value === 'string' && - Boolean(rule.value) + Boolean(rule.value) && + typeof rule.enabled === 'boolean' ); if ( @@ -177,11 +175,8 @@ export function assertStateSnapshot(snapshot) { !nullableString(snapshot.route.gatewayAddress) || !nullableDate(snapshot.route.lastVerifiedAt) || typeof snapshot.route.reason !== 'string' || - !snapshot.route.localRules || - !Array.isArray(snapshot.route.localRules.builtIn) || - !snapshot.route.localRules.builtIn.every(validRouteRule) || - !Array.isArray(snapshot.route.localRules.custom) || - !snapshot.route.localRules.custom.every(validRouteRule) || + !Array.isArray(snapshot.route.localRules) || + !snapshot.route.localRules.every(validRouteRule) || !snapshot.operation || !nullableString(snapshot.operation.kind) || !OPERATION_STATES.has(snapshot.operation.status) || diff --git a/src/shared/routingRules.js b/src/shared/routingRules.js index f600ca2..951895b 100644 --- a/src/shared/routingRules.js +++ b/src/shared/routingRules.js @@ -1,9 +1,9 @@ -export const BUILT_IN_DIRECT_RULES = Object.freeze([ - Object.freeze({ type: 'domain_suffix', value: 'ru' }), +export const INITIAL_ROUTE_RULES = Object.freeze([ + Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }), ]); const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']); -const MAX_RULES = 200; +export const MAX_ROUTE_RULES = 200; function hostname(value) { const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, ''); @@ -17,13 +17,16 @@ function hostname(value) { function normalizeRule(rule) { const type = String(rule?.type || '').trim(); if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type'); + if (Object.hasOwn(rule || {}, 'enabled') && typeof rule.enabled !== 'boolean') { + throw new TypeError('Invalid domain rule enabled state'); + } const value = type === 'domain_keyword' ? String(rule?.value || '').trim().toLowerCase() : hostname(rule?.value); if (!value || value.length > 253 || /[\s/:?#]/.test(value)) { throw new TypeError('Invalid domain rule value'); } - return { type, value }; + return { type, value, enabled: rule?.enabled !== false }; } export function normalizeRouteRules(value, { strict = false } = {}) { @@ -31,13 +34,13 @@ export function normalizeRouteRules(value, { strict = false } = {}) { if (strict) throw new TypeError('Route rules must be an array'); return []; } - if (strict && value.length > MAX_RULES) throw new TypeError(`Route rules limit is ${MAX_RULES}`); + if (strict && value.length > MAX_ROUTE_RULES) { + throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`); + } - const seen = new Set(BUILT_IN_DIRECT_RULES.map(({ type, value: builtInValue }) => ( - `${type}:${builtInValue}` - ))); + const seen = new Set(); const normalized = []; - for (const candidate of value.slice(0, MAX_RULES)) { + for (const candidate of value.slice(0, MAX_ROUTE_RULES)) { try { const rule = normalizeRule(candidate); const key = `${rule.type}:${rule.value}`; @@ -50,3 +53,9 @@ export function normalizeRouteRules(value, { strict = false } = {}) { } return normalized; } + +export function canAppendRouteRule(rules) { + return Array.isArray(rules) && + rules.length < MAX_ROUTE_RULES && + rules.every((rule) => String(rule?.value || '').trim()); +} diff --git a/src/shared/versions.js b/src/shared/versions.js index 1fd2912..127822a 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.3.0', - gatewayClient: '0.3.0', - gatewayBackend: '0.3.0', + macClient: '0.4.0', + gatewayClient: '0.4.0', + gatewayBackend: '0.4.0', }); export function parseVersion(value) { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 2b6b14b..ffd94ea 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -13,6 +13,7 @@ import { import { formatBytes } from '../utils/format.js'; import { instructionBlocks } from '../instructions.js'; import { createLatestRequest, operationBlocked } from '../state/operations.js'; +import { canAppendRouteRule } from '../../shared/routingRules.js'; import { HARBOR_VERSIONS, parseVersion, @@ -32,7 +33,7 @@ const VERSION_PARTS = [ ['hotfix', 'Hotfix'], ]; -function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) { +function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) { const parsed = parseVersion(version); const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–']; @@ -63,7 +64,7 @@ function VersionBadge({ code, component, componentKey, version, singBox, incompa {component} · {label} {values[index]} {description(key)} - {singBox && Runtime: sing-box {singBox}} + {runtime && {runtime}} {incompatible && Версии Gateway несовместимы.} @@ -83,12 +84,13 @@ function VersionDisplay({ isGateway, versionInfo }) { component="Mac client" componentKey="macClient" version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient} - singBox={runtimeSingBox} + runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null} /> ; } const backendVersion = versionInfo?.components?.gatewayBackend; + const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion; const compatibility = backendVersion && versionCompatibility({ ...HARBOR_VERSIONS, gatewayBackend: backendVersion, @@ -97,19 +99,25 @@ function VersionDisplay({ isGateway, versionInfo }) { return ; } @@ -208,6 +216,106 @@ const ROUTE_RULE_PLACEHOLDERS = { domain_keyword: 'cdn', }; +let localRuleDraftId = 0; +const createLocalRuleDraft = (rule) => ({ + ...rule, + enabled: rule?.enabled !== false, + _key: `route-rule-${localRuleDraftId += 1}`, +}); + +function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const optionRefs = useRef([]); + const listId = `${ruleKey}-types`; + const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value)); + + useEffect(() => { + if (!open) return undefined; + optionRefs.current[selectedIndex]?.focus(); + const close = (event) => { + if (event.type === 'keydown' && event.key !== 'Escape') return; + if (rootRef.current?.contains(event.target)) return; + setOpen(false); + }; + document.addEventListener('pointerdown', close); + document.addEventListener('keydown', close); + return () => { + document.removeEventListener('pointerdown', close); + document.removeEventListener('keydown', close); + }; + }, [open, selectedIndex]); + + function choose(type) { + onChange(type); + setOpen(false); + triggerRef.current?.focus(); + } + + function moveOption(event, offset) { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return; + event.preventDefault(); + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + return; + } + const current = optionRefs.current.indexOf(document.activeElement); + const next = event.key === 'Home' + ? 0 + : event.key === 'End' + ? ROUTE_RULE_OPTIONS.length - 1 + : (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length; + optionRefs.current[next]?.focus(); + } + + return ( +
+ +
+ {ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => ( + + ))} +
+
+ ); +} + function LocalRulesPanel({ open, rules, @@ -218,9 +326,14 @@ function LocalRulesPanel({ onAdd, onChange, onRemove, + onRemoveComplete, onClose, onSave, }) { + const activeRules = rules.filter((rule) => !rule.removing); + const canAdd = canAppendRouteRule(activeRules) && !blocked; + const incomplete = activeRules.some((rule) => !String(rule.value || '').trim()); + return (