Projects
This commit is contained in:
@@ -51,7 +51,8 @@ export function App() {
|
|||||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||||
<Route path="/workspaces" element={<Dashboard.WorkspaceScreen />} />
|
<Route path="/workspaces" element={<Dashboard.WorkspacesScreen />} />
|
||||||
|
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'chat-history-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 35 },
|
||||||
|
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,25 +1,14 @@
|
|||||||
import { useMemo, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
import { WorkspaceView } from '@/components/Workspace';
|
||||||
import { WorkspaceLayout } from '@/components/Workspace';
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChatSessions } from 'state/useChatSessions';
|
import { useChatSessions } from 'state/useChatSessions';
|
||||||
|
import type { SelectedSession } from 'officerdev';
|
||||||
import { SessionList } from './Screen';
|
import { defaultLayout } from './defaultLayout';
|
||||||
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
|
|
||||||
|
|
||||||
export { ChatHistory as ChatHistoryApp } from './Widget';
|
export { ChatHistory as ChatHistoryApp } from './Widget';
|
||||||
export { SessionList };
|
|
||||||
|
|
||||||
const layout: LayoutNode = {
|
|
||||||
type: 'group',
|
|
||||||
id: 'chat-history-root',
|
|
||||||
direction: 'horizontal',
|
|
||||||
children: [
|
|
||||||
{ node: { type: 'panel', id: 'chat-list', appType: null }, size: 35 },
|
|
||||||
{ node: { type: 'panel', id: 'chat-detail', appType: null }, size: 65 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
type SessionListPageProps = {
|
type SessionListPageProps = {
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
@@ -29,6 +18,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
const { sessionId } = useParams<{ sessionId: string }>();
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
const { sessions } = useChatSessions();
|
const { sessions } = useChatSessions();
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
|
const workspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -40,17 +30,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
setSelected({ id: sessionId, model: session?.model ?? null });
|
setSelected({ id: sessionId, model: session?.model ?? null });
|
||||||
}, [sessionId, isNew]);
|
}, [sessionId, isNew]);
|
||||||
|
|
||||||
const panelComponents: PanelComponents = useMemo(
|
|
||||||
() => ({
|
|
||||||
'chat-list': SessionList,
|
|
||||||
'chat-detail': ChatDetailPanel,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
<WorkspaceView workspace={workspace} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { useRef, useEffect, useState } from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
|
||||||
import { useChatSessions } from 'state/useChatSessions';
|
|
||||||
import { SessionBar, EmbeddableChat, type UsePiChatType, type Attachment } from 'officerdev';
|
|
||||||
import { Card } from '@/components/Card';
|
|
||||||
|
|
||||||
export type { Attachment };
|
|
||||||
|
|
||||||
type ChatPanelProps = {
|
|
||||||
chat: UsePiChatType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChatScreen = ({ chat }: ChatPanelProps) => {
|
|
||||||
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
|
||||||
|
|
||||||
const { sessions, deleteSession } = useChatSessions();
|
|
||||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
|
||||||
const listPath = '/chat';
|
|
||||||
|
|
||||||
// Capture initial state from navigation
|
|
||||||
const locationState = location.state as {
|
|
||||||
initialMessage?: string;
|
|
||||||
prefillInput?: string;
|
|
||||||
model?: string;
|
|
||||||
cwd?: { root?: string; path: string };
|
|
||||||
attachmentIds?: string[];
|
|
||||||
images?: { filename: string; dataUrl: string }[];
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
const initialMessage = locationState?.initialMessage
|
|
||||||
? {
|
|
||||||
text: locationState.initialMessage,
|
|
||||||
attachmentIds: locationState.attachmentIds,
|
|
||||||
images: locationState.images,
|
|
||||||
cwd: locationState.cwd,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const defaultInput = locationState?.prefillInput ?? '';
|
|
||||||
const initialModel = locationState?.model ?? null;
|
|
||||||
|
|
||||||
// Clear location state after capturing
|
|
||||||
useEffect(() => {
|
|
||||||
if (locationState) {
|
|
||||||
window.history.replaceState({}, '', location.pathname);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SessionBar
|
|
||||||
listPath={listPath}
|
|
||||||
sessionTitle={sessionTitle}
|
|
||||||
isConnected={isConnected}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
fullscreen={fullscreen}
|
|
||||||
onDelete={async () => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
await deleteSession(sessionId);
|
|
||||||
navigate(listPath);
|
|
||||||
}}
|
|
||||||
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EmbeddableChat
|
|
||||||
sessionId={sessionId ?? undefined}
|
|
||||||
initialModel={initialModel}
|
|
||||||
initialMessage={initialMessage}
|
|
||||||
defaultInput={defaultInput}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './ChatScreen';
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { CodeEditorView } from 'officerdev';
|
|
||||||
import { Widget } from 'widgets/Widget';
|
|
||||||
|
|
||||||
export const CodeEditor = () => (
|
|
||||||
<div className="h-full w-full flex items-center justify-center">
|
|
||||||
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
|
||||||
<CodeEditorView className="h-full w-full" />
|
|
||||||
</Widget>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
import { 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 { useGlobal } from 'hooks/useGlobal';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import { useProjectsState } from 'state/useProjectsState';
|
import { WorkspaceView } from '@/components/Workspace';
|
||||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
import type { LayoutNode, ProjectType } from '@/components/Workspace';
|
||||||
import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace';
|
import { generateSlug } from 'helpers/slug';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { generateSlug, slugify } from 'helpers/slug';
|
|
||||||
import { useAppRegistry } from 'officerdev';
|
|
||||||
import {
|
import {
|
||||||
SELECTED_PROJECT,
|
SELECTED_PROJECT,
|
||||||
CREATING_PROJECT,
|
CREATING_PROJECT,
|
||||||
@@ -20,600 +15,15 @@ import {
|
|||||||
NEW_PROJ_TYPE,
|
NEW_PROJ_TYPE,
|
||||||
NEW_PROJ_HAS_BACKEND,
|
NEW_PROJ_HAS_BACKEND,
|
||||||
NEW_PROJ_HAS_AUTH,
|
NEW_PROJ_HAS_AUTH,
|
||||||
NEW_PROJ_PREVIEW_LAYOUT,
|
} from 'officerdev';
|
||||||
} from './constants';
|
import { defaultLayout } from './defaultLayout';
|
||||||
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} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 NewProjectForm = () => {
|
|
||||||
const { registry } = useAppRegistry();
|
|
||||||
const newProjRegistry = {
|
|
||||||
...registry,
|
|
||||||
'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 },
|
|
||||||
};
|
|
||||||
|
|
||||||
return <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} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProjectListScreen = () => {
|
export const ProjectListScreen = () => {
|
||||||
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', defaultLayout);
|
const workspace = useWorkspacesState<LayoutNode>('screens/projects', defaultLayout);
|
||||||
const { registry } = useAppRegistry();
|
|
||||||
const projHomeRegistry = {
|
|
||||||
...registry,
|
|
||||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
|
|
||||||
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={projHomeRegistry} />
|
<WorkspaceView workspace={workspace} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useParams, Navigate } from 'react-router';
|
import { useParams, Navigate } from 'react-router';
|
||||||
import { useProjectsState } from 'state/useProjectsState';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
|
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
|
||||||
|
|
||||||
|
|
||||||
export const ProjectScreen = () => {
|
export const ProjectScreen = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [projects, , isLoaded] = useProjectsState<ProjectDefinition[]>('projects', []);
|
const { value: projects, isLoaded } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||||
const project = projects.find((p) => p.id === id);
|
const project = projects.find((p) => p.id === id);
|
||||||
|
|
||||||
if (!isLoaded) return null;
|
if (!isLoaded) return null;
|
||||||
@@ -16,17 +15,11 @@ export const ProjectScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
||||||
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
const workspace = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||||
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView
|
<WorkspaceView workspace={workspace} cwd={project.cwd} />
|
||||||
workspace={workspace}
|
|
||||||
layout={layout}
|
|
||||||
onLayoutChange={setLayout}
|
|
||||||
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
|
|
||||||
|
export 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
import type { LayoutNode } from '@/components/Workspace';
|
import { useParams, Navigate } from 'react-router';
|
||||||
import { WorkspaceView } from '@/components/Workspace';
|
|
||||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
|
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||||
|
|
||||||
export const WorkspaceScreen = () => {
|
export const WorkspaceScreen = () => {
|
||||||
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { value: workspaces, isLoaded } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
const ws = workspaces.find((w) => w.id === id);
|
||||||
|
|
||||||
|
if (!isLoaded) return null;
|
||||||
|
if (!ws) return <Navigate to="/workspaces" replace />;
|
||||||
|
|
||||||
|
return <WorkspaceScreenInner workspace={ws} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||||
|
const wsState = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} />
|
<WorkspaceView workspace={wsState} cwd={workspace.cwd} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
|
import { WorkspaceView } from '@/components/Workspace';
|
||||||
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
|
export const WorkspacesScreen = () => {
|
||||||
|
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceView workspace={workspace} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export { WorkspacesScreen } from './WorkspacesScreen';
|
||||||
export { WorkspaceScreen } from './WorkspaceScreen';
|
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './Layout';
|
export * from './Layout';
|
||||||
export * from './Home';
|
export * from './Home';
|
||||||
export * from './ChatScreen';
|
|
||||||
export * from './OnboardingAdmin';
|
export * from './OnboardingAdmin';
|
||||||
export * from './PasskeyGate';
|
export * from './PasskeyGate';
|
||||||
export * from './Plans';
|
export * from './Plans';
|
||||||
@@ -14,7 +13,6 @@ export * from './Tasks';
|
|||||||
|
|
||||||
export * from './Files';
|
export * from './Files';
|
||||||
export * from './ChatHistory';
|
export * from './ChatHistory';
|
||||||
export * from './CodeEditor';
|
|
||||||
export * from './Workspaces';
|
export * from './Workspaces';
|
||||||
export * from './Projects';
|
export * from './Projects';
|
||||||
export * from './Terminal';
|
export * from './Terminal';
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -288,6 +288,7 @@ const containerHome = '/home/officer';
|
|||||||
const resolveCwd = (home: string, cwd?: string) => {
|
const resolveCwd = (home: string, cwd?: string) => {
|
||||||
if (!cwd || cwd === '~') return home;
|
if (!cwd || cwd === '~') return home;
|
||||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||||
|
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
|
||||||
return home;
|
return home;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export type KeyMapping = { file: string; dir?: string };
|
export type KeyMapping = { file: string; dir?: string };
|
||||||
|
|
||||||
export type ResolveDirs = { wsDir: string; screensDir: string };
|
export type ResolveDirs = { wsDir: string; screensDir: string; projDir: string };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile } from '@@/data-path';
|
import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile, getUserProjectsDir } from '@@/data-path';
|
||||||
import type { KeyMapping, ResolveDirs } from './types';
|
import type { KeyMapping, ResolveDirs } from './types';
|
||||||
|
|
||||||
const RESERVED_DIRS = new Set(['screens']);
|
const RESERVED_DIRS = new Set(['screens']);
|
||||||
@@ -39,6 +39,35 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
|||||||
return { file: join(dir, 'host-terminals.json'), dir };
|
return { file: join(dir, 'host-terminals.json'), dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project keys — stored in {projDir}/{slug}/.officerdev/
|
||||||
|
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
|
||||||
|
if (projMetaMatch) {
|
||||||
|
const slug = projMetaMatch[1]!;
|
||||||
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||||
|
return { file: join(dir, 'meta.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projLayoutMatch = key.match(/^proj-layout-(.+)$/);
|
||||||
|
if (projLayoutMatch) {
|
||||||
|
const slug = projLayoutMatch[1]!;
|
||||||
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||||
|
return { file: join(dir, 'layout.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/);
|
||||||
|
if (projTerminalsMatch) {
|
||||||
|
const slug = projTerminalsMatch[1]!;
|
||||||
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||||
|
return { file: join(dir, 'terminals.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
|
||||||
|
if (projHostTerminalsMatch) {
|
||||||
|
const slug = projHostTerminalsMatch[1]!;
|
||||||
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||||
|
return { file: join(dir, 'host-terminals.json'), dir };
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +154,23 @@ async function readWorkspaceDir(dirPath: string, id: string, result: Record<stri
|
|||||||
if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals;
|
if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readProjectDir(projectPath: string, slug: string, result: Record<string, unknown>): Promise<unknown | null> {
|
||||||
|
const officerdevDir = join(projectPath, '.officerdev');
|
||||||
|
const meta = await readJsonFile(join(officerdevDir, 'meta.json'));
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
const layout = await readJsonFile(join(officerdevDir, 'layout.json'));
|
||||||
|
if (layout !== null) result[`proj-layout-${slug}`] = layout;
|
||||||
|
|
||||||
|
const terminals = await readJsonFile(join(officerdevDir, 'terminals.json'));
|
||||||
|
if (terminals !== null) result[`proj-terminals-${slug}`] = terminals;
|
||||||
|
|
||||||
|
const hostTerminals = await readJsonFile(join(officerdevDir, 'host-terminals.json'));
|
||||||
|
if (hostTerminals !== null) result[`proj-host-terminals-${slug}`] = hostTerminals;
|
||||||
|
|
||||||
|
return { ...(meta as object), id: slug, cwd: `/Projects/${slug}` };
|
||||||
|
}
|
||||||
|
|
||||||
async function readScreensDir(screensDir: string, result: Record<string, unknown>) {
|
async function readScreensDir(screensDir: string, result: Record<string, unknown>) {
|
||||||
let entries: import('node:fs').Dirent[] = [];
|
let entries: import('node:fs').Dirent[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -168,6 +214,23 @@ export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<
|
|||||||
await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result);
|
await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read project data — scan directories containing .officerdev/meta.json
|
||||||
|
const projects: unknown[] = [];
|
||||||
|
let projEntries: import('node:fs').Dirent[] = [];
|
||||||
|
try {
|
||||||
|
projEntries = await readdir(dirs.projDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
result['projects'] = projects;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of projEntries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const meta = await readProjectDir(join(dirs.projDir, entry.name), entry.name, result);
|
||||||
|
if (meta) projects.push(meta);
|
||||||
|
}
|
||||||
|
result['projects'] = projects;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,5 +238,6 @@ export function getDirs(email: string): ResolveDirs {
|
|||||||
return {
|
return {
|
||||||
wsDir: getUserWorkspacesDir(email),
|
wsDir: getUserWorkspacesDir(email),
|
||||||
screensDir: join(getUserWorkspacesDir(email), 'screens'),
|
screensDir: join(getUserWorkspacesDir(email), 'screens'),
|
||||||
|
projDir: getUserProjectsDir(email),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import {
|
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
|
||||||
getDirs,
|
|
||||||
migrateFromState,
|
|
||||||
migrateHomepageToScreens,
|
|
||||||
readAllWorkspacesState,
|
|
||||||
resolveKey,
|
|
||||||
writeJsonFile,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
export const workspacesRouter = createRouter();
|
export const workspacesRouter = createRouter();
|
||||||
|
|
||||||
@@ -37,6 +30,30 @@ workspacesRouter.patch('/', async (ctx) => {
|
|||||||
await mkdir(dirs.wsDir, { recursive: true });
|
await mkdir(dirs.wsDir, { recursive: true });
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(body)) {
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
// Handle proj-meta-{slug}: create/update/delete project
|
||||||
|
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
|
||||||
|
if (projMetaMatch) {
|
||||||
|
const slug = projMetaMatch[1]!;
|
||||||
|
const projectDir = join(dirs.projDir, slug);
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
await rm(projectDir, { recursive: true, force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const officerdevDir = join(projectDir, '.officerdev');
|
||||||
|
const metaFile = join(officerdevDir, 'meta.json');
|
||||||
|
const isNew = !(await Bun.file(metaFile).exists());
|
||||||
|
await mkdir(officerdevDir, { recursive: true });
|
||||||
|
await writeJsonFile(metaFile, value);
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
const proc = Bun.spawn(['git', 'init', projectDir]);
|
||||||
|
await proc.exited;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const mapping = resolveKey(dirs, key);
|
const mapping = resolveKey(dirs, key);
|
||||||
if (!mapping) continue;
|
if (!mapping) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ 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 getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects');
|
||||||
|
|
||||||
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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/workspaces';
|
import { workspacesRouter } from './api/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 { piRestRouter } from './api/pi/rest';
|
import { piRestRouter } from './api/pi/rest';
|
||||||
@@ -55,7 +54,6 @@ protectedRouter.route('/scrape', scrapeRouter);
|
|||||||
protectedRouter.route('/upload', uploadRouter);
|
protectedRouter.route('/upload', uploadRouter);
|
||||||
protectedRouter.route('/user', settingsRouter);
|
protectedRouter.route('/user', settingsRouter);
|
||||||
protectedRouter.route('/workspaces', workspacesRouter);
|
protectedRouter.route('/workspaces', 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);
|
||||||
protectedRouter.route('/', piRestRouter);
|
protectedRouter.route('/', piRestRouter);
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
|||||||
import { appRegistryMetas as chatMetas } from '../apps/Chat';
|
import { appRegistryMetas as chatMetas } from '../apps/Chat';
|
||||||
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
|
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
|
||||||
import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces';
|
import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces';
|
||||||
|
import { appRegistryMetas as projectMetas } from '../apps/Projects';
|
||||||
|
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||||
import { useAppRegistry } from './useAppRegistry';
|
import { useAppRegistry } from './useAppRegistry';
|
||||||
|
|
||||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas];
|
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas];
|
||||||
|
|
||||||
export const AppRegistry = () => {
|
export const AppRegistry = () => {
|
||||||
useAppRegistry(apps);
|
useAppRegistry(apps);
|
||||||
|
|||||||
+5
-8
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router';
|
|||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChatSessions } from 'state/useChatSessions';
|
import { useChatSessions } from 'state/useChatSessions';
|
||||||
import { usePiChat, EmbeddableChat } from 'officerdev';
|
import { usePiChat, EmbeddableChat } from '../Chat';
|
||||||
|
|
||||||
export type SelectedSession = {
|
export type SelectedSession = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,8 +68,6 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
|||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||||
|
|
||||||
// We need connection status for the DetailBar, so we still call usePi here
|
|
||||||
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
|
|
||||||
const chat = usePiChat(sessionId, model, { replaceUrl: false });
|
const chat = usePiChat(sessionId, model, { replaceUrl: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,10 +82,10 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
|||||||
window.history.replaceState(null, '', '/chat');
|
window.history.replaceState(null, '', '/chat');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
initialModel={model ?? undefined}
|
initialModel={model ?? undefined}
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -98,7 +96,6 @@ function NewChat() {
|
|||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
// We need connection status for DetailBar, so call usePi
|
|
||||||
const chat = usePiChat();
|
const chat = usePiChat();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,7 +143,7 @@ function NewChatPanel() {
|
|||||||
return <NewChat key="new" />;
|
return <NewChat key="new" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatDetailPanel() {
|
export const ChatDetailPanel = () => {
|
||||||
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -162,4 +159,4 @@ export function ChatDetailPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
|
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||||
}
|
};
|
||||||
+1
-1
@@ -22,7 +22,7 @@ export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
|
|||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Name is required');
|
setError('Name is required');
|
||||||
return;
|
return;
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { useChatGroups } from 'state/useChatGroups';
|
import { useChatGroups } from 'state/useChatGroups';
|
||||||
import type { GroupEntry } from 'officerdev';
|
import type { GroupEntry } from '../Chat';
|
||||||
|
|
||||||
type GroupContextMenuProps = {
|
type GroupContextMenuProps = {
|
||||||
group: GroupEntry;
|
group: GroupEntry;
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { useChatGroups } from 'state/useChatGroups';
|
import { useChatGroups } from 'state/useChatGroups';
|
||||||
import { useChatSessions } from 'state/useChatSessions';
|
import { useChatSessions } from 'state/useChatSessions';
|
||||||
import type { SessionEntry } from 'officerdev';
|
import type { SessionEntry } from '../Chat';
|
||||||
|
|
||||||
type SessionContextMenuProps = {
|
type SessionContextMenuProps = {
|
||||||
session: SessionEntry;
|
session: SessionEntry;
|
||||||
+1
-1
@@ -14,7 +14,7 @@ export const SessionList = () => {
|
|||||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||||
|
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
const selectedRef = useCallback(
|
const selectedRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
@@ -1 +1,26 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { MessageSquare, List } from 'lucide-react';
|
||||||
|
import { SessionList } from './SessionList';
|
||||||
|
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||||
|
|
||||||
export { SessionBar } from './SessionBar';
|
export { SessionBar } from './SessionBar';
|
||||||
|
export { SessionList };
|
||||||
|
export { ChatDetailPanel };
|
||||||
|
export type { SelectedSession } from './ChatDetailPanel';
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{
|
||||||
|
key: 'chat-session-list',
|
||||||
|
name: 'Sessions',
|
||||||
|
icon: List,
|
||||||
|
component: SessionList,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chat-detail',
|
||||||
|
name: 'Chat',
|
||||||
|
icon: MessageSquare,
|
||||||
|
component: ChatDetailPanel,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { useFileBrowserApp } from './useFileBrowserApp';
|
|||||||
|
|
||||||
type FileBrowserAppProps = {
|
type FileBrowserAppProps = {
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
rootOverride?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
|
export const FileBrowserApp = ({ basePath = '/', rootOverride }: FileBrowserAppProps) => {
|
||||||
const fileBrowserManager = useFileBrowserApp(basePath);
|
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride);
|
||||||
const { handleNavigate } = fileBrowserManager;
|
const { handleNavigate } = fileBrowserManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-1
@@ -5,5 +5,6 @@ const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
|||||||
|
|
||||||
export const FileBrowserPanelWrapper = () => {
|
export const FileBrowserPanelWrapper = () => {
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return <FileBrowserApp basePath={cwdToPath(cwd)} />;
|
const basePath = cwdToPath(cwd);
|
||||||
|
return <FileBrowserApp basePath={basePath} rootOverride={basePath !== '/' ? 'home' : undefined} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import { useTasks, type TaskSummary } from '../useTasks';
|
|||||||
import { useUserState } from 'state/useUserState';
|
import { useUserState } from 'state/useUserState';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
|
||||||
export const useFileBrowserApp = (basePath: string) => {
|
export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||||
const [currentPath, setCurrentPath] = useUserState<string>('files/currentPath', '/');
|
const [globalPath, setGlobalPath] = useUserState<string>('files/currentPath', '/');
|
||||||
|
const [localPath, setLocalPath] = useState(basePath);
|
||||||
|
const scoped = basePath !== '/';
|
||||||
|
const currentPath = scoped ? localPath : globalPath;
|
||||||
|
const setCurrentPath = scoped ? setLocalPath : setGlobalPath;
|
||||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
||||||
@@ -37,7 +41,7 @@ export const useFileBrowserApp = (basePath: string) => {
|
|||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewPath = searchParams.get('view');
|
const viewPath = searchParams.get('view');
|
||||||
const files = useFilesAPI(homeRoot);
|
const files = useFilesAPI(rootOverride ?? homeRoot);
|
||||||
const filesRef = useRef(files);
|
const filesRef = useRef(files);
|
||||||
filesRef.current = files;
|
filesRef.current = files;
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
|
|||||||
+15
-9
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { generateSlug } from 'helpers/slug';
|
import { generateSlug } from 'helpers/slug';
|
||||||
import { useProjectsState } from 'state/useProjectsState';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
|
import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -40,7 +40,7 @@ export const ProjectListApp = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
@@ -83,15 +83,21 @@ export const ProjectListApp = () => {
|
|||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
setProjects((prev) => prev.filter((p) => p.id !== deleting.id));
|
// Optimistic: remove from cache immediately
|
||||||
if (selected === deleting.id) setSelected(null);
|
const current = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||||
|
const optimistic = { ...current };
|
||||||
|
optimistic['projects'] = (optimistic['projects'] as ProjectDefinition[]).filter((p) => p.id !== deleting.id);
|
||||||
|
delete optimistic[`proj-layout-${deleting.id}`];
|
||||||
|
delete optimistic[`proj-terminals-${deleting.id}`];
|
||||||
|
delete optimistic[`proj-host-terminals-${deleting.id}`];
|
||||||
|
queryClient.setQueryData(['WORKSPACES_STATE'], optimistic);
|
||||||
|
|
||||||
const layoutKey = `proj-layout-${deleting.id}`;
|
if (selected === deleting.id) setSelected(null);
|
||||||
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);
|
setDeleting(null);
|
||||||
|
client
|
||||||
|
.patch('/workspaces', { [`proj-meta-${deleting.id}`]: null })
|
||||||
|
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
|
||||||
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (p: ProjectDefinition) => {
|
const handleClick = (p: ProjectDefinition) => {
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } 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 { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
|
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 { useAppRegistry } from '../../AppRegistry';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// --- 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 { value: projects } = useWorkspacesState<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 = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const desc = description.trim();
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
name: trimmed,
|
||||||
|
description: desc || undefined,
|
||||||
|
projectType,
|
||||||
|
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||||
|
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||||
|
templateIdx,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
client
|
||||||
|
.patch('/workspaces', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
|
||||||
|
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
setEditingId(null);
|
||||||
|
setSelected(editingId);
|
||||||
|
} else {
|
||||||
|
const existingIds = new Set(projects.map((p) => p.id));
|
||||||
|
let id = slugify(trimmed) || generateSlug();
|
||||||
|
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||||
|
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
|
||||||
|
client
|
||||||
|
.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: previewLayout })
|
||||||
|
.then((res) => {
|
||||||
|
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||||
|
navigate(`/projects/${id}`);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) ---
|
||||||
|
|
||||||
|
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 <WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 NewProjectForm = () => {
|
||||||
|
const { registry } = useAppRegistry();
|
||||||
|
const newProjRegistry = {
|
||||||
|
...registry,
|
||||||
|
'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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return <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 ws = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||||
|
return <WorkspaceView workspace={ws} cwd={project.cwd} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectPreview = () => {
|
||||||
|
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
const { value: projects } = useWorkspacesState<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { FolderKanban } from 'lucide-react';
|
||||||
|
import { ProjectListApp } from './ProjectListApp';
|
||||||
|
import { ProjectPreview } from './ProjectPreview';
|
||||||
|
|
||||||
|
export { ProjectListApp };
|
||||||
|
export { ProjectPreview };
|
||||||
|
export {
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{
|
||||||
|
key: 'project-list',
|
||||||
|
name: 'Projects',
|
||||||
|
icon: FolderKanban,
|
||||||
|
component: ProjectListApp,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project-preview',
|
||||||
|
name: 'Project Preview',
|
||||||
|
icon: FolderKanban,
|
||||||
|
component: ProjectPreview,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -37,7 +37,8 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: Comman
|
|||||||
|
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
|
|
||||||
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
|
const cwdPath = cwd && cwd !== '~' ? `~/${cwd.replace(/^\//, '')}` : null;
|
||||||
|
const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command;
|
||||||
|
|
||||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { mode, toggle } = useTerminalMode(panelId);
|
const { mode, toggle } = useTerminalMode(panelId);
|
||||||
const isHost = mode === 'host';
|
const isHost = mode === 'host';
|
||||||
const Icon = isHost ? Monitor : TerminalSquare;
|
const scoped = cwd !== '~';
|
||||||
|
const Icon = isHost && !scoped ? Monitor : TerminalSquare;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||||
{user?.role === 'Super Admin' && (
|
{user?.role === 'Super Admin' && !scoped && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const EMPTY_TERMINALS: Record<string, string> = {};
|
|||||||
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||||
const { workspaceId, cwd } = useWorkspace();
|
const { workspaceId, cwd } = useWorkspace();
|
||||||
const { mode } = useTerminalMode(panelId);
|
const { mode } = useTerminalMode(panelId);
|
||||||
const sandboxed = mode === 'sandboxed';
|
const scoped = cwd !== '~';
|
||||||
|
const sandboxed = scoped || mode === 'sandboxed';
|
||||||
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
|
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
|
||||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||||
const setTerminalsRef = useRef(setTerminals);
|
const setTerminalsRef = useRef(setTerminals);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export * from './AppRegistry';
|
|||||||
export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat';
|
export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat';
|
||||||
export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
|
export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
|
||||||
export * from './apps/Chat/types';
|
export * from './apps/Chat/types';
|
||||||
export { SessionBar } from './apps/ChatHistory';
|
export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
||||||
|
export type { SelectedSession } from './apps/ChatHistory';
|
||||||
export { CodeEditorView } from './apps/CodeEditor';
|
export { CodeEditorView } from './apps/CodeEditor';
|
||||||
export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser';
|
export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser';
|
||||||
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
|
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
|
||||||
@@ -14,3 +15,4 @@ export type { FileType } from './apps/FileViewer';
|
|||||||
export { TerminalView } from './apps/Terminal';
|
export { TerminalView } from './apps/Terminal';
|
||||||
export type { TerminalViewProps } from './apps/Terminal';
|
export type { TerminalViewProps } from './apps/Terminal';
|
||||||
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
|
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
|
||||||
|
export { ProjectListApp, ProjectPreview, 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 './apps/Projects';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
|
|||||||
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
|
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
|
||||||
export { useUserState } from './useUserState';
|
export { useUserState } from './useUserState';
|
||||||
export { useWorkspacesState } from './useWorkspacesState';
|
export { useWorkspacesState } from './useWorkspacesState';
|
||||||
export { useProjectsState } from './useProjectsState';
|
|
||||||
export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
|
export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
|
||||||
export type { ModelOption } from './useModels';
|
export type { ModelOption } from './useModels';
|
||||||
export { useRecentModels } from './useRecentModels';
|
export { useRecentModels } from './useRecentModels';
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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 './useSettings';
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user