Skip to content
Open
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
112 changes: 104 additions & 8 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";

import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
import { cn } from "../../lib/utils";
import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
Expand Down Expand Up @@ -1340,13 +1341,15 @@ type SavedBackendListRowProps = {
environment: EnvironmentPresentation;
removingEnvironmentId: EnvironmentId | null;
onConnect: (environmentId: EnvironmentId) => void;
onRename: (environment: EnvironmentPresentation) => void;
onRemove: (environmentId: EnvironmentId) => void;
};

function SavedBackendListRow({
environment,
removingEnvironmentId,
onConnect,
onRename,
onRemove,
}: SavedBackendListRowProps) {
const environmentId = environment.environmentId;
Expand Down Expand Up @@ -1389,14 +1392,12 @@ function SavedBackendListRow({
[copyTraceIdToClipboard],
);
const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
const sshTarget =
environment.entry.target._tag === "SshConnectionTarget" &&
Option.isSome(environment.entry.profile) &&
environment.entry.profile.value._tag === "SshConnectionProfile"
? environment.entry.profile.value.target
: null;
const metadataBits = [
sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,
environment.displayUrl
? environment.entry.target._tag === "SshConnectionTarget"
? `SSH ${environment.displayUrl}`
: environment.displayUrl
: null,
Comment thread
cursor[bot] marked this conversation as resolved.
environment.relayManaged ? "T3 Connect" : null,
].filter((value): value is string => value !== null);

Expand Down Expand Up @@ -1456,6 +1457,14 @@ function SavedBackendListRow({
) : null}
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
<Button
size="xs"
variant="outline"
disabled={removingEnvironmentId === environmentId}
onClick={() => onRename(environment)}
>
Rename
</Button>
{isWslEnvironment ? (
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -1713,6 +1722,8 @@ export function ConnectionsSettings() {
const desktopBridge = window.desktopBridge;
const { environments } = useEnvironments();
const primaryEnvironment = usePrimaryEnvironment();
const environmentDisplayNames = useClientSettings((settings) => settings.environmentDisplayNames);
const updateClientSettings = useUpdateClientSettings();
const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
reportFailure: false,
Expand Down Expand Up @@ -1796,6 +1807,9 @@ export function ConnectionsSettings() {
const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);
const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =
useState<EnvironmentId | null>(null);
const [renameEnvironmentTarget, setRenameEnvironmentTarget] =
useState<EnvironmentPresentation | null>(null);
const [renameEnvironmentValue, setRenameEnvironmentValue] = useState("");
const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false);
const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false);
const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false);
Expand Down Expand Up @@ -2121,7 +2135,7 @@ export function ConnectionsSettings() {
return;
}

const result = await connectSshEnvironment({ target, label: "" });
const result = await connectSshEnvironment({ target, label: target.alias });
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result)));
Expand Down Expand Up @@ -2228,6 +2242,41 @@ export function ConnectionsSettings() {
[retryEnvironment],
);

const handleStartRenameEnvironment = useCallback(
(environment: EnvironmentPresentation) => {
setRenameEnvironmentTarget(environment);
setRenameEnvironmentValue(environmentDisplayNames[environment.environmentId] ?? "");
},
[environmentDisplayNames],
);

const handleCloseRenameEnvironment = useCallback(() => {
setRenameEnvironmentTarget(null);
setRenameEnvironmentValue("");
}, []);

const handleSaveRenameEnvironment = useCallback(() => {
if (!renameEnvironmentTarget) {
return;
}
const environmentId = renameEnvironmentTarget.environmentId;
const displayName = renameEnvironmentValue.trim();
const nextDisplayNames = { ...environmentDisplayNames };
if (displayName === "") {
delete nextDisplayNames[environmentId];
} else {
nextDisplayNames[environmentId] = displayName;
}
updateClientSettings({ environmentDisplayNames: nextDisplayNames });
handleCloseRenameEnvironment();
}, [
environmentDisplayNames,
handleCloseRenameEnvironment,
renameEnvironmentTarget,
renameEnvironmentValue,
updateClientSettings,
]);

