From 299bc421e22bf3a2c9c4dae95a96066aae3f09eb Mon Sep 17 00:00:00 2001 From: ppw-stack <58170323+ppw-stack@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:32:36 +0800 Subject: [PATCH] feat(mobile-pairing): combobox with manual network address entry (#6501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): manual network address entry for mobile pairing * docs(plan): manual network address entry for mobile pairing * docs(plan): fix two test-spec issues in Task 1 * feat(mobile-pairing): add parseManualNetworkAddress validator * docs(plan): fix buildComboboxEntries filter rule * feat(mobile-pairing): buildComboboxEntries for network interface combobox * feat(mobile-pairing): combobox with manual address entry * docs(spec): align buildComboboxEntries behavior with corrected plan * fix(mobile-pairing): use text-destructive token for inline error * fix(mobile-pairing): route Use row through translate() with i18next interpolation * refactor(mobile-pairing): extract NetworkInterfaceCombobox shared by MobileHero and Settings section The Popover + Command + manual-entry UX lived only in the Settings → Mobile → Network Interface section. The mobile pairing screen ("Step 2 of 2 — Pair this computer") had its own copy of the same Select-based dropdown that did not support manual address entry. A user trying to pair with a Tailscale MagicDNS hostname from the pairing screen could not enter it. Extract the typeable combobox into a shared `NetworkInterfaceCombobox` component. Both surfaces now render the same Popover + Command with manual address entry, the `Use "..."` row, the inline validation error, and the `(custom)` trigger label. Settings keeps its own Generate QR button, Refresh + Tooltip, and Tailnet accordion around the combobox. The pairing screen keeps its existing layout (label + combobox + refresh icon). Net deletion: ~160 lines. Net behavior gain: manual address entry is now reachable from both surfaces, not only Settings. * style(ui): give CommandInput a visible background so the search box is not lost The Popover content above the CommandList renders the cmdk CommandInput with only a thin bottom border. Against a white popover background it visually disappears, especially in the mobile pairing screen where the popover sits inside a dark phone mockup. Add a subtle `bg-muted/30` + `py-1` so the input row is unambiguous, without changing the input's shape or behavior. * fix(mobile-pairing): drop cmdk CommandItem, use plain buttons inside Popover In `pnpm dev` HMR cycle the cmdk CommandItem `onSelect` dispatch was unreliable — clicking the item fired the synthetic event but the parent React state never received it, so the trigger label never updated after the user picked a manual address or a refreshed interface. Replace the `Command` + `CommandItem` primitives inside NetworkInterfaceCombobox with a native `` + ` + + + + + + + {showInlineError ? ERROR_MESSAGE : translate( + 'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-empty', + 'No matching interfaces' + )} + + {entries.map((entry, index) => { + if (entry.kind === 'interface') { + return ( + handleSelectInterface(entry.iface)} + > + {formatInterfaceLabel(entry.iface)} + + ) + } + const isFirstUseQuery = index > 0 + return ( +
+ {isFirstUseQuery ? : null} + handleSelectUseQuery(entry.address)} + > + Use "{entry.address}" + +
+ ) + })} +
+
+
+ + + + + + + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', + 'Refresh network interfaces' + )} + + + + {showInlineError ? ( +

+ {ERROR_MESSAGE} +

+ ) : null} + + + + + + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9', + 'Connect outside your Wi-Fi with a tailnet' + )} + + +

+ {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', + 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' + )} +

+
    +
  1. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', + 'Install' + )}{' '} + {' '} + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', + 'on your computer and phone.' + )} +
  2. +
  3. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', + 'Sign in to the same tailnet on both devices.' + )} +
  4. +
  5. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', + 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' + )} +
  6. +
  7. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', + 'Regenerate the QR code and scan it from the Orca mobile app.' + )} +
  8. +
