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,