diff --git a/.changeset/afraid-moments-punch.md b/.changeset/afraid-moments-punch.md new file mode 100644 index 00000000..19ee078c --- /dev/null +++ b/.changeset/afraid-moments-punch.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Add useWorkspace hook diff --git a/.changeset/pre.json b/.changeset/pre.json index 9b57e8d9..37845fab 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -9,6 +9,7 @@ "@pulse-editor/web": "0.1.1-alpha.13" }, "changesets": [ + "afraid-moments-punch", "angry-llamas-smash", "beige-pandas-rhyme", "bumpy-parents-pull", diff --git a/npm-packages/react-api/CHANGELOG.md b/npm-packages/react-api/CHANGELOG.md index 83fdb858..be5a27fe 100644 --- a/npm-packages/react-api/CHANGELOG.md +++ b/npm-packages/react-api/CHANGELOG.md @@ -1,5 +1,13 @@ # @pulse-editor/react-api +## 0.1.1-beta.56 + +### Patch Changes + +- Add useWorkspace hook +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-beta.56 + ## 0.1.1-beta.55 ### Patch Changes diff --git a/npm-packages/react-api/package.json b/npm-packages/react-api/package.json index a6baa072..4f8d4c70 100644 --- a/npm-packages/react-api/package.json +++ b/npm-packages/react-api/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-editor/react-api", - "version": "0.1.1-beta.55", + "version": "0.1.1-beta.56", "main": "dist/main.js", "files": [ "dist" @@ -37,7 +37,7 @@ "typescript-eslint": "^8.30.1" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-beta.55", + "@pulse-editor/shared-utils": "0.1.1-beta.56", "react": "^19.0.0", "react-dom": "^19.0.0" } diff --git a/npm-packages/react-api/src/hooks/editor/use-toolbar.ts b/npm-packages/react-api/src/hooks/editor/use-toolbar.ts deleted file mode 100644 index c0d7fd59..00000000 --- a/npm-packages/react-api/src/hooks/editor/use-toolbar.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function useToolbar() { - return {}; -} diff --git a/npm-packages/react-api/src/hooks/editor/use-workspace.ts b/npm-packages/react-api/src/hooks/editor/use-workspace.ts new file mode 100644 index 00000000..4aa2ae48 --- /dev/null +++ b/npm-packages/react-api/src/hooks/editor/use-workspace.ts @@ -0,0 +1,30 @@ +import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; +import { useEffect, useState } from "react"; +import useIMC from "../imc/use-imc"; + +export default function useWorkspace() { + const [workspaceId, setWorkspaceId] = useState(undefined); + + const receiverHandlerMap = new Map< + IMCMessageTypeEnum, + (senderWindow: Window, message: IMCMessage) => Promise + >(); + + const { imc, isReady } = useIMC(receiverHandlerMap, "theme"); + + // Upon initial load, request theme from main app + useEffect(() => { + if (isReady) { + imc + ?.sendMessage(IMCMessageTypeEnum.EditorAppRequestWorkspace) + .then((result) => { + const { id }: { id: string } = result; + setWorkspaceId((prev) => id); + }); + } + }, [isReady]); + + return { + workspaceId, + }; +} diff --git a/npm-packages/react-api/src/main.ts b/npm-packages/react-api/src/main.ts index 6c685690..fd5d4bc9 100644 --- a/npm-packages/react-api/src/main.ts +++ b/npm-packages/react-api/src/main.ts @@ -5,7 +5,6 @@ import useLoading from "./hooks/editor/use-loading"; import useNotification from "./hooks/editor/use-notification"; import useRegisterAction from "./hooks/editor/use-register-action"; import useTheme from "./hooks/editor/use-theme"; -import useToolbar from "./hooks/editor/use-toolbar"; import useImageGen from "./hooks/ai-modality/use-image-gen"; import useLLM from "./hooks/ai-modality/use-llm"; @@ -17,6 +16,7 @@ import usePulseEnv from "./hooks/editor/use-env"; import useOwnedAppView from "./hooks/editor/use-owned-app-view"; import useReceiveFile from "./hooks/editor/use-receive-file"; import useSnapshotState from "./hooks/editor/use-snapshot-state"; +import useWorkspace from "./hooks/editor/use-workspace"; import useTerminal from "./hooks/terminal/use-terminal"; import ReceiveFileProvider from "./providers/receive-file-provider"; import SnapshotProvider from "./providers/snapshot-provider"; @@ -41,6 +41,6 @@ export { useTTS, useTerminal, useTheme, - useToolbar, useVideoGen, + useWorkspace, }; diff --git a/npm-packages/shared-utils/CHANGELOG.md b/npm-packages/shared-utils/CHANGELOG.md index a048847d..431f4b37 100644 --- a/npm-packages/shared-utils/CHANGELOG.md +++ b/npm-packages/shared-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @pulse-editor/shared-utils +## 0.1.1-beta.56 + +### Patch Changes + +- Add useWorkspace hook + ## 0.1.1-beta.55 ### Patch Changes diff --git a/npm-packages/shared-utils/package.json b/npm-packages/shared-utils/package.json index 164977f7..07e828c8 100644 --- a/npm-packages/shared-utils/package.json +++ b/npm-packages/shared-utils/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-editor/shared-utils", - "version": "0.1.1-beta.55", + "version": "0.1.1-beta.56", "main": "dist/main.js", "files": [ "dist" diff --git a/npm-packages/shared-utils/src/types/types.ts b/npm-packages/shared-utils/src/types/types.ts index b2df2cb2..f33973ca 100644 --- a/npm-packages/shared-utils/src/types/types.ts +++ b/npm-packages/shared-utils/src/types/types.ts @@ -44,6 +44,8 @@ export enum IMCMessageTypeEnum { EditorAppReceiveFileUri = "editor-app-receive-file-uri", // App uses owned app EditorAppUseOwnedApp = "editor-app-use-owned-app", + // App requests workspace info + EditorAppRequestWorkspace = "editor-app-request-workspace", // #endregion // #region Platform API interaction messages (require OS-like environment) diff --git a/remote-workspace/src/index.ts b/remote-workspace/src/index.ts index cdb54667..3d32ff58 100644 --- a/remote-workspace/src/index.ts +++ b/remote-workspace/src/index.ts @@ -33,14 +33,20 @@ async function startServers() { ? true : false; - await addAPIServer(server, expressApp, workspaceId, serverPort, frontendUrl); + await addAPIServer( + server, + expressApp, + "api-" + workspaceId, + serverPort, + frontendUrl, + ); console.log( - `API server is running at ${isHttps ? "https" : "http"}://${address}:${serverPort}/${workspaceId}`, + `API server is running at ${isHttps ? "https" : "http"}://${address}:${serverPort}/api-${workspaceId}`, ); - await addTerminalServer(server, workspaceId); + await addTerminalServer(server, "api-" + workspaceId); console.log( - `Terminal server is running at ${isHttps ? "wss" : "ws"}://${address}:${serverPort}/${workspaceId}/terminal/ws`, + `Terminal server is running at ${isHttps ? "wss" : "ws"}://${address}:${serverPort}/api-${workspaceId}/terminal/ws`, ); } diff --git a/remote-workspace/src/servers/api-server/index.ts b/remote-workspace/src/servers/api-server/index.ts index f39e7bb9..1cfb3570 100644 --- a/remote-workspace/src/servers/api-server/index.ts +++ b/remote-workspace/src/servers/api-server/index.ts @@ -42,7 +42,7 @@ async function createEndpoints( res.redirect(url.toString()); }); - app.get("/:instanceId/test", (req, res) => { + app.get("/:instanceId/check-health", (req, res) => { const id = req.params.instanceId; if (id !== instanceId) { return res.status(400).send("Invalid instance ID"); diff --git a/web/components/app-loaders/sandbox-app-loader.tsx b/web/components/app-loaders/sandbox-app-loader.tsx index ae54683a..9b40c307 100644 --- a/web/components/app-loaders/sandbox-app-loader.tsx +++ b/web/components/app-loaders/sandbox-app-loader.tsx @@ -178,7 +178,7 @@ export default function SandboxAppLoader({ getHandlerMap(viewModel), ); } - }, [viewModel, editorContext?.editorStates, editorContext?.persistSettings]); + }, [viewModel, editorContext?.editorStates, editorContext?.persistSettings, platformApi]); function getHandlerMap(model: ViewModel) { const newMap = new Map(); diff --git a/web/components/explorer/file-system/fs-explorer.tsx b/web/components/explorer/file-system/fs-explorer.tsx index 506bc138..a239cd5f 100644 --- a/web/components/explorer/file-system/fs-explorer.tsx +++ b/web/components/explorer/file-system/fs-explorer.tsx @@ -4,11 +4,12 @@ import { IMCContext } from "@/components/providers/imc-provider"; import { PlatformEnum } from "@/lib/enums"; import { usePlatformApi } from "@/lib/hooks/use-platform-api"; import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; +import { useWorkspace } from "@/lib/hooks/use-workspace"; import { getPlatform } from "@/lib/platform-api/platform-checker"; import { TreeViewGroupRef } from "@/lib/types"; -import { addToast, Button } from "@heroui/react"; +import { addToast, Button, Spinner } from "@heroui/react"; import { IMCMessageTypeEnum, ViewModeEnum } from "@pulse-editor/shared-utils"; -import { useContext, useEffect, useRef } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import Icon from "../../misc/icon"; import { EditorContext } from "../../providers/editor-context-provider"; @@ -25,6 +26,9 @@ export default function FileSystemExplorer({ const platform = getPlatform(); const { platformApi } = usePlatformApi(); const { activeTabView } = useTabViewManager(); + const { refreshWorkspaceContent } = useWorkspace(); + + const [isLoading, setIsLoading] = useState(true); const rootGroupRef = useRef(null); @@ -45,6 +49,14 @@ export default function FileSystemExplorer({ } }, [editorContext?.editorStates.explorerSelectedNodeRefs]); + useEffect(() => { + if (editorContext?.editorStates.workspaceContent) { + setIsLoading(false); + } else { + setIsLoading(true); + } + }, [editorContext?.editorStates.workspaceContent]); + async function viewFile(uri: string) { // Simply send uri to selected app node or app view if (activeTabView?.type === ViewModeEnum.App) { @@ -191,12 +203,6 @@ export default function FileSystemExplorer({ - {content?.length === 0 && ( -

- Empty content. Create a new file to get started. -

- )} -
+ + {isLoading && editorContext?.editorStates.currentWorkspace && ( +
+ +
+ )} + + {content.length === 0 && !isLoading && ( +

+ Empty content. Create a new file to get started. +

+ )} ); diff --git a/web/components/explorer/file-system/tree-view.tsx b/web/components/explorer/file-system/tree-view.tsx index efa35fdb..c2ba9c69 100644 --- a/web/components/explorer/file-system/tree-view.tsx +++ b/web/components/explorer/file-system/tree-view.tsx @@ -4,7 +4,6 @@ import ContextMenu from "@/components/interface/context-menu"; import Icon from "@/components/misc/icon"; import { EditorContext } from "@/components/providers/editor-context-provider"; import { PlatformEnum } from "@/lib/enums"; -import { useWorkspace } from "@/lib/hooks/use-workspace"; import { AbstractPlatformAPI } from "@/lib/platform-api/abstract-platform-api"; import { getPlatform } from "@/lib/platform-api/platform-checker"; import { @@ -36,11 +35,13 @@ const TreeViewNode = forwardRef(function TreeViewNode( viewFile, platformApi, parentGroupRef, + refreshWorkspaceContent, }: { object: FileSystemObject; viewFile: (uri: string) => Promise; platformApi?: AbstractPlatformAPI; parentGroupRef: RefObject; + refreshWorkspaceContent: () => Promise; }, ref: Ref, ) { @@ -56,7 +57,6 @@ const TreeViewNode = forwardRef(function TreeViewNode( }, })); - const { refreshWorkspaceContent } = useWorkspace(); const { setNodeRef, listeners } = useDraggable({ id: `draggable-file-${object.uri}`, data: { @@ -183,7 +183,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( parentGroupRef.current?.getFolderUri() + "/" + newName; platformApi?.rename(object.uri, newUri).then(() => { - refreshWorkspaceContent(platformApi); + refreshWorkspaceContent(); }); setIsRenaming(false); @@ -195,7 +195,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( parentGroupRef.current?.getFolderUri() + "/" + newName; platformApi?.rename(object.uri, newUri).then(() => { - refreshWorkspaceContent(platformApi); + refreshWorkspaceContent(); }); setIsRenaming(false); @@ -324,12 +324,14 @@ const TreeViewNode = forwardRef(function TreeViewNode( color="danger" onPress={(e) => { platformApi?.delete(object.uri).then(() => { - refreshWorkspaceContent(platformApi); + refreshWorkspaceContent(); }); setContextMenuState({ x: 0, y: 0, isOpen: false }); }} > -

Delete

+

+ Delete +

@@ -344,6 +346,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( viewFile={viewFile} folderUri={object.uri} platformApi={platformApi} + refreshWorkspaceContent={refreshWorkspaceContent} /> )} @@ -356,11 +359,13 @@ function TreeViewNodeWrapper({ viewFile, platformApi, parentGroupRef, + refreshWorkspaceContent, }: { object: FileSystemObject; viewFile: (uri: string) => Promise; platformApi?: AbstractPlatformAPI; parentGroupRef: RefObject; + refreshWorkspaceContent: () => Promise; }) { const nodeRef = useRef(null); @@ -371,6 +376,7 @@ function TreeViewNodeWrapper({ viewFile={viewFile} platformApi={platformApi} parentGroupRef={parentGroupRef} + refreshWorkspaceContent={refreshWorkspaceContent} /> ); } @@ -380,11 +386,13 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( objects, viewFile, folderUri, + refreshWorkspaceContent, platformApi, }: { objects: FileSystemObject[]; viewFile: (uri: string) => Promise; folderUri: string; + refreshWorkspaceContent: () => Promise; platformApi?: AbstractPlatformAPI; }, ref: Ref, @@ -413,8 +421,6 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( }, })); - const { refreshWorkspaceContent } = useWorkspace(); - const [isCreatingNewFile, setIsCreatingNewFile] = useState(false); const [isCreatingNewFolder, setIsCreatingNewFolder] = useState(false); @@ -430,7 +436,7 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( } platformApi.createFolder(uri).then(() => { - refreshWorkspaceContent(platformApi); + refreshWorkspaceContent(); }); setFolderNameInputValue(""); @@ -446,7 +452,7 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( } platformApi.createFile(uri).then(() => { - refreshWorkspaceContent(platformApi); + refreshWorkspaceContent(); }); setFileNameInputValue(""); @@ -463,6 +469,7 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( viewFile={viewFile} platformApi={platformApi} parentGroupRef={ref as RefObject} + refreshWorkspaceContent={refreshWorkspaceContent} /> ); })} @@ -499,12 +506,24 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( onValueChange={setFileNameInputValue} onKeyDown={(e) => { if (e.key === "Enter") { + // Cancel on empty input + if (fileNameInputValue === "") { + setIsCreatingNewFile(false); + return; + } + const uri = folderUri + "/" + fileNameInputValue; createNewFile(uri); } }} onFocusChange={(isFocused) => { if (!isFocused) { + // Cancel on empty input + if (fileNameInputValue === "") { + setIsCreatingNewFile(false); + return; + } + const uri = folderUri + "/" + fileNameInputValue; createNewFile(uri); } diff --git a/web/components/explorer/project/project-explorer.tsx b/web/components/explorer/project/project-explorer.tsx index ae14b001..f80a7ff4 100644 --- a/web/components/explorer/project/project-explorer.tsx +++ b/web/components/explorer/project/project-explorer.tsx @@ -1,9 +1,10 @@ "use client"; import { SideMenuTabEnum } from "@/lib/enums"; +import { useAuth } from "@/lib/hooks/use-auth"; import { usePlatformApi } from "@/lib/hooks/use-platform-api"; import { ProjectInfo } from "@/lib/types"; -import { Button } from "@heroui/react"; +import { Button, Spinner } from "@heroui/react"; import { useContext, useEffect, useState } from "react"; import ProjectSettingsModal from "../../modals/project-settings-modal"; import { EditorContext } from "../../providers/editor-context-provider"; @@ -12,12 +13,14 @@ import ProjectItem from "./project-item"; export default function ProjectExplorer() { const editorContext = useContext(EditorContext); + const { session } = useAuth(); const { platformApi } = usePlatformApi(); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsProject, setSettingsProject] = useState< ProjectInfo | undefined >(undefined); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (editorContext?.editorStates.project) { @@ -26,9 +29,10 @@ export default function ProjectExplorer() { }, [editorContext?.editorStates.project]); useEffect(() => { - if (platformApi) { + if (platformApi && session) { const homePath = editorContext?.persistSettings?.projectHomePath; + setIsLoading(true); platformApi.listProjects(homePath).then((projects) => { editorContext?.setEditorStates((prev) => { return { @@ -36,42 +40,59 @@ export default function ProjectExplorer() { projectsInfo: projects, }; }); + setIsLoading(false); }); } - }, [editorContext?.persistSettings, platformApi]); + }, [editorContext?.persistSettings, platformApi, session]); return ( -
-
-

View Projects

- - {editorContext?.editorStates.projectsInfo?.map((project, index) => ( - { - editorContext.setEditorStates((prev) => ({ - ...prev, - sideMenuTab: SideMenuTabEnum.Apps, - })); +
+ {session ? ( +
+

View Projects

+ + {isLoading && + (editorContext?.editorStates.projectsInfo ?? []).length === 0 && ( +
+ +
+ )} + {editorContext?.editorStates.projectsInfo?.map((project, index) => ( + { + editorContext.setEditorStates((prev) => ({ + ...prev, + sideMenuTab: SideMenuTabEnum.Apps, + })); + }} + /> + ))} + - ))} - -
+
+ ) : ( +
+

+ Sign in to view your projects, +
+ or open a local project with desktop client. +

+
+ )}
); } diff --git a/web/components/explorer/workspace/workspace-explorer.tsx b/web/components/explorer/workspace/workspace-explorer.tsx index b60e47d0..230cf463 100644 --- a/web/components/explorer/workspace/workspace-explorer.tsx +++ b/web/components/explorer/workspace/workspace-explorer.tsx @@ -14,12 +14,11 @@ export default function WorkspaceExplorer() { const workspaceHook = useWorkspace(); const { platformApi } = usePlatformApi(); + const { refreshWorkspaceContent, isWorkspaceRunning } = useWorkspace(); const [isWorkspaceSettingsModalOpen, setIsWorkspaceSettingsModalOpen] = useState(false); - const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); - useEffect(() => { async function openProjectInWorkspace() { if (!platformApi) { @@ -27,30 +26,33 @@ export default function WorkspaceExplorer() { } if (getPlatform() === PlatformEnum.Electron && !workspaceHook.workspace) { - await workspaceHook.refreshWorkspaceContent(platformApi); - } else { - if (isCreatingWorkspace && workspaceHook.workspace) { - const homePath = editorContext?.persistSettings?.projectHomePath; - const projectName = editorContext?.editorStates.project; - if (!projectName) { - return; - } + await refreshWorkspaceContent(); + } else if (workspaceHook.workspace) { + const homePath = editorContext?.persistSettings?.projectHomePath; + const projectName = editorContext?.editorStates.project; + if (!projectName) { + return; + } - const uri = homePath + "/" + projectName; - const hasPath = await platformApi.hasPath(uri); + await workspaceHook.waitUntilWorkspaceRunning(); - if (!hasPath) { - await platformApi.createFolder(uri); - } + const uri = homePath + "/" + projectName; + const hasPath = await platformApi.hasPath(uri); - await workspaceHook.refreshWorkspaceContent(platformApi); - setIsCreatingWorkspace(false); + if (!hasPath) { + await platformApi.createFolder(uri); } + + await refreshWorkspaceContent(); } } openProjectInWorkspace(); - }, [platformApi]); + }, [ + platformApi, + workspaceHook.workspace, + workspaceHook.waitUntilWorkspaceRunning, + ]); return (
@@ -79,7 +81,7 @@ export default function WorkspaceExplorer() { } size="sm" disabledKeys={workspaceHook.workspace ? [] : ["settings"]} - onSelectionChange={(key) => { + onSelectionChange={async (key) => { if ( key.currentKey === "__internal-create-new" || key.currentKey === "__internal-settings" @@ -89,11 +91,7 @@ export default function WorkspaceExplorer() { const selectedWorkspace = workspaceHook.cloudWorkspaces?.find( (workspace) => workspace.id === key.currentKey, ); - workspaceHook.selectWorkspace(selectedWorkspace?.id); - if (selectedWorkspace) { - // Create project path inside workspace if it doesn't exist - setIsCreatingWorkspace(true); - } + await workspaceHook.selectWorkspace(selectedWorkspace?.id); }} > <> @@ -139,14 +137,18 @@ export default function WorkspaceExplorer() {
{getPlatform() === PlatformEnum.Electron || workspaceHook.workspace ? ( - { - editorContext?.setEditorStates((prev) => ({ - ...prev, - isSideMenuOpen: false, - })); - }} - /> + !isWorkspaceRunning ? ( +
Workspace is starting
+ ) : ( + { + editorContext?.setEditorStates((prev) => ({ + ...prev, + isSideMenuOpen: false, + })); + }} + /> + ) ) : (

diff --git a/web/components/misc/qr-display.tsx b/web/components/misc/qr-display.tsx index e76c534f..06002c8d 100644 --- a/web/components/misc/qr-display.tsx +++ b/web/components/misc/qr-display.tsx @@ -1,16 +1,22 @@ import { generateQR } from "@/lib/share/qr-gen"; -import { useEffect, useState } from "react"; -import Loading from "../interface/status-screens/loading"; import { Button } from "@heroui/react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; +import Loading from "../interface/status-screens/loading"; import Icon from "./icon"; export default function QRDisplay({ url }: { url: string }) { + const { resolvedTheme } = useTheme(); + const [image, setImage] = useState(undefined); useEffect(() => { async function fetchQR() { - const imgBlob = await generateQR(url); + const imgBlob = await generateQR( + url, + resolvedTheme === "dark" ? "dark" : "light", + ); const imgUrl = URL.createObjectURL(imgBlob); setImage(imgUrl); } diff --git a/web/components/modals/workspace-settings-model.tsx b/web/components/modals/workspace-settings-model.tsx index 3c9feefa..11eba027 100644 --- a/web/components/modals/workspace-settings-model.tsx +++ b/web/components/modals/workspace-settings-model.tsx @@ -94,6 +94,11 @@ export default function WorkspaceSettingsModal({ } try { // Delete workspace + addToast({ + title: "Deleting workspace", + description: `Deleting workspace ${workspaceName}`, + }); + await deleteWorkspace(workspace.id); addToast({ title: "Workspace deleted", diff --git a/web/components/providers/editor-context-provider.tsx b/web/components/providers/editor-context-provider.tsx index 9c6132c6..db8b5c21 100644 --- a/web/components/providers/editor-context-provider.tsx +++ b/web/components/providers/editor-context-provider.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAuth } from "@/lib/hooks/use-auth"; import { usePlatformApi } from "@/lib/hooks/use-platform-api"; import { getLLMModel } from "@/lib/modalities/llm/llm"; import { getSTTModel } from "@/lib/modalities/stt/stt"; @@ -41,6 +42,10 @@ export default function EditorContextProvider({ }: { children: React.ReactNode; }) { + // --- Platform API --- + const { platformApi } = usePlatformApi(); + const { session } = useAuth(); + // --- Editor States --- const [editorStates, setEditorStates] = useState(defaultEditorStates); @@ -52,9 +57,6 @@ export default function EditorContextProvider({ ); const [isSettingsLoaded, setIsSettingsLoaded] = useState(false); - // --- Platform API --- - const { platformApi } = usePlatformApi(); - // Track all pressed keys useEffect(() => { window.addEventListener("keydown", (e) => { @@ -84,9 +86,9 @@ export default function EditorContextProvider({ }; }, []); - // Load settings from local storage + // Load settings useEffect(() => { - if (platformApi) { + if (platformApi && session) { platformApi ?.getPersistentSettings() .then((loadedSettings: PersistentSettings) => { @@ -94,18 +96,18 @@ export default function EditorContextProvider({ setIsSettingsLoaded(true); }); } - }, [platformApi]); + }, [platformApi, session]); - // Save settings to local storage + // Save settings useEffect(() => { - if (isSettingsLoaded) { + if (isSettingsLoaded && session) { if (settings) { platformApi?.setPersistentSettings(settings); } else { platformApi?.resetPersistentSettings(); } } - }, [settings]); + }, [settings, session, isSettingsLoaded]); // Load STT useEffect(() => { diff --git a/web/components/providers/imc-provider.tsx b/web/components/providers/imc-provider.tsx index 937fe583..6419b50a 100644 --- a/web/components/providers/imc-provider.tsx +++ b/web/components/providers/imc-provider.tsx @@ -540,6 +540,18 @@ export default function InterModuleCommunicationProvider({ return result[0]; }, ], + [ + IMCMessageTypeEnum.EditorAppRequestWorkspace, + async ( + senderWindow: Window, + message: IMCMessage, + abortSignal?: AbortSignal, + ) => { + return { + id: editorContext?.editorStates.currentWorkspace?.id, + }; + }, + ] ]); return newMap; diff --git a/web/lib/hooks/use-auth.ts b/web/lib/hooks/use-auth.ts index 21b4f606..2dd22928 100644 --- a/web/lib/hooks/use-auth.ts +++ b/web/lib/hooks/use-auth.ts @@ -21,16 +21,20 @@ export function useAuth() { } = useSWR( !editorContext?.editorStates.isSigningIn ? `/api/auth/session` : null, async (url: string) => { - const res = await fetchAPI(url); - if (!res.ok) { - throw new Error("Failed to fetch session data"); - } - const data = await res.json(); - // return undefined if data's content is empty - if (Object.keys(data).length === 0) { - return undefined; + try { + const res = await fetchAPI(url); + if (!res.ok) { + console.error("Failed to fetch session data"); + } + const data = await res.json(); + // return undefined if data's content is empty + if (Object.keys(data).length === 0) { + return undefined; + } + return data as Session; + } catch (error) { + // noop } - return data as Session; }, ); diff --git a/web/lib/hooks/use-platform-api.ts b/web/lib/hooks/use-platform-api.ts index c6142d83..cfad02ac 100644 --- a/web/lib/hooks/use-platform-api.ts +++ b/web/lib/hooks/use-platform-api.ts @@ -1,9 +1,6 @@ -import { PlatformEnum } from "@/lib/enums"; import { useEffect, useState } from "react"; import { AbstractPlatformAPI } from "../platform-api/abstract-platform-api"; -import { CloudAPI } from "../platform-api/cloud/cloud-api"; -import { ElectronAPI } from "../platform-api/electron/electron-api"; -import { getPlatform } from "../platform-api/platform-checker"; +import { getAbstractPlatformAPI } from "../platform-api/get-platform-api"; import { useWorkspace } from "./use-workspace"; export function usePlatformApi() { @@ -14,39 +11,19 @@ export function usePlatformApi() { >(undefined); useEffect(() => { - const api = getAbstractPlatformAPI(); + const api = getAbstractPlatformAPI(workspace); setPlatformApi(api); }, []); - // When workspace changes, reset platform API if needed + // When workspace changes, update platform API if needed. + // So the platform api switch to the latest workspace context. useEffect(() => { - if (platformApi && workspace) { - const api = getAbstractPlatformAPI(); + if (workspace) { + const api = getAbstractPlatformAPI(workspace); setPlatformApi(api); } }, [workspace]); - function getAbstractPlatformAPI(): AbstractPlatformAPI { - const platform = getPlatform(); - - if (platform === PlatformEnum.Capacitor) { - // return new CapacitorAPI(); - return new CloudAPI(workspace); - } else if (platform === PlatformEnum.Electron) { - return new ElectronAPI(); - } else if ( - platform === PlatformEnum.Web || - platform === PlatformEnum.WebMobile - ) { - return new CloudAPI(workspace); - } else if (platform === PlatformEnum.VSCode) { - // platformApi.current = new VSCodeAPI(); - throw new Error("VSCode API not implemented"); - } else { - throw new Error("Unknown platform"); - } - } - return { platformApi, }; diff --git a/web/lib/hooks/use-workspace.ts b/web/lib/hooks/use-workspace.ts index a78cfede..72df9a54 100644 --- a/web/lib/hooks/use-workspace.ts +++ b/web/lib/hooks/use-workspace.ts @@ -1,7 +1,7 @@ import { EditorContext } from "@/components/providers/editor-context-provider"; -import { useContext } from "react"; +import { useCallback, useContext, useEffect, useRef } from "react"; import useSWR from "swr"; -import { AbstractPlatformAPI } from "../platform-api/abstract-platform-api"; +import { getAbstractPlatformAPI } from "../platform-api/get-platform-api"; import { fetchAPI } from "../pulse-editor-website/backend"; import { RemoteWorkspace } from "../types"; import { useAuth } from "./use-auth"; @@ -10,6 +10,8 @@ export function useWorkspace() { const editorContext = useContext(EditorContext); const { session } = useAuth(); + const workspace = editorContext?.editorStates?.currentWorkspace; + const { data: cloudWorkspaces, mutate: mutateCloudWorkspaces } = useSWR< RemoteWorkspace[] >(session ? `/api/workspace/list` : null, async (url: string) => { @@ -26,7 +28,38 @@ export function useWorkspace() { return workspaces; }); - const workspace = editorContext?.editorStates?.currentWorkspace; + // Check workspace status + const { data: isWorkspaceRunning } = useSWR( + workspace + ? `/api/workspace/check-health?workspaceId=${workspace.id}` + : null, + async (url: string) => { + const res = await fetchAPI(url); + if (!res.ok) { + throw new Error("Failed to fetch workspace health status"); + } + + const { + status, + }: { + status: string; + } = await res.json(); + return status === "ready"; + }, + { + refreshInterval: 5000, + }, + ); + + const waitUntilRunningResolve = useRef<() => void>(null); + + useEffect(() => { + if (isWorkspaceRunning && waitUntilRunningResolve.current) { + waitUntilRunningResolve.current(); + waitUntilRunningResolve.current = null; + } + }, [isWorkspaceRunning]); + const setWorkspace = (ws: RemoteWorkspace | undefined) => { if (!editorContext) { throw new Error("Editor context is not available"); @@ -94,7 +127,7 @@ export function useWorkspace() { await mutateCloudWorkspaces(); } - function selectWorkspace(workspaceId: string | undefined) { + async function selectWorkspace(workspaceId: string | undefined) { if (!editorContext) { throw new Error("Editor context is not available"); } @@ -137,12 +170,30 @@ export function useWorkspace() { mutateCloudWorkspaces(); } - async function refreshWorkspaceContent(platformApi: AbstractPlatformAPI) { + async function refreshWorkspaceContent( + ws: RemoteWorkspace | undefined = workspace, + ) { + if (!ws) { + // Reset all content + editorContext?.setEditorStates((prev) => { + return { + ...prev, + workspaceContent: undefined, + explorerSelectedNodeRefs: [], + }; + }); + return; + } + + await waitUntilWorkspaceRunning(); + + const api = getAbstractPlatformAPI(ws); + const projectUri = editorContext?.persistSettings?.projectHomePath + "/" + editorContext?.editorStates.project; - const objects = await platformApi?.listPathContent(projectUri, { + const objects = await api?.listPathContent(projectUri, { include: "all", isRecursive: true, }); @@ -158,13 +209,26 @@ export function useWorkspace() { console.log("Found project content:", objects); } + const waitUntilWorkspaceRunning = useCallback(async ( ) => { + if (isWorkspaceRunning) { + return; + } + return new Promise((resolve, reject) => { + waitUntilRunningResolve.current = resolve; + }); + }, [ + isWorkspaceRunning + ]) + return { workspace, + isWorkspaceRunning, cloudWorkspaces, createWorkspace, updateWorkspace, selectWorkspace, deleteWorkspace, refreshWorkspaceContent, + waitUntilWorkspaceRunning, }; } diff --git a/web/lib/platform-api/cloud/cloud-api.ts b/web/lib/platform-api/cloud/cloud-api.ts index f0447ddc..d04d895e 100644 --- a/web/lib/platform-api/cloud/cloud-api.ts +++ b/web/lib/platform-api/cloud/cloud-api.ts @@ -6,6 +6,7 @@ import { ProjectInfo, RemoteWorkspace, } from "@/lib/types"; +import { addToast } from "@heroui/react"; import toast from "react-hot-toast"; import { AbstractPlatformAPI } from "../abstract-platform-api"; @@ -55,7 +56,14 @@ export class CloudAPI extends AbstractPlatformAPI { const response = await fetchAPI("/api/project/list"); if (!response.ok) { - throw new Error("Failed to fetch projects"); + addToast({ + title: "Failed to fetch projects", + description: + response.status === 401 + ? "Cannot list projects. Are you signed in?" + : undefined, + color: "danger", + }); } const projects = await response.json(); diff --git a/web/lib/platform-api/get-platform-api.ts b/web/lib/platform-api/get-platform-api.ts new file mode 100644 index 00000000..a6127fee --- /dev/null +++ b/web/lib/platform-api/get-platform-api.ts @@ -0,0 +1,28 @@ +import { PlatformEnum } from "../enums"; +import { RemoteWorkspace } from "../types"; +import { AbstractPlatformAPI } from "./abstract-platform-api"; +import { CloudAPI } from "./cloud/cloud-api"; +import { ElectronAPI } from "./electron/electron-api"; +import { getPlatform } from "./platform-checker"; + +export function getAbstractPlatformAPI( + workspace: RemoteWorkspace | undefined, +): AbstractPlatformAPI { + const platform = getPlatform(); + + if (platform === PlatformEnum.Capacitor) { + return new CloudAPI(workspace); + } else if (platform === PlatformEnum.Electron) { + return new ElectronAPI(); + } else if ( + platform === PlatformEnum.Web || + platform === PlatformEnum.WebMobile + ) { + return new CloudAPI(workspace); + } else if (platform === PlatformEnum.VSCode) { + // platformApi.current = new VSCodeAPI(); + throw new Error("VSCode API not implemented"); + } else { + throw new Error("Unknown platform"); + } +} diff --git a/web/lib/share/qr-gen.ts b/web/lib/share/qr-gen.ts index 9d533687..68a8c718 100644 --- a/web/lib/share/qr-gen.ts +++ b/web/lib/share/qr-gen.ts @@ -1,45 +1,56 @@ import QRCodeStyling, { Options } from "qr-code-styling"; -const config: Partial = { - type: "canvas", - shape: "square", - width: 480, - height: 480, - data: undefined, - margin: 0, - qrOptions: { typeNumber: 0, mode: "Byte", errorCorrectionLevel: "Q" }, - imageOptions: { - saveAsBlob: true, - hideBackgroundDots: true, - imageSize: 0.5, - margin: 4, - }, - dotsOptions: { - type: "rounded", - color: "#6a1a4c", - roundSize: true, - gradient: { - type: "linear", - rotation: 0.7853981633974483, - colorStops: [ - { offset: 0, color: "#000000" }, - { offset: 1, color: "#e49c21" }, - ], +function getConfig(theme: "dark" | "light") { + const config: Partial = { + type: "canvas", + shape: "square", + width: 480, + height: 480, + data: undefined, + margin: 0, + qrOptions: { typeNumber: 0, mode: "Byte", errorCorrectionLevel: "Q" }, + imageOptions: { + saveAsBlob: true, + hideBackgroundDots: true, + imageSize: 0.5, + margin: 4, }, - }, - backgroundOptions: { round: 0, color: "#ffffff", gradient: undefined }, - image: "/pulse_logo.svg", - cornersSquareOptions: { - type: "extra-rounded", - color: "#7e5002", - gradient: undefined, - }, - cornersDotOptions: { type: undefined, color: "#000000", gradient: undefined }, -}; + dotsOptions: { + type: "rounded", + color: "#6a1a4c", + roundSize: true, + gradient: { + type: "linear", + rotation: 0.7853981633974483, + colorStops: [ + { offset: 0, color: theme === "light" ? "#000000" : "#ffffff" }, + { offset: 1, color: "#e49c21" }, + ], + }, + }, + backgroundOptions: { + round: 0, + color: "#ffffff00", + gradient: undefined, + }, + image: "/pulse_logo.svg", + cornersSquareOptions: { + type: "extra-rounded", + color: "#7e5002", + gradient: undefined, + }, + cornersDotOptions: { + type: undefined, + color: "#e49c21", + gradient: undefined, + }, + }; + return config; +} -export async function generateQR(url: string) { +export async function generateQR(url: string, theme: "dark" | "light") { const qrData = { - ...config, + ...getConfig(theme), data: url, }; const qrCode = new QRCodeStyling(qrData);