Code editor and widget
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}`)),
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user