no idea
This commit is contained in:
@@ -54,6 +54,10 @@ export function App() {
|
|||||||
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
|
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
|
||||||
<Route path="/workspaces/new" element={<Dashboard.NewWorkspaceRedirect />} />
|
<Route path="/workspaces/new" element={<Dashboard.NewWorkspaceRedirect />} />
|
||||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||||
|
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||||
|
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||||
|
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||||
|
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ const initialLayout: LayoutNode = {
|
|||||||
{
|
{
|
||||||
node: {
|
node: {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'home-right',
|
id: 'home-center',
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
children: [
|
children: [
|
||||||
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
|
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
|
||||||
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
|
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
size: 80,
|
size: 60,
|
||||||
},
|
},
|
||||||
|
{ node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { Background } from './Background';
|
import { Background } from './Background';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { Dock, dockItems } from './Dock';
|
import { Dock, dockItems } from './Dock';
|
||||||
@@ -6,12 +8,18 @@ type DashboardLayoutProps = {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const visibleItems = useMemo(
|
||||||
|
() => dockItems.filter((item) => !item.role || item.role === user?.role),
|
||||||
|
[user?.role],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||||
<Header />
|
<Header />
|
||||||
<section className="relative h-dvh snap-start overflow-hidden">
|
<section className="relative h-dvh snap-start overflow-hidden">
|
||||||
<Background />
|
<Background />
|
||||||
<Dock items={dockItems} />
|
<Dock items={visibleItems} />
|
||||||
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type DockItem = {
|
|||||||
to: string;
|
to: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
color: string;
|
color: string;
|
||||||
|
role?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DockProps = {
|
type DockProps = {
|
||||||
@@ -109,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react';
|
import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
|
||||||
|
|
||||||
export const dockItems: DockItem[] = [
|
export const dockItems: DockItem[] = [
|
||||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||||
@@ -119,5 +120,7 @@ export const dockItems: DockItem[] = [
|
|||||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||||
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
||||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||||
|
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316', role: 'Super Admin' },
|
||||||
|
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
|
import { FolderKanban, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { generateSlug } from 'helpers/slug';
|
||||||
|
import { useProjectsState } from '@/state/useProjectsState';
|
||||||
|
import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
SELECTED_PROJECT,
|
||||||
|
CREATING_PROJECT,
|
||||||
|
EDITING_PROJECT,
|
||||||
|
NEW_PROJ_NAME,
|
||||||
|
NEW_PROJ_DESC,
|
||||||
|
NEW_PROJ_TEMPLATE,
|
||||||
|
NEW_PROJ_TYPE,
|
||||||
|
NEW_PROJ_HAS_BACKEND,
|
||||||
|
NEW_PROJ_HAS_AUTH,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
||||||
|
'landing-page': 'Landing Page',
|
||||||
|
'website': 'Website',
|
||||||
|
'app': 'App',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectListApp = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||||
|
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
|
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
|
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||||
|
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||||
|
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
|
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||||
|
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||||
|
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState<ProjectDefinition | null>(null);
|
||||||
|
const isProjectsPage = location.pathname === '/projects';
|
||||||
|
const filtered = search
|
||||||
|
? projects.filter((p) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return [p.name, p.id, p.description ?? '', p.cwd ?? '', p.projectType].some((field) =>
|
||||||
|
field.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: projects;
|
||||||
|
|
||||||
|
const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setSelected(null);
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(p.id);
|
||||||
|
setName(p.name);
|
||||||
|
setDescription(p.description ?? '');
|
||||||
|
setTemplateIdx(p.templateIdx ?? 0);
|
||||||
|
setProjectType(p.projectType);
|
||||||
|
setHasBackend(p.hasBackend ?? false);
|
||||||
|
setHasAuth(p.hasAuth ?? false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setDeleting(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setProjects((prev) => prev.filter((p) => p.id !== deleting.id));
|
||||||
|
if (selected === deleting.id) setSelected(null);
|
||||||
|
|
||||||
|
const layoutKey = `proj-layout-${deleting.id}`;
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['PROJECTS_STATE']) ?? {};
|
||||||
|
const { [layoutKey]: _, ...rest } = currentState;
|
||||||
|
queryClient.setQueryData(['PROJECTS_STATE'], rest);
|
||||||
|
client.patch('/user/projects-state', { [layoutKey]: null }).catch(() => {});
|
||||||
|
setDeleting(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (p: ProjectDefinition) => {
|
||||||
|
if (isProjectsPage) {
|
||||||
|
setSelected(p.id);
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
} else {
|
||||||
|
navigate(`/projects/${p.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
|
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400"
|
||||||
|
>
|
||||||
|
<FolderKanban className="h-4 w-4" />
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(null);
|
||||||
|
setEditing(null);
|
||||||
|
setName(generateSlug());
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
setProjectType('app');
|
||||||
|
setHasBackend(false);
|
||||||
|
setHasAuth(false);
|
||||||
|
setCreating(true);
|
||||||
|
if (!isProjectsPage) navigate('/projects');
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-500/90 text-white transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 shrink-0" />
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative px-3 pt-2">
|
||||||
|
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
|
placeholder="Search projects"
|
||||||
|
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-emerald-500/30 focus:border-emerald-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 px-3 pt-2">
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||||
|
isProjectsPage && selected === p.id
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400'
|
||||||
|
: 'text-white/80 hover:bg-duck-dark/5'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleClick(p)}
|
||||||
|
>
|
||||||
|
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left truncate">{p.name}</span>
|
||||||
|
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
|
||||||
|
{isProjectsPage && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(ev) => handleEdit(ev, p)}
|
||||||
|
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-emerald-400 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(ev) => handleDelete(ev, p)}
|
||||||
|
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
||||||
|
{search ? 'No matches' : 'No projects yet'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||||
|
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useProjectsState } from '@/state/useProjectsState';
|
||||||
|
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
|
import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { generateSlug, slugify } from 'helpers/slug';
|
||||||
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
|
import {
|
||||||
|
SELECTED_PROJECT,
|
||||||
|
CREATING_PROJECT,
|
||||||
|
EDITING_PROJECT,
|
||||||
|
NEW_PROJ_NAME,
|
||||||
|
NEW_PROJ_DESC,
|
||||||
|
NEW_PROJ_TEMPLATE,
|
||||||
|
NEW_PROJ_TYPE,
|
||||||
|
NEW_PROJ_HAS_BACKEND,
|
||||||
|
NEW_PROJ_HAS_AUTH,
|
||||||
|
NEW_PROJ_PREVIEW_LAYOUT,
|
||||||
|
} from './constants';
|
||||||
|
import { ProjectListApp } from './ProjectListApp';
|
||||||
|
|
||||||
|
const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'proj-home-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'proj-home-list', appType: 'project-list' }, size: 25 },
|
||||||
|
{ node: { type: 'panel', id: 'proj-home-right', appType: 'project-preview' }, size: 75 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Layout Templates ---
|
||||||
|
|
||||||
|
let tplCounter = 0;
|
||||||
|
const tplUid = () => `ptpl-${++tplCounter}`;
|
||||||
|
|
||||||
|
type LayoutTemplate = {
|
||||||
|
name: string;
|
||||||
|
layout: () => LayoutNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const templates: LayoutTemplate[] = [
|
||||||
|
{
|
||||||
|
name: 'Single',
|
||||||
|
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '2 Columns',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Main + Side',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sidebar',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '2x2 Grid',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cols + Bottom',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 70,
|
||||||
|
},
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Template Thumbnails ---
|
||||||
|
|
||||||
|
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return <div className="h-full w-full bg-emerald-500/15 border border-emerald-500/20 rounded-sm" />;
|
||||||
|
}
|
||||||
|
const isH = node.direction === 'horizontal';
|
||||||
|
return (
|
||||||
|
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
|
||||||
|
<ThumbnailNode node={child.node} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemplateCell = ({ index }: { index: number }) => {
|
||||||
|
const tpl = templates[index]!;
|
||||||
|
const [selected, setSelected] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
|
const node = tpl.layout();
|
||||||
|
const isSelected = selected === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(index)}
|
||||||
|
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
|
||||||
|
isSelected ? 'bg-emerald-500/10' : 'hover:bg-duck-dark/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
||||||
|
<ThumbnailNode node={node} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium ${isSelected ? 'text-emerald-400' : 'text-duck-dark/60'}`}>{tpl.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Panel: Template Picker (3x2 workspace) ---
|
||||||
|
|
||||||
|
const tplPanelLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'ptpl-root',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'ptpl-row-0',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'ptpl-0', appType: 'ptpl-0' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'ptpl-1', appType: 'ptpl-1' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'ptpl-2', appType: 'ptpl-2' }, size: 33.34 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'ptpl-row-1',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'ptpl-3', appType: 'ptpl-3' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'ptpl-4', appType: 'ptpl-4' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'ptpl-5', appType: 'ptpl-5' }, size: 33.34 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tplRegistry = Object.fromEntries(
|
||||||
|
templates.map((tpl, i) => [`ptpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const TemplatePanel = () => (
|
||||||
|
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||||
|
|
||||||
|
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||||
|
{ value: 'landing-page', label: 'Landing Page' },
|
||||||
|
{ value: 'website', label: 'Website' },
|
||||||
|
{ value: 'app', label: 'App' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DetailsPanel = () => {
|
||||||
|
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||||
|
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||||
|
const [projectType, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||||
|
const [hasBackend, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||||
|
const [hasAuth, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-y-auto">
|
||||||
|
<div className="flex h-full flex-col gap-4 p-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||||
|
<Type className="h-3 w-3" />
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
|
placeholder="My Project"
|
||||||
|
autoFocus
|
||||||
|
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
|
placeholder="What is this project for?"
|
||||||
|
rows={3}
|
||||||
|
className="resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-duck-dark/50">Project Type</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{PROJECT_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setProjectType(opt.value)}
|
||||||
|
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-colors cursor-pointer ${
|
||||||
|
projectType === opt.value
|
||||||
|
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-400'
|
||||||
|
: 'border-duck-dark/20 text-duck-dark/50 hover:border-duck-dark/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projectType === 'app' && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasBackend}
|
||||||
|
onChange={(ev) => setHasBackend(ev.target.checked)}
|
||||||
|
className="accent-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-duck-dark/60">Has Backend</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasAuth}
|
||||||
|
onChange={(ev) => setHasAuth(ev.target.checked)}
|
||||||
|
className="accent-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Panel: Create ---
|
||||||
|
|
||||||
|
const CreatePanel = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const client = useClient();
|
||||||
|
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||||
|
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||||
|
|
||||||
|
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||||
|
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
|
const [previewLayout] = useGlobal<LayoutNode>(NEW_PROJ_PREVIEW_LAYOUT, createDefaultLayout());
|
||||||
|
const [projectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||||
|
const [hasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||||
|
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
|
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
|
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
|
||||||
|
const isEditing = !!editingId;
|
||||||
|
const slug = slugify(name.trim()) || generateSlug();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const desc = description.trim();
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const existingIds = new Set(projects.filter((p) => p.id !== editingId).map((p) => p.id));
|
||||||
|
let newId = slugify(trimmed) || generateSlug();
|
||||||
|
while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`;
|
||||||
|
|
||||||
|
const idChanged = newId !== editingId;
|
||||||
|
|
||||||
|
setProjects((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === editingId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
id: newId,
|
||||||
|
name: trimmed,
|
||||||
|
cwd: '',
|
||||||
|
description: desc || undefined,
|
||||||
|
projectType,
|
||||||
|
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||||
|
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||||
|
templateIdx,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['PROJECTS_STATE']) ?? {};
|
||||||
|
const newLayoutKey = `proj-layout-${newId}`;
|
||||||
|
|
||||||
|
if (idChanged) {
|
||||||
|
const oldKeys = [`proj-layout-${editingId}`, `proj-terminals-${editingId}`, `proj-host-terminals-${editingId}`];
|
||||||
|
const cleaned = { ...currentState };
|
||||||
|
for (const k of oldKeys) delete cleaned[k];
|
||||||
|
queryClient.setQueryData(['PROJECTS_STATE'], { ...cleaned, [newLayoutKey]: previewLayout });
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = { [newLayoutKey]: previewLayout };
|
||||||
|
for (const k of oldKeys) patch[k] = null;
|
||||||
|
client.patch('/user/projects-state', patch).catch(() => {});
|
||||||
|
} else {
|
||||||
|
queryClient.setQueryData(['PROJECTS_STATE'], { ...currentState, [newLayoutKey]: previewLayout });
|
||||||
|
client.patch('/user/projects-state', { [newLayoutKey]: previewLayout }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingId(null);
|
||||||
|
setSelected(newId);
|
||||||
|
} else {
|
||||||
|
const existingIds = new Set(projects.map((p) => p.id));
|
||||||
|
let id = slugify(trimmed) || generateSlug();
|
||||||
|
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||||
|
|
||||||
|
const proj: ProjectDefinition = {
|
||||||
|
id,
|
||||||
|
name: trimmed,
|
||||||
|
cwd: '',
|
||||||
|
description: desc || undefined,
|
||||||
|
projectType,
|
||||||
|
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||||
|
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||||
|
templateIdx,
|
||||||
|
};
|
||||||
|
const layoutKey = `proj-layout-${proj.id}`;
|
||||||
|
|
||||||
|
setProjects((prev) => [...prev, proj]);
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['PROJECTS_STATE']) ?? {};
|
||||||
|
queryClient.setQueryData(['PROJECTS_STATE'], { ...currentState, [layoutKey]: previewLayout });
|
||||||
|
client.patch('/user/projects-state', { [layoutKey]: previewLayout }).catch(() => {});
|
||||||
|
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
navigate(`/projects/${proj.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
|
||||||
|
<Rocket className="h-8 w-8 text-emerald-500/40" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
|
||||||
|
<p className="text-xs font-mono text-duck-dark/40 mt-1">projects/{slug}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{isEditing ? 'Update Project' : 'Create Project'}
|
||||||
|
</Button>
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/projects/${editingId}`)}
|
||||||
|
variant="outline"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
|
Open Project
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Panel: Live Template Preview (full interactive sub-workspace) ---
|
||||||
|
// Uses local state for WorkspaceView to avoid cascading re-renders,
|
||||||
|
// and syncs to global query cache (write-only) so CreatePanel can read it.
|
||||||
|
|
||||||
|
const PREVIEW_LAYOUT_KEY = ['USE_GLOBAL', NEW_PROJ_PREVIEW_LAYOUT];
|
||||||
|
|
||||||
|
const TemplatePreviewInner = ({ templateIdx }: { templateIdx: number }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [layout, setLocalLayout] = useState<LayoutNode>(() => {
|
||||||
|
const initial = templates[templateIdx]!.layout();
|
||||||
|
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, initial);
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLayoutChange = (next: LayoutNode) => {
|
||||||
|
setLocalLayout(next);
|
||||||
|
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <WorkspaceView workspace={null} layout={layout} onLayoutChange={handleLayoutChange} registry={appRegistry} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemplatePreviewPanel = () => {
|
||||||
|
const [selectedIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
|
return <TemplatePreviewInner key={selectedIdx} templateIdx={selectedIdx} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- New Project Layout ---
|
||||||
|
|
||||||
|
const newProjLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'new-proj-root',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'new-proj-top',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'new-proj-details', appType: 'new-proj-details' }, size: 30 },
|
||||||
|
{ node: { type: 'panel', id: 'new-proj-template', appType: 'new-proj-template' }, size: 70 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'new-proj-bottom',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'new-proj-placeholder', appType: 'new-proj-placeholder' }, size: 70 },
|
||||||
|
{ node: { type: 'panel', id: 'new-proj-create', appType: 'new-proj-create' }, size: 30 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const newProjRegistry = {
|
||||||
|
...appRegistry,
|
||||||
|
'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
|
||||||
|
'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
|
||||||
|
'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||||
|
'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewProjectForm = () => (
|
||||||
|
<WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProjectPreviewEmpty = () => {
|
||||||
|
const [creating, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
|
const [editingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
|
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||||
|
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||||
|
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
|
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||||
|
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||||
|
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
|
|
||||||
|
if (creating || editingId) return <NewProjectForm />;
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setName(generateSlug());
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
setProjectType('app');
|
||||||
|
setHasBackend(false);
|
||||||
|
setHasAuth(false);
|
||||||
|
setCreating(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||||
|
<FolderKanban className="h-10 w-10 text-white/80" />
|
||||||
|
<p className="text-sm text-white/80">Select a project or create a new one</p>
|
||||||
|
<Button onClick={handleCreate} className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
||||||
|
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||||
|
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
|
||||||
|
|
||||||
|
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectPreview = () => {
|
||||||
|
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
const [projects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||||
|
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
||||||
|
|
||||||
|
if (!project) return <ProjectPreviewEmpty />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<ProjectPreviewInner key={project.id} project={project} />
|
||||||
|
<Link
|
||||||
|
to={`/projects/${project.id}`}
|
||||||
|
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-16 w-16 text-emerald-500/0 group-hover:text-emerald-500/60 transition-colors" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const projHomeRegistry = {
|
||||||
|
...appRegistry,
|
||||||
|
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
|
||||||
|
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectListScreen = () => {
|
||||||
|
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', defaultLayout);
|
||||||
|
|
||||||
|
if (!isLoaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={projHomeRegistry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewProjectRedirect = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||||
|
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||||
|
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
|
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
|
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||||
|
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||||
|
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(null);
|
||||||
|
setEditing(null);
|
||||||
|
setName(params.get('name') || generateSlug());
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
setProjectType('app');
|
||||||
|
setHasBackend(false);
|
||||||
|
setHasAuth(false);
|
||||||
|
setCreating(true);
|
||||||
|
navigate('/projects', { replace: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useParams, Navigate } from 'react-router';
|
||||||
|
import { useProjectsState } from '@/state/useProjectsState';
|
||||||
|
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
|
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
|
||||||
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
|
|
||||||
|
export const ProjectScreen = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [projects, , isLoaded] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||||
|
const project = projects.find((p) => p.id === id);
|
||||||
|
|
||||||
|
if (!isLoaded) return null;
|
||||||
|
if (!project) return <Navigate to="/projects" replace />;
|
||||||
|
|
||||||
|
return <ProjectScreenInner project={project} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
||||||
|
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||||
|
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
layout={layout}
|
||||||
|
onLayoutChange={setLayout}
|
||||||
|
registry={appRegistry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const SELECTED_PROJECT = 'SELECTED_PROJECT';
|
||||||
|
export const CREATING_PROJECT = 'CREATING_PROJECT';
|
||||||
|
export const EDITING_PROJECT = 'EDITING_PROJECT';
|
||||||
|
export const NEW_PROJ_NAME = 'NEW_PROJ_NAME';
|
||||||
|
export const NEW_PROJ_DESC = 'NEW_PROJ_DESC';
|
||||||
|
export const NEW_PROJ_TEMPLATE = 'NEW_PROJ_TEMPLATE';
|
||||||
|
export const NEW_PROJ_TYPE = 'NEW_PROJ_TYPE';
|
||||||
|
export const NEW_PROJ_HAS_BACKEND = 'NEW_PROJ_HAS_BACKEND';
|
||||||
|
export const NEW_PROJ_HAS_AUTH = 'NEW_PROJ_HAS_AUTH';
|
||||||
|
export const NEW_PROJ_PREVIEW_LAYOUT = 'NEW_PROJ_PREVIEW_LAYOUT';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProjectListScreen, NewProjectRedirect } from './ProjectListScreen';
|
||||||
|
export { ProjectScreen } from './ProjectScreen';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Navigate } from 'react-router';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
|
|
||||||
|
const defaultLayout: LayoutNode = { type: 'panel', id: 'terminal-root', appType: 'terminal-host' };
|
||||||
|
|
||||||
|
export const TerminalScreen = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [layout, setLayout] = useState<LayoutNode>(defaultLayout);
|
||||||
|
|
||||||
|
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceView workspace={null} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye } from 'lucide-react';
|
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye, FolderKanban, Columns2, PenLine } from 'lucide-react';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import type { AppRegistry } from '@/components/Workspace';
|
import type { AppRegistry } from '@/components/Workspace';
|
||||||
import { useWorkspace } from '@/components/Workspace';
|
import { useWorkspace } from '@/components/Workspace';
|
||||||
@@ -16,6 +16,7 @@ import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
|||||||
import { Files } from '../Files';
|
import { Files } from '../Files';
|
||||||
import { Catalog } from 'sounds';
|
import { Catalog } from 'sounds';
|
||||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||||
|
import { ProjectListApp } from '../Projects/ProjectListApp';
|
||||||
import { ChatLauncher } from '../Home/ChatLauncher';
|
import { ChatLauncher } from '../Home/ChatLauncher';
|
||||||
import { widgetRegistry } from 'widgets/widget-registry';
|
import { widgetRegistry } from 'widgets/widget-registry';
|
||||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||||
@@ -36,6 +37,39 @@ const FileBrowserWrapper = () => {
|
|||||||
|
|
||||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||||
|
|
||||||
|
const TerminalHeader = () => {
|
||||||
|
const { cwd } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||||
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HostTerminalHeader = () => {
|
||||||
|
const { cwd } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||||
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CodeEditorHeader = () => {
|
||||||
|
const { cwd } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Code className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium shrink-0">Code Editor</span>
|
||||||
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||||
|
|
||||||
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||||
@@ -105,6 +139,67 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
|
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CommandTerminalWrapper = ({ panelId, command, statePrefix }: { panelId: string; command: string; statePrefix: string }) => {
|
||||||
|
const { workspaceId, cwd } = useWorkspace();
|
||||||
|
const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
|
||||||
|
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||||
|
const setTerminalsRef = useRef(setTerminals);
|
||||||
|
setTerminalsRef.current = setTerminals;
|
||||||
|
|
||||||
|
const sessionId = terminals[panelId];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||||
|
}
|
||||||
|
}, [panelId, sessionId, setTerminals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setTerminalsRef.current((prev) => {
|
||||||
|
const { [panelId]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [panelId]);
|
||||||
|
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
|
||||||
|
|
||||||
|
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TmuxWrapper = ({ panelId }: { panelId: string }) => (
|
||||||
|
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
||||||
|
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const TmuxHeader = () => {
|
||||||
|
const { cwd } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium shrink-0">Tmux</span>
|
||||||
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NvimHeader = () => {
|
||||||
|
const { cwd } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium shrink-0">Neovim</span>
|
||||||
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type FileViewerChannelState = {
|
type FileViewerChannelState = {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -134,14 +229,17 @@ const FileViewerWorkspaceProvider = ({ panelId, children }: { panelId: string; c
|
|||||||
export const appRegistry: AppRegistry = {
|
export const appRegistry: AppRegistry = {
|
||||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
|
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
|
||||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
|
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory />, availableOnPanel: false },
|
||||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog />, availableOnPanel: false },
|
||||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper, header: CodeEditorHeader },
|
||||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
|
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper, header: TerminalHeader },
|
||||||
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper },
|
'tmux': { name: 'Tmux', icon: Columns2, component: TmuxWrapper, header: TmuxHeader },
|
||||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp /> },
|
'nvim': { name: 'Neovim', icon: PenLine, component: NvimWrapper, header: NvimHeader },
|
||||||
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider },
|
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper, header: HostTerminalHeader, availableOnPanel: false },
|
||||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
|
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp />, availableOnPanel: false },
|
||||||
|
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp />, availableOnPanel: false },
|
||||||
|
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider, availableOnPanel: false },
|
||||||
|
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180, availableOnPanel: false },
|
||||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||||
...widgetRegistry,
|
...widgetRegistry,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,3 +16,5 @@ export * from './Files';
|
|||||||
export * from './ChatHistory';
|
export * from './ChatHistory';
|
||||||
export * from './CodeEditor';
|
export * from './CodeEditor';
|
||||||
export * from './Workspaces';
|
export * from './Workspaces';
|
||||||
|
export * from './Projects';
|
||||||
|
export * from './Terminal';
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import type { UserState } from './types/user-settings';
|
||||||
|
|
||||||
|
const QUERY_KEY = ['PROJECTS_STATE'];
|
||||||
|
|
||||||
|
export function useProjectsState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||||
|
const client = useClient();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => client.get<UserState>('/user/projects-state'),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = key in state ? (state[key] as T) : defaultValue;
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(update: T | ((prev: T) => T)) => {
|
||||||
|
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||||
|
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||||
|
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||||
|
|
||||||
|
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||||
|
|
||||||
|
clientRef.current
|
||||||
|
.patch<UserState>('/user/projects-state', { [key]: newValue })
|
||||||
|
.then((serverState) => {
|
||||||
|
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
[key, defaultValue, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, setValue, isSuccess];
|
||||||
|
}
|
||||||
+5
-1
@@ -22,6 +22,8 @@ type WSData = {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers: Record<string, typeof piMonoWebsocket> = {
|
const handlers: Record<string, typeof piMonoWebsocket> = {
|
||||||
@@ -51,8 +53,10 @@ async function upgradeWs(req: Request, server: any, provider: /* 'claude' | 'ope
|
|||||||
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
||||||
const cwd = url.searchParams.get('cwd') ?? undefined;
|
const cwd = url.searchParams.get('cwd') ?? undefined;
|
||||||
const command = url.searchParams.get('command') ?? undefined;
|
const command = url.searchParams.get('command') ?? undefined;
|
||||||
|
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
|
||||||
|
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command },
|
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows },
|
||||||
});
|
});
|
||||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { getUserProjectsDir } from '@@/data-path';
|
||||||
|
|
||||||
|
export const projectsRouter = createRouter();
|
||||||
|
|
||||||
|
type KeyMapping = { file: string; dir?: string };
|
||||||
|
|
||||||
|
function resolveKey(projDir: string, key: string): KeyMapping | null {
|
||||||
|
if (key === 'projects') return { file: join(projDir, 'index.json') };
|
||||||
|
|
||||||
|
const layoutMatch = key.match(/^proj-layout-(.+)$/);
|
||||||
|
if (layoutMatch) {
|
||||||
|
const id = layoutMatch[1]!;
|
||||||
|
const dir = join(projDir, id);
|
||||||
|
return { file: join(dir, 'layout.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalsMatch = key.match(/^proj-terminals-(.+)$/);
|
||||||
|
if (terminalsMatch) {
|
||||||
|
const id = terminalsMatch[1]!;
|
||||||
|
const dir = join(projDir, id);
|
||||||
|
return { file: join(dir, 'terminals.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
|
||||||
|
if (hostTerminalsMatch) {
|
||||||
|
const id = hostTerminalsMatch[1]!;
|
||||||
|
const dir = join(projDir, id);
|
||||||
|
return { file: join(dir, 'host-terminals.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(path: string): Promise<unknown | null> {
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (await file.exists()) return file.json();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonFile(path: string, data: unknown) {
|
||||||
|
await Bun.write(path, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProjectDir(dirPath: string, id: string, result: Record<string, unknown>) {
|
||||||
|
const layout = await readJsonFile(join(dirPath, 'layout.json'));
|
||||||
|
if (layout !== null) result[`proj-layout-${id}`] = layout;
|
||||||
|
|
||||||
|
const terminals = await readJsonFile(join(dirPath, 'terminals.json'));
|
||||||
|
if (terminals !== null) result[`proj-terminals-${id}`] = terminals;
|
||||||
|
|
||||||
|
const hostTerminals = await readJsonFile(join(dirPath, 'host-terminals.json'));
|
||||||
|
if (hostTerminals !== null) result[`proj-host-terminals-${id}`] = hostTerminals;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAllProjectsState(projDir: string): Promise<Record<string, unknown>> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const indexData = await readJsonFile(join(projDir, 'index.json'));
|
||||||
|
if (indexData !== null) result['projects'] = indexData;
|
||||||
|
|
||||||
|
let entries: import('node:fs').Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = await readdir(projDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
await readProjectDir(join(projDir, entry.name), entry.name, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /projects-state
|
||||||
|
projectsRouter.get('/projects-state', async (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const projDir = getUserProjectsDir(email);
|
||||||
|
|
||||||
|
const state = await readAllProjectsState(projDir);
|
||||||
|
return ctx.json(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /projects-state
|
||||||
|
projectsRouter.patch('/projects-state', async (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const body = ctx.get('body') as Record<string, unknown>;
|
||||||
|
const projDir = getUserProjectsDir(email);
|
||||||
|
|
||||||
|
await mkdir(projDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
// When writing the projects list, resolve cwds and create project dirs
|
||||||
|
if (key === 'projects' && Array.isArray(value)) {
|
||||||
|
for (const project of value) {
|
||||||
|
const projectFilesDir = join(projDir, project.id, 'files');
|
||||||
|
project.cwd = projectFilesDir;
|
||||||
|
await mkdir(projectFilesDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = resolveKey(projDir, key);
|
||||||
|
if (!mapping) continue;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
try {
|
||||||
|
await rm(mapping.file, { force: true });
|
||||||
|
if (mapping.dir) {
|
||||||
|
const remaining = await readdir(mapping.dir);
|
||||||
|
if (remaining.length === 0) await rm(mapping.dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
|
||||||
|
await writeJsonFile(mapping.file, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await readAllProjectsState(projDir);
|
||||||
|
return ctx.json(state);
|
||||||
|
});
|
||||||
@@ -2,11 +2,14 @@ FROM imbios/bun-node:22-slim
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y \
|
&& apt-get install -y \
|
||||||
python3 make g++ zsh git curl wget ca-certificates \
|
python3 make gcc g++ zsh git curl wget ca-certificates \
|
||||||
fortune-mod cowsay sudo gosu \
|
sudo gosu locales \
|
||||||
zip unzip tree btop net-tools tmux \
|
zip unzip tree btop net-tools tmux \
|
||||||
|
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
|
||||||
&& apt-get clean
|
&& apt-get clean
|
||||||
|
|
||||||
|
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
|
||||||
|
|
||||||
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
|
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
|
||||||
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
|
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
|
||||||
&& rm nvim-linux-x86_64.tar.gz
|
&& rm nvim-linux-x86_64.tar.gz
|
||||||
@@ -36,13 +39,20 @@ RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VE
|
|||||||
&& chmod +x /usr/local/bin/eza \
|
&& chmod +x /usr/local/bin/eza \
|
||||||
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
||||||
|
|
||||||
RUN mkdir -p /home/officer
|
ENV LAZYGIT_VERSION=0.44.1
|
||||||
|
RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" -o /tmp/lazygit.tar.gz \
|
||||||
|
&& tar -xzf /tmp/lazygit.tar.gz -C /tmp \
|
||||||
|
&& mv /tmp/lazygit /usr/local/bin/lazygit \
|
||||||
|
&& chmod +x /usr/local/bin/lazygit \
|
||||||
|
&& rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
|
||||||
|
|
||||||
|
|
||||||
|
RUN mkdir -p /home/officer/Documents /home/officer/Downloads /home/officer/Music /home/officer/Videos /home/officer/Pictures /home/officer/Desktop /home/officer/Projects
|
||||||
|
|
||||||
WORKDIR /home/officer
|
WORKDIR /home/officer
|
||||||
|
|
||||||
ENV TERMINAL_PTY_PORT=5337
|
ENV TERMINAL_PTY_PORT=5337
|
||||||
|
|
||||||
ENV PATH="/usr/games:${PATH}"
|
|
||||||
|
|
||||||
EXPOSE 5337
|
EXPOSE 5337
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,3 @@ alias setupmines="WINEPREFIX=~/wine/minesweeper winecfg"
|
|||||||
alias httpserver="python -m http.server 8888"
|
alias httpserver="python -m http.server 8888"
|
||||||
|
|
||||||
clear
|
clear
|
||||||
fortune | cowsay
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir } from '@@/data-path';
|
||||||
import { officerdb, Users } from 'officerdb';
|
import { officerdb, Users } from 'officerdb';
|
||||||
|
|
||||||
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string };
|
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
||||||
type ShellInfo = { command: string; args: string[]; name: string };
|
type ShellInfo = { command: string; args: string[]; name: string };
|
||||||
type BridgeSession = {
|
type BridgeSession = {
|
||||||
client: ServerWebSocket<WSData>;
|
client: ServerWebSocket<WSData>;
|
||||||
@@ -337,6 +337,8 @@ export const terminalWebsocket = {
|
|||||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||||
homeDir: process.env.HOME,
|
homeDir: process.env.HOME,
|
||||||
userLabel: email,
|
userLabel: email,
|
||||||
|
cols: ws.data.cols,
|
||||||
|
rows: ws.data.rows,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -397,6 +399,8 @@ export const terminalWebsocket = {
|
|||||||
cwd: resolveCwd(containerHome, ws.data.cwd),
|
cwd: resolveCwd(containerHome, ws.data.cwd),
|
||||||
homeDir: containerHome,
|
homeDir: containerHome,
|
||||||
userLabel: email,
|
userLabel: email,
|
||||||
|
cols: ws.data.cols,
|
||||||
|
rows: ws.data.rows,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state
|
|||||||
|
|
||||||
export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces');
|
export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces');
|
||||||
|
|
||||||
|
export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'projects');
|
||||||
|
|
||||||
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
||||||
|
|
||||||
export const getNativeSkillsDir = () => join(SEED_PATH, 'skills');
|
export const getNativeSkillsDir = () => join(SEED_PATH, 'skills');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { scrapeRouter } from './api/scrape/scrape';
|
|||||||
import { uploadRouter } from './api/upload/upload';
|
import { uploadRouter } from './api/upload/upload';
|
||||||
import { settingsRouter } from './api/settings/settings';
|
import { settingsRouter } from './api/settings/settings';
|
||||||
import { workspacesRouter } from './api/settings/workspaces';
|
import { workspacesRouter } from './api/settings/workspaces';
|
||||||
|
import { projectsRouter } from './api/settings/projects';
|
||||||
import { taskLogsRouter } from './api/task-logs/task-logs';
|
import { taskLogsRouter } from './api/task-logs/task-logs';
|
||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
@@ -59,6 +60,7 @@ protectedRouter.route('/scrape', scrapeRouter);
|
|||||||
protectedRouter.route('/upload', uploadRouter);
|
protectedRouter.route('/upload', uploadRouter);
|
||||||
protectedRouter.route('/user', settingsRouter);
|
protectedRouter.route('/user', settingsRouter);
|
||||||
protectedRouter.route('/user', workspacesRouter);
|
protectedRouter.route('/user', workspacesRouter);
|
||||||
|
protectedRouter.route('/user', projectsRouter);
|
||||||
protectedRouter.route('/task-logs', taskLogsRouter);
|
protectedRouter.route('/task-logs', taskLogsRouter);
|
||||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type TerminalViewProps = {
|
|||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
|
initialInput?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
theme?: TerminalTheme;
|
theme?: TerminalTheme;
|
||||||
@@ -37,7 +38,7 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
|
|||||||
selectionBackground: '#3a3a5e',
|
selectionBackground: '#3a3a5e',
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string) => {
|
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string, cols?: number, rows?: number) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||||
const separator = wsPath.includes('?') ? '&' : '?';
|
const separator = wsPath.includes('?') ? '&' : '?';
|
||||||
@@ -46,6 +47,8 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
|
|||||||
if (sandboxed === false) url += '&sandboxed=false';
|
if (sandboxed === false) url += '&sandboxed=false';
|
||||||
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
||||||
if (command) url += `&command=${encodeURIComponent(command)}`;
|
if (command) url += `&command=${encodeURIComponent(command)}`;
|
||||||
|
if (cols) url += `&cols=${cols}`;
|
||||||
|
if (rows) url += `&rows=${rows}`;
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +60,7 @@ export const TerminalView = ({
|
|||||||
sandboxed = true,
|
sandboxed = true,
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
|
initialInput,
|
||||||
fontSize = 14,
|
fontSize = 14,
|
||||||
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
||||||
theme,
|
theme,
|
||||||
@@ -69,19 +73,20 @@ export const TerminalView = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<XTerm | null>(null);
|
const termRef = useRef<XTerm | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
||||||
const isMounted = useMounted();
|
const isMounted = useMounted();
|
||||||
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
|
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
|
||||||
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
||||||
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
||||||
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
||||||
const commandRef = useRef(command);
|
const commandRef = useRef(command);
|
||||||
|
const initialInputRef = useRef(initialInput);
|
||||||
|
|
||||||
onReadyRef.current = onReady;
|
onReadyRef.current = onReady;
|
||||||
onExitRef.current = onExit;
|
onExitRef.current = onExit;
|
||||||
onCommandDoneRef.current = onCommandDone;
|
onCommandDoneRef.current = onCommandDone;
|
||||||
onDisconnectRef.current = onDisconnect;
|
onDisconnectRef.current = onDisconnect;
|
||||||
commandRef.current = command;
|
commandRef.current = command;
|
||||||
|
initialInputRef.current = initialInput;
|
||||||
|
|
||||||
const background = theme?.background ?? DEFAULT_THEME.background;
|
const background = theme?.background ?? DEFAULT_THEME.background;
|
||||||
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
|
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
|
||||||
@@ -94,135 +99,134 @@ export const TerminalView = ({
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
const initTimeout = setTimeout(() => {
|
|
||||||
if (disposed) return;
|
|
||||||
const term = new XTerm({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontSize,
|
|
||||||
fontFamily,
|
|
||||||
theme: {
|
|
||||||
background,
|
|
||||||
foreground,
|
|
||||||
cursor,
|
|
||||||
selectionBackground,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
// Step 2: Create at 80x24, then fit after layout settles.
|
||||||
term.loadAddon(fitAddon);
|
const term = new XTerm({
|
||||||
term.open(container);
|
cursorBlink: true,
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
fontSize,
|
||||||
|
fontFamily,
|
||||||
|
theme: {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
cursor,
|
||||||
|
selectionBackground,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
const fitAddon = new FitAddon();
|
||||||
if (viewport) {
|
term.loadAddon(fitAddon);
|
||||||
viewport.style.scrollbarWidth = 'none';
|
term.open(container);
|
||||||
viewport.style.overflow = 'hidden';
|
if (autoFocus) term.focus();
|
||||||
}
|
|
||||||
|
|
||||||
fitAddon.fit();
|
termRef.current = term;
|
||||||
if (autoFocus) term.focus();
|
onReadyRef.current?.(term);
|
||||||
|
|
||||||
termRef.current = term;
|
// Wait for layout to fully settle (double rAF), then fit + connect
|
||||||
fitAddonRef.current = fitAddon;
|
requestAnimationFrame(() => {
|
||||||
onReadyRef.current?.(term);
|
requestAnimationFrame(() => {
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command));
|
|
||||||
wsRef.current = ws;
|
|
||||||
let commandSent = false;
|
|
||||||
let commandDone = false;
|
|
||||||
let commandOutput = '';
|
|
||||||
const EXIT_MARKER = '__OFFICER_EXIT_';
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMessage = (ev: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(ev.data as string);
|
|
||||||
if (msg.type === 'output') {
|
|
||||||
term.write(msg.data);
|
|
||||||
if (commandRef.current && !commandSent) {
|
|
||||||
commandSent = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
const wrapped = onCommandDoneRef.current
|
|
||||||
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
|
|
||||||
: commandRef.current;
|
|
||||||
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
if (commandSent && !commandDone && onCommandDoneRef.current) {
|
|
||||||
commandOutput += msg.data as string;
|
|
||||||
const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
|
|
||||||
if (markerMatch) {
|
|
||||||
const exitCode = Number(markerMatch[1]);
|
|
||||||
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
|
|
||||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
||||||
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
|
|
||||||
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
|
|
||||||
commandDone = true;
|
|
||||||
onCommandDoneRef.current(exitCode, output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'exit') {
|
|
||||||
term.write('\r\n[Process exited]\r\n');
|
|
||||||
onExitRef.current?.();
|
|
||||||
} else if (msg.type === 'detached') {
|
|
||||||
term.write('\r\n[Session taken over]\r\n');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
term.write('\r\n[Disconnected]\r\n');
|
|
||||||
onDisconnectRef.current?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.addEventListener('open', handleOpen);
|
|
||||||
ws.addEventListener('message', handleMessage);
|
|
||||||
ws.addEventListener('close', handleClose);
|
|
||||||
|
|
||||||
const dataDisposable = term.onData((data) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'input', data }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const cols = term.cols;
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
const rows = term.rows;
|
||||||
}
|
|
||||||
|
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows));
|
||||||
|
wsRef.current = ws;
|
||||||
|
let commandSent = false;
|
||||||
|
let commandDone = false;
|
||||||
|
let initialInputSent = false;
|
||||||
|
let commandOutput = '';
|
||||||
|
const EXIT_MARKER = '__OFFICER_EXIT_';
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = (ev: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data as string);
|
||||||
|
if (msg.type === 'output') {
|
||||||
|
term.write(msg.data);
|
||||||
|
if (commandRef.current && !commandSent) {
|
||||||
|
commandSent = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
const wrapped = onCommandDoneRef.current
|
||||||
|
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
|
||||||
|
: commandRef.current;
|
||||||
|
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
if (!commandRef.current && initialInputRef.current && !initialInputSent) {
|
||||||
|
initialInputSent = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'input', data: initialInputRef.current + '\r' }));
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
if (commandSent && !commandDone && onCommandDoneRef.current) {
|
||||||
|
commandOutput += msg.data as string;
|
||||||
|
const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
|
||||||
|
if (markerMatch) {
|
||||||
|
const exitCode = Number(markerMatch[1]);
|
||||||
|
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
|
||||||
|
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
|
||||||
|
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
|
||||||
|
commandDone = true;
|
||||||
|
onCommandDoneRef.current(exitCode, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'exit') {
|
||||||
|
term.write('\r\n[Process exited]\r\n');
|
||||||
|
onExitRef.current?.();
|
||||||
|
} else if (msg.type === 'detached') {
|
||||||
|
term.write('\r\n[Session taken over]\r\n');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
term.write('\r\n[Disconnected]\r\n');
|
||||||
|
onDisconnectRef.current?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener('open', handleOpen);
|
||||||
|
ws.addEventListener('message', handleMessage);
|
||||||
|
ws.addEventListener('close', handleClose);
|
||||||
|
|
||||||
|
const dataDisposable = term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'input', data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(container as any).__terminalCleanup = () => {
|
||||||
|
dataDisposable.dispose();
|
||||||
|
ws.removeEventListener('open', handleOpen);
|
||||||
|
ws.removeEventListener('message', handleMessage);
|
||||||
|
ws.removeEventListener('close', handleClose);
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
resizeObserver.observe(container);
|
});
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
dataDisposable.dispose();
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
ws.removeEventListener('open', handleOpen);
|
|
||||||
ws.removeEventListener('message', handleMessage);
|
|
||||||
ws.removeEventListener('close', handleClose);
|
|
||||||
};
|
|
||||||
|
|
||||||
(container as any).__terminalCleanup = cleanup;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearTimeout(initTimeout);
|
|
||||||
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
|
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
delete (container as any).__terminalCleanup;
|
delete (container as any).__terminalCleanup;
|
||||||
wsRef.current?.close();
|
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
termRef.current?.dispose();
|
termRef.current?.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
fitAddonRef.current = null;
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
isMounted,
|
isMounted,
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ type AppPickerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||||
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
|
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget && entry.availableOnPanel !== false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
<div className="flex flex-wrap gap-1.5 max-w-md">
|
||||||
{entries.map(([key, entry]) => (
|
{entries.map(([key, entry]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
|
className="flex items-center gap-1.5 rounded-full border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-1.5 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
|
||||||
onClick={() => onSelect(key)}
|
onClick={() => onSelect(key)}
|
||||||
>
|
>
|
||||||
<entry.icon className="h-5 w-5" />
|
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
|
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { ArrowLeftRight, X } from 'lucide-react';
|
import { useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ArrowLeftRight, X, Minus } from 'lucide-react';
|
||||||
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
||||||
import { useWorkspace } from './WorkspaceContext';
|
import { useWorkspace } from './WorkspaceContext';
|
||||||
import { Card } from '../Card';
|
import { Card } from '../Card';
|
||||||
@@ -37,12 +39,17 @@ const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelCompone
|
|||||||
typeof v === 'object' && v !== null && 'component' in v;
|
typeof v === 'object' && v !== null && 'component' in v;
|
||||||
|
|
||||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
||||||
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||||
|
const isMaximized = maximizedPanelId === panelId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
|
||||||
|
{isMaximized ? 'Restore' : 'Maximize'}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
||||||
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
||||||
@@ -101,6 +108,61 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => {
|
||||||
|
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||||
|
const isMaximized = maximizedPanelId === panelId;
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
onClearApp();
|
||||||
|
}, [onClearApp]);
|
||||||
|
|
||||||
|
const handleRestore = useCallback(() => {
|
||||||
|
setMaximizedPanelId(null);
|
||||||
|
}, [setMaximizedPanelId]);
|
||||||
|
|
||||||
|
const handleMaximize = useCallback(() => {
|
||||||
|
setMaximizedPanelId(panelId);
|
||||||
|
}, [panelId, setMaximizedPanelId]);
|
||||||
|
|
||||||
|
if (isMaximized) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestore}
|
||||||
|
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||||
|
title="Restore"
|
||||||
|
>
|
||||||
|
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||||
|
title={isLastPanel ? 'Clear app' : 'Close panel'}
|
||||||
|
>
|
||||||
|
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMaximize}
|
||||||
|
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||||
|
title="Maximize"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||||
|
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
|
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
|
||||||
// const DragHandle = ({ panelId }: { panelId: string }) => {
|
// const DragHandle = ({ panelId }: { panelId: string }) => {
|
||||||
// const { setDragSourceId, dragSourceId } = useWorkspace();
|
// const { setDragSourceId, dragSourceId } = useWorkspace();
|
||||||
@@ -120,6 +182,9 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||||
|
const { maximizedPanelId, transitioningPanelId } = useWorkspace();
|
||||||
|
const isMaximized = maximizedPanelId === panel.id;
|
||||||
|
|
||||||
const rawPanelComponent = components?.[panel.id];
|
const rawPanelComponent = components?.[panel.id];
|
||||||
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
|
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
|
||||||
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
|
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
|
||||||
@@ -144,9 +209,6 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
|||||||
<>
|
<>
|
||||||
<SwapOverlay panelId={panel.id} />
|
<SwapOverlay panelId={panel.id} />
|
||||||
<SwapSourceIndicator panelId={panel.id} />
|
<SwapSourceIndicator panelId={panel.id} />
|
||||||
{/* TODO: re-enable when drag-to-reposition is polished */}
|
|
||||||
{/* <DragHandle panelId={panel.id} /> */}
|
|
||||||
{/* <DragSourceDimmer panelId={panel.id} /> */}
|
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -180,67 +242,93 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// App with header — render header chrome + body
|
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
|
||||||
if (HeaderComponent) {
|
const DefaultHeader = entry ? () => (
|
||||||
const headerBar = (
|
<>
|
||||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<HeaderComponent panelId={panel.id} />
|
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
||||||
{onClose && (
|
</>
|
||||||
<button
|
) : null;
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const body = (
|
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
|
||||||
<AppComponent panelId={panel.id} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const inner = ProviderComponent ? (
|
const trafficLights = interactive ? (
|
||||||
<ProviderComponent panelId={panel.id}>
|
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
||||||
{headerBar}
|
) : null;
|
||||||
{body}
|
|
||||||
</ProviderComponent>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{headerBar}
|
|
||||||
{body}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return contextMenu(
|
const headerContent = (
|
||||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||||
<div
|
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
||||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
{onClose && (
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{inner}
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{trafficLights}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerBar = interactive ? (
|
||||||
|
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||||
|
{headerContent}
|
||||||
|
</PanelContextMenu>
|
||||||
|
) : headerContent;
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||||
|
<AppComponent panelId={panel.id} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inner = ProviderComponent ? (
|
||||||
|
<ProviderComponent panelId={panel.id}>
|
||||||
|
{headerBar}
|
||||||
|
{body}
|
||||||
|
</ProviderComponent>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{headerBar}
|
||||||
|
{body}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMaximized) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Placeholder to preserve layout space */}
|
||||||
|
<div data-panel-id={panel.id} className="h-full w-full p-1">
|
||||||
|
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
|
||||||
</div>
|
</div>
|
||||||
{overlays}
|
{/* Maximized overlay — portalled to escape stacking contexts */}
|
||||||
</div>,
|
{createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 p-2">
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
|
||||||
|
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: no header
|
return (
|
||||||
return contextMenu(
|
|
||||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||||
<div
|
<div
|
||||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
|
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto">
|
{inner}
|
||||||
<AppComponent panelId={panel.id} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
{overlays}
|
{overlays}
|
||||||
</div>,
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ type WorkspaceContextValue = {
|
|||||||
dragSourceId: string | null;
|
dragSourceId: string | null;
|
||||||
setDragSourceId: (id: string | null) => void;
|
setDragSourceId: (id: string | null) => void;
|
||||||
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
|
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
|
||||||
|
maximizedPanelId: string | null;
|
||||||
|
setMaximizedPanelId: (id: string | null) => void;
|
||||||
|
transitioningPanelId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -24,6 +27,9 @@ const WorkspaceContext = createContext<WorkspaceContextValue>({
|
|||||||
dragSourceId: null,
|
dragSourceId: null,
|
||||||
setDragSourceId: noop,
|
setDragSourceId: noop,
|
||||||
onMove: noop,
|
onMove: noop,
|
||||||
|
maximizedPanelId: null,
|
||||||
|
setMaximizedPanelId: noop,
|
||||||
|
transitioningPanelId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop }}>
|
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null }}>
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
layout={layout}
|
layout={layout}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from './layout-utils';
|
||||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
// TODO: re-enable when drag-to-reposition is polished
|
|
||||||
// import { DragOverlay } from './DragOverlay';
|
|
||||||
|
|
||||||
type WorkspaceViewProps = {
|
type WorkspaceViewProps = {
|
||||||
workspace: WorkspaceDefinition | null;
|
workspace: WorkspaceDefinition | null;
|
||||||
@@ -17,6 +16,22 @@ type WorkspaceViewProps = {
|
|||||||
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||||
|
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
|
||||||
|
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setMaximizedAnimated = useCallback((id: string | null) => {
|
||||||
|
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
|
||||||
|
const panelId = maximizedPanelId ?? id;
|
||||||
|
if (doc.startViewTransition && panelId) {
|
||||||
|
setTransitioningPanelId(panelId);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const transition = doc.startViewTransition(() => flushSync(() => setMaximizedPanelId(id)));
|
||||||
|
transition.finished.finally(() => setTransitioningPanelId(null));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMaximizedPanelId(id);
|
||||||
|
}
|
||||||
|
}, [maximizedPanelId]);
|
||||||
|
|
||||||
const handleSetApp = useCallback(
|
const handleSetApp = useCallback(
|
||||||
(panelId: string, appType: string | null) => {
|
(panelId: string, appType: string | null) => {
|
||||||
@@ -72,16 +87,17 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!swapSourceId && !dragSourceId) return;
|
if (!swapSourceId && !dragSourceId && !maximizedPanelId) return;
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
setSwapSourceId(null);
|
setSwapSourceId(null);
|
||||||
setDragSourceId(null);
|
setDragSourceId(null);
|
||||||
|
setMaximizedAnimated(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [swapSourceId, dragSourceId]);
|
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider
|
<WorkspaceProvider
|
||||||
@@ -94,6 +110,9 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
|
|||||||
dragSourceId,
|
dragSourceId,
|
||||||
setDragSourceId: startDrag,
|
setDragSourceId: startDrag,
|
||||||
onMove: handleMove,
|
onMove: handleMove,
|
||||||
|
maximizedPanelId,
|
||||||
|
setMaximizedPanelId: setMaximizedAnimated,
|
||||||
|
transitioningPanelId,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||||
export type { DropPosition } from './layout-utils';
|
export type { DropPosition } from './layout-utils';
|
||||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ export type WorkspaceDefinition = {
|
|||||||
templateIdx?: number;
|
templateIdx?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectType = 'landing-page' | 'website' | 'app';
|
||||||
|
|
||||||
|
export type ProjectDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
description?: string;
|
||||||
|
projectType: ProjectType;
|
||||||
|
hasBackend?: boolean;
|
||||||
|
hasAuth?: boolean;
|
||||||
|
gitRepo?: string;
|
||||||
|
templateIdx?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AppRegistryEntry = {
|
export type AppRegistryEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
@@ -33,6 +47,7 @@ export type AppRegistryEntry = {
|
|||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
fixedHeight?: number;
|
fixedHeight?: number;
|
||||||
widget?: boolean;
|
widget?: boolean;
|
||||||
|
availableOnPanel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col items-end">
|
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
||||||
<WidgetPicker onSelect={addWidget} />
|
<WidgetPicker onSelect={addWidget} />
|
||||||
|
|||||||
Reference in New Issue
Block a user