ready for app-registry refactor

This commit is contained in:
2026-02-21 17:28:10 +00:00
parent 6d97edb6ab
commit fe7874dd65
21 changed files with 281 additions and 246 deletions
@@ -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 };
}
+3
View File
@@ -0,0 +1,3 @@
export * from './workspaces';
export * from './types';
export * from './utils';
+3
View File
@@ -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);
});
+62
View File
@@ -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);
});
+6 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 = {