add name shuffle

Kim committed Mar 19, 2026 at 20:00 UTC 45aadb338ab7227106c6e3d18adb4bac087bb91f
2 files changed +100 -28
frontend/src/components/TunnelCommandForm.tsx
+89 -27
@@ -1,8 +1,9 @@
1 -import { useEffect, useId, useMemo, useState } from "react";
1 +import { useEffect, useId, useMemo, useState, type ChangeEvent } from "react";
2 import { Check, Copy, X } from "lucide-react";
3 import { Input } from "@/components/ui/input";
4 import { cn } from "@/lib/utils";
5 import {
6 + buildDefaultTunnelName,
7 buildTunnelCommand,
8 buildTunnelPreviewURL,
9 normalizeAbsoluteHTTPURL,
@@ -57,12 +58,23 @@ export function TunnelCommandForm({
58
59 const [target, setTarget] = useState(defaultHost);
60 const [name, setName] = useState("");
61 + const [isAutoName, setIsAutoName] = useState(true);
62 + const [nameShuffleKey, setNameShuffleKey] = useState("default");
63 const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
64 const [defaultRelays, setDefaultRelays] = useState(true);
65 const [urlInput, setUrlInput] = useState("");
66 const [copied, setCopied] = useState(false);
67 const [os, setOs] = useState<TunnelCommandOS>("unix");
68 const [thumbnailURL, setThumbnailURL] = useState("");
69 + const resolvedNameSeed = useMemo(
70 + () => `${nameSeed}:${nameShuffleKey}`,
71 + [nameSeed, nameShuffleKey]
72 + );
73 + const generatedName = useMemo(
74 + () => buildDefaultTunnelName(target, resolvedNameSeed),
75 + [resolvedNameSeed, target]
76 + );
77 + const effectiveName = isAutoName ? generatedName : name;
78
79 const normalizedThumbnailURL = useMemo(
80 () => normalizeAbsoluteHTTPURL(thumbnailURL),
@@ -82,7 +94,7 @@ export function TunnelCommandForm({
94 buildTunnelCommand({
95 currentOrigin,
96 target,
85 - name,
97 + name: effectiveName,
98 nameSeed,
99 relayUrls: isHero ? [] : relayUrls,
100 defaultRelays: isHero ? true : defaultRelays,
@@ -92,8 +104,8 @@ export function TunnelCommandForm({
104 [
105 currentOrigin,
106 defaultRelays,
107 + effectiveName,
108 isHero,
96 - name,
109 nameSeed,
110 normalizedThumbnailURL,
111 os,
@@ -102,8 +114,8 @@ export function TunnelCommandForm({
114 ]
115 );
116 const previewURL = useMemo(
105 - () => buildTunnelPreviewURL(currentOrigin, name, target, nameSeed),
106 - [currentOrigin, name, nameSeed, target]
117 + () => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
118 + [currentOrigin, effectiveName, nameSeed, target]
119 );
120
121 useEffect(() => {
@@ -160,6 +172,30 @@ export function TunnelCommandForm({
172 }
173 };
174
175 + const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
176 + const next = event.target.value;
177 + if (next.trim() === "") {
178 + setName("");
179 + setIsAutoName(true);
180 + return;
181 + }
182 +
183 + setName(next);
184 + setIsAutoName(false);
185 + };
186 +
187 + const handleShuffleName = () => {
188 + const next =
189 + typeof window !== "undefined" &&
190 + typeof window.crypto?.randomUUID === "function"
191 + ? window.crypto.randomUUID()
192 + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
193 +
194 + setName("");
195 + setIsAutoName(true);
196 + setNameShuffleKey(next);
197 + };
198 +
199 const commandSection = (
200 <div className="space-y-2">
201 <label
@@ -254,6 +290,14 @@ export function TunnelCommandForm({
290 ? "border-white/8 bg-black/20 text-slate-100 placeholder:text-slate-500"
291 : "border-border bg-white"
292 );
293 + const nameInputValue = isAutoName ? generatedName : name;
294 + const shuffleButtonClass = cn(
295 + "shrink-0 rounded-lg border px-3 text-xs font-semibold transition-colors",
296 + isHero ? "h-10" : "h-12",
297 + isTerminal
298 + ? "border-white/8 bg-black/20 text-slate-300 hover:text-white"
299 + : "border-border bg-white text-text-muted hover:text-foreground"
300 + );
301
302 return (
303 <div className={cn("space-y-5", className)}>
@@ -278,16 +322,25 @@ export function TunnelCommandForm({
322
323 <div className="space-y-1.5">
324 <label htmlFor={`${inputId}-name`} className={heroFieldLabelClass}>
281 - Name
325 + Public Name
326 </label>
283 - <Input
284 - id={`${inputId}-name`}
285 - type="text"
286 - value={name}
287 - onChange={(event) => setName(event.target.value)}
288 - placeholder="auto"
289 - className={heroInputClass}
290 - />
327 + <div className="flex items-center gap-2">
328 + <Input
329 + id={`${inputId}-name`}
330 + type="text"
331 + value={nameInputValue}
332 + onChange={handleNameChange}
333 + className={cn(heroInputClass, "flex-1")}
334 + />
335 + <button
336 + type="button"
337 + onClick={handleShuffleName}
338 + className={shuffleButtonClass}
339 + aria-label="Shuffle public name"
340 + >
341 + 🎲
342 + </button>
343 + </div>
344 </div>
345 </div>
346 ) : (
@@ -327,19 +380,28 @@ export function TunnelCommandForm({
380 >
381 Service Name
382 </label>
330 - <Input
331 - id={`${inputId}-name`}
332 - type="text"
333 - value={name}
334 - onChange={(event) => setName(event.target.value)}
335 - placeholder="auto-generated when empty"
336 - className={cn(
337 - "h-12 rounded-xl",
338 - isTerminal
339 - ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
340 - : "border-border bg-white"
341 - )}
342 - />
383 + <div className="flex items-center gap-2">
384 + <Input
385 + id={`${inputId}-name`}
386 + type="text"
387 + value={nameInputValue}
388 + onChange={handleNameChange}
389 + className={cn(
390 + "h-12 flex-1 rounded-xl",
391 + isTerminal
392 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
393 + : "border-border bg-white"
394 + )}
395 + />
396 + <button
397 + type="button"
398 + onClick={handleShuffleName}
399 + className={shuffleButtonClass}
400 + aria-label="Shuffle public name"
401 + >
402 + 🎲
403 + </button>
404 + </div>
405 </div>
406 </>
407 )}
frontend/src/lib/tunnelCommand.ts
+11 -1
@@ -1,5 +1,8 @@
1 import { API_PATHS } from "@/lib/apiPaths";
2 -import { resolveExposeName } from "../../../utils/exposeName";
2 +import {
3 + buildDefaultExposeName,
4 + resolveExposeName,
5 +} from "../../../utils/exposeName";
6
7 export type TunnelCommandOS = "unix" | "windows";
8
@@ -16,6 +19,13 @@ export interface TunnelCommandOptions {
19
20 const DEFAULT_PREVIEW_HOST = "portal.run";
21
22 +export function buildDefaultTunnelName(
23 + target: string,
24 + nameSeed: string
25 +): string {
26 + return buildDefaultExposeName(target, nameSeed);
27 +}
28 +
29 export function buildTunnelCommand({
30 currentOrigin,
31 defaultRelays,