Skip to content

Commit 350813a

Browse files
authored
add ttyd exec util function (#111)
1 parent 7a382b0 commit 350813a

15 files changed

Lines changed: 1076 additions & 29 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use client'
2+
3+
import { TtydExecTest } from '@/components/terminal/ttyd-exec-test'
4+
5+
interface TtydExecTestClientProps {
6+
ttydUrl: string
7+
accessToken: string
8+
}
9+
10+
export function TtydExecTestClient({ ttydUrl, accessToken }: TtydExecTestClientProps) {
11+
return <TtydExecTest ttydUrl={ttydUrl} accessToken={accessToken} />
12+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Terminal Exec Test Page
3+
*
4+
* Test page for verifying the ttyd-exec utility works in the browser.
5+
* Accessible at: /projects/[id]/exec-test
6+
*/
7+
8+
import { notFound,redirect } from 'next/navigation'
9+
10+
import { auth } from '@/lib/auth'
11+
import { prisma } from '@/lib/db'
12+
13+
import { TtydExecTestClient } from './client'
14+
15+
export default async function ExecTestPage({
16+
params,
17+
}: {
18+
params: Promise<{ id: string }>
19+
}) {
20+
const session = await auth()
21+
22+
if (!session) {
23+
redirect('/login')
24+
}
25+
26+
const { id } = await params
27+
28+
// Get project with sandbox
29+
const project = await prisma.project.findFirst({
30+
where: {
31+
id: id,
32+
userId: session.user.id,
33+
},
34+
include: {
35+
sandboxes: true,
36+
environments: true,
37+
},
38+
})
39+
40+
if (!project) {
41+
notFound()
42+
}
43+
44+
const sandbox = project.sandboxes[0]
45+
46+
// Get TTYD access token from environments
47+
const ttydAccessToken = project.environments.find(
48+
(env) => env.key === 'TTYD_ACCESS_TOKEN'
49+
)?.value
50+
51+
if (!sandbox?.ttydUrl || !ttydAccessToken) {
52+
return (
53+
<div className="p-8">
54+
<div className="max-w-2xl mx-auto bg-gray-900 text-white rounded-lg p-6">
55+
<h2 className="text-lg font-semibold mb-4 text-red-400">Configuration Missing</h2>
56+
<div className="space-y-2 text-sm text-gray-300">
57+
<p>
58+
<strong>ttydUrl:</strong> {sandbox?.ttydUrl || 'Not available'}
59+
</p>
60+
<p>
61+
<strong>accessToken:</strong> {ttydAccessToken ? 'Configured' : 'Not configured'}
62+
</p>
63+
<p>
64+
<strong>Sandbox Status:</strong> {sandbox?.status || 'No sandbox'}
65+
</p>
66+
</div>
67+
<p className="mt-4 text-yellow-400 text-sm">
68+
Make sure the sandbox is RUNNING and TTYD_ACCESS_TOKEN is set in environments.
69+
</p>
70+
</div>
71+
</div>
72+
)
73+
}
74+
75+
// Parse the ttydUrl to get base URL (without query params)
76+
const ttydBaseUrl = new URL(sandbox.ttydUrl)
77+
ttydBaseUrl.search = '' // Remove query params
78+
const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '')
79+
80+
return (
81+
<div className="p-8 overflow-auto h-full">
82+
<TtydExecTestClient ttydUrl={baseUrl} accessToken={ttydAccessToken} />
83+
</div>
84+
)
85+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* ProjectContentWrapper Styles
3+
*
4+
* Controls visibility of terminal and content panels
5+
* Uses data attributes for semantic clarity
6+
*/
7+
8+
.wrapper {
9+
width: 100%;
10+
height: 100%;
11+
position: relative;
12+
}
13+
14+
.panel {
15+
width: 100%;
16+
height: 100%;
17+
}
18+
19+
/* Hide panels when not visible */
20+
.panel[data-visible='false'] {
21+
display: none;
22+
content-visibility: hidden; /* Modern CSS optimization */
23+
}
24+
25+
/* Terminal panel - block layout */
26+
.terminalPanel[data-visible='true'] {
27+
display: block;
28+
content-visibility: auto;
29+
}
30+
31+
/* Content panel - flex layout */
32+
.contentPanel[data-visible='true'] {
33+
display: flex;
34+
flex-direction: column;
35+
overflow: hidden;
36+
min-height: 0;
37+
content-visibility: auto;
38+
}
Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,78 @@
1+
/**
2+
* ProjectContentWrapper Component
3+
*
4+
* Manages visibility of terminal and content panels based on current route.
5+
* Uses CSS-based visibility toggling to preserve component state during navigation.
6+
*
7+
* Architecture:
8+
* - Terminal panel: Persisted in layout, never unmounts (preserves WebSocket state)
9+
* - Content panel: Regular page content (overview, settings, etc.)
10+
* - Visibility controlled via data attributes + CSS instead of conditional rendering
11+
*
12+
* Why this approach:
13+
* - Leverages Next.js 16 Layout Persistence feature
14+
* - Avoids component unmount/remount overhead
15+
* - Maintains WebSocket connections and terminal state
16+
* - Better performance than Parallel Routes (no redundant RSC fetches)
17+
*/
18+
119
'use client';
220

321
import { usePathname } from 'next/navigation';
422

523
import { TerminalContainer, type TerminalContainerProps } from '@/components/terminal/terminal-container';
624

25+
import styles from './project-content-wrapper.module.css';
26+
27+
// ============================================================================
28+
// Types
29+
// ============================================================================
30+
731
interface ProjectContentWrapperProps extends TerminalContainerProps {
832
children: React.ReactNode;
933
}
1034

35+
// ============================================================================
36+
// Component
37+
// ============================================================================
38+
1139
export function ProjectContentWrapper({
1240
children,
1341
project,
1442
sandbox,
1543
}: ProjectContentWrapperProps) {
1644
const pathname = usePathname();
17-
// Check if the current path ends with /terminal
18-
const isTerminalPage = pathname?.endsWith('/terminal');
45+
46+
// Determine which panel to display based on current route
47+
const isTerminalPage = pathname?.endsWith('/terminal') ?? false;
1948

2049
return (
21-
<>
22-
<div
23-
className="w-full h-full"
24-
style={{ display: isTerminalPage ? 'block' : 'none' }}
50+
<div className={styles.wrapper}>
51+
{/* Terminal Panel - Persisted across navigation */}
52+
<div
53+
data-visible={isTerminalPage}
54+
className={`${styles.panel} ${styles.terminalPanel}`}
55+
aria-hidden={!isTerminalPage}
56+
aria-live={isTerminalPage ? 'polite' : undefined}
57+
role="region"
58+
aria-label="Terminal Console"
2559
>
26-
<TerminalContainer
27-
project={project}
28-
sandbox={sandbox}
60+
<TerminalContainer
61+
project={project}
62+
sandbox={sandbox}
2963
isVisible={isTerminalPage}
3064
/>
3165
</div>
32-
<div
33-
className="w-full h-full flex flex-col overflow-hidden min-h-0"
34-
style={{ display: !isTerminalPage ? 'flex' : 'none' }}
66+
67+
{/* Content Panel - Regular pages (overview, settings, env, etc.) */}
68+
<div
69+
data-visible={!isTerminalPage}
70+
className={`${styles.panel} ${styles.contentPanel}`}
71+
aria-hidden={isTerminalPage}
72+
role="main"
3573
>
3674
{children}
3775
</div>
38-
</>
76+
</div>
3977
);
4078
}

components/terminal/terminal-container.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ type Sandbox = Prisma.SandboxGetPayload<object>;
3636
export interface TerminalContainerProps {
3737
project: Project;
3838
sandbox: Sandbox | undefined;
39+
/**
40+
* Controls whether the terminal is visible
41+
* - Used to optimize performance by avoiding unnecessary fit() calls when hidden
42+
* - Passed down to child components to coordinate visibility state
43+
* - Default: true
44+
*/
3945
isVisible?: boolean;
4046
}
4147

@@ -136,6 +142,12 @@ export function TerminalContainer({ project, sandbox, isVisible = true }: Termin
136142
}}
137143
>
138144
{/* Each tab maintains its own terminal instance */}
145+
{/*
146+
isVisible combines two conditions:
147+
1. isVisible: Terminal page is visible (not hidden by routing)
148+
2. tab.id === activeTabId: This specific tab is active
149+
Only when both are true will the terminal fit() to correct dimensions
150+
*/}
139151
<TerminalDisplay
140152
key={tab.id}
141153
sandboxId={sandbox?.id ?? ''}

components/terminal/terminal-display.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export interface TerminalDisplayProps {
3939
fileBrowserUrl?: string | null;
4040
fileBrowserUsername?: string;
4141
fileBrowserPassword?: string;
42+
/**
43+
* Indicates whether this terminal instance is currently visible to the user
44+
* - Controls when to trigger terminal resize/fit operations
45+
* - Prevents incorrect dimension calculations when container is hidden (display: none)
46+
* - Passed down from TerminalContainer which combines route visibility and active tab state
47+
* - Default: true
48+
*/
4249
isVisible?: boolean;
4350
}
4451

@@ -110,6 +117,11 @@ export function TerminalDisplay({
110117

111118
{/* Terminal Instance */}
112119
<div className="flex-1 w-full p-2">
120+
{/*
121+
Pass visibility state to XtermTerminal
122+
- XtermTerminal will use this to decide when to call fit()
123+
- Avoids fitting terminal when container dimensions are 0 (display: none)
124+
*/}
113125
<XtermTerminal
114126
key={`xterm-${tabId}`}
115127
wsUrl={ttydUrl}

components/terminal/terminal-toolbar.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function TerminalToolbar({
8787
];
8888

8989
// Only show endpoints that have a valid domain URL
90-
const networkEndpoints = allEndpoints.filter(endpoint => endpoint.domain);
90+
const networkEndpoints = allEndpoints.filter((endpoint) => endpoint.domain);
9191

9292
const copyToClipboard = async (text: string, field: string) => {
9393
try {
@@ -223,9 +223,7 @@ export function TerminalToolbar({
223223
<div className="flex-1 min-w-0">
224224
<div className="text-[10px] text-gray-500 mb-0.5">Password</div>
225225
<code className="text-xs text-blue-400 break-all">
226-
{showPassword
227-
? fileBrowserCredentials.password
228-
: '••••••••••••••••'}
226+
{showPassword ? fileBrowserCredentials.password : '••••••••••••••••'}
229227
</code>
230228
</div>
231229
<button

0 commit comments

Comments
 (0)