@atercates/claude-deck 0.2.14 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/tunnels/[port]/route.ts +57 -0
- package/app/api/tunnels/route.ts +18 -0
- package/app/api/worktrees/route.ts +29 -0
- package/components/NewClaudeSessionDialog.tsx +197 -15
- package/components/Pane/DesktopTabBar.tsx +11 -0
- package/components/Pane/MobileTabBar.tsx +6 -0
- package/components/SessionList/ActiveSessionsSection.tsx +140 -3
- package/components/Terminal/hooks/websocket-connection.ts +17 -5
- package/components/views/types.ts +2 -0
- package/data/tunnels/queries.ts +51 -0
- package/lib/claude/watcher.ts +10 -1
- package/lib/db/migrations.ts +11 -1
- package/lib/db/types.ts +1 -0
- package/lib/dev-servers.ts +84 -66
- package/lib/status-monitor.ts +108 -4
- package/lib/tunnels.ts +157 -0
- package/lib/worktrees.ts +9 -0
- package/package.json +1 -1
- package/scripts/lib/prerequisites.sh +93 -18
- package/scripts/nginx-http.conf +19 -0
- package/server.ts +105 -47
- package/components/NewSessionDialog/AdvancedSettings.tsx +0 -69
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isCloudflaredInstalled, startTunnel, stopTunnel } from "@/lib/tunnels";
|
|
3
|
+
|
|
4
|
+
export async function POST(
|
|
5
|
+
_request: NextRequest,
|
|
6
|
+
{ params }: { params: Promise<{ port: string }> }
|
|
7
|
+
) {
|
|
8
|
+
try {
|
|
9
|
+
const { port: portStr } = await params;
|
|
10
|
+
const port = parseInt(portStr, 10);
|
|
11
|
+
if (isNaN(port) || port <= 0) {
|
|
12
|
+
return NextResponse.json({ error: "Invalid port" }, { status: 400 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const installed = await isCloudflaredInstalled();
|
|
16
|
+
if (!installed) {
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{
|
|
19
|
+
error: "cloudflared is not installed",
|
|
20
|
+
code: "CLOUDFLARED_NOT_FOUND",
|
|
21
|
+
},
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await startTunnel(port);
|
|
27
|
+
return NextResponse.json(result);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error("Error starting tunnel:", error);
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "Failed to start tunnel" },
|
|
32
|
+
{ status: 500 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function DELETE(
|
|
38
|
+
_request: NextRequest,
|
|
39
|
+
{ params }: { params: Promise<{ port: string }> }
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
const { port: portStr } = await params;
|
|
43
|
+
const port = parseInt(portStr, 10);
|
|
44
|
+
if (isNaN(port) || port <= 0) {
|
|
45
|
+
return NextResponse.json({ error: "Invalid port" }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await stopTunnel(port);
|
|
49
|
+
return NextResponse.json({ ok: true });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error("Error stopping tunnel:", error);
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{ error: "Failed to stop tunnel" },
|
|
54
|
+
{ status: 500 }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { isCloudflaredInstalled, getTunnelsList } from "@/lib/tunnels";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
try {
|
|
6
|
+
const [installed, tunnels] = await Promise.all([
|
|
7
|
+
isCloudflaredInstalled(),
|
|
8
|
+
Promise.resolve(getTunnelsList()),
|
|
9
|
+
]);
|
|
10
|
+
return NextResponse.json({ installed, tunnels });
|
|
11
|
+
} catch (error) {
|
|
12
|
+
console.error("Error getting tunnels:", error);
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: "Failed to get tunnels" },
|
|
15
|
+
{ status: 500 }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { createWorktree } from "@/lib/worktrees";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: NextRequest) {
|
|
5
|
+
try {
|
|
6
|
+
const { projectPath, featureName, baseBranch } = await request.json();
|
|
7
|
+
|
|
8
|
+
if (!projectPath || !featureName) {
|
|
9
|
+
return NextResponse.json(
|
|
10
|
+
{ error: "projectPath and featureName are required" },
|
|
11
|
+
{ status: 400 }
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const worktree = await createWorktree({
|
|
16
|
+
projectPath,
|
|
17
|
+
featureName,
|
|
18
|
+
baseBranch,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return NextResponse.json(worktree, { status: 201 });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{ error: `Failed to create worktree: ${message}` },
|
|
26
|
+
{ status: 400 }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
4
|
import {
|
|
5
5
|
Dialog,
|
|
6
6
|
DialogContent,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
SelectTrigger,
|
|
17
17
|
SelectValue,
|
|
18
18
|
} from "@/components/ui/select";
|
|
19
|
-
import { Sparkles } from "lucide-react";
|
|
19
|
+
import { Sparkles, GitBranch, Loader2 } from "lucide-react";
|
|
20
20
|
import type { ClaudeProject } from "@/data/claude";
|
|
21
21
|
|
|
22
22
|
const ADJECTIVES = [
|
|
@@ -50,6 +50,12 @@ function generateName() {
|
|
|
50
50
|
return `${adj}-${noun}`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
interface GitInfo {
|
|
54
|
+
isGitRepo: boolean;
|
|
55
|
+
branches: string[];
|
|
56
|
+
defaultBranch: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
interface NewClaudeSessionDialogProps {
|
|
54
60
|
open: boolean;
|
|
55
61
|
projectName: string;
|
|
@@ -69,10 +75,57 @@ export function NewClaudeSessionDialog({
|
|
|
69
75
|
const [selectedProject, setSelectedProject] = useState(projectName);
|
|
70
76
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
71
77
|
|
|
78
|
+
// Worktree state
|
|
79
|
+
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
|
80
|
+
const [useWorktree, setUseWorktree] = useState(false);
|
|
81
|
+
const [featureName, setFeatureName] = useState("");
|
|
82
|
+
const [baseBranch, setBaseBranch] = useState("main");
|
|
83
|
+
const [creating, setCreating] = useState(false);
|
|
84
|
+
|
|
85
|
+
const cwd = (() => {
|
|
86
|
+
if (selectedProject) {
|
|
87
|
+
const project = projects?.find((p) => p.name === selectedProject);
|
|
88
|
+
return project?.directory || undefined;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
// Check git repo when cwd changes
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!cwd) {
|
|
96
|
+
setGitInfo(null);
|
|
97
|
+
setUseWorktree(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
fetch("/api/git/check", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ path: cwd }),
|
|
106
|
+
})
|
|
107
|
+
.then((r) => r.json())
|
|
108
|
+
.then((data) => {
|
|
109
|
+
if (cancelled) return;
|
|
110
|
+
setGitInfo(data);
|
|
111
|
+
if (data.defaultBranch) setBaseBranch(data.defaultBranch);
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
if (!cancelled) setGitInfo(null);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
cancelled = true;
|
|
119
|
+
};
|
|
120
|
+
}, [cwd]);
|
|
121
|
+
|
|
72
122
|
useEffect(() => {
|
|
73
123
|
if (open) {
|
|
74
124
|
setName(generateName());
|
|
75
125
|
setSelectedProject(projectName);
|
|
126
|
+
setUseWorktree(false);
|
|
127
|
+
setFeatureName("");
|
|
128
|
+
setCreating(false);
|
|
76
129
|
setTimeout(() => {
|
|
77
130
|
inputRef.current?.focus();
|
|
78
131
|
inputRef.current?.select();
|
|
@@ -80,20 +133,63 @@ export function NewClaudeSessionDialog({
|
|
|
80
133
|
}
|
|
81
134
|
}, [open, projectName]);
|
|
82
135
|
|
|
83
|
-
const handleSubmit = (
|
|
84
|
-
e.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
136
|
+
const handleSubmit = useCallback(
|
|
137
|
+
async (e: React.FormEvent) => {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
const sessionName = name.trim() || generateName();
|
|
140
|
+
let targetCwd = cwd;
|
|
141
|
+
|
|
142
|
+
if (useWorktree && featureName.trim() && cwd) {
|
|
143
|
+
setCreating(true);
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch("/api/worktrees", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
projectPath: cwd,
|
|
150
|
+
featureName: featureName.trim(),
|
|
151
|
+
baseBranch,
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
const data = await res.json();
|
|
156
|
+
alert(data.error || "Failed to create worktree");
|
|
157
|
+
setCreating(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const worktree = await res.json();
|
|
161
|
+
targetCwd = worktree.worktreePath;
|
|
162
|
+
} catch {
|
|
163
|
+
alert("Failed to create worktree");
|
|
164
|
+
setCreating(false);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
setCreating(false);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onConfirm(sessionName, targetCwd, selectedProject || undefined);
|
|
171
|
+
},
|
|
172
|
+
[
|
|
173
|
+
name,
|
|
174
|
+
cwd,
|
|
175
|
+
useWorktree,
|
|
176
|
+
featureName,
|
|
177
|
+
baseBranch,
|
|
178
|
+
selectedProject,
|
|
179
|
+
onConfirm,
|
|
180
|
+
]
|
|
181
|
+
);
|
|
92
182
|
|
|
93
183
|
const showProjectSelector = projects && projects.length > 0 && !projectName;
|
|
184
|
+
const branchPreview = featureName
|
|
185
|
+
.trim()
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
188
|
+
.replace(/^-+|-+$/g, "")
|
|
189
|
+
.slice(0, 50);
|
|
94
190
|
|
|
95
191
|
return (
|
|
96
|
-
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
192
|
+
<Dialog open={open} onOpenChange={(o) => !o && !creating && onClose()}>
|
|
97
193
|
<DialogContent className="sm:max-w-sm">
|
|
98
194
|
<DialogHeader>
|
|
99
195
|
<DialogTitle className="text-base">New session</DialogTitle>
|
|
@@ -127,6 +223,7 @@ export function NewClaudeSessionDialog({
|
|
|
127
223
|
<p className="text-muted-foreground text-xs">{projectName}</p>
|
|
128
224
|
)
|
|
129
225
|
)}
|
|
226
|
+
|
|
130
227
|
<div>
|
|
131
228
|
<div className="flex gap-2">
|
|
132
229
|
<Input
|
|
@@ -135,6 +232,7 @@ export function NewClaudeSessionDialog({
|
|
|
135
232
|
onChange={(e) => setName(e.target.value)}
|
|
136
233
|
placeholder="Session name"
|
|
137
234
|
className="h-9"
|
|
235
|
+
disabled={creating}
|
|
138
236
|
/>
|
|
139
237
|
<Button
|
|
140
238
|
type="button"
|
|
@@ -142,21 +240,105 @@ export function NewClaudeSessionDialog({
|
|
|
142
240
|
size="icon-sm"
|
|
143
241
|
className="h-9 w-9 shrink-0"
|
|
144
242
|
onClick={() => setName(generateName())}
|
|
243
|
+
disabled={creating}
|
|
145
244
|
>
|
|
146
245
|
<Sparkles className="h-3.5 w-3.5" />
|
|
147
246
|
</Button>
|
|
148
247
|
</div>
|
|
149
248
|
</div>
|
|
249
|
+
|
|
250
|
+
{gitInfo?.isGitRepo && (
|
|
251
|
+
<div className="bg-accent/40 space-y-3 rounded-lg p-3">
|
|
252
|
+
<div className="flex items-center gap-2">
|
|
253
|
+
<input
|
|
254
|
+
type="checkbox"
|
|
255
|
+
id="useWorktree"
|
|
256
|
+
checked={useWorktree}
|
|
257
|
+
onChange={(e) => setUseWorktree(e.target.checked)}
|
|
258
|
+
className="border-border bg-background accent-primary h-4 w-4 rounded"
|
|
259
|
+
disabled={creating}
|
|
260
|
+
/>
|
|
261
|
+
<label
|
|
262
|
+
htmlFor="useWorktree"
|
|
263
|
+
className="flex cursor-pointer items-center gap-1.5 text-sm font-medium"
|
|
264
|
+
>
|
|
265
|
+
<GitBranch className="h-3.5 w-3.5" />
|
|
266
|
+
Create isolated worktree
|
|
267
|
+
</label>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{useWorktree && (
|
|
271
|
+
<div className="space-y-3 pl-6">
|
|
272
|
+
<div className="space-y-1">
|
|
273
|
+
<label className="text-muted-foreground text-xs">
|
|
274
|
+
Feature name
|
|
275
|
+
</label>
|
|
276
|
+
<Input
|
|
277
|
+
value={featureName}
|
|
278
|
+
onChange={(e) => setFeatureName(e.target.value)}
|
|
279
|
+
placeholder="add-dark-mode"
|
|
280
|
+
className="h-8 text-sm"
|
|
281
|
+
disabled={creating}
|
|
282
|
+
/>
|
|
283
|
+
{branchPreview && (
|
|
284
|
+
<p className="text-muted-foreground text-xs">
|
|
285
|
+
Branch: feature/{branchPreview}
|
|
286
|
+
</p>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
<div className="space-y-1">
|
|
290
|
+
<label className="text-muted-foreground text-xs">
|
|
291
|
+
Base branch
|
|
292
|
+
</label>
|
|
293
|
+
<Select
|
|
294
|
+
value={baseBranch}
|
|
295
|
+
onValueChange={setBaseBranch}
|
|
296
|
+
disabled={creating}
|
|
297
|
+
>
|
|
298
|
+
<SelectTrigger className="h-8 text-sm">
|
|
299
|
+
<SelectValue />
|
|
300
|
+
</SelectTrigger>
|
|
301
|
+
<SelectContent>
|
|
302
|
+
{gitInfo.branches.map((branch) => (
|
|
303
|
+
<SelectItem key={branch} value={branch}>
|
|
304
|
+
{branch}
|
|
305
|
+
</SelectItem>
|
|
306
|
+
))}
|
|
307
|
+
</SelectContent>
|
|
308
|
+
</Select>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
|
|
150
315
|
<div className="flex justify-end gap-2">
|
|
151
|
-
<Button
|
|
316
|
+
<Button
|
|
317
|
+
type="button"
|
|
318
|
+
variant="ghost"
|
|
319
|
+
size="sm"
|
|
320
|
+
onClick={onClose}
|
|
321
|
+
disabled={creating}
|
|
322
|
+
>
|
|
152
323
|
Cancel
|
|
153
324
|
</Button>
|
|
154
325
|
<Button
|
|
155
326
|
type="submit"
|
|
156
327
|
size="sm"
|
|
157
|
-
disabled={
|
|
328
|
+
disabled={
|
|
329
|
+
creating ||
|
|
330
|
+
(showProjectSelector && !selectedProject) ||
|
|
331
|
+
(useWorktree && !featureName.trim())
|
|
332
|
+
}
|
|
158
333
|
>
|
|
159
|
-
|
|
334
|
+
{creating ? (
|
|
335
|
+
<>
|
|
336
|
+
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
337
|
+
Creating worktree...
|
|
338
|
+
</>
|
|
339
|
+
) : (
|
|
340
|
+
"Create"
|
|
341
|
+
)}
|
|
160
342
|
</Button>
|
|
161
343
|
</div>
|
|
162
344
|
</form>
|
|
@@ -291,6 +291,17 @@ export function DesktopTabBar({
|
|
|
291
291
|
|
|
292
292
|
{/* Pane Controls */}
|
|
293
293
|
<div className="ml-auto flex items-center gap-0.5 px-2">
|
|
294
|
+
{session?.branch_name && (
|
|
295
|
+
<Tooltip>
|
|
296
|
+
<TooltipTrigger asChild>
|
|
297
|
+
<span className="text-muted-foreground flex items-center gap-1 text-[10px]">
|
|
298
|
+
<GitBranch className="h-3 w-3" />
|
|
299
|
+
{session.branch_name}
|
|
300
|
+
</span>
|
|
301
|
+
</TooltipTrigger>
|
|
302
|
+
<TooltipContent>Worktree branch</TooltipContent>
|
|
303
|
+
</Tooltip>
|
|
304
|
+
)}
|
|
294
305
|
{session?.working_directory && session.working_directory !== "~" && (
|
|
295
306
|
<OpenInVSCode workingDirectory={session.working_directory} />
|
|
296
307
|
)}
|
|
@@ -122,6 +122,12 @@ export function MobileTabBar({
|
|
|
122
122
|
<span className="truncate text-sm font-medium">
|
|
123
123
|
{session?.name || "No session"}
|
|
124
124
|
</span>
|
|
125
|
+
{session?.branch_name && (
|
|
126
|
+
<span className="text-muted-foreground flex items-center gap-0.5 text-[10px]">
|
|
127
|
+
<GitBranch className="h-2.5 w-2.5" />
|
|
128
|
+
{session.branch_name}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
125
131
|
{sessionList.length > 0 && (
|
|
126
132
|
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
|
|
127
133
|
)}
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState, useEffect } from "react";
|
|
3
|
+
import { useMemo, useState, useEffect, useCallback } from "react";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Activity,
|
|
8
|
+
AlertCircle,
|
|
9
|
+
Moon,
|
|
10
|
+
Globe,
|
|
11
|
+
Share2,
|
|
12
|
+
X,
|
|
13
|
+
Loader2,
|
|
14
|
+
Copy,
|
|
15
|
+
Check,
|
|
16
|
+
} from "lucide-react";
|
|
6
17
|
import type { SessionStatus } from "@/components/views/types";
|
|
18
|
+
import {
|
|
19
|
+
useCloudflaredStatus,
|
|
20
|
+
useStartTunnel,
|
|
21
|
+
useStopTunnel,
|
|
22
|
+
} from "@/data/tunnels/queries";
|
|
7
23
|
|
|
8
24
|
interface ActiveSessionsSectionProps {
|
|
9
25
|
sessionStatuses: Record<string, SessionStatus>;
|
|
@@ -37,11 +53,13 @@ export function ActiveSessionsSection({
|
|
|
37
53
|
const hasWaiting = activeSessions.some((s) => s.status === "waiting");
|
|
38
54
|
const [expanded, setExpanded] = useState(hasWaiting);
|
|
39
55
|
|
|
40
|
-
// Auto-expand when a session starts waiting
|
|
41
56
|
useEffect(() => {
|
|
42
57
|
if (hasWaiting) setExpanded(true);
|
|
43
58
|
}, [hasWaiting]);
|
|
44
59
|
|
|
60
|
+
const { data: cfStatus } = useCloudflaredStatus();
|
|
61
|
+
const cloudflaredInstalled = cfStatus?.installed ?? false;
|
|
62
|
+
|
|
45
63
|
if (activeSessions.length === 0) return null;
|
|
46
64
|
|
|
47
65
|
return (
|
|
@@ -92,6 +110,19 @@ export function ActiveSessionsSection({
|
|
|
92
110
|
{session.lastLine}
|
|
93
111
|
</span>
|
|
94
112
|
)}
|
|
113
|
+
{session.listeningPorts &&
|
|
114
|
+
session.listeningPorts.length > 0 && (
|
|
115
|
+
<div className="mt-0.5 space-y-0.5">
|
|
116
|
+
{session.listeningPorts.map((port) => (
|
|
117
|
+
<PortBadge
|
|
118
|
+
key={port}
|
|
119
|
+
port={port}
|
|
120
|
+
tunnelUrl={session.tunnelUrls?.[port]}
|
|
121
|
+
cloudflaredInstalled={cloudflaredInstalled}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
95
126
|
</div>
|
|
96
127
|
</button>
|
|
97
128
|
))}
|
|
@@ -101,6 +132,112 @@ export function ActiveSessionsSection({
|
|
|
101
132
|
);
|
|
102
133
|
}
|
|
103
134
|
|
|
135
|
+
function PortBadge({
|
|
136
|
+
port,
|
|
137
|
+
tunnelUrl,
|
|
138
|
+
cloudflaredInstalled,
|
|
139
|
+
}: {
|
|
140
|
+
port: number;
|
|
141
|
+
tunnelUrl?: string;
|
|
142
|
+
cloudflaredInstalled: boolean;
|
|
143
|
+
}) {
|
|
144
|
+
const startTunnel = useStartTunnel();
|
|
145
|
+
const stopTunnel = useStopTunnel();
|
|
146
|
+
const [copied, setCopied] = useState(false);
|
|
147
|
+
|
|
148
|
+
const isStarting = startTunnel.isPending;
|
|
149
|
+
const hasTunnel = !!tunnelUrl;
|
|
150
|
+
|
|
151
|
+
const handleShare = useCallback(
|
|
152
|
+
(e: React.MouseEvent) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
if (hasTunnel) {
|
|
155
|
+
stopTunnel.mutate(port);
|
|
156
|
+
} else {
|
|
157
|
+
startTunnel.mutate(port);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[hasTunnel, port, startTunnel, stopTunnel]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const handleCopy = useCallback(
|
|
164
|
+
(e: React.MouseEvent) => {
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
if (tunnelUrl) {
|
|
167
|
+
navigator.clipboard.writeText(tunnelUrl);
|
|
168
|
+
setCopied(true);
|
|
169
|
+
setTimeout(() => setCopied(false), 2000);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
[tunnelUrl]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="flex flex-col gap-0.5">
|
|
177
|
+
<div className="flex items-center gap-1">
|
|
178
|
+
<a
|
|
179
|
+
href={`http://localhost:${port}`}
|
|
180
|
+
target="_blank"
|
|
181
|
+
rel="noopener noreferrer"
|
|
182
|
+
onClick={(e) => e.stopPropagation()}
|
|
183
|
+
className="inline-flex items-center gap-0.5 rounded bg-sky-500/15 px-1.5 py-0.5 font-mono text-[10px] text-sky-400 transition-colors hover:bg-sky-500/25"
|
|
184
|
+
>
|
|
185
|
+
<Globe className="h-2.5 w-2.5" />
|
|
186
|
+
{port}
|
|
187
|
+
</a>
|
|
188
|
+
{cloudflaredInstalled && (
|
|
189
|
+
<button
|
|
190
|
+
onClick={handleShare}
|
|
191
|
+
disabled={isStarting}
|
|
192
|
+
className={cn(
|
|
193
|
+
"rounded p-0.5 transition-colors",
|
|
194
|
+
hasTunnel
|
|
195
|
+
? "text-emerald-400 hover:bg-emerald-500/20"
|
|
196
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
197
|
+
)}
|
|
198
|
+
title={hasTunnel ? "Stop sharing" : "Share via tunnel"}
|
|
199
|
+
>
|
|
200
|
+
{isStarting ? (
|
|
201
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
202
|
+
) : hasTunnel ? (
|
|
203
|
+
<X className="h-3 w-3" />
|
|
204
|
+
) : (
|
|
205
|
+
<Share2 className="h-3 w-3" />
|
|
206
|
+
)}
|
|
207
|
+
</button>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
{tunnelUrl && (
|
|
211
|
+
<div className="flex items-center gap-1">
|
|
212
|
+
<a
|
|
213
|
+
href={tunnelUrl}
|
|
214
|
+
target="_blank"
|
|
215
|
+
rel="noopener noreferrer"
|
|
216
|
+
onClick={(e) => e.stopPropagation()}
|
|
217
|
+
className="inline-flex max-w-[180px] items-center gap-0.5 truncate rounded bg-emerald-500/15 px-1.5 py-0.5 font-mono text-[10px] text-emerald-400 transition-colors hover:bg-emerald-500/25"
|
|
218
|
+
>
|
|
219
|
+
<Globe className="h-2.5 w-2.5 flex-shrink-0" />
|
|
220
|
+
{tunnelUrl
|
|
221
|
+
.replace("https://", "")
|
|
222
|
+
.replace(".trycloudflare.com", "")}
|
|
223
|
+
</a>
|
|
224
|
+
<button
|
|
225
|
+
onClick={handleCopy}
|
|
226
|
+
className="text-muted-foreground hover:text-foreground rounded p-0.5 transition-colors"
|
|
227
|
+
title="Copy URL"
|
|
228
|
+
>
|
|
229
|
+
{copied ? (
|
|
230
|
+
<Check className="h-3 w-3 text-emerald-400" />
|
|
231
|
+
) : (
|
|
232
|
+
<Copy className="h-3 w-3" />
|
|
233
|
+
)}
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
104
241
|
function StatusIcon({ status }: { status: string }) {
|
|
105
242
|
if (status === "running") {
|
|
106
243
|
return (
|
|
@@ -34,7 +34,14 @@ export function createWebSocketConnection(
|
|
|
34
34
|
intentionalCloseRef: React.MutableRefObject<boolean>
|
|
35
35
|
): WebSocketManager {
|
|
36
36
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
37
|
-
|
|
37
|
+
let ptyId: string | null = null;
|
|
38
|
+
|
|
39
|
+
function buildWsUrl() {
|
|
40
|
+
const base = `${protocol}//${window.location.host}/ws/terminal`;
|
|
41
|
+
return ptyId ? `${base}?ptyId=${ptyId}` : base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ws = new WebSocket(buildWsUrl());
|
|
38
45
|
wsRef.current = ws;
|
|
39
46
|
|
|
40
47
|
const sendResize = (cols: number, rows: number) => {
|
|
@@ -93,10 +100,11 @@ export function createWebSocketConnection(
|
|
|
93
100
|
callbacks.onConnectionStateChange("reconnecting");
|
|
94
101
|
reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
|
|
95
102
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
// Reattach to the same PTY if we have an ID
|
|
104
|
+
if (ptyId) {
|
|
105
|
+
term.clear();
|
|
106
|
+
}
|
|
107
|
+
const newWs = new WebSocket(buildWsUrl());
|
|
100
108
|
wsRef.current = newWs;
|
|
101
109
|
newWs.onopen = savedHandlers.onopen;
|
|
102
110
|
newWs.onmessage = savedHandlers.onmessage;
|
|
@@ -135,6 +143,10 @@ export function createWebSocketConnection(
|
|
|
135
143
|
resetInactivityTimer();
|
|
136
144
|
try {
|
|
137
145
|
const msg = JSON.parse(event.data);
|
|
146
|
+
if (msg.type === "pty-id") {
|
|
147
|
+
ptyId = msg.ptyId;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
138
150
|
if (msg.type === "output") {
|
|
139
151
|
const buffer = term.buffer.active;
|
|
140
152
|
const scrollYBefore = buffer.viewportY;
|