resource settings page
This commit is contained in:
+67
-26
@@ -2,7 +2,33 @@ import { useState } from 'react';
|
|||||||
import { Box, Circle, Server, Wrench } from 'lucide-react';
|
import { Box, Circle, Server, Wrench } from 'lucide-react';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useResources, type Resource } from '@/state/useResources';
|
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
|
||||||
|
|
||||||
|
type ResourceItemProps = {
|
||||||
|
resource: Resource;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) => (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'}`}
|
||||||
|
>
|
||||||
|
{r.port ? (
|
||||||
|
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">{r.name}</div>
|
||||||
|
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<Circle
|
||||||
|
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
export const ResourceSidebar = () => {
|
export const ResourceSidebar = () => {
|
||||||
const { resources, isLoading } = useResources();
|
const { resources, isLoading } = useResources();
|
||||||
@@ -17,6 +43,9 @@ export const ResourceSidebar = () => {
|
|||||||
r.description.toLowerCase().includes(query),
|
r.description.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const apiBased = filtered?.filter((r: Resource) => getResourceCategory(r) === 'api-based') ?? [];
|
||||||
|
const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
<div className="p-3 pb-2">
|
<div className="p-3 pb-2">
|
||||||
@@ -26,35 +55,47 @@ export const ResourceSidebar = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||||
{isLoading && <p className="text-xs text-duck-dark/50 px-3 py-2">Loading...</p>}
|
{isLoading && <p className="text-xs text-duck-dark/50 px-3 py-2">Loading...</p>}
|
||||||
{filtered?.map((r: Resource) => {
|
{apiBased.length > 0 && (
|
||||||
const isActive = selectedId === r.id;
|
<>
|
||||||
return (
|
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||||
<button
|
<Server className="h-3 w-3 text-duck-teal" />
|
||||||
key={r.id}
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
|
||||||
onClick={() => setSelectedId(r.id)}
|
</div>
|
||||||
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
|
{apiBased.map((r: Resource) => (
|
||||||
}`}
|
<ResourceItem
|
||||||
>
|
key={r.id}
|
||||||
{r.port ? (
|
resource={r}
|
||||||
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
|
isActive={selectedId === r.id}
|
||||||
) : (
|
onSelect={() => setSelectedId(r.id)}
|
||||||
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">{r.name}</div>
|
|
||||||
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
|
||||||
</div>
|
|
||||||
<Circle
|
|
||||||
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
))}
|
||||||
);
|
</>
|
||||||
})}
|
)}
|
||||||
|
{localCli.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||||
|
<Wrench className="h-3 w-3 text-duck-dark/40" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">Local CLI</span>
|
||||||
|
</div>
|
||||||
|
{localCli.map((r: Resource) => (
|
||||||
|
<ResourceItem
|
||||||
|
key={r.id}
|
||||||
|
resource={r}
|
||||||
|
isActive={selectedId === r.id}
|
||||||
|
onSelect={() => setSelectedId(r.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Copy, Check, Server, Wrench } from 'lucide-react';
|
import { Copy, Check, Server, Wrench, Loader2 } from 'lucide-react';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import { useResources, type Resource } from '@/state/useResources';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
|
||||||
|
|
||||||
const CopyCommand = ({ command }: { command: string }) => {
|
const CopyCommand = ({ command }: { command: string }) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -33,6 +35,140 @@ const CommandRow = ({ label, command }: { label: string; command: string }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type ConnectionSectionProps = {
|
||||||
|
resource: Resource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
|
||||||
|
const { saveConnectionConfig, pingResource } = useResources();
|
||||||
|
const [url, setUrl] = useState(resource.connectionConfig?.url ?? '');
|
||||||
|
const [apiKey, setApiKey] = useState(resource.connectionConfig?.credentials?.apiKey ?? '');
|
||||||
|
const [username, setUsername] = useState(resource.connectionConfig?.credentials?.username ?? '');
|
||||||
|
const [password, setPassword] = useState(resource.connectionConfig?.credentials?.password ?? '');
|
||||||
|
const [pinging, setPinging] = useState(false);
|
||||||
|
const [pingResult, setPingResult] = useState<PingResult | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handlePing = async () => {
|
||||||
|
setPinging(true);
|
||||||
|
setPingResult(null);
|
||||||
|
try {
|
||||||
|
const result = await pingResource(resource.id, url);
|
||||||
|
setPingResult(result);
|
||||||
|
} catch {
|
||||||
|
setPingResult({ reachable: false, latencyMs: null });
|
||||||
|
} finally {
|
||||||
|
setPinging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const credentials =
|
||||||
|
apiKey || username || password
|
||||||
|
? { apiKey: apiKey || undefined, username: username || undefined, password: password || undefined }
|
||||||
|
: undefined;
|
||||||
|
await saveConnectionConfig(resource.id, { url, credentials });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCredentials = !!(
|
||||||
|
resource.connectionConfig?.credentials?.apiKey || resource.connectionConfig?.credentials?.username
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-semibold text-duck-dark mb-3">Connection</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-duck-dark/50 mb-1 block">Base URL</label>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(ev) => setUrl(ev.target.value)}
|
||||||
|
placeholder="http://127.0.0.1:64202"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(hasCredentials || apiKey) && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-duck-dark/50 mb-1 block">API Key</label>
|
||||||
|
<Input
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(ev) => setApiKey(ev.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
type="password"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(hasCredentials || username || password) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-duck-dark/50 mb-1 block">Username</label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(ev) => setUsername(ev.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-duck-dark/50 mb-1 block">Password</label>
|
||||||
|
<Input
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
type="password"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging || !url} className="text-xs">
|
||||||
|
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving || !url} className="text-xs">
|
||||||
|
{saving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{pingResult && (
|
||||||
|
<span className={`text-xs ${pingResult.reachable ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
|
||||||
|
<div className="mb-3">
|
||||||
|
{resource.installed ? (
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
||||||
|
Installed{resource.version ? ` (${resource.version})` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />}
|
||||||
|
{resource.uninstallCommand && <CommandRow label="Uninstall" command={resource.uninstallCommand} />}
|
||||||
|
{resource.manageCommand && <CommandRow label="Manage" command={resource.manageCommand} />}
|
||||||
|
{resource.verifyCommand && <CommandRow label="Verify" command={resource.verifyCommand} />}
|
||||||
|
{resource.updateCommand && <CommandRow label="Update" command={resource.updateCommand} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const Resources = () => {
|
export const Resources = () => {
|
||||||
const { resources } = useResources();
|
const { resources } = useResources();
|
||||||
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||||
@@ -47,6 +183,8 @@ export const Resources = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const category = getResourceCategory(resource);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto p-6">
|
<div className="h-full overflow-y-auto p-6">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -61,27 +199,20 @@ export const Resources = () => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/50 rounded-full px-2 py-0.5">{resource.type}</span>
|
<span className="text-xs bg-duck-dark/5 text-duck-dark/50 rounded-full px-2 py-0.5">{resource.type}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs rounded-full px-2 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/50'}`}
|
||||||
|
>
|
||||||
|
{category === 'api-based' ? 'API Based' : 'Local CLI'}
|
||||||
|
</span>
|
||||||
{resource.port && (
|
{resource.port && (
|
||||||
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
|
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
|
||||||
)}
|
)}
|
||||||
{resource.installed ? (
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
|
||||||
{resource.version ?? 'Running'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
|
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
|
||||||
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />}
|
<LocalAvailabilitySection resource={resource} />
|
||||||
{resource.uninstallCommand && <CommandRow label="Uninstall" command={resource.uninstallCommand} />}
|
|
||||||
{resource.manageCommand && <CommandRow label="Manage" command={resource.manageCommand} />}
|
|
||||||
{resource.verifyCommand && <CommandRow label="Verify" command={resource.verifyCommand} />}
|
|
||||||
{resource.updateCommand && <CommandRow label="Update" command={resource.updateCommand} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-4
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Copy, Check, Play } from 'lucide-react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useServerSettings } from '@/state/useServerSettings';
|
import { useServerSettings } from '@/state/useServerSettings';
|
||||||
|
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
|
||||||
|
|
||||||
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
||||||
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
|
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
|
||||||
@@ -85,15 +87,26 @@ export const AIHarnessesSection = () => {
|
|||||||
setTimeout(() => setCopied(null), 1500);
|
setTimeout(() => setCopied(null), 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CopyCommand = ({ command }: { command: string }) => (
|
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
|
||||||
|
|
||||||
|
const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
|
||||||
<div className="mt-2 text-xs text-amber-600">
|
<div className="mt-2 text-xs text-amber-600">
|
||||||
Not globally accessible. Run:
|
Not globally accessible. Run:
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
|
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRunCommand({ command, refetchKeys })}
|
||||||
|
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
|
||||||
|
title="Run in terminal"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyToClipboard(command)}
|
onClick={() => copyToClipboard(command)}
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||||
|
title="Copy command"
|
||||||
>
|
>
|
||||||
{copied === command ? (
|
{copied === command ? (
|
||||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||||
@@ -142,7 +155,7 @@ export const AIHarnessesSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!opencodeVersion.globalPath && opencodeVersion.path && (
|
{!opencodeVersion.globalPath && opencodeVersion.path && (
|
||||||
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
|
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} refetchKeys={['OPENCODE_VERSION']} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -194,7 +207,7 @@ export const AIHarnessesSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!claudeVersion.globalPath && claudeVersion.path && (
|
{!claudeVersion.globalPath && claudeVersion.path && (
|
||||||
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
|
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} refetchKeys={['CLAUDE_CODE_VERSION']} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Terminal, Server } from 'lucide-react';
|
import { Terminal, Server, X } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
import { WorkspaceLayout } from '@/components/Workspace';
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { appRegistry } from '../../Workspaces/app-registry';
|
import { appRegistry } from '../../Workspaces/app-registry';
|
||||||
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||||
import { AIHarnessesSection } from './AIHarnessesSection';
|
import { AIHarnessesSection } from './AIHarnessesSection';
|
||||||
|
import { TerminalView } from 'apps/Terminal';
|
||||||
|
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
|
||||||
|
|
||||||
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
|
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
|
||||||
|
|
||||||
@@ -19,7 +23,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({
|
|||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
|
|
||||||
const layout: LayoutNode = {
|
const baseLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'server-root',
|
id: 'server-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
@@ -29,11 +33,72 @@ const layout: LayoutNode = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const splitLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'server-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'server-left', appType: null }, size: 20 },
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'server-right-group',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'server-right', appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: 'server-terminal', appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SystemTerminalPanel = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
|
||||||
|
|
||||||
|
const close = () => setState(null);
|
||||||
|
|
||||||
|
const onExit = () => {
|
||||||
|
if (state) {
|
||||||
|
for (const key of state.refetchKeys) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
|
||||||
|
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
|
||||||
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TerminalView
|
||||||
|
className="flex-1"
|
||||||
|
sandboxed={false}
|
||||||
|
command={state.command}
|
||||||
|
sessionId={`run-cmd-${Date.now()}`}
|
||||||
|
onExit={onExit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ServerSettings = () => {
|
export const ServerSettings = () => {
|
||||||
|
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
|
||||||
|
|
||||||
|
const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
|
||||||
|
|
||||||
const panelComponents: PanelComponents = useMemo(
|
const panelComponents: PanelComponents = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
'server-left': Sidebar,
|
'server-left': Sidebar,
|
||||||
'server-right': Content,
|
'server-right': Content,
|
||||||
|
'server-terminal': SystemTerminalPanel,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type RunCommandState = {
|
||||||
|
command: string;
|
||||||
|
refetchKeys: string[];
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export const RUN_COMMAND_CHANNEL = 'server-settings:run-command';
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
export type ResourceCredentials = {
|
||||||
|
apiKey?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceConnectionConfig = {
|
||||||
|
url: string;
|
||||||
|
credentials?: ResourceCredentials;
|
||||||
|
};
|
||||||
|
|
||||||
export type Resource = {
|
export type Resource = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,17 +26,37 @@ export type Resource = {
|
|||||||
updateCommand: string | null;
|
updateCommand: string | null;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
connectionConfig: ResourceConnectionConfig | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PingResult = {
|
||||||
|
reachable: boolean;
|
||||||
|
latencyMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceCategory = 'api-based' | 'local-cli';
|
||||||
|
|
||||||
|
export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
|
||||||
|
|
||||||
const RESOURCES_KEY = ['RESOURCES'];
|
const RESOURCES_KEY = ['RESOURCES'];
|
||||||
|
|
||||||
export const useResources = () => {
|
export const useResources = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: resources, isLoading } = useQuery({
|
const { data: resources, isLoading } = useQuery({
|
||||||
queryKey: RESOURCES_KEY,
|
queryKey: RESOURCES_KEY,
|
||||||
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
|
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { resources, isLoading };
|
const saveConnectionConfig = async (id: string, config: Partial<ResourceConnectionConfig>) => {
|
||||||
|
await client.patch(`/server-settings/resources/config/${id}`, config);
|
||||||
|
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pingResource = async (id: string, url?: string) => {
|
||||||
|
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { resources, isLoading, saveConnectionConfig, pingResource };
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -20,6 +20,7 @@ type WSData = {
|
|||||||
sandboxed: boolean;
|
sandboxed: boolean;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
command?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers: Record<string, typeof claudeWebsocket> = {
|
const handlers: Record<string, typeof claudeWebsocket> = {
|
||||||
@@ -47,8 +48,9 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco
|
|||||||
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
||||||
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
||||||
const cwd = url.searchParams.get('cwd') ?? undefined;
|
const cwd = url.searchParams.get('cwd') ?? undefined;
|
||||||
|
const command = url.searchParams.get('command') ?? undefined;
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd },
|
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command },
|
||||||
});
|
});
|
||||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { readdir } from 'node:fs/promises';
|
import { readdir, mkdir } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getResourcesDir } from '../../data-path';
|
import { getResourcesDir } from '../../data-path';
|
||||||
|
|
||||||
|
type ResourceCredentials = {
|
||||||
|
apiKey?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceConnectionConfig = {
|
||||||
|
url: string;
|
||||||
|
credentials?: ResourceCredentials;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourcesConfig = Record<string, ResourceConnectionConfig>;
|
||||||
|
|
||||||
type Resource = {
|
type Resource = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,11 +32,15 @@ type Resource = {
|
|||||||
updateCommand: string | null;
|
updateCommand: string | null;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
connectionConfig: ResourceConnectionConfig | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
|
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
|
||||||
|
|
||||||
function parseResourceFile(filename: string, content: string): Omit<Resource, 'installed' | 'version'> {
|
function parseResourceFile(
|
||||||
|
filename: string,
|
||||||
|
content: string,
|
||||||
|
): Omit<Resource, 'installed' | 'version' | 'connectionConfig'> {
|
||||||
const id = filename
|
const id = filename
|
||||||
.replace(/^SERVICE_/, '')
|
.replace(/^SERVICE_/, '')
|
||||||
.replace(/\.md$/, '')
|
.replace(/\.md$/, '')
|
||||||
@@ -60,6 +78,23 @@ function parseResourceFile(filename: string, content: string): Omit<Resource, 'i
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONFIG_FILENAME = 'resources-config.json';
|
||||||
|
|
||||||
|
const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME);
|
||||||
|
|
||||||
|
async function readConfig(): Promise<ResourcesConfig> {
|
||||||
|
const path = getConfigPath();
|
||||||
|
if (!existsSync(path)) return {};
|
||||||
|
const text = await Bun.file(path).text();
|
||||||
|
return JSON.parse(text) as ResourcesConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeConfig(config: ResourcesConfig): Promise<void> {
|
||||||
|
const dir = getResourcesDir();
|
||||||
|
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
||||||
|
await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
const CHECK_TIMEOUT_MS = 3_000;
|
const CHECK_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
async function checkPort(port: number): Promise<boolean> {
|
async function checkPort(port: number): Promise<boolean> {
|
||||||
@@ -107,14 +142,36 @@ function extractFirstPort(portStr: string): number | null {
|
|||||||
return match ? parseInt(match[1]!, 10) : null;
|
return match ? parseInt(match[1]!, 10) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkResourceStatus(
|
async function checkUrl(url: string): Promise<boolean> {
|
||||||
resource: Omit<Resource, 'installed' | 'version'>,
|
try {
|
||||||
): Promise<{ installed: boolean; version: string | null }> {
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||||
|
await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckResourceStatusParams = {
|
||||||
|
resource: Omit<Resource, 'installed' | 'version' | 'connectionConfig'>;
|
||||||
|
configUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkResourceStatus({
|
||||||
|
resource,
|
||||||
|
configUrl,
|
||||||
|
}: CheckResourceStatusParams): Promise<{ installed: boolean; version: string | null }> {
|
||||||
if (resource.path) {
|
if (resource.path) {
|
||||||
const fullPath = `${getResourcesDir()}/${resource.path}`;
|
const fullPath = `${getResourcesDir()}/${resource.path}`;
|
||||||
return { installed: existsSync(fullPath), version: null };
|
return { installed: existsSync(fullPath), version: null };
|
||||||
}
|
}
|
||||||
if (resource.port) {
|
if (resource.port) {
|
||||||
|
if (configUrl) {
|
||||||
|
const reachable = await checkUrl(configUrl);
|
||||||
|
if (reachable) return { installed: true, version: null };
|
||||||
|
}
|
||||||
const port = extractFirstPort(resource.port);
|
const port = extractFirstPort(resource.port);
|
||||||
if (port) {
|
if (port) {
|
||||||
const reachable = await checkPort(port);
|
const reachable = await checkPort(port);
|
||||||
@@ -127,10 +184,20 @@ async function checkResourceStatus(
|
|||||||
return { installed: false, version: null };
|
return { installed: false, version: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConnectionConfig(
|
||||||
|
resource: Omit<Resource, 'installed' | 'version' | 'connectionConfig'>,
|
||||||
|
config: ResourcesConfig,
|
||||||
|
): ResourceConnectionConfig | null {
|
||||||
|
if (!resource.port) return null;
|
||||||
|
if (config[resource.id]) return config[resource.id]!;
|
||||||
|
const port = extractFirstPort(resource.port);
|
||||||
|
return { url: `http://127.0.0.1:${port ?? resource.port}` };
|
||||||
|
}
|
||||||
|
|
||||||
async function loadResources(): Promise<Resource[]> {
|
async function loadResources(): Promise<Resource[]> {
|
||||||
const dir = getResourcesDir();
|
const dir = getResourcesDir();
|
||||||
if (!existsSync(dir)) return [];
|
if (!existsSync(dir)) return [];
|
||||||
const files = await readdir(dir);
|
const [files, config] = await Promise.all([readdir(dir), readConfig()]);
|
||||||
const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md'));
|
const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md'));
|
||||||
|
|
||||||
const parsed = await Promise.all(
|
const parsed = await Promise.all(
|
||||||
@@ -142,8 +209,10 @@ async function loadResources(): Promise<Resource[]> {
|
|||||||
|
|
||||||
const resources = await Promise.all(
|
const resources = await Promise.all(
|
||||||
parsed.map(async (r) => {
|
parsed.map(async (r) => {
|
||||||
const status = await checkResourceStatus(r);
|
const configUrl = config[r.id]?.url;
|
||||||
return { ...r, ...status };
|
const status = await checkResourceStatus({ resource: r, configUrl });
|
||||||
|
const connectionConfig = buildConnectionConfig(r, config);
|
||||||
|
return { ...r, ...status, connectionConfig };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,6 +226,45 @@ resourcesRouter.get('/', async (ctx) => {
|
|||||||
return ctx.json(resources);
|
return ctx.json(resources);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resourcesRouter.get('/config', async (ctx) => {
|
||||||
|
const config = await readConfig();
|
||||||
|
return ctx.json(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
resourcesRouter.patch('/config/:id', async (ctx) => {
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
const body = await ctx.req.json<Partial<ResourceConnectionConfig>>();
|
||||||
|
const config = await readConfig();
|
||||||
|
const existing = config[id] ?? { url: '' };
|
||||||
|
config[id] = { ...existing, ...body };
|
||||||
|
await writeConfig(config);
|
||||||
|
return ctx.json(config[id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
resourcesRouter.post('/:id/ping', async (ctx) => {
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({}));
|
||||||
|
const config = await readConfig();
|
||||||
|
const resources = await loadResources();
|
||||||
|
const resource = resources.find((r) => r.id === id);
|
||||||
|
if (!resource) return ctx.json({ error: 'Resource not found' }, 404);
|
||||||
|
|
||||||
|
const url = body.url ?? config[id]?.url ?? resource.connectionConfig?.url;
|
||||||
|
if (!url) return ctx.json({ error: 'No URL configured' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = performance.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||||
|
await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const latencyMs = Math.round(performance.now() - start);
|
||||||
|
return ctx.json({ reachable: true, latencyMs });
|
||||||
|
} catch {
|
||||||
|
return ctx.json({ reachable: false, latencyMs: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
resourcesRouter.get('/:id', async (ctx) => {
|
resourcesRouter.get('/:id', async (ctx) => {
|
||||||
const resources = await loadResources();
|
const resources = await loadResources();
|
||||||
const resource = resources.find((r) => r.id === ctx.req.param('id'));
|
const resource = resources.find((r) => r.id === ctx.req.param('id'));
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type TerminalViewProps = {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
command?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
theme?: TerminalTheme;
|
theme?: TerminalTheme;
|
||||||
@@ -35,7 +36,7 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
|
|||||||
selectionBackground: '#3a3a5e',
|
selectionBackground: '#3a3a5e',
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string) => {
|
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||||
const separator = wsPath.includes('?') ? '&' : '?';
|
const separator = wsPath.includes('?') ? '&' : '?';
|
||||||
@@ -43,6 +44,7 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
|
|||||||
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||||
if (sandboxed === false) url += '&sandboxed=false';
|
if (sandboxed === false) url += '&sandboxed=false';
|
||||||
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
||||||
|
if (command) url += `&command=${encodeURIComponent(command)}`;
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export const TerminalView = ({
|
|||||||
sessionId,
|
sessionId,
|
||||||
sandboxed = true,
|
sandboxed = true,
|
||||||
cwd,
|
cwd,
|
||||||
|
command,
|
||||||
fontSize = 14,
|
fontSize = 14,
|
||||||
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
||||||
theme,
|
theme,
|
||||||
@@ -116,7 +119,7 @@ export const TerminalView = ({
|
|||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
onReadyRef.current?.(term);
|
onReadyRef.current?.(term);
|
||||||
|
|
||||||
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
|
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command));
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
@@ -191,6 +194,7 @@ export const TerminalView = ({
|
|||||||
sessionId,
|
sessionId,
|
||||||
sandboxed,
|
sandboxed,
|
||||||
cwd,
|
cwd,
|
||||||
|
command,
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
background,
|
background,
|
||||||
|
|||||||
Reference in New Issue
Block a user