const handleRemoveSavedBackend = useCallback(
async (environmentId: EnvironmentId) => {
setRemovingSavedEnvironmentId(environmentId);
Expand Down Expand Up @@ -3385,6 +3434,7 @@ export function ConnectionsSettings() {
environment={environment}
removingEnvironmentId={removingSavedEnvironmentId}
onConnect={handleConnectSavedBackend}
onRename={handleStartRenameEnvironment}
onRemove={handleRemoveSavedBackend}
/>
))}
Expand All @@ -3393,6 +3443,52 @@ export function ConnectionsSettings() {
savedEnvironments={savedEnvironments}
/>
</SettingsSection>
<Dialog
open={renameEnvironmentTarget !== null}
onOpenChange={(open) => {
if (!open) {
handleCloseRenameEnvironment();
}
}}
>
<DialogPopup className="max-w-md">
<DialogHeader>
<DialogTitle>Rename environment</DialogTitle>
<DialogDescription>
Set an optional name for this client. Leave it empty to use the environment’s default
name.
</DialogDescription>
</DialogHeader>
<DialogPanel className="space-y-4">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-foreground">Display name</span>
<Input
autoFocus
value={renameEnvironmentValue}
placeholder={renameEnvironmentTarget?.defaultLabel}
onChange={(event) => setRenameEnvironmentValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleSaveRenameEnvironment();
}
}}
/>
</label>
{renameEnvironmentTarget?.displayUrl ? (
<p className="truncate text-xs text-muted-foreground">
{renameEnvironmentTarget.displayUrl}
</p>
) : null}
</DialogPanel>
<DialogFooter>
<Button variant="outline" onClick={handleCloseRenameEnvironment}>
Cancel
</Button>
<Button onClick={handleSaveRenameEnvironment}>Save</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
</SettingsPageContainer>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { ensureLocalApi } from "~/localApi";
import * as Struct from "effect/Struct";
import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
import { usePrimaryEnvironment } from "~/state/environments";
import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
import { useAtomCommand } from "~/state/use-atom-command";

const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
Expand Down Expand Up @@ -278,7 +278,7 @@ export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
}

export function useUpdatePrimarySettings() {
return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null);
return useUpdateSettingsTarget(useAtomValue(primaryEnvironmentIdAtom));
}

