FN-7478: add local server desktop switch option
Restore the desktop connection manager's local server destination alongside saved remotes. - Add a Local Server card for desktop-shell switching with active state handling. - Switch remote profile selection into remote mode before activating the profile. - Cover desktop local/remote switching behavior with tests and localized labels. - Document the switch-server local option and add the published package changeset. Files changed: .changeset/fn-7478-local-server-switch-option.md | 7 +++ docs/native-shell.md | 3 +- .../components/NativeShellConnectionManager.css | 19 ++++++ .../components/NativeShellConnectionManager.tsx | 55 ++++++++++++++--- .../NativeShellConnectionManager.test.tsx | 71 ++++++++++++++++++++-- packages/i18n/locales/en/app.json | 5 +- packages/i18n/locales/es/app.json | 5 +- packages/i18n/locales/fr/app.json | 5 +- packages/i18n/locales/ko/app.json | 5 +- packages/i18n/locales/zh-CN/app.json | 5 +- packages/i18n/locales/zh-TW/app.json | 5 +- packages/i18n/src/resources.d.ts | 3 + 12 files changed, 168 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7478 Fusion-Task-Lineage: 86a94faa-4799-46cc-aaed-b7901ee9698c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7478-local-server-switch-option.md
Normal file
7
.changeset/fn-7478-local-server-switch-option.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore Local Server in the desktop Switch server list.
|
||||||
|
category: fix
|
||||||
|
dev: Desktop Connection Manager now lists local and saved remote destinations together.
|
||||||
@@ -57,7 +57,8 @@ Profiles are first-class saved objects shared by onboarding and Connection Manag
|
|||||||
|
|
||||||
Connection Manager supports:
|
Connection Manager supports:
|
||||||
|
|
||||||
- **Use** (activate profile)
|
- **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.
|
||||||
|
- **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**
|
- **Add connection**
|
||||||
|
|||||||
@@ -24,6 +24,25 @@
|
|||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.native-shell-connection-manager__destination {
|
||||||
|
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;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.native-shell-connection-manager__profile-actions {
|
.native-shell-connection-manager__profile-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
|||||||
@@ -82,6 +82,32 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
|||||||
resetEditor();
|
resetEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDesktopShell = shellState.host === "desktop-shell";
|
||||||
|
const isDesktopLocalActive = isDesktopShell && shellState.desktopMode === "local";
|
||||||
|
const isProfileActive = (profile: ShellConnectionProfile) => (
|
||||||
|
shellState.activeProfileId === profile.id && (!isDesktopShell || shellState.desktopMode === "remote")
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUseLocalServer = async () => {
|
||||||
|
resetEditor();
|
||||||
|
setDeleteCandidate(null);
|
||||||
|
await shellApi.setDesktopMode("local");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseProfile = async (profileId: string) => {
|
||||||
|
resetEditor();
|
||||||
|
if (isDesktopShell) {
|
||||||
|
/*
|
||||||
|
* FNXC:DesktopSwitchServer 2026-07-03-00:00:
|
||||||
|
* Desktop Switch server presents local and remote destinations in one list. A remote profile
|
||||||
|
* selection must explicitly enter remote mode before activation, while the Local Server entry
|
||||||
|
* is the symmetric return path to the embedded runtime without deleting saved profiles.
|
||||||
|
*/
|
||||||
|
await shellApi.setDesktopMode("remote");
|
||||||
|
}
|
||||||
|
await shellApi.setActiveProfile(profileId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay open">
|
<div className="modal-overlay open">
|
||||||
<div className="modal native-shell-connection-manager" role="dialog" aria-label={t("shell.connectionManagerLabel", "Connection Manager")}>
|
<div className="modal native-shell-connection-manager" role="dialog" aria-label={t("shell.connectionManagerLabel", "Connection Manager")}>
|
||||||
@@ -92,14 +118,25 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shellState.host === "desktop-shell" && (
|
<div className="native-shell-connection-manager__profiles">
|
||||||
<div className="native-shell-connection-manager__mode-row">
|
{isDesktopShell && (
|
||||||
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>{t("shell.modeLocal", "Local")}</button>
|
<button
|
||||||
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>{t("shell.modeRemote", "Remote")}</button>
|
type="button"
|
||||||
</div>
|
className="card native-shell-connection-manager__profile native-shell-connection-manager__destination"
|
||||||
|
onClick={() => void handleUseLocalServer()}
|
||||||
|
aria-pressed={isDesktopLocalActive}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{t("shell.localServerTitle", "Local Server")}</strong>
|
||||||
|
<span className="settings-muted">{t("shell.localServerDescription", "Use the embedded Fusion server on this device.")}</span>
|
||||||
|
{isDesktopLocalActive && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="native-shell-connection-manager__profile-actions" aria-hidden="true">
|
||||||
|
<span className="btn btn-sm">{isDesktopLocalActive ? t("shell.localServerActive", "Current") : t("shell.use", "Use")}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="native-shell-connection-manager__profiles">
|
|
||||||
{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">{t("shell.noServersSaved", "No remote servers saved yet.")}</p>
|
||||||
@@ -128,7 +165,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
|||||||
<div>
|
<div>
|
||||||
<strong>{profile.name}</strong>
|
<strong>{profile.name}</strong>
|
||||||
<div className="settings-muted">{profile.serverUrl}</div>
|
<div className="settings-muted">{profile.serverUrl}</div>
|
||||||
{profile.id === shellState.activeProfileId && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
|
{isProfileActive(profile) && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="native-shell-connection-manager__profile-actions">
|
<div className="native-shell-connection-manager__profile-actions">
|
||||||
<button
|
<button
|
||||||
@@ -142,7 +179,7 @@ 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 shellApi.setActiveProfile(profile.id)}>{t("shell.use", "Use")}</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 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,20 +22,81 @@ function createShellApi() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remoteProfile = {
|
||||||
|
id: "remote-1",
|
||||||
|
name: "Remote",
|
||||||
|
serverUrl: "https://fusion.example.com",
|
||||||
|
authToken: "token-value",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
};
|
||||||
|
|
||||||
describe("NativeShellConnectionManager", () => {
|
describe("NativeShellConnectionManager", () => {
|
||||||
it("switches desktop mode", async () => {
|
it("shows a desktop Local Server destination and switches back from an active remote profile", async () => {
|
||||||
const shellApi = createShellApi();
|
const shellApi = createShellApi();
|
||||||
render(
|
render(
|
||||||
<NativeShellConnectionManager
|
<NativeShellConnectionManager
|
||||||
open={true}
|
open={true}
|
||||||
shellApi={shellApi}
|
shellApi={shellApi}
|
||||||
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] }}
|
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: "remote-1", profiles: [remoteProfile] }}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Local"));
|
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Remote")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Local Server/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
|
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
|
||||||
|
expect(shellApi.setActiveProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks Local Server active in desktop local mode and switches remote profile use into remote mode", async () => {
|
||||||
|
const shellApi = createShellApi();
|
||||||
|
render(
|
||||||
|
<NativeShellConnectionManager
|
||||||
|
open={true}
|
||||||
|
shellApi={shellApi}
|
||||||
|
shellState={{
|
||||||
|
host: "desktop-shell",
|
||||||
|
desktopMode: "local",
|
||||||
|
activeProfileId: "remote-1",
|
||||||
|
profiles: [
|
||||||
|
remoteProfile,
|
||||||
|
{ ...remoteProfile, id: "remote-2", serverUrl: "https://other.example.com" },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Local Server/i })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByLabelText("Use Remote")[1]!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(shellApi.setDesktopMode).toHaveBeenCalledWith("remote");
|
||||||
|
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("remote-2");
|
||||||
|
});
|
||||||
|
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", () => {
|
||||||
|
const shellApi = createShellApi();
|
||||||
|
render(
|
||||||
|
<NativeShellConnectionManager
|
||||||
|
open={true}
|
||||||
|
shellApi={shellApi}
|
||||||
|
shellState={{ host: "desktop-shell", activeProfileId: null, profiles: [] }}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Add server" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows active profile indicator and requires delete confirmation", async () => {
|
it("shows active profile indicator and requires delete confirmation", async () => {
|
||||||
@@ -49,6 +110,7 @@ describe("NativeShellConnectionManager", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull();
|
||||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByLabelText("Delete Prod"));
|
fireEvent.click(screen.getByLabelText("Delete Prod"));
|
||||||
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument();
|
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument();
|
||||||
@@ -79,7 +141,7 @@ describe("NativeShellConnectionManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports empty-state recovery and QR import", async () => {
|
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
|
||||||
@@ -90,6 +152,7 @@ describe("NativeShellConnectionManager", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull();
|
||||||
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]!);
|
fireEvent.click(screen.getAllByText("Scan QR")[0]!);
|
||||||
|
|
||||||
|
|||||||
@@ -6947,7 +6947,10 @@
|
|||||||
"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}}",
|
||||||
|
"localServerTitle": "Local Server",
|
||||||
|
"localServerDescription": "Use the embedded Fusion server on this device.",
|
||||||
|
"localServerActive": "Current"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"addSkill": "Add a skill…",
|
"addSkill": "Add a skill…",
|
||||||
|
|||||||
@@ -6937,7 +6937,10 @@
|
|||||||
"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}}",
|
||||||
|
"localServerTitle": "Servidor local",
|
||||||
|
"localServerDescription": "Usa el servidor Fusion integrado en este dispositivo.",
|
||||||
|
"localServerActive": "Actual"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"addSkill": "Añadir una habilidad…",
|
"addSkill": "Añadir una habilidad…",
|
||||||
|
|||||||
@@ -6937,7 +6937,10 @@
|
|||||||
"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}}",
|
||||||
|
"localServerTitle": "Serveur local",
|
||||||
|
"localServerDescription": "Utiliser le serveur Fusion intégré sur cet appareil.",
|
||||||
|
"localServerActive": "Actuel"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"addSkill": "Ajouter une compétence…",
|
"addSkill": "Ajouter une compétence…",
|
||||||
|
|||||||
@@ -6937,7 +6937,10 @@
|
|||||||
"serverUrlLabel": "서버 URL",
|
"serverUrlLabel": "서버 URL",
|
||||||
"serverUrlProtocolError": "서버 URL은 http 또는 https를 사용해야 합니다",
|
"serverUrlProtocolError": "서버 URL은 http 또는 https를 사용해야 합니다",
|
||||||
"use": "사용",
|
"use": "사용",
|
||||||
"useProfile": "{{name}} 사용"
|
"useProfile": "{{name}} 사용",
|
||||||
|
"localServerTitle": "로컬 서버",
|
||||||
|
"localServerDescription": "이 기기의 내장 Fusion 서버를 사용합니다.",
|
||||||
|
"localServerActive": "현재"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"addSkill": "스킬 추가…",
|
"addSkill": "스킬 추가…",
|
||||||
|
|||||||
@@ -6937,7 +6937,10 @@
|
|||||||
"serverUrlLabel": "服务器 URL",
|
"serverUrlLabel": "服务器 URL",
|
||||||
"serverUrlProtocolError": "服务器 URL 必须使用 http 或 https",
|
"serverUrlProtocolError": "服务器 URL 必须使用 http 或 https",
|
||||||
"use": "使用",
|
"use": "使用",
|
||||||
"useProfile": "使用 {{name}}"
|
"useProfile": "使用 {{name}}",
|
||||||
|
"localServerTitle": "本地服务器",
|
||||||
|
"localServerDescription": "使用此设备上的内置 Fusion 服务器。",
|
||||||
|
"localServerActive": "当前"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"addSkill": "添加技能…",
|
"addSkill": "添加技能…",
|
||||||
|
|||||||
@@ -6937,7 +6937,10 @@
|
|||||||
"serverUrlLabel": "伺服器 URL",
|
"serverUrlLabel": "伺服器 URL",
|
||||||
"serverUrlProtocolError": "伺服器 URL 必須使用 http 或 https",
|
"serverUrlProtocolError": "伺服器 URL 必須使用 http 或 https",
|
||||||
"use": "使用",
|
"use": "使用",
|
||||||
"useProfile": "使用 {{name}}"
|
"useProfile": "使用 {{name}}",
|
||||||
|
"localServerTitle": "本機伺服器",
|
||||||
|
"localServerDescription": "使用此裝置上的內建 Fusion 伺服器。",
|
||||||
|
"localServerActive": "目前"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"addSkill": "新增技能…",
|
"addSkill": "新增技能…",
|
||||||
|
|||||||
3
packages/i18n/src/resources.d.ts
vendored
3
packages/i18n/src/resources.d.ts
vendored
@@ -6901,6 +6901,9 @@ export default interface Resources {
|
|||||||
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
|
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
|
||||||
"deleteProfile": "Delete {{name}}",
|
"deleteProfile": "Delete {{name}}",
|
||||||
"editProfile": "Edit {{name}}",
|
"editProfile": "Edit {{name}}",
|
||||||
|
"localServerActive": "Current",
|
||||||
|
"localServerDescription": "Use the embedded Fusion server on this device.",
|
||||||
|
"localServerTitle": "Local Server",
|
||||||
"modeLocal": "Local",
|
"modeLocal": "Local",
|
||||||
"modeRemote": "Remote",
|
"modeRemote": "Remote",
|
||||||
"nameLabel": "Name",
|
"nameLabel": "Name",
|
||||||
|
|||||||
Reference in New Issue
Block a user