ready for app-registry refactor
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { ChatLauncher as ChatLauncherComponent } from 'apps/Chat';
|
||||
|
||||
export const ChatLauncher = () => {
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettings();
|
||||
const piModels = useVisiblePiModels();
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(settings.chat.defaultModel);
|
||||
}, [settings.chat.defaultModel]);
|
||||
|
||||
const handleSubmit = (data: {
|
||||
prompt: string;
|
||||
model: string | null;
|
||||
attachmentIds?: string[];
|
||||
images?: { filename: string; dataUrl: string }[];
|
||||
}) => {
|
||||
navigate('/chat/new', {
|
||||
state: {
|
||||
initialMessage: data.prompt,
|
||||
model: data.model,
|
||||
attachmentIds: data.attachmentIds,
|
||||
images: data.images,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget title="Start Chat">
|
||||
<ChatLauncherComponent
|
||||
availableModels={piModels}
|
||||
selectedModel={model}
|
||||
onModelChange={setModel}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const HomeScreen = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={workspace} registry={appRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'home-left', appType: 'workspace-list' }, size: 20 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-center',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 60,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
import { useState } from 'react';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
const initialLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'home-left', appType: 'workspace-list' }, size: 20 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-center',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 60,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
export const HomeScreen = () => {
|
||||
const [layout, setLayout] = useState<LayoutNode>(initialLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView layout={layout} onLayoutChange={setLayout} registry={appRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export * from './HomeScreen';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const TerminalScreen = () => {
|
||||
const { user } = useAuth();
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/terminal', defaultLayout);
|
||||
|
||||
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={workspace} registry={appRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'terminal-screen',
|
||||
appType: 'terminal-host',
|
||||
};
|
||||
@@ -1,30 +1 @@
|
||||
import { useState } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
|
||||
export const TerminalScreen = () => {
|
||||
const { user } = useAuth();
|
||||
const [layout, setLayout] = useState<LayoutNode>(defaultLayout);
|
||||
|
||||
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={null}
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={appRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'terminal-screen',
|
||||
appType: 'terminal-host'
|
||||
};
|
||||
export * from './TerminalScreen';
|
||||
|
||||
@@ -32,7 +32,7 @@ export const WorkspaceListApp = () => {
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [workspaces, setWorkspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||
@@ -77,7 +77,7 @@ export const WorkspaceListApp = () => {
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const { [layoutKey]: _, ...rest } = currentState;
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
|
||||
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
|
||||
client.patch('/workspaces', { [layoutKey]: null }).catch(() => {});
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ const CreatePanel = () => {
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||
const [filePath] = useUserState<string>('files/currentPath', '/');
|
||||
const [workspaces, setWorkspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
|
||||
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
|
||||
|
||||
@@ -325,10 +325,10 @@ const CreatePanel = () => {
|
||||
// Delete old files, write new layout
|
||||
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
|
||||
for (const k of oldKeys) patch[k] = null;
|
||||
client.patch('/user/workspaces-state', patch).catch(() => {});
|
||||
client.patch('/workspaces', patch).catch(() => {});
|
||||
} else {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
|
||||
client.patch('/user/workspaces-state', { [newLayoutKey]: wsLayout }).catch(() => {});
|
||||
client.patch('/workspaces', { [newLayoutKey]: wsLayout }).catch(() => {});
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
@@ -351,7 +351,7 @@ const CreatePanel = () => {
|
||||
setWorkspaces((prev) => [...prev, ws]);
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/user/workspaces-state', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
client.patch('/workspaces', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
@@ -478,14 +478,14 @@ const WorkspacePreviewEmpty = () => {
|
||||
};
|
||||
|
||||
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const [layout, setLayout] = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
|
||||
return <WorkspaceView workspace={ws} registry={appRegistry} cwd={workspace.cwd} />;
|
||||
};
|
||||
|
||||
const WorkspacePreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [workspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const { value: workspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
|
||||
|
||||
if (!workspace) return <WorkspacePreviewEmpty />;
|
||||
@@ -506,13 +506,11 @@ const WorkspacePreview = () => {
|
||||
const registry = { ...appRegistry, 'workspace-preview': { name: 'Workspace Preview', icon: LayoutGrid, component: WorkspacePreview } };
|
||||
|
||||
export const WorkspaceListScreen = () => {
|
||||
const [layout, setLayout, isLoaded] = useWorkspacesState<LayoutNode>('ws-layout-ws-homepage', defaultLayout);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/homepage', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={registry} />
|
||||
<WorkspaceLayout layout={workspace.value} onLayoutChange={workspace.setValue} registry={registry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { appRegistry } from './app-registry';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [workspaces, , isLoaded] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const { value: workspaces, isLoaded } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = workspaces.find((ws) => ws.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
@@ -16,16 +16,11 @@ export const WorkspaceScreen = () => {
|
||||
};
|
||||
|
||||
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const [layout, setLayout] = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={appRegistry}
|
||||
/>
|
||||
<WorkspaceView workspace={ws} registry={appRegistry} cwd={workspace.cwd} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,22 +9,30 @@ import { TerminalView } from 'apps/Terminal';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||
import { usePi } from 'apps/Chat';
|
||||
import { ChatPanel } from 'apps/Chat';
|
||||
import { EmbeddableChat, ChatLauncher, usePi } from 'apps/Chat';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||
import { Files } from '../Files';
|
||||
import { Catalog } from 'sounds';
|
||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||
import { ProjectListApp } from '../Projects/ProjectListApp';
|
||||
import { ChatLauncher } from '../Home/ChatLauncher';
|
||||
import { widgetRegistry } from 'widgets/widget-registry';
|
||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||
|
||||
const ChatWidget = () => {
|
||||
const ChatWidget = () => <EmbeddableChat className="h-full" />;
|
||||
|
||||
const ChatLauncherWidget = () => {
|
||||
const pi = usePi();
|
||||
const models = useVisiblePiModels();
|
||||
return <ChatPanel chat={pi} availableModels={models} />;
|
||||
console.log(models)
|
||||
return (
|
||||
<ChatLauncher
|
||||
availableModels={models}
|
||||
selectedModel={pi.selectedModel}
|
||||
onModelChange={pi.setSelectedModel}
|
||||
onSubmit={({ prompt, model, attachmentIds, images }) => pi.sendPrompt(prompt, attachmentIds, images)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
||||
@@ -75,7 +83,7 @@ const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
|
||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
@@ -105,7 +113,7 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { user } = useAuth();
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
|
||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
@@ -142,7 +150,7 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const CommandTerminalWrapper = ({ panelId, command, statePrefix }: { panelId: string; command: string; statePrefix: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
|
||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
@@ -239,7 +247,7 @@ export const appRegistry: AppRegistry = {
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp />, availableOnPanel: false },
|
||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp />, availableOnPanel: false },
|
||||
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider, availableOnPanel: false },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180, availableOnPanel: false },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: ChatLauncherWidget, fixedHeight: 180, availableOnPanel: false },
|
||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||
...widgetRegistry,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
@@ -6,7 +6,7 @@ import type { UserState } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['WORKSPACES_STATE'];
|
||||
|
||||
export function useWorkspacesState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||
export function useWorkspacesState<T>(key: string, defaultValue: T) {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,10 +16,23 @@ export function useWorkspacesState<T>(key: string, defaultValue: T): [T, (value:
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/user/workspaces-state'),
|
||||
queryFn: () => client.get<UserState>('/workspaces'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Seed default to backend when key is missing after initial fetch
|
||||
const seededKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isSuccess || seededKeyRef.current === key) return;
|
||||
seededKeyRef.current = key;
|
||||
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
if (!(key in currentState)) {
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
|
||||
clientRef.current.patch('/workspaces', { [key]: defaultValue }).catch(() => { });
|
||||
}
|
||||
}, [isSuccess, key, defaultValue, queryClient]);
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
@@ -30,10 +43,10 @@ export function useWorkspacesState<T>(key: string, defaultValue: T): [T, (value:
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
clientRef.current.patch('/user/workspaces-state', { [key]: newValue }).catch(() => {});
|
||||
clientRef.current.patch('/workspaces', { [key]: newValue }).catch(() => { });
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue, isSuccess];
|
||||
return { key, value, setValue, isLoaded: isSuccess };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './workspaces';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,3 @@
|
||||
export type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
export type ResolveDirs = { wsDir: string; screensDir: string };
|
||||
@@ -1,58 +1,58 @@
|
||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile } from '@@/data-path';
|
||||
import type { KeyMapping, ResolveDirs } from './types';
|
||||
|
||||
export const workspacesRouter = createRouter();
|
||||
const RESERVED_DIRS = new Set(['screens']);
|
||||
|
||||
type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
type ResolveDirs = { wsDir: string; homepageDir: string };
|
||||
|
||||
function workspaceDir(dirs: ResolveDirs, id: string) {
|
||||
return id === 'ws-homepage' ? dirs.homepageDir : join(dirs.wsDir, id);
|
||||
}
|
||||
|
||||
function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
||||
export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
||||
if (key === 'workspaces') return { file: join(dirs.wsDir, 'index.json') };
|
||||
if (key === 'ws-terminals-default') return { file: join(dirs.wsDir, 'default-terminals.json') };
|
||||
if (key === 'ws-host-terminals-default') return { file: join(dirs.wsDir, 'default-host-terminals.json') };
|
||||
|
||||
// screens/{name} → screens/{name}/layout.json
|
||||
const screensMatch = key.match(/^screens\/(.+)$/);
|
||||
if (screensMatch) {
|
||||
const name = screensMatch[1]!;
|
||||
const dir = join(dirs.screensDir, name);
|
||||
return { file: join(dir, 'layout.json'), dir };
|
||||
}
|
||||
|
||||
const layoutMatch = key.match(/^ws-layout-(.+)$/);
|
||||
if (layoutMatch) {
|
||||
const id = layoutMatch[1]!;
|
||||
const dir = workspaceDir(dirs, id);
|
||||
const dir = join(dirs.wsDir, id);
|
||||
return { file: join(dir, 'layout.json'), dir };
|
||||
}
|
||||
|
||||
const terminalsMatch = key.match(/^ws-terminals-(.+)$/);
|
||||
if (terminalsMatch) {
|
||||
const id = terminalsMatch[1]!;
|
||||
const dir = workspaceDir(dirs, id);
|
||||
const dir = join(dirs.wsDir, id);
|
||||
return { file: join(dir, 'terminals.json'), dir };
|
||||
}
|
||||
|
||||
const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
|
||||
if (hostTerminalsMatch) {
|
||||
const id = hostTerminalsMatch[1]!;
|
||||
const dir = workspaceDir(dirs, id);
|
||||
const dir = join(dirs.wsDir, id);
|
||||
return { file: join(dir, 'host-terminals.json'), dir };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJsonFile(path: string): Promise<unknown | null> {
|
||||
export 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) {
|
||||
export async function writeJsonFile(path: string, data: unknown) {
|
||||
await Bun.write(path, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
const stateFile = getUserStateFile(email);
|
||||
const file = Bun.file(stateFile);
|
||||
if (!(await file.exists())) return;
|
||||
@@ -66,7 +66,7 @@ async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
await mkdir(dirs.wsDir, { recursive: true });
|
||||
|
||||
for (const key of wsKeys) {
|
||||
const migratedKey = key === 'ws-layout-workspaces' ? 'ws-layout-ws-homepage' : key;
|
||||
const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key;
|
||||
const mapping = resolveKey(dirs, migratedKey);
|
||||
if (!mapping) continue;
|
||||
if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
|
||||
@@ -78,26 +78,40 @@ async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
await Bun.write(stateFile, JSON.stringify(cleaned, null, 2));
|
||||
}
|
||||
|
||||
async function migrateListLayout(dirs: ResolveDirs) {
|
||||
const oldFile = join(dirs.wsDir, 'list-layout.json');
|
||||
if (!(await Bun.file(oldFile).exists())) return;
|
||||
await mkdir(dirs.homepageDir, { recursive: true });
|
||||
await rename(oldFile, join(dirs.homepageDir, 'layout.json'));
|
||||
}
|
||||
|
||||
async function migrateHomepageFromWorkspaces(dirs: ResolveDirs) {
|
||||
const oldDir = join(dirs.wsDir, 'ws-homepage');
|
||||
const layoutFile = join(oldDir, 'layout.json');
|
||||
export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) {
|
||||
// Migrate from old ws-homepage/ dir to workspaces/screens/homepage/
|
||||
const oldHomepageDir = getUserHomepageWorkspaceDir(email);
|
||||
const layoutFile = join(oldHomepageDir, 'layout.json');
|
||||
if (!(await Bun.file(layoutFile).exists())) return;
|
||||
await mkdir(dirs.homepageDir, { recursive: true });
|
||||
|
||||
const targetDir = join(dirs.screensDir, 'homepage');
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) {
|
||||
const src = join(oldDir, name);
|
||||
const src = join(oldHomepageDir, name);
|
||||
if (await Bun.file(src).exists()) {
|
||||
await rename(src, join(dirs.homepageDir, name));
|
||||
await rename(src, join(targetDir, name));
|
||||
}
|
||||
}
|
||||
const remaining = await readdir(oldDir);
|
||||
if (remaining.length === 0) await rm(oldDir, { recursive: true, force: true });
|
||||
|
||||
const remaining = await readdir(oldHomepageDir);
|
||||
if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true });
|
||||
|
||||
// Also migrate from workspaces/ws-homepage/ if it exists
|
||||
const oldWsHomepageDir = join(dirs.wsDir, 'ws-homepage');
|
||||
const oldWsLayout = join(oldWsHomepageDir, 'layout.json');
|
||||
if (!(await Bun.file(oldWsLayout).exists())) return;
|
||||
|
||||
for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) {
|
||||
const src = join(oldWsHomepageDir, name);
|
||||
const target = join(targetDir, name);
|
||||
if (await Bun.file(src).exists() && !(await Bun.file(target).exists())) {
|
||||
await rename(src, target);
|
||||
}
|
||||
}
|
||||
|
||||
const wsRemaining = await readdir(oldWsHomepageDir);
|
||||
if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function readWorkspaceDir(dirPath: string, id: string, result: Record<string, unknown>) {
|
||||
@@ -111,7 +125,22 @@ async function readWorkspaceDir(dirPath: string, id: string, result: Record<stri
|
||||
if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals;
|
||||
}
|
||||
|
||||
async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
async function readScreensDir(screensDir: string, result: Record<string, unknown>) {
|
||||
let entries: import('node:fs').Dirent[] = [];
|
||||
try {
|
||||
entries = await readdir(screensDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const layout = await readJsonFile(join(screensDir, entry.name, 'layout.json'));
|
||||
if (layout !== null) result[`screens/${entry.name}`] = layout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const indexData = await readJsonFile(join(dirs.wsDir, 'index.json'));
|
||||
@@ -123,8 +152,8 @@ async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string,
|
||||
const defaultHostTerminals = await readJsonFile(join(dirs.wsDir, 'default-host-terminals.json'));
|
||||
if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals;
|
||||
|
||||
// Read ws-homepage from its own dir
|
||||
await readWorkspaceDir(dirs.homepageDir, 'ws-homepage', result);
|
||||
// Read screens
|
||||
await readScreensDir(dirs.screensDir, result);
|
||||
|
||||
// Read per-workspace subdirs
|
||||
let entries: import('node:fs').Dirent[] = [];
|
||||
@@ -135,63 +164,16 @@ async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string,
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue;
|
||||
await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getDirs(email: string): ResolveDirs {
|
||||
return { wsDir: getUserWorkspacesDir(email), homepageDir: getUserHomepageWorkspaceDir(email) };
|
||||
export function getDirs(email: string): ResolveDirs {
|
||||
return {
|
||||
wsDir: getUserWorkspacesDir(email),
|
||||
screensDir: join(getUserWorkspacesDir(email), 'screens'),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /workspaces-state
|
||||
workspacesRouter.get('/workspaces-state', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dirs = getDirs(email);
|
||||
|
||||
const dirFile = Bun.file(join(dirs.wsDir, 'index.json'));
|
||||
if (!(await dirFile.exists())) {
|
||||
await migrateFromState(email, dirs);
|
||||
}
|
||||
|
||||
await migrateListLayout(dirs);
|
||||
await migrateHomepageFromWorkspaces(dirs);
|
||||
|
||||
const state = await readAllWorkspacesState(dirs);
|
||||
return ctx.json(state);
|
||||
});
|
||||
|
||||
// PATCH /workspaces-state
|
||||
workspacesRouter.patch('/workspaces-state', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const dirs = getDirs(email);
|
||||
|
||||
await mkdir(dirs.wsDir, { recursive: true });
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const mapping = resolveKey(dirs, 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 readAllWorkspacesState(dirs);
|
||||
return ctx.json(state);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import {
|
||||
getDirs,
|
||||
migrateFromState,
|
||||
migrateHomepageToScreens,
|
||||
readAllWorkspacesState,
|
||||
resolveKey,
|
||||
writeJsonFile,
|
||||
} from './utils';
|
||||
|
||||
export const workspacesRouter = createRouter();
|
||||
|
||||
// GET /workspaces
|
||||
workspacesRouter.get('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dirs = getDirs(email);
|
||||
|
||||
const dirFile = Bun.file(join(dirs.wsDir, 'index.json'));
|
||||
if (!(await dirFile.exists())) {
|
||||
await migrateFromState(email, dirs);
|
||||
}
|
||||
|
||||
await migrateHomepageToScreens(dirs, email);
|
||||
|
||||
const state = await readAllWorkspacesState(dirs);
|
||||
return ctx.json(state);
|
||||
});
|
||||
|
||||
// PATCH /workspaces
|
||||
workspacesRouter.patch('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const dirs = getDirs(email);
|
||||
|
||||
await mkdir(dirs.wsDir, { recursive: true });
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const mapping = resolveKey(dirs, 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 readAllWorkspacesState(dirs);
|
||||
return ctx.json(state);
|
||||
});
|
||||
@@ -26,9 +26,13 @@ export const getArchivedSessionDir = (email: string, sessionId: string) =>
|
||||
|
||||
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||
|
||||
export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'settings.json');
|
||||
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');
|
||||
|
||||
export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state.json');
|
||||
export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'settings', 'settings.json');
|
||||
|
||||
export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state');
|
||||
|
||||
export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json');
|
||||
|
||||
export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces');
|
||||
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import { sessionsRouter } from './api/sessions/sessions';
|
||||
import { scrapeRouter } from './api/scrape/scrape';
|
||||
import { uploadRouter } from './api/upload/upload';
|
||||
import { settingsRouter } from './api/settings/settings';
|
||||
import { workspacesRouter } from './api/settings/workspaces';
|
||||
import { workspacesRouter } from './api/workspaces';
|
||||
import { projectsRouter } from './api/settings/projects';
|
||||
import { taskLogsRouter } from './api/task-logs/task-logs';
|
||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
@@ -54,7 +54,7 @@ protectedRouter.route('/', sessionsRouter);
|
||||
protectedRouter.route('/scrape', scrapeRouter);
|
||||
protectedRouter.route('/upload', uploadRouter);
|
||||
protectedRouter.route('/user', settingsRouter);
|
||||
protectedRouter.route('/user', workspacesRouter);
|
||||
protectedRouter.route('/workspaces', workspacesRouter);
|
||||
protectedRouter.route('/user', projectsRouter);
|
||||
protectedRouter.route('/task-logs', taskLogsRouter);
|
||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||
import type { LayoutNode, AppRegistry, WorkspaceState } from './types';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
workspace: WorkspaceDefinition | null;
|
||||
layout: LayoutNode;
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
workspace: WorkspaceState;
|
||||
registry: AppRegistry;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewProps) => {
|
||||
if (!workspace.isLoaded) return null;
|
||||
|
||||
const { value: layout, setValue: onLayoutChange } = workspace;
|
||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
|
||||
@@ -102,8 +104,8 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
|
||||
return (
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceId: workspace?.id ?? null,
|
||||
cwd: workspace?.cwd ?? '~',
|
||||
workspaceId: workspace.key,
|
||||
cwd,
|
||||
swapSourceId,
|
||||
setSwapSourceId,
|
||||
onSwap: handleSwap,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
|
||||
@@ -24,6 +24,13 @@ export type WorkspaceDefinition = {
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
key: string;
|
||||
value: LayoutNode;
|
||||
setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
export type ProjectType = 'landing-page' | 'website' | 'app';
|
||||
|
||||
export type ProjectDefinition = {
|
||||
|
||||
Reference in New Issue
Block a user