FN-7477: clarify desktop remote server setup

Clarifies the desktop Connection Manager so local mode is distinct from adding saved remote servers.

- Split the desktop local server card from the remote server profile flow and keep the editor closed until adding or editing a remote.
- Add desktop-specific remote server copy, accessible labels with profile URLs, and responsive styling for the revised layout.
- Update Connection Manager tests, native shell docs, localized strings, resource types, and the Fusion changeset.

Files changed:
 .changeset/fn-7477-connection-manager-clarity.md   |   7 +
 docs/native-shell.md                               |   5 +-
 .../components/NativeShellConnectionManager.css    |  65 ++++++---
 .../components/NativeShellConnectionManager.tsx    | 153 ++++++++++++--------
 .../NativeShellConnectionManager.test.tsx          | 156 +++++++++++++++------
 packages/i18n/locales/en/app.json                  |  15 +-
 packages/i18n/locales/es/app.json                  |  15 +-
 packages/i18n/locales/fr/app.json                  |  15 +-
 packages/i18n/locales/ko/app.json                  |  15 +-
 packages/i18n/locales/zh-CN/app.json               |  15 +-
 packages/i18n/locales/zh-TW/app.json               |  15 +-
 packages/i18n/src/resources.d.ts                   |  13 +-
 12 files changed, 334 insertions(+), 155 deletions(-)

Fusion-Task-Id: FN-7477

Fusion-Task-Lineage: adc2df09-d713-4ccd-8fb6-20a32c6a553e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 17:08:40 -07:00
parent a2b09f277d
commit e8b73623d4
12 changed files with 346 additions and 167 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Clarify the desktop Connection Manager add-remote flow.
category: fix
dev: Desktop Connection Manager now separates Local Server context from saved remote profiles and collapses the remote editor until add/edit.

View File

@@ -57,11 +57,12 @@ Profiles are first-class saved objects shared by onboarding and Connection Manag
Connection Manager supports: Connection Manager supports:
- **Desktop Switch server** shows **Local Server** as a selectable destination in the same list as saved remote/server profiles. Selecting **Local Server** calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles. - **Desktop Switch server** presents the built-in **Local Server** separately from saved **Remote servers**. Local Server is always available in the desktop shell; selecting it calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles.
- **Add remote server** is the desktop CTA for saving another Fusion server profile. The remote profile editor stays collapsed until a user chooses **Add remote server** or edits an existing saved profile, so local-only desktop users do not see an empty setup form.
- **Use** (activate a saved remote profile). In desktop local mode, using a remote profile first switches desktop mode back to `remote`, then activates the selected profile. - **Use** (activate a saved remote profile). In desktop local mode, using a remote profile first switches desktop mode back to `remote`, then activates the selected profile.
- **Edit** (update name/URL/token) - **Edit** (update name/URL/token)
- **Delete** - **Delete**
- **Add connection** - **Mobile Add connection / Scan QR** remains focused on remote profile setup and does not show the desktop-only Local Server guidance.
Activation updates `activeProfileId` and stamps `lastUsedAt` on the selected profile. Activation updates `activeProfileId` and stamps `lastUsedAt` on the selected profile.

View File

