diff --git a/bun.lock b/bun.lock
index b0e06811..dc3de56e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
diff --git a/package.json b/package.json
index d374bd0a..896551a0 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index af71fffe..7ae93909 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -48,6 +48,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx b/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx
new file mode 100644
index 00000000..eb3066ed
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx
@@ -0,0 +1,10 @@
+import { CodeEditorView } from 'widgets/CodeEditor';
+import { Widget } from '@/components/Widget';
+
+export const CodeEditor = () => (
+
+
+
+
+
+);
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index 66d1d0ee..abae49fc 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -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' },
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index eb91c027..a1c757be 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -13,3 +13,4 @@ export * from './Tasks';
export * from './Terminal';
export * from './Files';
export * from './ChatHistory';
+export * from './CodeEditor';
diff --git a/src/apps/officer-web/styles/globals.css b/src/apps/officer-web/styles/globals.css
index 038d8bc1..29c7fb49 100644
--- a/src/apps/officer-web/styles/globals.css
+++ b/src/apps/officer-web/styles/globals.css
@@ -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;
}
diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts
index a13eca37..3c8f00c0 100644
--- a/src/servers/api/file-browser/router.ts
+++ b/src/servers/api/file-browser/router.ts
@@ -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');
diff --git a/src/workspaces/components/Card.tsx b/src/workspaces/components/Card.tsx
index 004cc6d8..949c4087 100644
--- a/src/workspaces/components/Card.tsx
+++ b/src/workspaces/components/Card.tsx
@@ -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 (
diff --git a/src/workspaces/components/Widget.tsx b/src/workspaces/components/Widget.tsx
new file mode 100644
index 00000000..a6dabf3b
--- /dev/null
+++ b/src/workspaces/components/Widget.tsx
@@ -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(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 (
+ *]: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}
+ >
+
+ {title &&
{title}}
+
+
+ {!minimized && (
+ <>
+ {collapsible && (
+
+ )}
+ {collapsible ? expanded && {children}
: children}
+ >
+ )}
+
+ );
+};
diff --git a/src/workspaces/widgets/CodeEditor/CodeEditor.tsx b/src/workspaces/widgets/CodeEditor/CodeEditor.tsx
new file mode 100644
index 00000000..4c1bd0ba
--- /dev/null
+++ b/src/workspaces/widgets/CodeEditor/CodeEditor.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+
+
+ {activeFile ? (
+
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 },
+ }}
+ />
+ ) : (
+
+ Open a file from the explorer to start editing
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/workspaces/widgets/CodeEditor/EditorTabs.tsx b/src/workspaces/widgets/CodeEditor/EditorTabs.tsx
new file mode 100644
index 00000000..ba572ea6
--- /dev/null
+++ b/src/workspaces/widgets/CodeEditor/EditorTabs.tsx
@@ -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 ;
+};
+
+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 (
+
+ {files.map((file) => {
+ const isActive = file.path === activePath;
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/src/workspaces/widgets/CodeEditor/FileTree.tsx b/src/workspaces/widgets/CodeEditor/FileTree.tsx
new file mode 100644
index 00000000..3b789fa3
--- /dev/null
+++ b/src/workspaces/widgets/CodeEditor/FileTree.tsx
@@ -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 ;
+};
+
+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(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 (
+
+
+ {isDir && expanded && children && (
+
+ {children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
+ const { listDir } = useFiles(root);
+ const [entries, setEntries] = useState(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 Loading...
;
+ }
+
+ if (!entries || entries.length === 0) {
+ return No files
;
+ }
+
+ return (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ );
+};
diff --git a/src/workspaces/widgets/CodeEditor/index.ts b/src/workspaces/widgets/CodeEditor/index.ts
new file mode 100644
index 00000000..c3ba9632
--- /dev/null
+++ b/src/workspaces/widgets/CodeEditor/index.ts
@@ -0,0 +1 @@
+export { CodeEditorView } from './CodeEditor';
diff --git a/src/workspaces/widgets/CodeEditor/language-map.ts b/src/workspaces/widgets/CodeEditor/language-map.ts
new file mode 100644
index 00000000..187aa2f1
--- /dev/null
+++ b/src/workspaces/widgets/CodeEditor/language-map.ts
@@ -0,0 +1,235 @@
+const extensionToLanguage: Record = {
+ // 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';
+};
diff --git a/src/workspaces/widgets/CodeEditor/useEditorState.ts b/src/workspaces/widgets/CodeEditor/useEditorState.ts
new file mode 100644
index 00000000..0471a5c8
--- /dev/null
+++ b/src/workspaces/widgets/CodeEditor/useEditorState.ts
@@ -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([]);
+ const [activePath, setActivePath] = useState(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 };
+};
diff --git a/src/workspaces/widgets/FileBrowser/useFiles.ts b/src/workspaces/widgets/FileBrowser/useFiles.ts
index 7bddb361..242bc17e 100644
--- a/src/workspaces/widgets/FileBrowser/useFiles.ts
+++ b/src/workspaces/widgets/FileBrowser/useFiles.ts
@@ -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)}`)),
diff --git a/src/workspaces/widgets/package.json b/src/workspaces/widgets/package.json
index 57cc316a..c8be1eab 100644
--- a/src/workspaces/widgets/package.json
+++ b/src/workspaces/widgets/package.json
@@ -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"
}
}