Skip to content

Commit 18ce811

Browse files
authored
feat: added workspace file browser (#59)
1 parent 5b86355 commit 18ce811

File tree

7 files changed

+398
-8
lines changed

7 files changed

+398
-8
lines changed

src/components/RightSidebar.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { PanelRightOpen, PanelRightClose, Monitor, Settings, Globe } from 'lucide-react';
1+
import {
2+
PanelRightOpen,
3+
PanelRightClose,
4+
Monitor,
5+
Settings,
6+
Globe,
7+
FolderOpen,
8+
} from 'lucide-react';
29
import { Button } from '@/components/ui/button';
310
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
411
import { useState } from 'react';
@@ -14,6 +21,7 @@ import { type Observable } from '@legendapp/state';
1421
import { use$ } from '@legendapp/state/react';
1522
import { ConversationSettings } from './ConversationSettings';
1623
import { BrowserPreview } from './BrowserPreview';
24+
import { WorkspaceExplorer } from './workspace/WorkspaceExplorer';
1725

1826
const VNC_URL = 'http://localhost:6080/vnc.html';
1927

@@ -26,7 +34,7 @@ export const RightSidebar: FC<Props> = ({ isOpen$, onToggle, conversationId }) =
2634
<div
2735
className={`border-l transition-all duration-300 ${
2836
isOpen
29-
? activeTab === 'computer' || activeTab === 'browser'
37+
? activeTab === 'computer' || activeTab === 'browser' || activeTab === 'workspace'
3038
? 'w-[48rem]'
3139
: 'w-[32rem]'
3240
: 'w-0'
@@ -35,7 +43,10 @@ export const RightSidebar: FC<Props> = ({ isOpen$, onToggle, conversationId }) =
3543
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
3644
<div className="flex h-12 items-center justify-between border-b px-4">
3745
<TabsList>
38-
<TabsTrigger value="details">Details</TabsTrigger>
46+
<TabsTrigger value="workspace">
47+
<FolderOpen className="mr-2 h-4 w-4" />
48+
Workspace
49+
</TabsTrigger>
3950
<TabsTrigger value="browser">
4051
<Globe className="mr-2 h-4 w-4" />
4152
Browser
@@ -54,15 +65,13 @@ export const RightSidebar: FC<Props> = ({ isOpen$, onToggle, conversationId }) =
5465
</Button>
5566
</div>
5667

57-
<div className="h-[calc(100%-3rem)] overflow-y-auto">
68+
<div className="h-[calc(100%-3rem)]">
5869
<TabsContent value="settings" className="m-0 h-full p-4">
5970
<ConversationSettings conversationId={conversationId} />
6071
</TabsContent>
6172

62-
<TabsContent value="details" className="m-0 h-full p-4">
63-
<div className="text-sm text-muted-foreground">
64-
Select a file or tool to view details
65-
</div>
73+
<TabsContent value="workspace" className="m-0 h-full">
74+
<WorkspaceExplorer conversationId={conversationId} />
6675
</TabsContent>
6776

6877
<TabsContent value="computer" className="m-0 h-full p-0">

src/components/workspace/FileList.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { FileIcon, FolderIcon, ArrowLeft } from 'lucide-react';
2+
import { formatDistanceToNow } from 'date-fns';
3+
import { ScrollArea } from '@/components/ui/scroll-area';
4+
import type { FileType } from '@/types/workspace';
5+
6+
interface FileListProps {
7+
files: FileType[];
8+
currentPath: string;
9+
onFileClick: (file: FileType) => void;
10+
onDirectoryClick: (path: string) => void;
11+
}
12+
13+
export function FileList({ files, currentPath, onFileClick, onDirectoryClick }: FileListProps) {
14+
const goToParent = () => {
15+
if (!currentPath) return;
16+
const parentPath = currentPath.split('/').slice(0, -1).join('/');
17+
onDirectoryClick(parentPath);
18+
};
19+
20+
return (
21+
<ScrollArea className="h-full px-2">
22+
<div className="space-y-1 py-2">
23+
{currentPath && (
24+
<button
25+
onClick={goToParent}
26+
className="flex w-full items-center justify-between rounded-md p-2 hover:bg-muted"
27+
>
28+
<div className="flex items-center">
29+
<ArrowLeft className="mr-2 h-4 w-4" />
30+
<span>..</span>
31+
</div>
32+
</button>
33+
)}
34+
{files.map((file) => (
35+
<button
36+
key={file.path}
37+
onClick={() =>
38+
file.type === 'directory' ? onDirectoryClick(file.path) : onFileClick(file)
39+
}
40+
className="flex w-full items-center justify-between rounded-md p-2 hover:bg-muted"
41+
>
42+
<div className="flex items-center">
43+
{file.type === 'directory' ? (
44+
<FolderIcon className="mr-2 h-4 w-4" />
45+
) : (
46+
<FileIcon className="mr-2 h-4 w-4" />
47+
)}
48+
<span className="text-sm">{file.name}</span>
49+
</div>
50+
<div className="flex items-center space-x-4 text-xs text-muted-foreground">
51+
<span>{formatDistanceToNow(new Date(file.modified), { addSuffix: true })}</span>
52+
{file.type === 'file' && <span>{(file.size / 1024).toFixed(1)} KB</span>}
53+
</div>
54+
</button>
55+
))}
56+
</div>
57+
</ScrollArea>
58+
);
59+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useEffect, useState, useCallback } from 'react';
2+
import { Loader2 } from 'lucide-react';
3+
import { useWorkspaceApi } from '@/utils/workspaceApi';
4+
import type { FileType, FilePreview } from '@/types/workspace';
5+
import { CodeDisplay } from '@/components/CodeDisplay';
6+
7+
interface FilePreviewProps {
8+
file: FileType;
9+
conversationId: string;
10+
}
11+
12+
export function FilePreview({ file, conversationId }: FilePreviewProps) {
13+
const [preview, setPreview] = useState<FilePreview | null>(null);
14+
const [error, setError] = useState<string | null>(null);
15+
const [loading, setLoading] = useState(true);
16+
17+
const { previewFile } = useWorkspaceApi();
18+
19+
const loadPreview = useCallback(async () => {
20+
try {
21+
setLoading(true);
22+
setError(null);
23+
const data = await previewFile(conversationId, file.path);
24+
setPreview(data);
25+
} catch (err) {
26+
setError(err instanceof Error ? err.message : 'Failed to load preview');
27+
} finally {
28+
setLoading(false);
29+
}
30+
}, [file.path, conversationId, previewFile]);
31+
32+
useEffect(() => {
33+
loadPreview();
34+
}, [loadPreview]);
35+
36+
if (loading) {
37+
return (
38+
<div className="flex h-full items-center justify-center">
39+
<Loader2 className="h-6 w-6 animate-spin" />
40+
</div>
41+
);
42+
}
43+
44+
if (error) {
45+
return <div className="flex h-full items-center justify-center text-destructive">{error}</div>;
46+
}
47+
48+
if (!preview) {
49+
return null;
50+
}
51+
52+
switch (preview.type) {
53+
case 'text':
54+
return (
55+
<div className="flex h-full flex-col">
56+
<div className="border-b p-2">
57+
<h3 className="font-medium">{file.name}</h3>
58+
<div className="text-sm text-muted-foreground">
59+
{(file.size / 1024).toFixed(1)} KB • {file.mime_type || 'Unknown type'}
60+
</div>
61+
</div>
62+
<div className="flex-1 overflow-auto">
63+
<CodeDisplay
64+
code={preview.content}
65+
language={file.mime_type?.split('/')[1] || 'plaintext'}
66+
maxHeight="none"
67+
/>
68+
</div>
69+
</div>
70+
);
71+
case 'image':
72+
return (
73+
<div className="flex h-full flex-col">
74+
<div className="border-b p-2">
75+
<h3 className="font-medium">{file.name}</h3>
76+
<div className="text-sm text-muted-foreground">
77+
{(file.size / 1024).toFixed(1)} KB • {file.mime_type || 'Unknown type'}
78+
</div>
79+
</div>
80+
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
81+
<img
82+
src={preview.content}
83+
alt={file.name}
84+
className="max-h-full max-w-full object-contain"
85+
/>
86+
</div>
87+
</div>
88+
);
89+
case 'binary':
90+
return (
91+
<div className="flex h-full flex-col">
92+
<div className="border-b p-2">
93+
<h3 className="font-medium">{file.name}</h3>
94+
<div className="text-sm text-muted-foreground">
95+
{(file.size / 1024).toFixed(1)} KB • {file.mime_type || 'Unknown type'}
96+
</div>
97+
</div>
98+
<div className="flex flex-1 items-center justify-center">
99+
<p className="text-muted-foreground">Binary file</p>
100+
</div>
101+
</div>
102+
);
103+
default:
104+
return null;
105+
}
106+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Button } from '@/components/ui/button';
2+
import { ChevronRight } from 'lucide-react';
3+
4+
interface PathSegmentsProps {
5+
path: string;
6+
onNavigate: (path: string) => void;
7+
}
8+
9+
export function PathSegments({ path, onNavigate }: PathSegmentsProps) {
10+
const segments = path ? path.split('/') : [];
11+
12+
return (
13+
<div className="flex items-center space-x-1 text-sm">
14+
<Button variant="ghost" size="sm" className="h-6 px-2" onClick={() => onNavigate('')}>
15+
/
16+
</Button>
17+
{segments.map((segment, index) => {
18+
if (!segment) return null;
19+
const segmentPath = segments.slice(0, index + 1).join('/');
20+
return (
21+
<div key={segmentPath} className="flex items-center">
22+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
23+
<Button
24+
variant="ghost"
25+
size="sm"
26+
className="h-6 px-2"
27+
onClick={() => onNavigate(segmentPath)}
28+
>
29+
{segment}
30+
</Button>
31+
</div>
32+
);
33+
})}
34+
</div>
35+
);
36+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
import { Loader2 } from 'lucide-react';
3+
import { useWorkspaceApi } from '@/utils/workspaceApi';
4+
import { FileList } from './FileList';
5+
import { FilePreview } from './FilePreview';
6+
import { PathSegments } from './PathSegments';
7+
import { Switch } from '@/components/ui/switch';
8+
import { Label } from '@/components/ui/label';
9+
import type { FileType } from '@/types/workspace';
10+
11+
interface WorkspaceExplorerProps {
12+
conversationId: string;
13+
}
14+
15+
export function WorkspaceExplorer({ conversationId }: WorkspaceExplorerProps) {
16+
const [files, setFiles] = useState<FileType[]>([]);
17+
const [currentPath, setCurrentPath] = useState('');
18+
const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
19+
const [showHidden, setShowHidden] = useState(false);
20+
const [loading, setLoading] = useState(true);
21+
const [error, setError] = useState<string | null>(null);
22+
const { listWorkspace } = useWorkspaceApi();
23+
24+
const loadFiles = useCallback(async () => {
25+
try {
26+
setLoading(true);
27+
setError(null);
28+
const data = await listWorkspace(conversationId, currentPath, showHidden);
29+
setFiles(data);
30+
} catch (err) {
31+
console.error('Error loading workspace:', err);
32+
setError(err instanceof Error ? err.message : 'Failed to load workspace');
33+
} finally {
34+
setLoading(false);
35+
}
36+
}, [conversationId, currentPath, showHidden, listWorkspace]);
37+
38+
useEffect(() => {
39+
loadFiles();
40+
}, [loadFiles]);
41+
42+
const handleFileClick = (file: FileType) => {
43+
setSelectedFile(file);
44+
};
45+
46+
const handleDirectoryClick = (path: string) => {
47+
setCurrentPath(path);
48+
setSelectedFile(null);
49+
};
50+
51+
if (error) {
52+
return <div className="flex h-full items-center justify-center text-destructive">{error}</div>;
53+
}
54+
55+
return (
56+
<div className="flex h-full flex-col">
57+
<div className="flex items-center justify-between border-b p-4">
58+
<PathSegments path={currentPath} onNavigate={handleDirectoryClick} />
59+
<div className="flex items-center space-x-2">
60+
<Switch id="show-hidden" checked={showHidden} onCheckedChange={setShowHidden} />
61+
<Label htmlFor="show-hidden">Show hidden files</Label>
62+
</div>
63+
</div>
64+
65+
<div className="flex min-h-0 flex-1">
66+
<div className="h-full w-1/2 overflow-hidden border-r">
67+
{loading ? (
68+
<div className="flex h-full items-center justify-center">
69+
<Loader2 className="h-6 w-6 animate-spin" />
70+
</div>
71+
) : (
72+
<FileList
73+
files={files}
74+
currentPath={currentPath}
75+
onFileClick={handleFileClick}
76+
onDirectoryClick={handleDirectoryClick}
77+
/>
78+
)}
79+
</div>
80+
<div className="h-full w-1/2 overflow-hidden">
81+
{selectedFile ? (
82+
<FilePreview file={selectedFile} conversationId={conversationId} />
83+
) : (
84+
<div className="flex h-full items-center justify-center text-muted-foreground">
85+
Select a file to preview
86+
</div>
87+
)}
88+
</div>
89+
</div>
90+
</div>
91+
);
92+
}

src/types/workspace.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface FileType {
2+
name: string;
3+
path: string;
4+
type: 'file' | 'directory';
5+
size: number;
6+
modified: string;
7+
mime_type: string | null;
8+
}
9+
10+
export interface TextPreview {
11+
type: 'text';
12+
content: string;
13+
}
14+
15+
export interface BinaryPreview {
16+
type: 'binary';
17+
metadata: FileType;
18+
}
19+
20+
export interface ImagePreview {
21+
type: 'image';
22+
content: string; // Blob URL
23+
}
24+
25+
export type FilePreview = TextPreview | BinaryPreview | ImagePreview;

0 commit comments

Comments
 (0)