@@ -2,21 +2,17 @@
width: min(100%, 42rem); width: min(100%, 42rem);
} }
.native-shell-connection-manager__mode-row {
display: flex;
gap: var(--space-sm);
padding: 0 var(--space-xl) var(--space-md);
}
.native-shell-connection-manager__profiles { .native-shell-connection-manager__profiles {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
padding: 0 var(--space-xl); padding: 0 var(--space-xl);
max-height: 16rem; max-height: 18rem;
overflow: auto; overflow: auto;
} }
.native-shell-connection-manager__overview,
.native-shell-connection-manager__section-heading,
.native-shell-connection-manager__profile { .native-shell-connection-manager__profile {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -24,40 +20,56 @@
gap: var(--space-md); gap: var(--space-md);
} }
.native-shell-connection-manager__destination { .native-shell-connection-manager__overview-copy {
width: 100%;
border-color: var(--border);
color: var(--text);
text-align: start;
cursor: pointer;
}
.native-shell-connection-manager__destination:hover,
.native-shell-connection-manager__destination:focus-visible {
border-color: var(--accent-primary);
}
.native-shell-connection-manager__destination > span:first-child {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs); gap: var(--space-xs);
} }
.native-shell-connection-manager__profile-actions { .native-shell-connection-manager__overview-copy h3,
.native-shell-connection-manager__section-heading h3,
.native-shell-connection-manager__editor h3 {
margin: 0;
}
.native-shell-connection-manager__overview-copy p,
.native-shell-connection-manager__section-heading p {
margin: var(--space-xs) 0 0;
}
.native-shell-connection-manager__section-heading {
padding: var(--space-sm) 0;
}
.native-shell-connection-manager__profile-actions,
.native-shell-connection-manager__mobile-actions {
display: inline-flex; display: inline-flex;
gap: var(--space-sm); gap: var(--space-sm);
flex-wrap: wrap;
}
.native-shell-connection-manager__mobile-actions {
padding: var(--space-md) var(--space-xl) 0;
} }
.native-shell-connection-manager__editor { .native-shell-connection-manager__editor {
padding-top: var(--space-lg); padding-top: var(--space-lg);
} }
.native-shell-connection-manager__editor h3 {
padding-bottom: var(--space-sm);
}
.native-shell-connection-manager__empty-state { .native-shell-connection-manager__empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
} }
.native-shell-connection-manager__empty-state p {
margin: 0;
}
.native-shell-connection-manager__active-pill { .native-shell-connection-manager__active-pill {
display: inline-flex; display: inline-flex;
margin-top: var(--space-xs); margin-top: var(--space-xs);
@@ -75,13 +87,20 @@
background: color-mix(in srgb, var(--color-warning) 8%, transparent); background: color-mix(in srgb, var(--color-warning) 8%, transparent);
} }
.native-shell-connection-manager__standalone-error {
margin: var(--space-md) var(--space-xl) 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.native-shell-connection-manager__overview,
.native-shell-connection-manager__section-heading,
.native-shell-connection-manager__profile { .native-shell-connection-manager__profile {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.native-shell-connection-manager__mode-row { .native-shell-connection-manager__section-heading > .btn,
flex-wrap: wrap; .native-shell-connection-manager__overview > .btn {
width: 100%;
} }
} }

View File

@@ -24,6 +24,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
if (!open) return null; if (!open) return null;
const isAddingConnection = editingProfileId === "__new__"; const isAddingConnection = editingProfileId === "__new__";
const isEditorOpen = editingProfileId !== null;
const editingProfile = isAddingConnection const editingProfile = isAddingConnection
? null ? null
: shellState.profiles.find((profile) => profile.id === editingProfileId) ?? activeProfile; : shellState.profiles.find((profile) => profile.id === editingProfileId) ?? activeProfile;
@@ -37,6 +38,12 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
setError(null); setError(null);
}; };
const startNewRemoteDraft = () => {
setEditingProfileId("__new__");
setDraft({ name: "", serverUrl: "", authToken: "" });
setError(null);
};
const saveCurrent = async () => { const saveCurrent = async () => {
setError(null); setError(null);
try { try {
@@ -120,44 +127,57 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
<div className="native-shell-connection-manager__profiles"> <div className="native-shell-connection-manager__profiles">
{isDesktopShell && ( {isDesktopShell && (
<button <>
type="button" {/*
className="card native-shell-connection-manager__profile native-shell-connection-manager__destination" FNXC:DesktopConnectionManager 2026-07-03-16:25:
onClick={() => void handleUseLocalServer()} Desktop Connection Manager must explain that Local Server is built in and remote servers are saved profiles. Keep the remote editor collapsed until the user explicitly adds or edits a remote server so first-run local mode never looks like incomplete setup.
aria-pressed={isDesktopLocalActive} */}
> <section className="card native-shell-connection-manager__overview" aria-labelledby="native-shell-connection-manager-local-heading">
<span> <div className="native-shell-connection-manager__overview-copy">
<strong>{t("shell.localServerTitle", "Local Server")}</strong> <h3 id="native-shell-connection-manager-local-heading">{t("shell.localServerTitle", "Local Server")}</h3>
<span className="settings-muted">{t("shell.localServerDescription", "Use the embedded Fusion server on this device.")}</span> <p className="settings-muted">{t("shell.localServerDescription", "Use the embedded Fusion server on this device.")}</p>
{isDesktopLocalActive && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>} {isDesktopLocalActive && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
</span> </div>
<span className="native-shell-connection-manager__profile-actions" aria-hidden="true"> <button
<span className="btn btn-sm">{isDesktopLocalActive ? t("shell.localServerActive", "Current") : t("shell.use", "Use")}</span> type="button"
</span> className="btn btn-sm"
</button> onClick={() => void handleUseLocalServer()}
aria-label={isDesktopLocalActive ? t("shell.currentLocalServer", "Current Local Server") : t("shell.useLocalServer", "Use Local Server")}
aria-pressed={isDesktopLocalActive}
>
{isDesktopLocalActive ? t("shell.localServerActive", "Current") : t("shell.use", "Use")}
</button>
</section>
<div className="native-shell-connection-manager__section-heading">
<div>
<h3>{t("shell.remoteServersTitle", "Remote servers")}</h3>
<p className="settings-muted">{t("shell.remoteServersDescription", "Save Fusion servers you want this desktop app to open later.")}</p>
</div>
<button type="button" className="btn btn-primary btn-sm" onClick={startNewRemoteDraft}>
{t("shell.addRemoteServer", "Add remote server")}
</button>
</div>
</>
)} )}
{shellState.profiles.length === 0 ? ( {shellState.profiles.length === 0 ? (
<div className="card native-shell-connection-manager__empty-state"> <div className="card native-shell-connection-manager__empty-state">
<p className="settings-muted">{t("shell.noServersSaved", "No remote servers saved yet.")}</p> <p className="settings-muted">
<div className="native-shell-connection-manager__profile-actions"> {isDesktopShell
<button ? t("shell.noRemoteServersDesktop", "No remote servers saved yet. Add one only when you want this desktop app to open another Fusion server.")
type="button" : t("shell.noServersSaved", "No remote servers saved yet.")}
className="btn btn-sm" </p>
onClick={() => { {!isDesktopShell && (
setEditingProfileId("__new__"); <div className="native-shell-connection-manager__profile-actions">
setDraft({ name: "", serverUrl: "", authToken: "" }); <button type="button" className="btn btn-sm" onClick={startNewRemoteDraft}>
setError(null); {t("shell.addServer", "Add server")}
}} </button>
>
{t("shell.addServer", "Add server")}
</button>
{shellState.host === "mobile-shell" && (
<button type="button" className="btn btn-sm" onClick={() => void handleScanQr()}> <button type="button" className="btn btn-sm" onClick={() => void handleScanQr()}>
{t("shell.scanQr", "Scan QR")} {t("shell.scanQr", "Scan QR")}
</button> </button>
)} </div>
</div> )}
</div> </div>
) : ( ) : (
shellState.profiles.map((profile) => ( shellState.profiles.map((profile) => (
@@ -171,7 +191,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
<button <button
type="button" type="button"
className="btn btn-sm" className="btn btn-sm"
aria-label={t("shell.editProfile", "Edit {{name}}", { name: profile.name })} aria-label={t("shell.editProfile", "Edit {{name}} at {{url}}", { name: profile.name, url: profile.serverUrl })}
onClick={() => { onClick={() => {
setEditingProfileId(profile.id); setEditingProfileId(profile.id);
setDraft(profile); setDraft(profile);
@@ -179,42 +199,53 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
> >
{t("actions.edit", "Edit")} {t("actions.edit", "Edit")}
</button> </button>
<button type="button" className="btn btn-sm" aria-label={t("shell.useProfile", "Use {{name}}", { name: profile.name })} onClick={() => void handleUseProfile(profile.id)}>{t("shell.use", "Use")}</button> <button
<button type="button" className="btn btn-sm btn-danger" aria-label={t("shell.deleteProfile", "Delete {{name}}", { name: profile.name })} onClick={() => setDeleteCandidate(profile)}>{t("actions.delete", "Delete")}</button> type="button"
className="btn btn-sm"
aria-label={t("shell.useProfile", "Use {{name}} at {{url}}", { name: profile.name, url: profile.serverUrl })}
onClick={() => void handleUseProfile(profile.id)}
>
{t("shell.use", "Use")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
aria-label={t("shell.deleteProfile", "Delete {{name}} at {{url}}", { name: profile.name, url: profile.serverUrl })}
onClick={() => setDeleteCandidate(profile)}
>
{t("actions.delete", "Delete")}
</button>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
<div className="native-shell-connection-manager__mode-row"> {!isDesktopShell && shellState.profiles.length > 0 && (
<button <div className="native-shell-connection-manager__mobile-actions">
type="button" <button type="button" className="btn" onClick={startNewRemoteDraft}>
className="btn" {t("shell.addConnection", "Add connection")}
onClick={() => { </button>
setEditingProfileId("__new__");
setDraft({ name: "", serverUrl: "", authToken: "" });
setError(null);
}}
>
{t("shell.addConnection", "Add connection")}
</button>
{shellState.host === "mobile-shell" && (
<button type="button" className="btn" onClick={() => void handleScanQr()}> <button type="button" className="btn" onClick={() => void handleScanQr()}>
{t("shell.scanQr", "Scan QR")} {t("shell.scanQr", "Scan QR")}
</button> </button>
)} </div>
</div> )}
<div className="form-group native-shell-connection-manager__editor"> {error && !isEditorOpen && <p className="form-error native-shell-connection-manager__standalone-error" role="alert">{error}</p>}
<label htmlFor="native-shell-connection-manager-name">{t("shell.nameLabel", "Name")}</label>
<input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} /> {isEditorOpen && (
<label htmlFor="native-shell-connection-manager-url">{t("shell.serverUrlLabel", "Server URL")}</label> <div className="form-group native-shell-connection-manager__editor">
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} /> <h3>{isAddingConnection ? t("shell.addRemoteServer", "Add remote server") : t("shell.editRemoteServer", "Edit remote server")}</h3>
<label htmlFor="native-shell-connection-manager-token">{t("shell.authTokenLabel", "Auth token (optional)")}</label> <label htmlFor="native-shell-connection-manager-name">{t("shell.nameLabel", "Name")}</label>
<input id="native-shell-connection-manager-token" className="input" type="password" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} /> <input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} />
{error && <p className="form-error" role="alert">{error}</p>} <label htmlFor="native-shell-connection-manager-url">{t("shell.serverUrlLabel", "Server URL")}</label>
</div> <input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
<label htmlFor="native-shell-connection-manager-token">{t("shell.authTokenLabel", "Auth token (optional)")}</label>
<input id="native-shell-connection-manager-token" className="input" type="password" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
{error && <p className="form-error" role="alert">{error}</p>}
</div>
)}
{deleteCandidate && ( {deleteCandidate && (
<div className="native-shell-connection-manager__delete-confirm" role="alertdialog" aria-label={t("shell.deleteConfirmLabel", "Delete server confirmation")}> <div className="native-shell-connection-manager__delete-confirm" role="alertdialog" aria-label={t("shell.deleteConfirmLabel", "Delete server confirmation")}>
@@ -228,8 +259,12 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>{t("actions.close", "Close")}</button> <button type="button" className="btn" onClick={onClose}>{t("actions.close", "Close")}</button>
<button type="button" className="btn" onClick={resetEditor}>{t("actions.cancel", "Cancel")}</button> {isEditorOpen && (
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>{t("actions.save", "Save")}</button> <>
<button type="button" className="btn" onClick={resetEditor}>{t("actions.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>{t("actions.save", "Save")}</button>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { NativeShellConnectionManager } from "../NativeShellConnectionManager"; import { NativeShellConnectionManager } from "../NativeShellConnectionManager";
@@ -6,10 +6,11 @@ function createShellApi() {
return { return {
getState: vi.fn(), getState: vi.fn(),
listProfiles: vi.fn(), listProfiles: vi.fn(),
saveProfile: vi.fn(async (input?: { id?: string }) => ({ saveProfile: vi.fn(async (input?: { id?: string; name?: string; serverUrl?: string; authToken?: string | null }) => ({
id: input?.id ?? "p2", id: input?.id ?? "p2",
name: "Prod", name: input?.name || "Prod",
serverUrl: "https://fusion.example.com", serverUrl: input?.serverUrl || "https://fusion.example.com",
authToken: input?.authToken ?? null,
createdAt: "", createdAt: "",
updatedAt: "", updatedAt: "",
})), })),
@@ -32,7 +33,37 @@ const remoteProfile = {
}; };
describe("NativeShellConnectionManager", () => { describe("NativeShellConnectionManager", () => {
it("shows a desktop Local Server destination and switches back from an active remote profile", async () => { it("explains desktop local mode with no remote profiles and hides the editor until add", () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "desktop-shell", desktopMode: "local", activeProfileId: null, profiles: [] }}
onClose={vi.fn()}
/>,
);
expect(screen.getByRole("heading", { name: "Local Server" })).toBeInTheDocument();
expect(screen.getByText("Use the embedded Fusion server on this device.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Current Local Server" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("heading", { name: "Remote servers" })).toBeInTheDocument();
expect(screen.getByText(/No remote servers saved yet/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add remote server" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add connection" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Local$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Remote$/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Add remote server" }));
expect(screen.getByRole("heading", { name: "Add remote server" })).toBeInTheDocument();
expect(screen.getByLabelText("Name")).toBeInTheDocument();
expect(screen.getByLabelText("Server URL")).toBeInTheDocument();
expect(screen.getByLabelText("Auth token (optional)")).toHaveAttribute("type", "password");
});
it("keeps the Local Server destination available from desktop remote mode", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
@@ -43,38 +74,42 @@ describe("NativeShellConnectionManager", () => {
/>, />,
); );
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Local Server" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Use Local Server" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByText("Remote")).toBeInTheDocument(); expect(screen.getByText("Remote")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Local Server/i })); fireEvent.click(screen.getByRole("button", { name: "Use Local Server" }));
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local")); await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
expect(shellApi.setActiveProfile).not.toHaveBeenCalled(); expect(shellApi.setActiveProfile).not.toHaveBeenCalled();
}); });
it("marks Local Server active in desktop local mode and switches remote profile use into remote mode", async () => { it("separates populated desktop remote profiles from local state and exposes deterministic actions", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
const duplicateNameProfiles = [
remoteProfile,
{ ...remoteProfile, id: "remote-2", serverUrl: "https://other.example.com" },
];
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
open={true} open={true}
shellApi={shellApi} shellApi={shellApi}
shellState={{ shellState={{ host: "desktop-shell", desktopMode: "local", activeProfileId: "remote-1", profiles: duplicateNameProfiles }}
host: "desktop-shell",
desktopMode: "local",
activeProfileId: "remote-1",
profiles: [
remoteProfile,
{ ...remoteProfile, id: "remote-2", serverUrl: "https://other.example.com" },
],
}}
onClose={vi.fn()} onClose={vi.fn()}
/>, />,
); );
expect(screen.getByRole("button", { name: /Local Server/i })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Current Local Server" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("Active")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add remote server" })).toBeInTheDocument();
expect(screen.queryByLabelText("Server URL")).not.toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("Use Remote")[1]!); const firstCard = screen.getByText("https://fusion.example.com").closest(".native-shell-connection-manager__profile");
expect(firstCard).not.toBeNull();
expect(within(firstCard as HTMLElement).getByRole("button", { name: "Edit Remote at https://fusion.example.com" })).toBeInTheDocument();
expect(within(firstCard as HTMLElement).getByRole("button", { name: "Use Remote at https://fusion.example.com" })).toBeInTheDocument();
expect(within(firstCard as HTMLElement).getByRole("button", { name: "Delete Remote at https://fusion.example.com" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Use Remote at https://other.example.com" }));
await waitFor(() => { await waitFor(() => {
expect(shellApi.setDesktopMode).toHaveBeenCalledWith("remote"); expect(shellApi.setDesktopMode).toHaveBeenCalledWith("remote");
@@ -83,7 +118,7 @@ describe("NativeShellConnectionManager", () => {
expect(shellApi.setDesktopMode.mock.invocationCallOrder[0]).toBeLessThan(shellApi.setActiveProfile.mock.invocationCallOrder[0]); expect(shellApi.setDesktopMode.mock.invocationCallOrder[0]).toBeLessThan(shellApi.setActiveProfile.mock.invocationCallOrder[0]);
}); });
it("renders Local Server deterministically with no remote profiles and an unset desktop mode", () => { it("validates and saves a new desktop remote profile only after add is chosen", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
@@ -94,12 +129,54 @@ describe("NativeShellConnectionManager", () => {
/>, />,
); );
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Add remote server" }));
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Prod" } });
expect(screen.getByRole("button", { name: "Add server" })).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("Server URL"), { target: { value: "ftp://fusion.example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Server URL must use http or https");
expect(shellApi.saveProfile).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Server URL"), { target: { value: "https://prod.example.com" } });
fireEvent.change(screen.getByLabelText("Auth token (optional)"), { target: { value: "secret-token" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({
id: undefined,
name: "Prod",
serverUrl: "https://prod.example.com",
authToken: "secret-token",
}));
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p2");
});
expect(screen.queryByText("secret-token")).not.toBeInTheDocument();
}); });
it("shows active profile indicator and requires delete confirmation", async () => { it("edits an existing profile from a collapsed desktop editor", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: "remote-1", profiles: [remoteProfile] }}
onClose={vi.fn()}
/>,
);
expect(screen.queryByLabelText("Server URL")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Edit Remote at https://fusion.example.com" }));
expect(screen.getByRole("heading", { name: "Edit remote server" })).toBeInTheDocument();
fireEvent.change(screen.getByDisplayValue("https://fusion.example.com"), { target: { value: "https://next.example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ id: "remote-1", serverUrl: "https://next.example.com" }));
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("remote-1");
});
});
it("keeps mobile QR, manual add, active state, and delete confirmation available", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
@@ -110,9 +187,20 @@ describe("NativeShellConnectionManager", () => {
/>, />,
); );
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull(); expect(screen.queryByRole("heading", { name: "Local Server" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add remote server" })).not.toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument(); expect(screen.getByText("Active")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Delete Prod")); expect(screen.getByRole("button", { name: "Add connection" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Scan QR" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Scan QR" }));
await waitFor(() => {
expect(shellApi.startQrScan).toHaveBeenCalled();
expect(screen.getByDisplayValue("https://qr.example.com")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Delete Prod at https://fusion.example.com" }));
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument(); expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete" })); fireEvent.click(screen.getByRole("button", { name: "Delete" }));
@@ -121,27 +209,7 @@ describe("NativeShellConnectionManager", () => {
}); });
}); });
it("edits and saves active profile", async () => { it("keeps mobile empty state focused on QR and manual entry without desktop guidance", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }}
onClose={vi.fn()}
/>,
);
fireEvent.change(screen.getByDisplayValue("https://fusion.example.com"), { target: { value: "https://next.example.com" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ id: "p1", serverUrl: "https://next.example.com" }));
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
});
});
it("supports empty-state recovery and QR import without a mobile Local Server option", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
@@ -152,13 +220,13 @@ describe("NativeShellConnectionManager", () => {
/>, />,
); );
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull(); expect(screen.queryByRole("heading", { name: "Local Server" })).not.toBeInTheDocument();
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument(); expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
fireEvent.click(screen.getAllByText("Scan QR")[0]!); expect(screen.getByRole("button", { name: "Add server" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Scan QR" })).toBeInTheDocument();
expect(screen.queryByLabelText("Server URL")).not.toBeInTheDocument();
await waitFor(() => { fireEvent.click(screen.getByRole("button", { name: "Add server" }));
expect(shellApi.startQrScan).toHaveBeenCalled(); expect(screen.getByLabelText("Server URL")).toBeInTheDocument();
expect(screen.getByDisplayValue("https://qr.example.com")).toBeInTheDocument();
});
}); });
}); });

View File

@@ -6956,8 +6956,8 @@
"defaultProfileName": "Remote Server", "defaultProfileName": "Remote Server",
"deleteConfirmLabel": "Delete server confirmation", "deleteConfirmLabel": "Delete server confirmation",
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.", "deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
"deleteProfile": "Delete {{name}}", "deleteProfile": "Delete {{name}} at {{url}}",
"editProfile": "Edit {{name}}", "editProfile": "Edit {{name}} at {{url}}",
"modeLocal": "Local", "modeLocal": "Local",
"modeRemote": "Remote", "modeRemote": "Remote",
"nameLabel": "Name", "nameLabel": "Name",
@@ -6966,10 +6966,17 @@
"serverUrlLabel": "Server URL", "serverUrlLabel": "Server URL",
"serverUrlProtocolError": "Server URL must use http or https", "serverUrlProtocolError": "Server URL must use http or https",
"use": "Use", "use": "Use",
"useProfile": "Use {{name}}", "useProfile": "Use {{name}} at {{url}}",
"localServerTitle": "Local Server", "localServerTitle": "Local Server",
"localServerDescription": "Use the embedded Fusion server on this device.", "localServerDescription": "Use the embedded Fusion server on this device.",
"localServerActive": "Current" "localServerActive": "Current",
"addRemoteServer": "Add remote server",
"editRemoteServer": "Edit remote server",
"remoteServersTitle": "Remote servers",
"remoteServersDescription": "Save Fusion servers you want this desktop app to open later.",
"noRemoteServersDesktop": "No remote servers saved yet. Add one only when you want this desktop app to open another Fusion server.",
"useLocalServer": "Use Local Server",
"currentLocalServer": "Current Local Server"
}, },
"skills": { "skills": {
"addSkill": "Add a skill…", "addSkill": "Add a skill…",

View File

@@ -6946,8 +6946,8 @@
"defaultProfileName": "Servidor remoto", "defaultProfileName": "Servidor remoto",
"deleteConfirmLabel": "Confirmación de eliminación del servidor", "deleteConfirmLabel": "Confirmación de eliminación del servidor",
"deleteConfirmMessage": "¿Eliminar {{name}}? Esto eliminará el perfil guardado.", "deleteConfirmMessage": "¿Eliminar {{name}}? Esto eliminará el perfil guardado.",
"deleteProfile": "Eliminar {{name}}", "deleteProfile": "Eliminar {{name}} en {{url}}",
"editProfile": "Editar {{name}}", "editProfile": "Editar {{name}} en {{url}}",
"modeLocal": "Local", "modeLocal": "Local",
"modeRemote": "Remoto", "modeRemote": "Remoto",
"nameLabel": "Nombre", "nameLabel": "Nombre",
@@ -6956,10 +6956,17 @@
"serverUrlLabel": "URL del servidor", "serverUrlLabel": "URL del servidor",
"serverUrlProtocolError": "La URL del servidor debe usar http o https", "serverUrlProtocolError": "La URL del servidor debe usar http o https",
"use": "Usar", "use": "Usar",
"useProfile": "Usar {{name}}", "useProfile": "Usar {{name}} en {{url}}",
"localServerTitle": "Servidor local", "localServerTitle": "Servidor local",
"localServerDescription": "Usa el servidor Fusion integrado en este dispositivo.", "localServerDescription": "Usa el servidor Fusion integrado en este dispositivo.",
"localServerActive": "Actual" "localServerActive": "Actual",
"addRemoteServer": "Añadir servidor remoto",
"editRemoteServer": "Editar servidor remoto",
"remoteServersTitle": "Servidores remotos",
"remoteServersDescription": "Guarda los servidores Fusion que quieras que esta aplicación de escritorio abra más tarde.",
"noRemoteServersDesktop": "Aún no hay servidores remotos guardados. Añade uno solo cuando quieras que esta aplicación de escritorio abra otro servidor Fusion.",
"useLocalServer": "Usar servidor local",
"currentLocalServer": "Servidor local actual"
}, },
"skills": { "skills": {
"addSkill": "Añadir una habilidad…", "addSkill": "Añadir una habilidad…",

View File

@@ -6946,8 +6946,8 @@
"defaultProfileName": "Serveur distant", "defaultProfileName": "Serveur distant",
"deleteConfirmLabel": "Confirmation de suppression du serveur", "deleteConfirmLabel": "Confirmation de suppression du serveur",
"deleteConfirmMessage": "Supprimer {{name}} ? Cela supprimera le profil enregistré.", "deleteConfirmMessage": "Supprimer {{name}} ? Cela supprimera le profil enregistré.",
"deleteProfile": "Supprimer {{name}}", "deleteProfile": "Supprimer {{name}} sur {{url}}",
"editProfile": "Modifier {{name}}", "editProfile": "Modifier {{name}} sur {{url}}",
"modeLocal": "Local", "modeLocal": "Local",
"modeRemote": "Distant", "modeRemote": "Distant",
"nameLabel": "Nom", "nameLabel": "Nom",
@@ -6956,10 +6956,17 @@
"serverUrlLabel": "URL du serveur", "serverUrlLabel": "URL du serveur",
"serverUrlProtocolError": "L'URL du serveur doit utiliser http ou https", "serverUrlProtocolError": "L'URL du serveur doit utiliser http ou https",
"use": "Utiliser", "use": "Utiliser",
"useProfile": "Utiliser {{name}}", "useProfile": "Utiliser {{name}} sur {{url}}",
"localServerTitle": "Serveur local", "localServerTitle": "Serveur local",
"localServerDescription": "Utiliser le serveur Fusion intégré sur cet appareil.", "localServerDescription": "Utiliser le serveur Fusion intégré sur cet appareil.",
"localServerActive": "Actuel" "localServerActive": "Actuel",
"addRemoteServer": "Ajouter un serveur distant",
"editRemoteServer": "Modifier le serveur distant",
"remoteServersTitle": "Serveurs distants",
"remoteServersDescription": "Enregistrez les serveurs Fusion que cette application de bureau doit ouvrir plus tard.",
"noRemoteServersDesktop": "Aucun serveur distant enregistré pour l'instant. Ajoutez-en un uniquement si cette application de bureau doit ouvrir un autre serveur Fusion.",
"useLocalServer": "Utiliser le serveur local",
"currentLocalServer": "Serveur local actuel"
}, },
"skills": { "skills": {
"addSkill": "Ajouter une compétence…", "addSkill": "Ajouter une compétence…",

View File

@@ -6946,8 +6946,8 @@
"defaultProfileName": "원격 서버", "defaultProfileName": "원격 서버",
"deleteConfirmLabel": "서버 삭제 확인", "deleteConfirmLabel": "서버 삭제 확인",
"deleteConfirmMessage": "{{name}}을(를) 삭제하시겠습니까? 저장된 프로필이 제거됩니다.", "deleteConfirmMessage": "{{name}}을(를) 삭제하시겠습니까? 저장된 프로필이 제거됩니다.",
"deleteProfile": "{{name}} 삭제", "deleteProfile": "{{url}}의 {{name}} 삭제",
"editProfile": "{{name}} 편집", "editProfile": "{{url}}의 {{name}} 편집",
"modeLocal": "로컬", "modeLocal": "로컬",
"modeRemote": "원격", "modeRemote": "원격",
"nameLabel": "이름", "nameLabel": "이름",
@@ -6956,10 +6956,17 @@
"serverUrlLabel": "서버 URL", "serverUrlLabel": "서버 URL",
"serverUrlProtocolError": "서버 URL은 http 또는 https를 사용해야 합니다", "serverUrlProtocolError": "서버 URL은 http 또는 https를 사용해야 합니다",
"use": "사용", "use": "사용",
"useProfile": "{{name}} 사용", "useProfile": "{{url}}의 {{name}} 사용",
"localServerTitle": "로컬 서버", "localServerTitle": "로컬 서버",
"localServerDescription": "이 기기의 내장 Fusion 서버를 사용합니다.", "localServerDescription": "이 기기의 내장 Fusion 서버를 사용합니다.",
"localServerActive": "현재" "localServerActive": "현재",
"addRemoteServer": "원격 서버 추가",
"editRemoteServer": "원격 서버 편집",
"remoteServersTitle": "원격 서버",
"remoteServersDescription": "이 데스크톱 앱에서 나중에 열 Fusion 서버를 저장합니다.",
"noRemoteServersDesktop": "저장된 원격 서버가 없습니다. 이 데스크톱 앱에서 다른 Fusion 서버를 열어야 할 때만 추가하세요.",
"useLocalServer": "로컬 서버 사용",
"currentLocalServer": "현재 로컬 서버"
}, },
"skills": { "skills": {
"addSkill": "스킬 추가…", "addSkill": "스킬 추가…",

View File

@@ -6946,8 +6946,8 @@
"defaultProfileName": "远程服务器", "defaultProfileName": "远程服务器",
"deleteConfirmLabel": "删除服务器确认", "deleteConfirmLabel": "删除服务器确认",
"deleteConfirmMessage": "删除 {{name}}?这将移除保存的配置文件。", "deleteConfirmMessage": "删除 {{name}}?这将移除保存的配置文件。",
"deleteProfile": "删除 {{name}}", "deleteProfile": "删除 {{url}} 上的 {{name}}",
"editProfile": "编辑 {{name}}", "editProfile": "编辑 {{url}} 上的 {{name}}",
"modeLocal": "本地", "modeLocal": "本地",
"modeRemote": "远程", "modeRemote": "远程",
"nameLabel": "名称", "nameLabel": "名称",
@@ -6956,10 +6956,17 @@
"serverUrlLabel": "服务器 URL", "serverUrlLabel": "服务器 URL",
"serverUrlProtocolError": "服务器 URL 必须使用 http 或 https", "serverUrlProtocolError": "服务器 URL 必须使用 http 或 https",
"use": "使用", "use": "使用",
"useProfile": "使用 {{name}}", "useProfile": "使用 {{url}} 上的 {{name}}",
"localServerTitle": "本地服务器", "localServerTitle": "本地服务器",
"localServerDescription": "使用此设备上的内置 Fusion 服务器。", "localServerDescription": "使用此设备上的内置 Fusion 服务器。",
"localServerActive": "当前" "localServerActive": "当前",
"addRemoteServer": "添加远程服务器",
"editRemoteServer": "编辑远程服务器",
"remoteServersTitle": "远程服务器",
"remoteServersDescription": "保存你希望此桌面应用稍后打开的 Fusion 服务器。",
"noRemoteServersDesktop": "尚未保存远程服务器。仅当你希望此桌面应用打开另一个 Fusion 服务器时再添加。",
"useLocalServer": "使用本地服务器",
"currentLocalServer": "当前本地服务器"
}, },
"skills": { "skills": {
"addSkill": "添加技能…", "addSkill": "添加技能…",

View File

@@ -6946,8 +6946,8 @@
"defaultProfileName": "遠端伺服器", "defaultProfileName": "遠端伺服器",
"deleteConfirmLabel": "刪除伺服器確認", "deleteConfirmLabel": "刪除伺服器確認",
"deleteConfirmMessage": "刪除 {{name}}?這將移除已保存的設定檔。", "deleteConfirmMessage": "刪除 {{name}}?這將移除已保存的設定檔。",
"deleteProfile": "刪除 {{name}}", "deleteProfile": "刪除 {{url}} 上的 {{name}}",
"editProfile": "編輯 {{name}}", "editProfile": "編輯 {{url}} 上的 {{name}}",
"modeLocal": "本機", "modeLocal": "本機",
"modeRemote": "遠端", "modeRemote": "遠端",
"nameLabel": "名稱", "nameLabel": "名稱",
@@ -6956,10 +6956,17 @@
"serverUrlLabel": "伺服器 URL", "serverUrlLabel": "伺服器 URL",
"serverUrlProtocolError": "伺服器 URL 必須使用 http 或 https", "serverUrlProtocolError": "伺服器 URL 必須使用 http 或 https",
"use": "使用", "use": "使用",
"useProfile": "使用 {{name}}", "useProfile": "使用 {{url}} 上的 {{name}}",
"localServerTitle": "本機伺服器", "localServerTitle": "本機伺服器",
"localServerDescription": "使用此裝置上的內建 Fusion 伺服器。", "localServerDescription": "使用此裝置上的內建 Fusion 伺服器。",
"localServerActive": "目前" "localServerActive": "目前",
"addRemoteServer": "新增遠端伺服器",
"editRemoteServer": "編輯遠端伺服器",
"remoteServersTitle": "遠端伺服器",
"remoteServersDescription": "儲存你希望此桌面應用稍後開啟的 Fusion 伺服器。",
"noRemoteServersDesktop": "尚未儲存遠端伺服器。只有在你希望此桌面應用開啟另一個 Fusion 伺服器時才需要新增。",
"useLocalServer": "使用本機伺服器",
"currentLocalServer": "目前的本機伺服器"
}, },
"skills": { "skills": {
"addSkill": "新增技能…", "addSkill": "新增技能…",

View File

@@ -6951,27 +6951,34 @@ export default interface Resources {
"shell": { "shell": {
"activePill": "Active", "activePill": "Active",
"addConnection": "Add connection", "addConnection": "Add connection",
"addRemoteServer": "Add remote server",
"addServer": "Add server", "addServer": "Add server",
"authTokenLabel": "Auth token (optional)", "authTokenLabel": "Auth token (optional)",
"connectionManager": "Connection Manager", "connectionManager": "Connection Manager",
"connectionManagerLabel": "Connection Manager", "connectionManagerLabel": "Connection Manager",
"defaultProfileName": "Remote Server", "defaultProfileName": "Remote Server",
"currentLocalServer": "Current Local Server",
"editRemoteServer": "Edit remote server",
"deleteConfirmLabel": "Delete server confirmation", "deleteConfirmLabel": "Delete server confirmation",
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.", "deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
"deleteProfile": "Delete {{name}}", "deleteProfile": "Delete {{name}} at {{url}}",
"editProfile": "Edit {{name}}", "editProfile": "Edit {{name}} at {{url}}",
"localServerActive": "Current", "localServerActive": "Current",
"localServerDescription": "Use the embedded Fusion server on this device.", "localServerDescription": "Use the embedded Fusion server on this device.",
"localServerTitle": "Local Server", "localServerTitle": "Local Server",
"modeLocal": "Local", "modeLocal": "Local",
"modeRemote": "Remote", "modeRemote": "Remote",
"nameLabel": "Name", "nameLabel": "Name",
"noRemoteServersDesktop": "No remote servers saved yet. Add one only when you want this desktop app to open another Fusion server.",
"noServersSaved": "No remote servers saved yet.", "noServersSaved": "No remote servers saved yet.",
"remoteServersDescription": "Save Fusion servers you want this desktop app to open later.",
"remoteServersTitle": "Remote servers",
"scanQr": "Scan QR", "scanQr": "Scan QR",
"serverUrlLabel": "Server URL", "serverUrlLabel": "Server URL",
"serverUrlProtocolError": "Server URL must use http or https", "serverUrlProtocolError": "Server URL must use http or https",
"use": "Use", "use": "Use",
"useProfile": "Use {{name}}" "useLocalServer": "Use Local Server",
"useProfile": "Use {{name}} at {{url}}"
}, },
"skills": { "skills": {
"addSkill": "Add a skill…", "addSkill": "Add a skill…",