Files FIles Files

This commit is contained in:
2026-02-20 04:28:02 +00:00
parent 1f7eb64eb3
commit d7503ca56b
20 changed files with 2195 additions and 633 deletions
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Box, Circle, Server, Wrench } from 'lucide-react';
import { Circle, Server, Wrench } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
type ResourceItemProps = {
@@ -24,46 +25,61 @@ const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) =>
<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'}`}
/>
<Circle className="h-2 w-2 shrink-0 mt-1.5 fill-green-500 text-green-500" />
</button>
);
export const ResourceSidebar = () => {
const { resources, isLoading } = useResources();
const [selectedId, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [showCatalog, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
const [search, setSearch] = useState('');
const query = search.toLowerCase();
const filtered = resources?.filter(
(r: Resource) =>
r.name.toLowerCase().includes(query) ||
r.subtitle.toLowerCase().includes(query) ||
r.description.toLowerCase().includes(query),
);
const installed = resources?.filter((r: Resource) => r.installed) ?? [];
const apiBased = filtered?.filter((r: Resource) => getResourceCategory(r) === 'api-based') ?? [];
const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? [];
const query = search.toLowerCase();
const filtered = query
? installed.filter(
(r: Resource) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query),
)
: installed;
const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based');
const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli');
const handleCatalog = () => {
setSelectedId(null);
setShowCatalog(true);
};
const handleSelect = (id: string) => {
setShowCatalog(false);
setSelectedId(id);
};
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Box className="h-4 w-4" />
Resources
</div>
</div>
<div className="px-3 pb-2">
<div className="flex flex-col h-full">
<div className="shrink-0 px-3 pt-3 pb-2 flex flex-col gap-2">
<h2 className="text-sm font-semibold text-duck-dark px-1">Resources</h2>
<Button
size="sm"
onClick={handleCatalog}
className="w-full text-xs cursor-pointer bg-duck-teal text-white hover:bg-duck-teal/90"
>
Catalog
</Button>
<Input
placeholder="Search..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
className="h-7 text-xs"
/>
</div>
<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 && filtered.length === 0 && (
<p className="text-xs text-duck-dark/40 px-3 py-2">{search ? 'No matches' : 'No active resources'}</p>
)}
{apiBased.length > 0 && (
<>
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
@@ -74,8 +90,8 @@ export const ResourceSidebar = () => {
<ResourceItem
key={r.id}
resource={r}
isActive={selectedId === r.id}
onSelect={() => setSelectedId(r.id)}
isActive={!showCatalog && selectedId === r.id}
onSelect={() => handleSelect(r.id)}
/>
))}
</>
@@ -90,8 +106,8 @@ export const ResourceSidebar = () => {
<ResourceItem
key={r.id}
resource={r}
isActive={selectedId === r.id}
onSelect={() => setSelectedId(r.id)}
isActive={!showCatalog && selectedId === r.id}
onSelect={() => handleSelect(r.id)}
/>
))}
</>
@@ -1,39 +1,22 @@
import { useState } from 'react';
import { Copy, Check, Server, Wrench, Loader2 } from 'lucide-react';
import { Server, Wrench, Loader2 } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { CommandBlock } from '@/components/CommandBlock';
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
const CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
<button
type="button"
onClick={copy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
</button>
</div>
);
};
const CommandRow = ({ label, command }: { label: string; command: string }) => (
<div className="text-xs text-duck-dark/50">
{label}:
<CopyCommand command={command} />
</div>
);
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type ConnectionSectionProps = {
resource: Resource;
@@ -147,7 +130,12 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
);
};
const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
type LocalAvailabilitySectionProps = {
resource: Resource;
onRun: (command: string) => void;
};
const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => (
<div>
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
<div className="mb-3">
@@ -159,60 +147,203 @@ const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
<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 className="flex flex-col gap-3">
{resource.installCommand && <CommandBlock label="Install" command={resource.installCommand} onRun={onRun} />}
{resource.uninstallCommand && (
<CommandBlock label="Uninstall" command={resource.uninstallCommand} onRun={onRun} />
)}
{resource.manageCommand && <CommandBlock label="Manage" command={resource.manageCommand} onRun={onRun} />}
{resource.verifyCommand && <CommandBlock label="Verify" command={resource.verifyCommand} onRun={onRun} />}
{resource.updateCommand && <CommandBlock label="Update" command={resource.updateCommand} onRun={onRun} />}
</div>
</div>
);
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
type CatalogCardProps = {
resource: Resource;
onSelect: (id: string) => void;
};
const resource = resources?.find((r: Resource) => r.id === selectedId);
if (!resource) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30">Select a resource to view details</p>
const CatalogCard = ({ resource: r, onSelect }: CatalogCardProps) => {
const category = getResourceCategory(r);
return (
<button
onClick={() => onSelect(r.id)}
className="flex flex-col gap-2 p-4 rounded-lg border border-duck-dark/10 text-left cursor-pointer transition-colors hover:border-duck-teal/30 hover:bg-duck-teal/5"
>
<div className="flex items-center gap-2">
{r.port ? (
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0" />
) : (
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0" />
)}
<span className="text-sm font-medium text-duck-dark truncate">{r.name}</span>
</div>
);
}
<p className="text-xs text-duck-dark/40 line-clamp-1">{r.subtitle}</p>
<div className="flex items-center gap-1.5">
<span className="text-[10px] bg-duck-dark/5 text-duck-dark/40 rounded-full px-1.5 py-0.5">{r.type}</span>
<span
className={`text-[10px] rounded-full px-1.5 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/40'}`}
>
{category === 'api-based' ? 'API' : 'CLI'}
</span>
</div>
</button>
);
};
const category = getResourceCategory(resource);
const ResourceCatalog = () => {
const { resources, isLoading } = useResources();
const [, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
const [search, setSearch] = useState('');
const query = search.toLowerCase();
const filtered =
resources?.filter(
(r: Resource) =>
!r.installed &&
(r.name.toLowerCase().includes(query) ||
r.subtitle.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');
const handleSelect = (id: string) => {
setShowCatalog(false);
setSelectedId(id);
};
return (
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1">
{resource.port ? (
<Server className="h-4 w-4 text-duck-teal shrink-0" />
) : (
<Wrench className="h-4 w-4 text-duck-dark/40 shrink-0" />
)}
<h2 className="text-lg font-bold text-duck-dark">{resource.name}</h2>
<span className="text-sm text-duck-dark/40">{resource.subtitle}</span>
</div>
<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 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 && (
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
)}
</div>
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
<LocalAvailabilitySection resource={resource} />
<h2 className="text-lg font-bold text-duck-dark mb-1">Resource Catalog</h2>
<p className="text-sm text-duck-dark/40 mb-4">All available resources. Select one to configure.</p>
<Input
placeholder="Search resources..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs mb-4 max-w-xs"
/>
{isLoading && <p className="text-xs text-duck-dark/50">Loading...</p>}
{apiBased.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Server className="h-3.5 w-3.5 text-duck-teal" />
<span className="text-xs font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
{apiBased.map((r: Resource) => (
<CatalogCard key={r.id} resource={r} onSelect={handleSelect} />
))}
</div>
</div>
)}
{localCli.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Wrench className="h-3.5 w-3.5 text-duck-dark/40" />
<span className="text-xs font-semibold uppercase tracking-wider text-duck-dark/40">Local CLI</span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
{localCli.map((r: Resource) => (
<CatalogCard key={r.id} resource={r} onSelect={handleSelect} />
))}
</div>
</div>
)}
</div>
);
};
const ResourceDetail = ({ resource }: { resource: Resource }) => {
const category = getResourceCategory(resource);
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [confirmCommand, setConfirmCommand] = useState<string | null>(null);
const handleRun = (command: string) => {
const isSudo = command.trimStart().startsWith('sudo');
if (isSudo) {
setConfirmCommand(command);
} else {
setRunCommand({ command });
}
};
return (
<>
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1">
{resource.port ? (
<Server className="h-4 w-4 text-duck-teal shrink-0" />
) : (
<Wrench className="h-4 w-4 text-duck-dark/40 shrink-0" />
)}
<h2 className="text-lg font-bold text-duck-dark">{resource.name}</h2>
<span className="text-sm text-duck-dark/40">{resource.subtitle}</span>
</div>
<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 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 && (
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
)}
</div>
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
<LocalAvailabilitySection resource={resource} onRun={handleRun} />
</div>
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
You are about to run a command with elevated privileges (sudo)
<br />
<span className="text-red-500">
in the host machine.
<br />
ARE YOU SURE?
</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<code className="text-sm font-mono bg-[#1a1a2e] text-[#e0e0e0] rounded-lg px-3 py-2 break-all">{confirmCommand}</code>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
onClick={() => {
if (confirmCommand) setRunCommand({ command: confirmCommand });
setConfirmCommand(null);
}}
>
Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [showCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
const resource = selectedId ? resources?.find((r: Resource) => r.id === selectedId) : null;
if (showCatalog || !resource) return <ResourceCatalog />;
return <ResourceDetail resource={resource} />;
};
@@ -1,11 +1,24 @@
import { useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClient } from 'hooks/useClient';
import { TerminalView } from 'apps/Terminal';
import { FileViewerView } from 'apps/FileViewer';
import { appRegistry } from '../../Workspaces/app-registry';
import { Resources } from './Resources';
import { ResourceSidebar } from './ResourceSidebar';
import {
RUN_COMMAND_CHANNEL,
ERROR_LOG_CHANNEL,
type RunCommandState,
type ErrorLogState,
} from './run-command-channel';
const layout: LayoutNode = {
const baseLayout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
@@ -15,11 +28,147 @@ const layout: LayoutNode = {
],
};
const splitLayout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'resources-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'resources-terminal', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const errorLayout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'resources-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'resources-error-log', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const ResourceTerminalPanel = () => {
const queryClient = useQueryClient();
const client = useClient();
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [, setErrorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
const [session, setSession] = useState<{ id: string; command: string } | null>(null);
useEffect(() => {
if (state && (!session || session.command !== state.command)) {
setSession({ id: `res-cmd-${Date.now()}`, command: state.command });
} else if (!state) {
setSession(null);
}
}, [state]);
const close = () => setState(null);
const onCommandDone = (exitCode: number, output: string) => {
queryClient.invalidateQueries({ queryKey: ['RESOURCES'] });
if (exitCode === 0) {
toast.success('Command completed successfully');
setTimeout(() => setState(null), 2000);
} else {
const command = session?.command ?? 'unknown';
const md = [
`# Command Failed (exit code ${exitCode})`,
'',
'```',
command,
'```',
'',
'## Output',
'',
'```',
output,
'```',
].join('\n');
client.post('/server-settings/resources/error-log', { command, output, exitCode }).catch(() => {});
setState(null);
setErrorLog({ content: md, fileName: 'error.md' });
}
};
if (!state || !session) 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={session.command}
sessionId={session.id}
onCommandDone={onCommandDone}
/>
</div>
);
};
const ErrorLogPanel = () => {
const [errorLog, setErrorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
if (!errorLog) return null;
return (
<FileViewerView
filePath=""
fileName={errorLog.fileName}
content={errorLog.content}
onClose={() => setErrorLog(null)}
/>
);
};
export const ResourceSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [errorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
const layout = useMemo(
() => (errorLog ? errorLayout : runCommand ? splitLayout : baseLayout),
[errorLog, runCommand],
);
const panelComponents: PanelComponents = useMemo(
() => ({
'resources-left': ResourceSidebar,
'resources-right': Resources,
'resources-terminal': ResourceTerminalPanel,
'resources-error-log': ErrorLogPanel,
}),
[],
);
@@ -0,0 +1,12 @@
export type RunCommandState = {
command: string;
} | null;
export const RUN_COMMAND_CHANNEL = 'resource-settings:run-command';
export type ErrorLogState = {
content: string;
fileName: string;
} | null;
export const ERROR_LOG_CHANNEL = 'resource-settings:error-log';