Skip to content

Commit 0f77faf

Browse files
Che-Zhuclaude
andauthored
feat(terminal): add directory selector for customizable deploy path (#127)
* refactor: move actions to root and add sandbox runCommand - Move lib/actions to root /actions directory - Implement runCommand action in actions/sandbox.ts - Extract TTYD context helper to lib/util/ttyd-context.ts - Create actions/types.ts for shared action types - Update component imports * refactor: restructure terminal toolbar component - Move terminal-toolbar.tsx to toolbar/toolbar.tsx - Update imports in terminal-container.tsx * refactor(terminal): extract app runner logic into custom hook and separate components - Extract app runner state and logic into useAppRunner custom hook - Split app runner UI into dedicated AppRunner and AppRunnerDialog components - Simplify TerminalToolbar by removing inline app runner implementation - Improve code organization and reusability This refactoring separates concerns by moving app runner business logic into a reusable hook and component, making the toolbar component cleaner and easier to maintain. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat(terminal): extract DirectorySelector as separate component with dropdown - Extract directory selector from app-runner.tsx into directory-selector.tsx - Implement dropdown menu using shadcn DropdownMenu for directory selection - Add placeholder options (./ and /app) for testing - Increase width from 80px to 120px for better readability - Support both controlled and uncontrolled modes via value/onChange props * feat(terminal): implement dynamic directory fetching in DirectorySelector - Add sandboxId prop and fetch directories via runCommand('find . -type d -maxdepth 2') - Filter out node_modules, .git, .next, and other build directories - Filter welcome message lines by only keeping paths starting with . or / - Add loading spinner while fetching directories - Match dropdown width to trigger button using CSS variable - Limit directory list to 20 items with overflow scroll - Move separator outside the flex container in AppRunner * feat(terminal): use selected directory as workdir when starting app - Add deployDirectory state to AppRunner and pass to DirectorySelector (controlled mode) - Modify useAppRunner to accept deployDir param and dynamically calculate workdir - workdir defaults to /home/fulling/next, appends relative path when non-root selected - Reduce directory search depth to maxdepth 1 for cleaner listing - Increase DirectorySelector width from 120px to 180px for better readability * fix: fix lint issue --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1b9b63c commit 0f77faf

File tree

11 files changed

+619
-225
lines changed

11 files changed

+619
-225
lines changed

actions/sandbox.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use server'
2+
3+
/**
4+
* Sandbox Server Actions
5+
*
6+
* Server Actions for sandbox operations. Frontend components call these
7+
* instead of API Routes directly.
8+
*
9+
* TODO: Migrate from app/api/sandbox/:
10+
* - app-status (GET/DELETE)
11+
* - exec (POST)
12+
* - cwd (GET/PUT)
13+
*/
14+
15+
import { auth } from '@/lib/auth'
16+
import { getSandboxTtydContext } from '@/lib/util/ttyd-context'
17+
import { execCommand, TtydExecError } from '@/lib/util/ttyd-exec'
18+
19+
import type { ExecResult } from './types'
20+
21+
/**
22+
* Execute a command in the sandbox and wait for output.
23+
*
24+
* @param sandboxId - The sandbox ID
25+
* @param command - The command to execute
26+
* @param timeoutMs - Optional timeout in milliseconds (default: 30000)
27+
*/
28+
export async function runCommand(
29+
sandboxId: string,
30+
command: string,
31+
timeoutMs?: number
32+
): Promise<ExecResult> {
33+
const session = await auth()
34+
35+
if (!session) {
36+
return { success: false, error: 'Unauthorized' }
37+
}
38+
39+
try {
40+
const { ttyd } = await getSandboxTtydContext(sandboxId, session.user.id)
41+
const { baseUrl, accessToken, authorization } = ttyd
42+
43+
const output = await execCommand(baseUrl, accessToken, command, timeoutMs, authorization)
44+
45+
return { success: true, output }
46+
} catch (error) {
47+
console.error('Failed to execute command in sandbox:', error)
48+
const errorMessage = error instanceof TtydExecError ? error.message : 'Unknown error'
49+
return { success: false, error: errorMessage }
50+
}
51+
}
52+
53+
/**
54+
* Execute a command in the sandbox without waiting for output.
55+
*
56+
* @param sandboxId - The sandbox ID
57+
* @param command - The command to execute
58+
*/
59+
export async function runCommandDetached(
60+
_sandboxId: string,
61+
_command: string
62+
): Promise<ExecResult> {
63+
// TODO: Implement detached command execution
64+
throw new Error('Not implemented')
65+
}

actions/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Type definitions for Server Actions
3+
*/
4+
5+
// =============================================================================
6+
// Sandbox Actions
7+
// =============================================================================
8+
9+
export type ExecResult = {
10+
success: boolean
11+
output?: string
12+
error?: string
13+
}

components/home-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import Link from 'next/link';
66
import { useRouter } from 'next/navigation';
77
import { useSession } from 'next-auth/react';
88

9+
import { authenticateWithSealos } from '@/actions/sealos-auth';
910
import { MatrixRain } from '@/components/MatrixRain';
1011
import { Button } from '@/components/ui/button';
11-
import { authenticateWithSealos } from '@/lib/actions/sealos-auth';
1212
import { useSealos } from '@/provider/sealos';
1313

1414
/**

components/terminal/terminal-container.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
import { useState } from 'react';
1717
import type { Prisma } from '@prisma/client';
1818

19+
import { type Tab, TerminalToolbar } from './toolbar/toolbar';
1920
import { TerminalDisplay } from './terminal-display';
20-
import { type Tab, TerminalToolbar } from './terminal-toolbar';
2121

2222
// ============================================================================
2323
// Types
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
AlertDialog,
3+
AlertDialogAction,
4+
AlertDialogCancel,
5+
AlertDialogContent,
6+
AlertDialogDescription,
7+
AlertDialogFooter,
8+
AlertDialogHeader,
9+
AlertDialogTitle,
10+
} from '@/components/ui/alert-dialog';
11+
12+
interface AppRunnerDialogProps {
13+
open: boolean;
14+
onOpenChange: (open: boolean) => void;
15+
onConfirm: () => void;
16+
sandboxUrl: string | null | undefined;
17+
}
18+
19+
export function AppRunnerDialog({
20+
open,
21+
onOpenChange,
22+
onConfirm,
23+
sandboxUrl,
24+
}: AppRunnerDialogProps) {
25+
return (
26+
<AlertDialog open={open} onOpenChange={onOpenChange}>
27+
<AlertDialogContent className="bg-[#252526] border-[#3e3e42] text-white">
28+
<AlertDialogHeader>
29+
<AlertDialogTitle>Run Application & Keep Active?</AlertDialogTitle>
30+
<AlertDialogDescription className="text-gray-400 space-y-3" asChild>
31+
<div className="text-sm text-gray-400 space-y-3">
32+
<div>
33+
This will build and start your application by running:
34+
<br />
35+
<code className="bg-[#1e1e1e] px-1.5 py-0.5 rounded text-xs border border-[#3e3e42] mt-1 inline-block font-mono text-blue-400">
36+
pnpm build && pnpm start
37+
</code>
38+
</div>
39+
40+
<div className="bg-[#1e1e1e]/50 rounded-md border border-[#3e3e42]/50 text-sm">
41+
<div className="p-3 space-y-2">
42+
<div className="flex gap-2.5 items-start">
43+
<span className="text-blue-400 mt-0.5"></span>
44+
<span>App runs continuously in the background</span>
45+
</div>
46+
<div className="flex gap-2.5 items-start">
47+
<span className="text-blue-400 mt-0.5"></span>
48+
<span>Remains active even if you leave this page</span>
49+
</div>
50+
<div className="flex gap-2.5 items-start">
51+
<span className="text-blue-400 mt-0.5"></span>
52+
<span>
53+
Can be stopped anytime by clicking this button again
54+
</span>
55+
</div>
56+
</div>
57+
58+
{sandboxUrl && (
59+
<div className="px-3 pb-3 pt-2 border-t border-[#3e3e42]/30">
60+
<div className="text-xs text-gray-500 mb-1">
61+
Once running, your application will be available at:
62+
</div>
63+
<a
64+
href={sandboxUrl}
65+
target="_blank"
66+
rel="noopener noreferrer"
67+
className="text-xs text-[#3794ff] hover:text-[#4fc1ff] break-all underline underline-offset-2 hover:underline-offset-4 transition-all block"
68+
>
69+
{sandboxUrl}
70+
</a>
71+
</div>
72+
)}
73+
</div>
74+
</div>
75+
</AlertDialogDescription>
76+
</AlertDialogHeader>
77+
<AlertDialogFooter>
78+
<AlertDialogCancel className="bg-transparent border-[#3e3e42] text-gray-300 hover:bg-[#37373d] hover:text-white">
79+
Cancel
80+
</AlertDialogCancel>
81+
<AlertDialogAction
82+
onClick={onConfirm}
83+
className="bg-[#007fd4] hover:bg-[#0060a0] text-white"
84+
>
85+
Confirm & Run
86+
</AlertDialogAction>
87+
</AlertDialogFooter>
88+
</AlertDialogContent>
89+
</AlertDialog>
90+
);
91+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import type { Prisma } from '@prisma/client';
5+
import { Loader2, Play, Square } from 'lucide-react';
6+
7+
import { useAppRunner } from '@/hooks/use-app-runner';
8+
import { cn } from '@/lib/utils';
9+
10+
import { AppRunnerDialog } from './app-runner-dialog';
11+
import { DirectorySelector } from './directory-selector';
12+
13+
type Sandbox = Prisma.SandboxGetPayload<object>;
14+
15+
interface AppRunnerProps {
16+
sandbox: Sandbox | undefined;
17+
}
18+
19+
export function AppRunner({ sandbox }: AppRunnerProps) {
20+
const [showStartConfirm, setShowStartConfirm] = useState(false);
21+
const [deployDirectory, setDeployDirectory] = useState('./');
22+
const {
23+
isStartingApp,
24+
isStoppingApp,
25+
isAppRunning,
26+
startApp,
27+
stopApp,
28+
} = useAppRunner(sandbox?.id, deployDirectory);
29+
30+
// Toggle app start/stop
31+
const handleToggleApp = () => {
32+
if (isAppRunning) {
33+
stopApp();
34+
} else {
35+
setShowStartConfirm(true); // Open confirmation modal
36+
}
37+
};
38+
39+
const handleConfirmStart = () => {
40+
setShowStartConfirm(false);
41+
startApp();
42+
};
43+
44+
return (
45+
<>
46+
<div className="flex items-center gap-2">
47+
{/* Directory Selector */}
48+
<DirectorySelector
49+
sandboxId={sandbox?.id}
50+
value={deployDirectory}
51+
onChange={setDeployDirectory}
52+
/>
53+
54+
55+
56+
{/* Run App Button */}
57+
<button
58+
onClick={handleToggleApp}
59+
disabled={isStartingApp || isStoppingApp || !sandbox}
60+
className={cn(
61+
'px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 disabled:cursor-not-allowed',
62+
isAppRunning
63+
? 'text-green-400 hover:text-red-400 hover:bg-red-400/10 bg-green-400/10'
64+
: 'text-gray-300 hover:text-white hover:bg-[#37373d] disabled:opacity-50'
65+
)}
66+
title={
67+
isAppRunning
68+
? 'Click to stop. Your app will no longer be accessible.'
69+
: 'Build and run your app in production mode. It will keep running even if you close this terminal.'
70+
}
71+
>
72+
{isStartingApp || isStoppingApp ? (
73+
<Loader2 className="h-3 w-3 animate-spin" />
74+
) : isAppRunning ? (
75+
<Square className="h-3 w-3" />
76+
) : (
77+
<Play className="h-3 w-3" />
78+
)}
79+
<span>
80+
{isStartingApp ? 'Starting...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Running' : 'Run App'}
81+
</span>
82+
</button>
83+
</div>
84+
85+
{/* Separator */}
86+
<div className="h-4 w-[1px] bg-[#3e3e42]" />
87+
88+
{/* Confirmation Alert Dialog */}
89+
<AppRunnerDialog
90+
open={showStartConfirm}
91+
onOpenChange={setShowStartConfirm}
92+
onConfirm={handleConfirmStart}
93+
sandboxUrl={sandbox?.publicUrl}
94+
/>
95+
</>
96+
);
97+
}

0 commit comments

Comments
 (0)