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 ( +
+ + +
+
+ Explorer +
+ +
+
+ + +
+ + {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" } }