export function useUpdateClientSettings() {
Expand Down
17 changes: 12 additions & 5 deletions apps/web/src/state/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Option from "effect/Option";
import { useMemo } from "react";

import { environmentCatalog } from "../connection/catalog";
import { useClientSettings } from "../hooks/useSettings";
import { environmentPresentations, useEnvironmentPresentation } from "./presentation";
import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
import { useEnvironmentQuery } from "./query";
Expand All @@ -18,18 +19,22 @@ import { usePreparedConnection } from "./session";
export interface EnvironmentPresentation extends BaseEnvironmentPresentation {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly defaultLabel: string;
readonly displayUrl: string | null;
readonly relayManaged: boolean;
}

function projectEnvironmentPresentation(
environmentId: EnvironmentId,
presentation: BaseEnvironmentPresentation,
environmentDisplayNames: Readonly<Record<string, string>>,
): EnvironmentPresentation {
const defaultLabel = presentation.entry.target.label;
return {
...presentation,
environmentId,
label: presentation.entry.target.label,
label: environmentDisplayNames[environmentId] ?? defaultLabel,
defaultLabel,
displayUrl: connectionCatalogDisplayUrl(presentation.entry),
relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",
};
Expand All @@ -39,13 +44,14 @@ export function useEnvironments() {
const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom);
const presentationById = useAtomValue(environmentPresentations.presentationsAtom);
const environmentDisplayNames = useClientSettings((settings) => settings.environmentDisplayNames);

const environments = useMemo(
() =>
[...presentationById.entries()].map(([environmentId, presentation]) =>
projectEnvironmentPresentation(environmentId, presentation),
projectEnvironmentPresentation(environmentId, presentation, environmentDisplayNames),
),
[presentationById],
[environmentDisplayNames, presentationById],
);

return {
Expand All @@ -64,12 +70,13 @@ export function useEnvironment(
environmentId: EnvironmentId | null,
): EnvironmentPresentation | null {
const { presentation } = useEnvironmentPresentation(environmentId);
const environmentDisplayNames = useClientSettings((settings) => settings.environmentDisplayNames);
return useMemo(
() =>
environmentId === null || presentation === null
? null
: projectEnvironmentPresentation(environmentId, presentation),
[environmentId, presentation],
: projectEnvironmentPresentation(environmentId, presentation, environmentDisplayNames),
[environmentDisplayNames, environmentId, presentation],
);
}

Expand Down
33 changes: 32 additions & 1 deletion packages/client-runtime/src/connection/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Option from "effect/Option";

import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts";
import {
BearerConnectionProfile,
type ConnectionCatalogEntry,
SshConnectionProfile,
} from "./catalog.ts";
import {
BearerConnectionTarget,
ConnectionTransientError,
SshConnectionTarget,
type SupervisorConnectionState,
} from "./model.ts";
import {
Expand Down Expand Up @@ -55,6 +60,32 @@ describe("connection presentation", () => {
expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");
});

it("formats SSH display information without a missing username and preserves the port", () => {
const target = new SshConnectionTarget({
environmentId: EnvironmentId.make("environment-ssh"),
label: "SSH environment",
connectionId: "connection-ssh",
});
const entry: ConnectionCatalogEntry = {
target,
profile: Option.some(
new SshConnectionProfile({
connectionId: target.connectionId,
environmentId: target.environmentId,
label: target.label,
target: {
alias: "devbox",
hostname: "devbox.example.test",
username: null,
port: 2222,
},
}),
),
};

expect(connectionCatalogDisplayUrl(entry)).toBe("devbox.example.test:2222");
});

it("distinguishes initial connection, reconnect, and retry errors", () => {
expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({
phase: "connecting",
Expand Down
9 changes: 6 additions & 3 deletions packages/client-runtime/src/connection/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,12 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri
? entry.profile.value.httpBaseUrl
: null;
case "SshConnectionTarget":
return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile"
? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}`
: null;
if (Option.isNone(entry.profile) || entry.profile.value._tag !== "SshConnectionProfile") {
return null;
}
const { hostname, port, username } = entry.profile.value.target;
const authority = username ? `${username}@${hostname}` : hostname;
return port ? `${authority}:${port}` : authority;
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import * as Schema from "effect/Schema";

import { EnvironmentId } from "./baseSchemas.ts";
import { ProviderInstanceId } from "./providerInstance.ts";
import {
ClientSettingsSchema,
Expand Down Expand Up @@ -33,6 +34,30 @@ describe("ClientSettings word wrap", () => {
});
});

describe("ClientSettings environment display names", () => {
const environmentId = EnvironmentId.make("environment-1");

it("defaults to no client-local overrides", () => {
expect(decodeClientSettings({}).environmentDisplayNames).toEqual({});
});

it("trims saved overrides", () => {
expect(
decodeClientSettings({
environmentDisplayNames: { [environmentId]: " Workstation " },
}).environmentDisplayNames,
).toEqual({ [environmentId]: "Workstation" });
});

it("rejects empty overrides", () => {
expect(() =>
decodeClientSettingsPatch({
environmentDisplayNames: { [environmentId]: " " },
}),
).toThrow();
});
});

describe("ClientSettings glass opacity", () => {
it("defaults to a readable translucent surface", () => {
expect(decodeClientSettings({}).glassOpacity).toBe(80);
Expand Down
Loading
Loading