+
+
+
+ + ) + } + ``` + + Notes for the implementer: + - `Command shouldFilter={false}` because we already filter in `buildComboboxEntries`. Without this, cmdk's default fuzzy filter would re-filter the manually-injected `__use__` row out of view. + - The `value` prop on each `CommandItem` keeps cmdk happy but does not affect our filtering; we own filtering. + - `useMemo` is used for `selectedIface`, `entries`, and `queryParse` so re-renders triggered by parent prop changes don't churn the combobox state. + - If `isCustomLabel` is unused after a lint pass (no caller in the final file), delete the helper — the spec lists it but the implementation inlines the check. + +- [ ] **Step 6: Run the new test file** + + ```bash + pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx + ``` + Expected: all six tests pass. + + If any fail: + - `toHaveTextContent` mismatches → check the trigger label format. + - `getByRole('option', { name: /Use "…"/ })` not found → verify `Command shouldFilter={false}` is set. + - Inline error not appearing → verify `showInlineError` is computed against `queryParse.ok`. + +- [ ] **Step 7: Run the full test suite for the settings folder** + + ```bash + pnpm vitest run src/renderer/src/components/settings/ + ``` + Expected: all green. Confirm nothing else broke (e.g. `MobilePairingQrSection.test.tsx` if it exists). + +- [ ] **Step 8: Lint, typecheck, build** + + ```bash + pnpm lint + pnpm typecheck + pnpm build + ``` + Expected: all green. If `pnpm lint` complains about an unused import (e.g. `isCustomLabel` or `CommandSeparator`), remove it and re-run. + +- [ ] **Step 9: Commit** + + ```bash + git add src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx \ + src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx + git commit -m "feat(mobile-pairing): combobox with manual address entry" + ``` + +--- + +## Task 4: End-to-end verification & PR prep + +**Files:** none modified; verification only. + +- [ ] **Step 1: Run the four CI checks locally** + + ```bash + pnpm lint + pnpm typecheck + pnpm test + pnpm build + ``` + Expected: all green. This is exactly what `.github/workflows/` runs. + +- [ ] **Step 2: Manual smoke check** + + If Orca can be launched locally with the desktop build: + 1. Open Settings → Mobile. + 2. Click the Network Interface dropdown — confirm both interfaces list. + 3. Type a tailnet address — confirm the `Use "…"` row appears. + 4. Click `Use "…"` — confirm the trigger switches to `… (custom)`. + 5. Click Refresh — confirm the custom selection survives. + 6. Type an invalid string — confirm the inline error appears and no `Use …` row is shown. + 7. Type an address that already matches an interface (e.g. `192.168.1.24`) — confirm the `Use` row is hidden and selecting the interface row does NOT show `(custom)`. + + Skip any step that requires a running desktop build and note it in the PR description. + +- [ ] **Step 3: Capture before/after screenshots** + + Save screenshots to a temp directory (NOT to the repo — `CONTRIBUTING.md` and `AGENTS.md` forbid committing PR evidence images). Use gstack browse if running the production binary: + ```bash + $HOME/.claude/skills/gstack/browse/dist/browse screenshot /tmp/orca-mobile-section-before.png --selector ... + ``` + Attach the screenshots to the PR conversation (never use `gh-attach`). + +- [ ] **Step 4: Push the branch and draft the PR** + + ```bash + git push origin HEAD + gh pr create \ + --title "feat(mobile-pairing): combobox with manual network address entry" \ + --body-file /tmp/pr-body.md + ``` + + The PR body should follow `pull_request_template.md`: + - **Summary:** explain that the desktop Network Interface dropdown now accepts a manually-typed IPv4 or Tailscale MagicDNS hostname. + - **Screenshots:** link to the conversation-attached screenshots. + - **Testing:** check all four `pnpm` boxes; note that `pnpm test` includes the three new test files (`manual-address.test.ts`, `mobile-network-interface-selection.test.ts`, `MobileNetworkInterfaceSection.test.tsx`). + - **AI Review Report:** confirm the review checked cross-platform (no platform-specific code; uses shadcn primitives + cmdk patterns already in the codebase), SSH/remote/local compatibility (no change to main-process IPC), agent/integration compatibility (no change), performance (UI re-render cost is bounded by `useMemo` over the interface list), UI quality (follows STYLEGUIDE.md; uses `text-statusRed` for the error), security (no new IPC; the address flows through the existing `selectedAddress` prop which `MobilePairingQrSection` already validated). + - **Security Audit:** no new IPC, no new auth surface, no new dependency, no new env var. The parser rejects malformed input early so the QR endpoint never sees invalid data. + - **Notes:** none — single platform-agnostic renderer change. + - **X handle:** include the contributor's X handle per CONTRIBUTING.md so maintainers can shout out. + +- [ ] **Step 5: Request review** + + ```bash + gh pr edit --add-reviewer + ``` + Or comment `@` in the PR if reviewer auto-assignment isn't available. + +--- + +## Definition of done + +- All four `pnpm` checks pass locally. +- All new and existing tests pass. +- The component renders correctly in a smoke test (or smoke-test steps are documented as skipped in the PR). +- The PR is open with a body matching `pull_request_template.md`. +- The contributor's X handle is in the PR description. diff --git a/package.json b/package.json index 3b16182c1..7e0b8470f 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,9 @@ "@stablyai/playwright-test": "^2.1.14", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-virtual": "^3.13.24", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@tiptap/extension-code-block-lowlight": "^3.22.5", "@tiptap/extension-details": "^3.22.5", "@tiptap/extension-image": "^3.22.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 989ae79cd..01f6ab4d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,15 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@tiptap/extension-code-block-lowlight': specifier: ^3.22.5 version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0) @@ -375,6 +384,9 @@ packages: 7zip-bin@5.2.0: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -2597,6 +2609,35 @@ packages: '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.22.5': resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} peerDependencies: @@ -2824,6 +2865,9 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3229,6 +3273,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3250,6 +3298,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -3601,6 +3652,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3875,6 +3929,12 @@ packages: os: [darwin] hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} @@ -4497,6 +4557,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -4868,6 +4932,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -5100,6 +5168,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -5472,6 +5544,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5618,6 +5694,9 @@ packages: typescript: optional: true + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -5670,6 +5749,10 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} @@ -6041,6 +6124,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6571,6 +6658,8 @@ snapshots: 7zip-bin@5.2.0: {} + '@adobe/css-tools@4.5.0': {} + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -8548,6 +8637,40 @@ snapshots: '@tanstack/virtual-core@3.14.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': dependencies: '@tiptap/pm': 3.22.5 @@ -8788,6 +8911,8 @@ snapshots: minimatch: 10.2.5 path-browserify: 1.0.1 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -9227,6 +9352,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} app-builder-bin@5.0.0-alpha.12: {} @@ -9280,6 +9407,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -9617,6 +9748,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -9907,6 +10040,10 @@ snapshots: verror: 1.10.1 optional: true + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10742,6 +10879,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -11024,6 +11163,8 @@ snapshots: dependencies: react: 19.2.5 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -11491,6 +11632,8 @@ snapshots: mimic-response@3.1.0: {} + min-indent@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -11895,6 +12038,12 @@ snapshots: powershell-utils@0.1.0: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -12116,6 +12265,8 @@ snapshots: react-dom: 19.2.5(react@19.2.5) typescript: 5.9.3 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): dependencies: '@types/hast': 3.0.4 @@ -12179,6 +12330,11 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + rehype-highlight@7.0.2: dependencies: '@types/hast': 3.0.4 @@ -12659,6 +12815,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 diff --git a/src/main/wsl-unc-delete.test.ts b/src/main/wsl-unc-delete.test.ts index fe63f687b..932924b76 100644 --- a/src/main/wsl-unc-delete.test.ts +++ b/src/main/wsl-unc-delete.test.ts @@ -52,15 +52,7 @@ describe('tryDeleteWslUncPath', () => { expect(execFileMock).toHaveBeenCalledTimes(1) const [binary, spawnArgs] = execFileMock.mock.calls[0] expect(binary).toBe('wsl.exe') - expect(spawnArgs).toEqual([ - '-d', - 'Ubuntu', - '--', - 'rm', - '-f', - '--', - '/home/me/repo/file.txt' - ]) + expect(spawnArgs).toEqual(['-d', 'Ubuntu', '--', 'rm', '-f', '--', '/home/me/repo/file.txt']) }) it('passes -rf for a recursive directory delete', async () => { diff --git a/src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx b/src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx new file mode 100644 index 000000000..c2b43ab41 --- /dev/null +++ b/src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { translate } from '@/i18n/i18n' +import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' + +type CustomNetworkAddressDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + // Why: prefill from the current selection when it is already a custom value + // so reopening to edit shows what is in use rather than a blank field. + initialValue?: string + onConfirm: (address: string) => void +} + +export function CustomNetworkAddressDialog({ + open, + onOpenChange, + initialValue, + onConfirm +}: CustomNetworkAddressDialogProps): React.JSX.Element { + const [value, setValue] = useState(initialValue ?? '') + + // Why: reseed each time the dialog opens so a prior cancelled edit doesn't + // leak into the next open. + useEffect(() => { + if (open) { + setValue(initialValue ?? '') + } + }, [open, initialValue]) + + const parsed = parseManualNetworkAddress(value) + // Why: only flag invalid input once the user has typed something — an empty + // field on open shouldn't read as an error. + const showError = value.trim() !== '' && !parsed.ok + + const submit = (): void => { + if (!parsed.ok) { + return + } + onConfirm(parsed.address) + onOpenChange(false) + } + + return ( + + + + + {translate( + 'auto.components.mobile.CustomNetworkAddressDialog.title', + 'Custom network address' + )} + + + {translate( + 'auto.components.mobile.CustomNetworkAddressDialog.description', + 'Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.' + )} + + +
+ + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submit() + } + }} + /> + {/* Why: neutral helper copy that doubles as validation guidance — + kept muted (not destructive-red) so a half-typed address doesn't + feel like a hard error. */} +

+ {translate( + 'auto.components.mobile.CustomNetworkAddressDialog.hint', + 'Enter an IP address or a Tailscale hostname (ends in .ts.net).' + )} +

+
+ + + + +
+
+ ) +} diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 2690f2155..af22a1dff 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,8 +1,8 @@ import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' +import { NetworkInterfacePicker } from './NetworkInterfacePicker' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' @@ -197,34 +197,16 @@ export function HeroFlow({ {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - +