diff --git a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx deleted file mode 100644 index bd7c9291..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx +++ /dev/null @@ -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(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 ( - - - - ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx new file mode 100644 index 00000000..5cf67f49 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx @@ -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('screens/home', defaultLayout); + + return ( +
+ +
+ ); +}; + diff --git a/src/apps/officer-web/Screens/Dashboard/Home/defaultLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Home/defaultLayout.tsx new file mode 100644 index 00000000..d22bf202 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Home/defaultLayout.tsx @@ -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 }, + ], +}; + diff --git a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx index e29e2480..31bc3cd6 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx @@ -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(initialLayout); - - return ( -
- -
- ); -}; +export * from './HomeScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx new file mode 100644 index 00000000..46d6e377 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx @@ -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('screens/terminal', defaultLayout); + + if (user?.role !== 'Super Admin') return ; + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Terminal/defaultLayout.ts new file mode 100644 index 00000000..2d4af981 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Terminal/defaultLayout.ts @@ -0,0 +1,7 @@ +import type { LayoutNode } from '@/components/Workspace'; + +export const defaultLayout: LayoutNode = { + type: 'panel', + id: 'terminal-screen', + appType: 'terminal-host', +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx index 8b4d49e9..f9000cb0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx @@ -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(defaultLayout); - - if (user?.role !== 'Super Admin') return ; - - return ( -
- -
- ); -}; - -const defaultLayout: LayoutNode = { - type: 'panel', - id: 'terminal-screen', - appType: 'terminal-host' -}; +export * from './TerminalScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx index b3667af7..be8ebbfa 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx @@ -32,7 +32,7 @@ export const WorkspaceListApp = () => { const navigate = useNavigate(); const client = useClient(); const queryClient = useQueryClient(); - const [workspaces, setWorkspaces] = useWorkspacesState('workspaces', []); + const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState('workspaces', []); const [selected, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); const [, setCreating] = useGlobal(CREATING_WORKSPACE_KEY, false); const [, setEditing] = useGlobal(EDITING_WORKSPACE_KEY, null); @@ -77,7 +77,7 @@ export const WorkspaceListApp = () => { const currentState = queryClient.getQueryData>(['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); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx index 5d764591..2c4bf286 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx @@ -279,7 +279,7 @@ const CreatePanel = () => { const client = useClient(); const [name, setName] = useGlobal(NEW_WS_NAME_KEY, ''); const [filePath] = useUserState('files/currentPath', '/'); - const [workspaces, setWorkspaces] = useWorkspacesState('workspaces', []); + const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState('workspaces', []); const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`; @@ -325,10 +325,10 @@ const CreatePanel = () => { // Delete old files, write new layout const patch: Record = { [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>(['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(`ws-layout-${workspace.id}`, createDefaultLayout()); + const ws = useWorkspacesState(`ws-layout-${workspace.id}`, createDefaultLayout()); - return ; + return ; }; const WorkspacePreview = () => { const [selectedId] = useGlobal(SELECTED_WORKSPACE_KEY, null); - const [workspaces] = useWorkspacesState('workspaces', []); + const { value: workspaces } = useWorkspacesState('workspaces', []); const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null; if (!workspace) return ; @@ -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('ws-layout-ws-homepage', defaultLayout); - - if (!isLoaded) return null; + const workspace = useWorkspacesState('screens/homepage', defaultLayout); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx index 196f4d33..8c4bc729 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx @@ -6,7 +6,7 @@ import { appRegistry } from './app-registry'; export const WorkspaceScreen = () => { const { id } = useParams<{ id: string }>(); - const [workspaces, , isLoaded] = useWorkspacesState('workspaces', []); + const { value: workspaces, isLoaded } = useWorkspacesState('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(`ws-layout-${workspace.id}`, createDefaultLayout()); + const ws = useWorkspacesState(`ws-layout-${workspace.id}`, createDefaultLayout()); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx index ad04d4f8..20167b7f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx @@ -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 = () => ; + +const ChatLauncherWidget = () => { const pi = usePi(); const models = useVisiblePiModels(); - return ; + console.log(models) + return ( + pi.sendPrompt(prompt, attachmentIds, images)} + /> + ); }; const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1)); @@ -75,7 +83,7 @@ const EMPTY_TERMINALS: Record = {}; const TerminalWrapper = ({ panelId }: { panelId: string }) => { const { workspaceId, cwd } = useWorkspace(); const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default'; - const [terminals, setTerminals] = useWorkspacesState>(stateKey, EMPTY_TERMINALS); + const { value: terminals, setValue: setTerminals } = useWorkspacesState>(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>(stateKey, EMPTY_TERMINALS); + const { value: terminals, setValue: setTerminals } = useWorkspacesState>(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>(stateKey, EMPTY_TERMINALS); + const { value: terminals, setValue: setTerminals } = useWorkspacesState>(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: () => , availableOnPanel: false }, 'project-list': { name: 'Projects', icon: FolderKanban, component: () => , 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: () => , 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, }; diff --git a/src/apps/officer-web/state/useWorkspacesState.ts b/src/apps/officer-web/state/useWorkspacesState.ts index f053a69f..e777a560 100644 --- a/src/apps/officer-web/state/useWorkspacesState.ts +++ b/src/apps/officer-web/state/useWorkspacesState.ts @@ -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(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] { +export function useWorkspacesState(key: string, defaultValue: T) { const client = useClient(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); @@ -16,10 +16,23 @@ export function useWorkspacesState(key: string, defaultValue: T): [T, (value: const { data: state = {}, isSuccess } = useQuery({ queryKey: QUERY_KEY, enabled: isAuthenticated, - queryFn: () => client.get('/user/workspaces-state'), + queryFn: () => client.get('/workspaces'), staleTime: Infinity, }); + // Seed default to backend when key is missing after initial fetch + const seededKeyRef = useRef(null); + useEffect(() => { + if (!isSuccess || seededKeyRef.current === key) return; + seededKeyRef.current = key; + + const currentState = queryClient.getQueryData(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(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 }; } diff --git a/src/servers/api/workspaces/index.ts b/src/servers/api/workspaces/index.ts new file mode 100644 index 00000000..3552c352 --- /dev/null +++ b/src/servers/api/workspaces/index.ts @@ -0,0 +1,3 @@ +export * from './workspaces'; +export * from './types'; +export * from './utils'; diff --git a/src/servers/api/workspaces/types.ts b/src/servers/api/workspaces/types.ts new file mode 100644 index 00000000..3a731779 --- /dev/null +++ b/src/servers/api/workspaces/types.ts @@ -0,0 +1,3 @@ +export type KeyMapping = { file: string; dir?: string }; + +export type ResolveDirs = { wsDir: string; screensDir: string }; diff --git a/src/servers/api/settings/workspaces.ts b/src/servers/api/workspaces/utils.ts similarity index 53% rename from src/servers/api/settings/workspaces.ts rename to src/servers/api/workspaces/utils.ts index 617c8ba4..a333e6bc 100644 --- a/src/servers/api/settings/workspaces.ts +++ b/src/servers/api/workspaces/utils.ts @@ -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 { +export async function readJsonFile(path: string): Promise { 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) { @@ -111,7 +125,22 @@ async function readWorkspaceDir(dirPath: string, id: string, result: Record> { +async function readScreensDir(screensDir: string, result: Record) { + 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> { const result: Record = {}; const indexData = await readJsonFile(join(dirs.wsDir, 'index.json')); @@ -123,8 +152,8 @@ async function readAllWorkspacesState(dirs: ResolveDirs): Promise { - 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; - 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); -}); diff --git a/src/servers/api/workspaces/workspaces.ts b/src/servers/api/workspaces/workspaces.ts new file mode 100644 index 00000000..20084712 --- /dev/null +++ b/src/servers/api/workspaces/workspaces.ts @@ -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; + 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); +}); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index a930fdcd..9b3c3f98 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -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'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ecd36df7..05571818 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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); diff --git a/src/workspaces/components/Workspace/WorkspaceView.tsx b/src/workspaces/components/Workspace/WorkspaceView.tsx index 417a6a50..d8fce883 100644 --- a/src/workspaces/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/components/Workspace/WorkspaceView.tsx @@ -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(null); const [dragSourceId, setDragSourceId] = useState(null); const [maximizedPanelId, setMaximizedPanelId] = useState(null); @@ -102,8 +104,8 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W return ( LayoutNode)) => void; + isLoaded: boolean; +}; + export type ProjectType = 'landing-page' | 'website' | 'app'; export type ProjectDefinition = {