Repository navigation
feat(web): rename saved environments from settings #31245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
edaec2e
1d065e9
ed77b47
85390be
98b039b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { | ||
| isAtomCommandInterrupted, | ||
| squashAtomCommandFailure, | ||
| } from "@t3tools/client-runtime/state/runtime"; | ||
| import type { EnvironmentId } from "@t3tools/contracts"; | ||
| import { useState } from "react"; | ||
|
|
||
| import { environmentCatalog } from "~/connection/catalog"; | ||
| import { useAtomCommand } from "~/state/use-atom-command"; | ||
| import { Button } from "../ui/button"; | ||
| import { | ||
| Dialog, | ||
| DialogClose, | ||
| DialogFooter, | ||
| DialogHeader, | ||
| DialogPanel, | ||
| DialogPopup, | ||
| DialogTitle, | ||
| } from "../ui/dialog"; | ||
| import { Input } from "../ui/input"; | ||
| import { Label } from "../ui/label"; | ||
| import { stackedThreadToast, toastManager } from "../ui/toast"; | ||
|
|
||
| // The popup unmounts when closed, so each open starts from the current label. | ||
| function RenameEnvironmentForm({ | ||
| environmentId, | ||
| label: currentLabel, | ||
| onDone, | ||
| }: { | ||
| readonly environmentId: EnvironmentId; | ||
| readonly label: string; | ||
| readonly onDone: () => void; | ||
| }) { | ||
| const [label, setLabel] = useState(currentLabel); | ||
| const setEnvironmentLabel = useAtomCommand(environmentCatalog.setLabel, "Rename environment"); | ||
| const canSave = label.trim() !== ""; | ||
|
|
||
| const save = async () => { | ||
| if (!canSave) return; | ||
| const result = await setEnvironmentLabel({ environmentId, label }); | ||
| if (result._tag === "Success") { | ||
| onDone(); | ||
| return; | ||
| } | ||
| if (isAtomCommandInterrupted(result)) return; | ||
| const error = squashAtomCommandFailure(result); | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not rename environment", | ||
| description: error instanceof Error ? error.message : "The label was not saved.", | ||
| }), | ||
| ); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <DialogHeader> | ||
| <DialogTitle>Rename environment</DialogTitle> | ||
| </DialogHeader> | ||
| <DialogPanel> | ||
| <form | ||
| id="rename-environment-form" | ||
| className="grid gap-1.5" | ||
| onSubmit={(event) => { | ||
| event.preventDefault(); | ||
| void save(); | ||
| }} | ||
| > | ||
| <Label htmlFor="environment-label">Label</Label> | ||
| <Input | ||
| id="environment-label" | ||
| value={label} | ||
| onChange={(event) => setLabel(event.target.value)} | ||
| autoFocus | ||
| /> | ||
| </form> | ||
| </DialogPanel> | ||
| <DialogFooter variant="bare"> | ||
| <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose> | ||
| <Button type="submit" form="rename-environment-form" disabled={!canSave}> | ||
| Save | ||
| </Button> | ||
| </DialogFooter> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| export function RenameEnvironmentDialog({ | ||
| open, | ||
| onOpenChange, | ||
| environmentId, | ||
| label, | ||
| }: { | ||
| readonly open: boolean; | ||
| readonly onOpenChange: (open: boolean) => void; | ||
| readonly environmentId: EnvironmentId; | ||
| readonly label: string; | ||
| }) { | ||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogPopup className="max-w-md"> | ||
| <RenameEnvironmentForm | ||
| environmentId={environmentId} | ||
| label={label} | ||
| onDone={() => onOpenChange(false)} | ||
| /> | ||
| </DialogPopup> | ||
| </Dialog> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,8 @@ import { | |
| findRouteToSameAddress, | ||
| hasSavedBearerRoute, | ||
| mergeLearnedRoutes, | ||
| profileWithLabel, | ||
| routeWithLabel, | ||
| routesAfterRemoving, | ||
| upsertRoute, | ||
| type ReportedEndpoint, | ||
|
|
@@ -151,6 +153,16 @@ export class EnvironmentRegistry extends Context.Service< | |
| | PlatformEnvironmentRemovalError | ||
| | ConnectionBlockedError | ||
| >; | ||
| readonly setLabel: ( | ||
| environmentId: EnvironmentId, | ||
| label: string, | ||
| ) => Effect.Effect< | ||
| void, | ||
| | Persistence.ConnectionPersistenceError | ||
| | ConnectionAttemptError | ||
| | EnvironmentNotRegisteredError | ||
| | PlatformEnvironmentRemovalError | ||
| >; | ||
| readonly removeRelayEnvironments: () => Effect.Effect< | ||
| void, | ||
| | Persistence.ConnectionPersistenceError | ||
|
|
@@ -202,7 +214,7 @@ export class EnvironmentRegistry extends Context.Service< | |
|
|
||
| interface EnvironmentServiceScope { | ||
| readonly entry: ConnectionCatalogEntry; | ||
| readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; | ||
| readonly supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor; | ||
| readonly scope: Scope.Closeable; | ||
| } | ||
|
|
||
|
|
@@ -1025,6 +1037,54 @@ export const make = Effect.gen(function* () { | |
| ); | ||
| }); | ||
|
|
||
| const setLabel = Effect.fn("EnvironmentRegistry.setLabel")(function* ( | ||
| environmentId: EnvironmentId, | ||
| label: string, | ||
| ) { | ||
| const trimmed = label.trim(); | ||
| if (trimmed === "") { | ||
| return yield* new ConnectionBlockedError({ | ||
| reason: "configuration", | ||
| detail: "Environment label cannot be empty.", | ||
| }); | ||
| } | ||
| yield* withLeaseLock( | ||
| environmentId, | ||
| Effect.gen(function* () { | ||
| const entry = yield* userEntry(environmentId); | ||
| const routes = connectionRoutes(entry); | ||
| if (routes.every((route) => route.target.label === trimmed)) return; | ||
| // Every route carries the label: the first one is displayed, and reordering can change it. | ||
| const renamed = routes.map((route) => routeWithLabel(route, trimmed)); | ||
| for (const { profile } of routes) { | ||
| if (Option.isNone(profile)) continue; | ||
| // The SSH broker saves a freshly resolved target only to the store, so relabel that copy. | ||
| const saved = yield* profiles.get(profile.value.connectionId); | ||
| yield* profiles.put( | ||
| profileWithLabel( | ||
| Option.getOrElse(saved, () => profile.value), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the saved SSH profile in the live entry. If the SSH broker has saved a newer
|
||
| trimmed, | ||
| ), | ||
| ); | ||
| } | ||
| const next = entryWithRoutes(entry, renamed); | ||
| yield* registrations.setRoutes(environmentId, persistedRoutes(next)); | ||
| // A label changes no address, so the supervisor keeps its session. Its next connect uses | ||
| // the new label; its own messages keep the old one until it is next recreated. | ||
| const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); | ||
| if (lease !== undefined) { | ||
| yield* lease.supervisor.setLabel(trimmed); | ||
| yield* SubscriptionRef.update(serviceScopes, (current) => | ||
| new Map(current).set(environmentId, { ...lease, entry: next }), | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| ); | ||
| } | ||
| yield* SubscriptionRef.update(entries, (current) => | ||
| new Map(current).set(environmentId, next), | ||
| ); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( | ||
| function* () { | ||
| const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] | ||
|
|
@@ -1193,6 +1253,7 @@ export const make = Effect.gen(function* () { | |
| remove, | ||
| removeRoute, | ||
| reorderRoutes, | ||
| setLabel, | ||
| removeRelayEnvironments, | ||
| retryNow, | ||
| setEnabled, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep the form stable while Save is pending.
If a user edits Label after pressing Save, the first request can succeed and
onDone()closes the dialog before the new edit is saved. Save also remains available for another submission. Track the pending request, disable editing and repeat submission, and show “Saving…” until it settles.useAtomCommanddoes not provide a pending guard. (raw.githubusercontent.com)Based on learnings, a submit handler needs a pending guard, completion cleanup, and loading feedback.
Source: Learnings