Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/web/src/components/settings/RenameEnvironmentDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useId, useState } from "react";

import { environmentCatalog } from "~/connection/catalog";
import type { EnvironmentPresentation } from "~/state/environments";
import { useAtomCommand } from "~/state/use-atom-command";
import { Button } from "../ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";

export function RenameEnvironmentDialog({
environment,
onClose,
}: {
readonly environment: EnvironmentPresentation;
readonly onClose: () => void;
}) {
const rename = useAtomCommand(environmentCatalog.rename, "Rename environment");
const formId = useId();
const [name, setName] = useState(environment.label);
const [open, setOpen] = useState(true);
const [saving, setSaving] = useState(false);
const label = name.trim();

const save = async (): Promise<void> => {
if (saving || label.length === 0) return;
if (label === environment.label) {
setOpen(false);
return;
}
setSaving(true);
const result = await rename({ environmentId: environment.environmentId, label });
setSaving(false);
if (result._tag === "Success") setOpen(false);
};

return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!saving) setOpen(next);
}}
onOpenChangeComplete={(next) => {
if (!next) onClose();
}}
>
<DialogPopup showCloseButton={!saving}>
<DialogHeader>
<DialogTitle>Rename environment</DialogTitle>
<DialogDescription>
Change the name shown on this device. The host name and connection stay the same.
</DialogDescription>
</DialogHeader>
<DialogPanel>
<form
id={formId}
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<label className="grid gap-2">
<span className="text-sm font-medium">Environment name</span>
<Input
value={name}
disabled={saving}
onChange={(event) => setName(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
/>
</label>
</form>
</DialogPanel>
<DialogFooter>
<DialogClose disabled={saving} render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button type="submit" form={formId} disabled={saving || label.length === 0}>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/SavedEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ChevronRightIcon,
ChevronsLeftRightEllipsisIcon,
EllipsisIcon,
PencilIcon,
RouteIcon,
} from "lucide-react";
import { useAtomValue } from "@effect/atom-react";
Expand All @@ -29,6 +30,7 @@ import { SettingsRow } from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
import { EnvironmentRoutesList } from "./EnvironmentRoutesList";
import { RenameEnvironmentDialog } from "./RenameEnvironmentDialog";
import {
ConnectionDiagnosisPanel,
ConnectionDiagnosisSummary,
Expand Down Expand Up @@ -115,6 +117,7 @@ export function SavedBackendListRow({
onAddRoute,
}: SavedBackendListRowProps) {
const [routesOpen, setRoutesOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [permissionsOpen, setPermissionsOpen] = useState(false);
const [diagnosisOpen, setDiagnosisOpen] = useState(false);
const environmentId = environment.environmentId;
Expand Down Expand Up @@ -409,6 +412,10 @@ export function SavedBackendListRow({
<EllipsisIcon className="size-3.5" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem onClick={() => setRenameOpen(true)}>
<PencilIcon />
Rename…
</MenuItem>
<EnvironmentIconMenu
environmentId={environmentId}
serverConfig={environment.serverConfig}
Expand All @@ -429,6 +436,9 @@ export function SavedBackendListRow({
</MenuItem>
</MenuPopup>
</Menu>
{renameOpen ? (
<RenameEnvironmentDialog environment={environment} onClose={() => setRenameOpen(false)} />
) : null}
</EnvironmentRow>
);
}
Expand Down
4 changes: 4 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ in the receiving app. Connection settings are under **Settings → Environments*
on web and desktop and **Settings → Environments** on mobile. A loopback address
such as `127.0.0.1` reaches only the device opening the link.

On web and desktop, rename a saved environment from its **⋯ → Rename** action in
**Settings → Environments**. The name is saved only on this device; renaming does
not change the host name or interrupt running agents.

On a computer with the desktop app, you can open the printed `t3code://pair`
app link instead. The desktop app asks you to confirm the server address, then
adds it.
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/connection/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as Schema from "effect/Schema";
const ConnectionTargetBase = {
environmentId: EnvironmentId,
label: Schema.String,
// Discovery may refresh the host label, but must not overwrite a name chosen on this client.
labelSource: Schema.optionalKey(Schema.Literal("user")),
};

export class PrimaryConnectionTarget extends Schema.TaggedClass<PrimaryConnectionTarget>()(
Expand Down
114 changes: 111 additions & 3 deletions packages/client-runtime/src/connection/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Scheduler from "effect/Scheduler";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
Expand All @@ -42,15 +43,15 @@ import * as Connectivity from "./connectivity.ts";
import * as ConnectionCredentialStore from "./credentialStore.ts";
import * as ConnectionDriver from "./driver.ts";
import type { RouteCheck } from "./driver.ts";
import { connectionRouteId, connectionRouteKind } from "./routes.ts";
import { connectionRouteId, connectionRouteKind, connectionRoutes } from "./routes.ts";
import {
ConnectionTransientError,
ConnectionBlockedError,
BearerConnectionTarget,
PrimaryConnectionTarget,
RelayConnectionTarget,
SshConnectionTarget,
type ConnectionTarget,
ConnectionTarget,
type PreparedConnection,
type SupervisorConnectionState,
} from "./model.ts";
Expand Down Expand Up @@ -158,6 +159,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
readonly beforeRegistrationRemove?: (
environmentId: EnvironmentId,
) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
readonly beforeRegistrationSetRoutes?: Effect.Effect<
void,
Persistence.ConnectionPersistenceError
>;
readonly beforeCredentialPut?: (
connectionId: string,
) => Effect.Effect<void, ConnectionTransientError>;
Expand Down Expand Up @@ -235,7 +240,11 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
}
}),
setRoutes: (environmentId, routes) =>
Ref.update(storedTargets, (current) => replaceRoutes(current, environmentId, routes)),
(options?.beforeRegistrationSetRoutes ?? Effect.void).pipe(
Effect.andThen(
Ref.update(storedTargets, (current) => replaceRoutes(current, environmentId, routes)),
),
),
remove: (environmentId) =>
Effect.gen(function* () {
yield* options?.beforeRegistrationRemove?.(environmentId) ?? Effect.void;
Expand Down Expand Up @@ -1189,6 +1198,105 @@ describe("EnvironmentRegistry", () => {
}),
);

it.effect("renames every saved route without interrupting the connection", () =>
Effect.gen(function* () {
const environmentId = SSH_CONNECTION.environmentId;
const relay = new RelayConnectionTarget({ ...RELAY_TARGET, environmentId });
const bearer = new BearerConnectionTarget({ ...BEARER_TARGET, environmentId });
const profile = new BearerConnectionProfile({ ...BEARER_PROFILE, environmentId });
const harness = yield* makeHarness([SSH_CONNECTION, relay, bearer], [SSH_PROFILE, profile]);

const saved = yield* Effect.gen(function* () {
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
yield* registry.start;
const connected = yield* awaitConnectionState(
registry,
environmentId,
(state) => state.phase === "connected",
);
const supervisor = yield* registry.run(
environmentId,
EnvironmentSupervisor.EnvironmentSupervisor,
);

yield* registry.rename(environmentId, " Dev server ");
const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId)!;
expect(connectionRoutes(entry).map((route) => route.target.label)).toEqual([
"Dev server",
"Dev server",
"Dev server",
]);
expect(yield* registry.state(environmentId)).toEqual(connected);
expect(
yield* registry.run(environmentId, EnvironmentSupervisor.EnvironmentSupervisor),
).toBe(supervisor);
expect(yield* Ref.get(harness.releasedSessions)).toBe(0);
expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([]);

const invalid = yield* registry.rename(environmentId, " ").pipe(Effect.flip);
expect(invalid._tag).toBe("ConnectionBlockedError");
expect((yield* SubscriptionRef.get(registry.entries)).get(environmentId)).toBe(entry);
const targetsJson = Schema.fromJsonString(Schema.Array(ConnectionTarget));
return yield* Schema.decodeEffect(targetsJson)(
yield* Schema.encodeEffect(targetsJson)(yield* Ref.get(harness.storedTargets)),
);
}).pipe(Effect.provide(harness.layer), Effect.scoped);

const restored = yield* makeHarness(saved, [SSH_PROFILE, profile]);
yield* Effect.gen(function* () {
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
expect(
(yield* SubscriptionRef.get(registry.entries)).get(environmentId)?.target.label,
).toBe("Dev server");
yield* registry.register(new RelayConnectionRegistration({ target: relay }));
yield* registry.reorderRoutes(environmentId, [
"relay",
SSH_CONNECTION.connectionId,
bearer.connectionId,
]);
const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId)!;
expect(connectionRoutes(entry).map((route) => route.target.label)).toEqual([
"Dev server",
"Dev server",
"Dev server",
]);
yield* registry.rename(environmentId, "Workstation");
expect((yield* Ref.get(restored.storedTargets)).map((target) => target.label)).toEqual([
"Workstation",
"Workstation",
"Workstation",
]);
}).pipe(Effect.provide(restored.layer), Effect.scoped);
}),
);

it.effect("keeps the original name when saving a rename fails", () =>
Effect.gen(function* () {
const failure = new Persistence.ConnectionPersistenceError({
operation: "set-connection-routes",
message: "Storage unavailable",
});
const harness = yield* makeHarness([RELAY_TARGET], [], [], {
initialDisabled: [RELAY_TARGET.environmentId],
beforeRegistrationSetRoutes: Effect.fail(failure),
});
yield* Effect.gen(function* () {
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
const previous = yield* SubscriptionRef.get(registry.entries);
expect(
yield* registry.rename(RELAY_TARGET.environmentId, "Dev server").pipe(Effect.flip),
).toBe(failure);
expect(yield* SubscriptionRef.get(registry.entries)).toBe(previous);
expect(yield* Ref.get(harness.storedTargets)).toEqual([RELAY_TARGET]);
expect(yield* Ref.get(harness.sessions)).toHaveLength(0);
const missing = yield* registry
.rename(SECOND_RELAY_TARGET.environmentId, "Other")
.pipe(Effect.flip);
expect(missing._tag).toBe("EnvironmentNotRegisteredError");
}).pipe(Effect.provide(harness.layer), Effect.scoped);
}),
);

it.effect("switching an SSH environment off tears down its managed backend", () =>
Effect.gen(function* () {
const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
Expand Down
Loading