Code editor and widget

This commit is contained in:
2026-02-17 23:48:46 +00:00
parent d3f94e45ea
commit 030cf9fbd3
18 changed files with 871 additions and 9 deletions
+17 -1
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -74,6 +75,7 @@
"lucide-react": "^0.562.0",
"markdown-it": "^14.1.0",
"material-file-icons": "^2.4.0",
"monaco-editor": "^0.55.1",
"next-themes": "^0.4.6",
"node-pty": "^1.1.0",
"nodemailer": "^7.0.12",
@@ -483,6 +485,10 @@
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
"@next/env": ["@next/env@15.1.2", "", {}, "sha512-Hm3jIGsoUl6RLB1vzY+dZeqb+/kWPZ+h34yiWxW0dV87l8Im/eMOwpOA+a0L78U0HM04syEjXuRlCozqpwuojQ=="],
@@ -985,6 +991,8 @@
"@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -1241,6 +1249,8 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
@@ -1521,7 +1531,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"material-file-icons": ["material-file-icons@2.4.0", "", {}, "sha512-MgxhwBgoiNXyQdZVtXvdqP8t7Fu/Z3zW1aPeYN+UqtepzbKyf41b+Wme6DnwGk5Crt2JzmWLtl1XGE2YMooaQw=="],
@@ -1635,6 +1645,8 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
@@ -1961,6 +1973,8 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
@@ -2227,6 +2241,8 @@
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+6 -4
View File
@@ -26,6 +26,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -83,8 +84,8 @@
"helpers": "workspace:*",
"hono": "^4.11.1",
"hooks": "workspace:*",
"i18n": "workspace:*",
"html2canvas": "^1.4.1",
"i18n": "workspace:*",
"idb-keyval": "^6.2.2",
"injector": "workspace:*",
"input-otp": "^1.4.2",
@@ -93,13 +94,13 @@
"lucide-react": "^0.562.0",
"markdown-it": "^14.1.0",
"material-file-icons": "^2.4.0",
"monaco-editor": "^0.55.1",
"next-themes": "^0.4.6",
"node-pty": "^1.1.0",
"nodemailer": "^7.0.12",
"officerdb": "workspace:*",
"pg": "^8.16.3",
"postgres": "^3.4.5",
"widgets": "workspace:*",
"react": "^19",
"react-countup": "^6.5.3",
"react-day-picker": "^9.13.0",
@@ -122,8 +123,9 @@
"three": "^0.182.0",
"types": "workspace:*",
"vaul": "^1.1.2",
"zod": "^4.2.1",
"ws": "^8.18.1"
"widgets": "workspace:*",
"ws": "^8.18.1",
"zod": "^4.2.1"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
+1
View File
@@ -48,6 +48,7 @@ export function App() {
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.Files />} />
<Route path="/terminal" element={<Dashboard.Terminal />} />
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
<Route path="/skills" element={<Dashboard.Skills />} />
<Route path="/tasks" element={<Dashboard.Tasks />} />
<Route path="/processes" element={<Dashboard.Processes />} />
@@ -0,0 +1,10 @@
import { CodeEditorView } from 'widgets/CodeEditor';
import { Widget } from '@/components/Widget';
export const CodeEditor = () => (
<div className="h-full w-full flex items-center justify-center">
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
<CodeEditorView className="h-full w-full" />
</Widget>
</div>
);
@@ -91,13 +91,14 @@ export const Dock = ({ items, className }: DockProps) => {
};
import { Terminal, TerminalSquare, FileText, FolderOpen } from 'lucide-react';
import { Terminal, TerminalSquare, FileText, FolderOpen, Code } from 'lucide-react';
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
export const dockItems: DockItem[] = [
{ label: 'Chat', to: '/chat', icon: Terminal, color: '#60a5fa' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Skills', to: '/skills', icon: Sparkles, color: '#c084fc' },
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
@@ -13,3 +13,4 @@ export * from './Tasks';
export * from './Terminal';
export * from './Files';
export * from './ChatHistory';
export * from './CodeEditor';
+24
View File
@@ -298,6 +298,30 @@
}
/* File Browser — Context menu hover behavior */
/* Code Editor — thin dark scrollbars */
.code-editor-scrollable {
scrollbar-width: thin;
scrollbar-color: #444 transparent;
}
.code-editor-scrollable::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.code-editor-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.code-editor-scrollable::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.code-editor-scrollable::-webkit-scrollbar-thumb:hover {
background: #555;
}
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) {
pointer-events: none;
}
+17
View File
@@ -79,6 +79,23 @@ router.get('/read', async (ctx) => {
return ctx.json({ content, size: s.size });
});
// Write file contents
router.post('/write', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { path, content } = ctx.get('body') as { path: string; content: string };
if (!path) throw errors.BAD_REQUEST('path is required');
if (typeof content !== 'string') throw errors.BAD_REQUEST('content must be a string');
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
if (new TextEncoder().encode(content).length > MAX_SIZE) throw errors.BAD_REQUEST('Content too large (max 5 MB)');
const absPath = resolveUserPath(rootDir, path);
await mkdir(dirname(absPath), { recursive: true });
await Bun.write(absPath, content);
return ctx.json({ ok: true });
});
// Create directory
router.post('/mkdir', async (ctx) => {
const user = ctx.get('user');
+2 -2
View File
@@ -1,4 +1,4 @@
import type { CSSProperties, ComponentPropsWithoutRef } from 'react';
import type { CSSProperties, ComponentPropsWithRef } from 'react';
import { cn } from 'helpers/cn';
export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
@@ -11,7 +11,7 @@ export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
...overrides,
});
type CardProps = ComponentPropsWithoutRef<'div'>;
type CardProps = ComponentPropsWithRef<'div'>;
export const Card = ({ className, style, ...props }: CardProps) => {
return (
+155
View File
@@ -0,0 +1,155 @@
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, Minus, Plus } from 'lucide-react';
import { cn } from 'helpers/cn';
import { Card } from './Card';
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
title?: string;
resizable?: boolean;
collapsible?: boolean | { title: string; icon?: LucideIcon };
moveable?: boolean;
};
export const Widget = ({ title, className, style, resizable, collapsible, moveable, children, ...props }: WidgetProps) => {
const [expanded, setExpanded] = useState(true);
const [minimized, setMinimized] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const cardRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
startX: number;
startY: number;
originX: number;
originY: number;
naturalLeft: number;
naturalTop: number;
cardWidth: number;
cardHeight: number;
} | null>(null);
const preToggleRect = useRef<{ left: number; top: number } | null>(null);
const HEADER_HEIGHT = 64;
const toggleMinimized = useCallback(() => {
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect();
preToggleRect.current = { left: rect.left, top: rect.top };
}
setMinimized((v) => !v);
}, []);
useLayoutEffect(() => {
if (!cardRef.current || !preToggleRect.current) return;
const prev = preToggleRect.current;
preToggleRect.current = null;
const newRect = cardRef.current.getBoundingClientRect();
setPosition((p) => ({
x: p.x + (prev.left - newRect.left),
y: p.y + (prev.top - newRect.top),
}));
}, [minimized]);
const onPointerDown = useCallback(
(ev: ReactPointerEvent) => {
if (!moveable) return;
const target = ev.target as HTMLElement;
const card = ev.currentTarget as HTMLElement;
const isCardPadding = target === card;
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
if (!isCardPadding && !isHeader) return;
if (isHeader && ev.detail === 2) {
toggleMinimized();
return;
}
const rect = card.getBoundingClientRect();
dragRef.current = {
startX: ev.clientX,
startY: ev.clientY,
originX: position.x,
originY: position.y,
naturalLeft: rect.left - position.x,
naturalTop: rect.top - position.y,
cardWidth: rect.width,
cardHeight: rect.height,
};
card.setPointerCapture(ev.pointerId);
},
[moveable, position],
);
const onPointerMove = useCallback((ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dx = ev.clientX - d.startX;
const dy = ev.clientY - d.startY;
const newX = d.originX + dx;
const newY = d.originY + dy;
const vw = window.innerWidth;
const vh = window.innerHeight;
setPosition({
x: Math.min(Math.max(newX, -d.naturalLeft), vw - d.naturalLeft - d.cardWidth),
y: Math.min(Math.max(newY, HEADER_HEIGHT - d.naturalTop), vh - d.naturalTop - d.cardHeight),
});
}, []);
const onPointerUp = useCallback(() => {
dragRef.current = null;
}, []);
const resizableStyle: CSSProperties | undefined = resizable ? { resize: 'both', overflow: 'auto' } : undefined;
const moveableStyle: CSSProperties | undefined = moveable
? { position: 'relative', transform: `translate(${position.x}px, ${position.y}px)` }
: undefined;
return (
<Card
ref={cardRef}
className={cn(
'relative p-0',
collapsible && 'pt-0',
moveable && 'cursor-grab [&>*]:cursor-auto',
className,
minimized && '!h-auto !w-auto',
)}
style={{ ...resizableStyle, ...moveableStyle, ...style }}
onPointerDown={moveable ? onPointerDown : undefined}
onPointerMove={moveable ? onPointerMove : undefined}
onPointerUp={moveable ? onPointerUp : undefined}
{...props}
>
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
<button
type="button"
className="ml-auto p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => toggleMinimized()}
>
{minimized ? <Plus size={14} /> : <Minus size={14} />}
</button>
</div>
{!minimized && (
<>
{collapsible && (
<button
type="button"
className="flex w-full items-center gap-2 p-3 cursor-pointer"
onClick={() => setExpanded((v) => !v)}
>
{typeof collapsible === 'object' && collapsible.icon && (
<collapsible.icon size={16} className="text-muted-foreground" />
)}
{typeof collapsible === 'object' && collapsible.title && (
<span className="text-sm text-muted-foreground">{collapsible.title}</span>
)}
<span className="ml-auto text-muted-foreground">
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</span>
</button>
)}
{collapsible ? expanded && <div className="px-5 pb-5">{children}</div> : children}
</>
)}
</Card>
);
};
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef } from 'react';
import Editor, { type OnMount } from '@monaco-editor/react';
import type { editor as MonacoEditor } from 'monaco-editor';
import { toast } from 'sonner';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useFiles } from 'widgets/FileBrowser';
import { FileTree } from './FileTree';
import { EditorTabs } from './EditorTabs';
import { useEditorState } from './useEditorState';
import { getLanguage } from './language-map';
type CodeEditorViewProps = {
className?: string;
theme?: string;
root?: string;
initialPath?: string;
};
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
useEditorState();
const { readFile, writeFile } = useFiles(root);
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
const activeFile = getActiveFile();
const handleOpenFile = useCallback(
async (path: string, name: string) => {
const existing = files.find((f) => f.path === path);
if (existing) {
setActivePath(path);
return;
}
try {
const res = await readFile(path);
openFile(path, name, res.content);
} catch {
toast.error('Failed to read file');
}
},
[files, setActivePath, readFile, openFile],
);
const handleSave = useCallback(async () => {
if (!activeFile || !activeFile.isDirty) return;
try {
await writeFile(activeFile.path, activeFile.content);
markSaved(activeFile.path, activeFile.content);
toast.success('File saved');
} catch {
toast.error('Failed to save file');
}
}, [activeFile, writeFile, markSaved]);
const handleEditorMount: OnMount = useCallback(
(editor, monaco) => {
editorRef.current = editor;
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave();
});
},
[handleSave],
);
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const bg = theme === 'vs-dark' ? '#1e1e1e' : theme === 'vs' ? '#ffffff' : '#1e1e1e';
const borderColor = theme === 'vs-dark' ? '#333' : '#e0e0e0';
return (
<div className={className} style={{ background: bg }}>
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg" style={{ borderColor }}>
<ResizablePanel defaultSize={20} minSize={10} maxSize={40}>
<div className="h-full flex flex-col" style={{ background: bg }}>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider"
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
>
Explorer
</div>
<FileTree root={root} basePath={initialPath ?? '/'} onOpenFile={handleOpenFile} />
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={80}>
<div className="h-full flex flex-col" style={{ background: bg }}>
<EditorTabs
files={files}
activePath={activePath}
onSelect={setActivePath}
onClose={closeFile}
theme={theme}
/>
{activeFile ? (
<Editor
key={activeFile.path}
theme={theme}
language={getLanguage(activeFile.name)}
value={activeFile.content}
onChange={(value) => setContent(activeFile.path, value ?? '')}
onMount={handleEditorMount}
options={{
automaticLayout: true,
minimap: { enabled: true },
fontSize: 14,
tabSize: 2,
wordWrap: 'on',
scrollBeyondLastLine: false,
renderWhitespace: 'selection',
smoothScrolling: true,
cursorBlinking: 'smooth',
cursorSmoothCaretAnimation: 'on',
padding: { top: 8 },
}}
/>
) : (
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
Open a file from the explorer to start editing
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
};
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState';
type EditorTabsProps = {
files: OpenFile[];
activePath: string | null;
onSelect: (path: string) => void;
onClose: (path: string) => void;
theme?: string;
};
const FileIcon = ({ name }: { name: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
};
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme }: EditorTabsProps) => {
if (files.length === 0) return null;
const isDark = theme !== 'vs';
const borderColor = isDark ? '#333' : '#e0e0e0';
const activeBg = isDark ? '#1e1e1e' : '#ffffff';
const inactiveBg = isDark ? '#181818' : '#f3f3f3';
const activeColor = isDark ? '#ccc' : '#333';
const inactiveColor = isDark ? '#888' : '#666';
return (
<div className="flex items-center overflow-x-auto shrink-0 code-editor-scrollable" style={{ borderBottom: `1px solid ${borderColor}` }}>
{files.map((file) => {
const isActive = file.path === activePath;
return (
<button
key={file.path}
onClick={() => onSelect(file.path)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors"
style={{
background: isActive ? activeBg : inactiveBg,
color: isActive ? activeColor : inactiveColor,
borderRight: `1px solid ${borderColor}`,
}}
>
<FileIcon name={file.name} />
<span>{file.name}</span>
{file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />}
<span
role="button"
className="ml-1 p-0.5 rounded transition-colors"
style={{ color: inactiveColor }}
onClick={(ev) => {
ev.stopPropagation();
onClose(file.path);
}}
>
<X className="h-3 w-3" />
</span>
</button>
);
})}
</div>
);
};
@@ -0,0 +1,142 @@
import { useState, useMemo, useCallback } from 'react';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from 'widgets/FileBrowser';
type FileTreeProps = {
root: string;
basePath: string;
onOpenFile: (path: string, name: string) => void;
};
type TreeNodeProps = {
entry: DirEntry;
parentPath: string;
root: string;
onOpenFile: (path: string, name: string) => void;
depth: number;
};
const FileIcon = ({ name }: { name: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
};
const sortEntries = (entries: DirEntry[]) => {
const dirs = entries.filter((e) => e.type === 'directory').sort((a, b) => a.name.localeCompare(b.name));
const files = entries.filter((e) => e.type === 'file').sort((a, b) => a.name.localeCompare(b.name));
return [...dirs, ...files];
};
const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps) => {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const { listDir } = useFiles(root);
const isDir = entry.type === 'directory';
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
const handleClick = useCallback(async () => {
if (!isDir) {
onOpenFile(fullPath, entry.name);
return;
}
if (!expanded && children === null) {
setLoading(true);
try {
const res = await listDir(fullPath);
setChildren(sortEntries(res.entries));
} catch {
setChildren([]);
}
setLoading(false);
}
setExpanded((prev) => !prev);
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]);
return (
<div>
<button
onClick={handleClick}
className="flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5"
style={{ paddingLeft: `${depth * 12 + 4}px` }}
>
{isDir ? (
<>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
)}
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
</>
) : (
<>
<span className="w-3.5 shrink-0" />
<FileIcon name={entry.name} />
</>
)}
<span className="truncate">{entry.name}</span>
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
</button>
{isDir && expanded && children && (
<div>
{children.map((child) => (
<TreeNode
key={child.name}
entry={child}
parentPath={fullPath}
root={root}
onOpenFile={onOpenFile}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
};
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
const { listDir } = useFiles(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const loadRoot = useCallback(async () => {
setLoading(true);
try {
const res = await listDir(basePath);
setEntries(sortEntries(res.entries));
} catch {
setEntries([]);
}
setLoading(false);
}, [listDir, basePath]);
if (entries === null && !loading) {
loadRoot();
}
if (loading && entries === null) {
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
}
if (!entries || entries.length === 0) {
return <div className="p-2 text-sm text-[#888]">No files</div>;
}
return (
<div className="py-1 overflow-y-auto h-full code-editor-scrollable">
{entries.map((entry) => (
<TreeNode
key={entry.name}
entry={entry}
parentPath={basePath}
root={root}
onOpenFile={onOpenFile}
depth={0}
/>
))}
</div>
);
};
@@ -0,0 +1 @@
export { CodeEditorView } from './CodeEditor';
@@ -0,0 +1,235 @@
const extensionToLanguage: Record<string, string> = {
// JavaScript / TypeScript
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
// Web
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
less: 'less',
vue: 'html',
svelte: 'html',
// Data / Config
json: 'json',
jsonc: 'json',
json5: 'json',
geojson: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'ini',
ini: 'ini',
cfg: 'ini',
conf: 'ini',
properties: 'ini',
env: 'ini',
xml: 'xml',
xsl: 'xml',
xslt: 'xml',
xsd: 'xml',
svg: 'xml',
plist: 'xml',
csproj: 'xml',
fsproj: 'xml',
vcxproj: 'xml',
sln: 'xml',
// Markdown / Text
md: 'markdown',
mdx: 'markdown',
markdown: 'markdown',
txt: 'plaintext',
log: 'plaintext',
// Shell
sh: 'shell',
bash: 'shell',
zsh: 'shell',
fish: 'shell',
ksh: 'shell',
csh: 'shell',
ps1: 'powershell',
psm1: 'powershell',
psd1: 'powershell',
bat: 'bat',
cmd: 'bat',
// Python
py: 'python',
pyw: 'python',
pyi: 'python',
pyx: 'python',
ipynb: 'json',
// Ruby
rb: 'ruby',
erb: 'ruby',
gemspec: 'ruby',
rake: 'ruby',
// Rust
rs: 'rust',
// Go
go: 'go',
mod: 'go',
// Java / JVM
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
scala: 'scala',
sc: 'scala',
groovy: 'groovy',
gradle: 'groovy',
// C / C++ / Objective-C
c: 'c',
h: 'c',
cpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
hpp: 'cpp',
hxx: 'cpp',
hh: 'cpp',
m: 'objective-c',
mm: 'objective-c',
// C# / F#
cs: 'csharp',
csx: 'csharp',
fs: 'fsharp',
fsx: 'fsharp',
fsi: 'fsharp',
// Swift
swift: 'swift',
// Dart
dart: 'dart',
// PHP
php: 'php',
phtml: 'php',
// SQL
sql: 'sql',
mysql: 'sql',
pgsql: 'pgsql',
// Lua
lua: 'lua',
// R
r: 'r',
rmd: 'markdown',
// Perl
pl: 'perl',
pm: 'perl',
perl: 'perl',
// GraphQL
graphql: 'graphql',
gql: 'graphql',
// Docker
dockerfile: 'dockerfile',
// Elixir / Erlang
ex: 'elixir',
exs: 'elixir',
erl: 'erlang',
hrl: 'erlang',
// Haskell
hs: 'haskell',
lhs: 'haskell',
// Clojure
clj: 'clojure',
cljs: 'clojure',
cljc: 'clojure',
edn: 'clojure',
// Handlebars
hbs: 'handlebars',
handlebars: 'handlebars',
// Twig
twig: 'twig',
// Pug
pug: 'pug',
jade: 'pug',
// Coffee
coffee: 'coffeescript',
// Diff / Patch
diff: 'diff',
patch: 'diff',
// Protocol Buffers
proto: 'protobuf',
// Terraform
tf: 'hcl',
tfvars: 'hcl',
hcl: 'hcl',
// ABAP
abap: 'abap',
// Apex
apex: 'apex',
cls: 'apex',
trigger: 'apex',
// Pascal
pas: 'pascal',
pp: 'pascal',
// Tcl
tcl: 'tcl',
// Scheme / Lisp
scm: 'scheme',
ss: 'scheme',
rkt: 'scheme',
lisp: 'scheme',
lsp: 'scheme',
el: 'scheme',
// Misc
sol: 'sol',
bicep: 'bicep',
azcli: 'azcli',
redis: 'redis',
sb: 'sb',
st: 'st',
lex: 'lexon',
};
export const getLanguage = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const base = filename.toLowerCase();
if (base === 'dockerfile' || base.startsWith('dockerfile.')) return 'dockerfile';
if (base === 'makefile' || base === 'gnumakefile') return 'shell';
if (base === 'gemfile' || base === 'rakefile' || base === 'vagrantfile') return 'ruby';
if (base === 'justfile') return 'shell';
if (base === '.gitignore' || base === '.dockerignore' || base === '.editorconfig') return 'ini';
if (base === '.prettierrc' || base === '.eslintrc' || base === 'tsconfig.json' || base === 'package.json')
return 'json';
// Dotfiles that are shell scripts
if (/^\.(bash|zsh|sh|ksh|csh)rc$/.test(base)) return 'shell';
if (/^\.(bash_|zsh_|sh_)/.test(base)) return 'shell'; // .bash_profile, .zsh_history, etc.
if (base === '.profile' || base === '.bash_logout' || base === '.bash_login') return 'shell';
return extensionToLanguage[ext] ?? 'plaintext';
};
@@ -0,0 +1,56 @@
import { useState, useCallback } from 'react';
export type OpenFile = {
path: string;
name: string;
content: string;
originalContent: string;
isDirty: boolean;
};
export const useEditorState = () => {
const [files, setFiles] = useState<OpenFile[]>([]);
const [activePath, setActivePath] = useState<string | null>(null);
const openFile = useCallback((path: string, name: string, content: string) => {
setFiles((prev) => {
const existing = prev.find((f) => f.path === path);
if (existing) return prev;
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
});
setActivePath(path);
}, []);
const closeFile = useCallback(
(path: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.path !== path);
if (activePath === path) {
const idx = prev.findIndex((f) => f.path === path);
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
setActivePath(newActive?.path ?? null);
}
return next;
});
},
[activePath],
);
const setContent = useCallback((path: string, content: string) => {
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
);
}, []);
const markSaved = useCallback((path: string, content: string) => {
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
);
}, []);
const getActiveFile = useCallback((): OpenFile | null => {
return files.find((f) => f.path === activePath) ?? null;
}, [files, activePath]);
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
};
@@ -34,6 +34,9 @@ export const useFiles = (root: string = 'home') => {
readFile: (path: string) =>
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
writeFile: (path: string, content: string) =>
client.post(withRoot('/file-browser/write'), { path, content }),
search: (query: string) =>
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
+2 -1
View File
@@ -5,6 +5,7 @@
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./Chat": "./Chat/index.ts"
"./Chat": "./Chat/index.ts",
"./CodeEditor": "./CodeEditor/index.ts"
}
}