diff --git a/npm-packages/react-api/eslint.config.js b/npm-packages/react-api/eslint.config.js index 02c9b275..6d7b6e2c 100644 --- a/npm-packages/react-api/eslint.config.js +++ b/npm-packages/react-api/eslint.config.js @@ -1,7 +1,7 @@ -import globals from "globals"; import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; import pluginReact from "eslint-plugin-react"; +import globals from "globals"; +import tseslint from "typescript-eslint"; /** @type {import('eslint').Linter.Config[]} */ export default [ @@ -26,4 +26,12 @@ export default [ "@typescript-eslint/no-unused-vars": "off", }, }, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), + project: "./tsconfig.json", + }, + }, + }, ]; diff --git a/npm-packages/react-api/src/hooks/agent/use-agent-tools.ts b/npm-packages/react-api/src/hooks/agent/use-agent-tools.ts index 1a2ac186..8c7df5c7 100644 --- a/npm-packages/react-api/src/hooks/agent/use-agent-tools.ts +++ b/npm-packages/react-api/src/hooks/agent/use-agent-tools.ts @@ -1,5 +1,5 @@ import { AgentTool, IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; /** * Add or use agent tools in the editor. diff --git a/npm-packages/react-api/src/hooks/agent/use-agents.ts b/npm-packages/react-api/src/hooks/agent/use-agents.ts index d506399d..50c43f87 100644 --- a/npm-packages/react-api/src/hooks/agent/use-agents.ts +++ b/npm-packages/react-api/src/hooks/agent/use-agents.ts @@ -4,7 +4,7 @@ import { IMCMessageTypeEnum, LLMConfig, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useAgents() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-image-gen.ts b/npm-packages/react-api/src/hooks/ai-modality/use-image-gen.ts index 854264d8..5c1b6a05 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-image-gen.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-image-gen.ts @@ -3,7 +3,7 @@ import { IMCMessage, IMCMessageTypeEnum, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useImageGen() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-llm.ts b/npm-packages/react-api/src/hooks/ai-modality/use-llm.ts index 17f9df37..dac9d930 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-llm.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-llm.ts @@ -3,7 +3,7 @@ import { IMCMessageTypeEnum, LLMConfig, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useLLM() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-ocr.ts b/npm-packages/react-api/src/hooks/ai-modality/use-ocr.ts index 6c8f8a32..555a1534 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-ocr.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-ocr.ts @@ -1,6 +1,6 @@ import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useOCR() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-speech2speech.ts b/npm-packages/react-api/src/hooks/ai-modality/use-speech2speech.ts index 0281f5c1..f4c08db9 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-speech2speech.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-speech2speech.ts @@ -1,5 +1,5 @@ import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; import { useState } from "react"; /** diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-stt.ts b/npm-packages/react-api/src/hooks/ai-modality/use-stt.ts index 7f8dd2a9..da5b1709 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-stt.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-stt.ts @@ -3,7 +3,7 @@ import { IMCMessageTypeEnum, STTConfig, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useSTT() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-tts.ts b/npm-packages/react-api/src/hooks/ai-modality/use-tts.ts index f9cdd203..c6b653c3 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-tts.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-tts.ts @@ -3,7 +3,7 @@ import { IMCMessageTypeEnum, TTSConfig, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useTTS() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/ai-modality/use-video-gen.ts b/npm-packages/react-api/src/hooks/ai-modality/use-video-gen.ts index 67a9f11b..8b05e4aa 100644 --- a/npm-packages/react-api/src/hooks/ai-modality/use-video-gen.ts +++ b/npm-packages/react-api/src/hooks/ai-modality/use-video-gen.ts @@ -3,7 +3,7 @@ import { IMCMessageTypeEnum, VideoModelConfig, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useVideoGen() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/editor/use-env.ts b/npm-packages/react-api/src/hooks/editor/use-env.ts index 6b9a95ff..3f673ca0 100644 --- a/npm-packages/react-api/src/hooks/editor/use-env.ts +++ b/npm-packages/react-api/src/hooks/editor/use-env.ts @@ -1,6 +1,6 @@ import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; import { useEffect, useState } from "react"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function usePulseEnv() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/editor/use-file-view.ts b/npm-packages/react-api/src/hooks/editor/use-file-view.ts deleted file mode 100644 index 99a9e228..00000000 --- a/npm-packages/react-api/src/hooks/editor/use-file-view.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - IMCMessage, - IMCMessageTypeEnum, - ViewModel, -} from "@pulse-editor/shared-utils"; -import { useEffect, useState } from "react"; -import useIMC from "../../lib/use-imc"; - -export default function useFileView() { - const [viewModel, setViewModel] = useState(undefined); - - const receiverHandlerMap = new Map< - IMCMessageTypeEnum, - (senderWindow: Window, message: IMCMessage) => Promise - >(); - - const { imc, isReady } = useIMC(receiverHandlerMap); - - useEffect(() => { - if (isReady) { - imc?.sendMessage(IMCMessageTypeEnum.PlatformReadFile).then((model) => { - setViewModel(model); - }); - } - }, [isReady]); - - function updateViewModel(viewModel: ViewModel) { - imc?.sendMessage(IMCMessageTypeEnum.PlatformWriteFile, viewModel); - } - - return { - viewModel, - updateViewModel, - }; -} diff --git a/npm-packages/react-api/src/hooks/editor/use-file.ts b/npm-packages/react-api/src/hooks/editor/use-file.ts new file mode 100644 index 00000000..9264e80e --- /dev/null +++ b/npm-packages/react-api/src/hooks/editor/use-file.ts @@ -0,0 +1,62 @@ +import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; +import { useCallback, useEffect, useState } from "react"; +import useIMC from "../imc/use-imc"; + +export default function useFile(uri: string | undefined) { + const [file, setFile] = useState(undefined); + + const receiverHandlerMap = new Map< + IMCMessageTypeEnum, + (senderWindow: Window, message: IMCMessage) => Promise + >([ + [ + IMCMessageTypeEnum.PlatformFileUpdate, + async (senderWindow: Window, message: IMCMessage) => { + const updatedFile = message.payload as File; + setFile(updatedFile); + }, + ], + ]); + + const { imc, isReady } = useIMC(receiverHandlerMap); + + const saveFile = useCallback( + (fileContent: string) => { + if (!uri) return; + else if (!file) return; + + // Update file content + const newFile = new File([fileContent], file.name, { + type: file.type, + lastModified: Date.now(), + }); + setFile(newFile); + + if (isReady && uri) { + imc?.sendMessage(IMCMessageTypeEnum.PlatformWriteFile, { + uri, + file: newFile, + }); + } + }, + [uri, file, isReady] + ); + + // Read file when uri changes + useEffect(() => { + if (isReady) { + imc + ?.sendMessage(IMCMessageTypeEnum.PlatformReadFile, { + uri, + }) + .then((f: File | undefined) => { + setFile(f); + }); + } + }, [isReady, uri]); + + return { + file, + saveFile, + }; +} diff --git a/npm-packages/react-api/src/hooks/editor/use-loading.ts b/npm-packages/react-api/src/hooks/editor/use-loading.ts index 22b8d797..f5269722 100644 --- a/npm-packages/react-api/src/hooks/editor/use-loading.ts +++ b/npm-packages/react-api/src/hooks/editor/use-loading.ts @@ -1,6 +1,6 @@ import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; import { useEffect, useState } from "react"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useLoading() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/editor/use-notification.ts b/npm-packages/react-api/src/hooks/editor/use-notification.ts index a352ac64..d45802af 100644 --- a/npm-packages/react-api/src/hooks/editor/use-notification.ts +++ b/npm-packages/react-api/src/hooks/editor/use-notification.ts @@ -4,7 +4,7 @@ import { IMCMessageTypeEnum, } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; export default function useNotification() { const receiverHandlerMap = new Map< diff --git a/npm-packages/react-api/src/hooks/editor/use-receive-file.ts b/npm-packages/react-api/src/hooks/editor/use-receive-file.ts new file mode 100644 index 00000000..d7658b07 --- /dev/null +++ b/npm-packages/react-api/src/hooks/editor/use-receive-file.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { ReceiveFileContext } from "../../providers/receive-file-provider"; + +export default function useReceiveFile() { + const context = useContext(ReceiveFileContext); + + if (!context) { + throw new Error("useReceiveFile must be used within a ReceiveFileProvider"); + } + + return { receivedFileUri: context.selectedFileUri }; +} diff --git a/npm-packages/react-api/src/hooks/editor/use-register-action.ts b/npm-packages/react-api/src/hooks/editor/use-register-action.ts index f1ff6346..36ae295d 100644 --- a/npm-packages/react-api/src/hooks/editor/use-register-action.ts +++ b/npm-packages/react-api/src/hooks/editor/use-register-action.ts @@ -6,7 +6,7 @@ import { TypedVariable, } from "@pulse-editor/shared-utils"; import { DependencyList, useEffect, useRef, useState } from "react"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; /** * Register an app action to listen to IMC messages from the core, diff --git a/npm-packages/react-api/src/hooks/editor/use-theme.ts b/npm-packages/react-api/src/hooks/editor/use-theme.ts index 2972bdee..00de0108 100644 --- a/npm-packages/react-api/src/hooks/editor/use-theme.ts +++ b/npm-packages/react-api/src/hooks/editor/use-theme.ts @@ -1,6 +1,6 @@ import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; -import { useState } from "react"; -import useIMC from "../../lib/use-imc"; +import { useEffect, useState } from "react"; +import useIMC from "../imc/use-imc"; export default function useTheme() { const [theme, setTheme] = useState("light"); @@ -17,7 +17,19 @@ export default function useTheme() { } ); - const { imc } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap); + + // Upon initial load, request theme from main app + useEffect(() => { + if (isReady) { + imc + ?.sendMessage(IMCMessageTypeEnum.EditorAppRequestTheme) + .then((result) => { + console.log("Received theme from main app:", result); + setTheme((prev) => result); + }); + } + }, [isReady]); return { theme, diff --git a/npm-packages/react-api/src/lib/use-imc.tsx b/npm-packages/react-api/src/hooks/imc/use-imc.tsx similarity index 100% rename from npm-packages/react-api/src/lib/use-imc.tsx rename to npm-packages/react-api/src/hooks/imc/use-imc.tsx diff --git a/npm-packages/react-api/src/hooks/terminal/use-terminal.ts b/npm-packages/react-api/src/hooks/terminal/use-terminal.ts index 7c624e3a..1ff94f2b 100644 --- a/npm-packages/react-api/src/hooks/terminal/use-terminal.ts +++ b/npm-packages/react-api/src/hooks/terminal/use-terminal.ts @@ -1,5 +1,5 @@ import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; -import useIMC from "../../lib/use-imc"; +import useIMC from "../imc/use-imc"; import { useEffect, useState } from "react"; export default function useTerminal() { diff --git a/npm-packages/react-api/src/main.ts b/npm-packages/react-api/src/main.ts index 2b2e7b02..a4949fd9 100644 --- a/npm-packages/react-api/src/main.ts +++ b/npm-packages/react-api/src/main.ts @@ -1,6 +1,6 @@ import useAgentTools from "./hooks/agent/use-agent-tools"; import useAgents from "./hooks/agent/use-agents"; -import useFileView from "./hooks/editor/use-file-view"; +import useFile from "./hooks/editor/use-file"; import useLoading from "./hooks/editor/use-loading"; import useNotification from "./hooks/editor/use-notification"; import useRegisterAction from "./hooks/editor/use-register-action"; @@ -14,21 +14,25 @@ import useSTT from "./hooks/ai-modality/use-stt"; import useTTS from "./hooks/ai-modality/use-tts"; import useVideoGen from "./hooks/ai-modality/use-video-gen"; import usePulseEnv from "./hooks/editor/use-env"; +import useReceiveFile from "./hooks/editor/use-receive-file"; import useSnapshotState from "./hooks/editor/use-snapshot-state"; import useTerminal from "./hooks/terminal/use-terminal"; +import ReceiveFileProvider from "./providers/receive-file-provider"; import SnapshotProvider from "./providers/snapshot-provider"; export { + ReceiveFileProvider, SnapshotProvider, useAgentTools, useAgents, - useFileView, + useFile, useImageGen, useLLM, useLoading, useNotification, useOCR, usePulseEnv, + useReceiveFile, useRegisterAction, useSTT, useSnapshotState, diff --git a/npm-packages/react-api/src/providers/receive-file-provider.tsx b/npm-packages/react-api/src/providers/receive-file-provider.tsx new file mode 100644 index 00000000..f660af5a --- /dev/null +++ b/npm-packages/react-api/src/providers/receive-file-provider.tsx @@ -0,0 +1,42 @@ +import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; +import React, { createContext, ReactNode, useState } from "react"; +import useIMC from "../hooks/imc/use-imc"; + +export const ReceiveFileContext = createContext< + ReceiveFileContextType | undefined +>(undefined); + +export type ReceiveFileContextType = { + // Define any state or functions you want to provide here + selectedFileUri: string | undefined; +}; + +export default function ReceiveFileProvider({ + children, +}: { + children: ReactNode; +}) { + const [receivedFileUri, setReceivedFileUri] = useState( + undefined + ); + + const receiverHandlerMap = new Map< + IMCMessageTypeEnum, + (senderWindow: Window, message: IMCMessage) => Promise + >([ + [ + IMCMessageTypeEnum.EditorAppReceiveFileUri, + async (senderWindow: Window, message: IMCMessage) => { + const { uri } = message.payload; + setReceivedFileUri((prev) => uri); + }, + ], + ]); + useIMC(receiverHandlerMap); + + return ( + + {children} + + ); +} diff --git a/npm-packages/react-api/src/providers/snapshot-provider.tsx b/npm-packages/react-api/src/providers/snapshot-provider.tsx index e20a5149..63094913 100644 --- a/npm-packages/react-api/src/providers/snapshot-provider.tsx +++ b/npm-packages/react-api/src/providers/snapshot-provider.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useState, } from "react"; -import useIMC from "../lib/use-imc"; +import useIMC from "../hooks/imc/use-imc"; export const SnapshotContext = createContext( undefined @@ -23,6 +23,8 @@ export default function SnapshotProvider({ }: { children: ReactNode; }) { + const [states, setStates] = useState<{ [key: string]: any }>({}); + const receiverHandlerMap = new Map< IMCMessageTypeEnum, (senderWindow: Window, message: IMCMessage) => Promise @@ -48,8 +50,6 @@ export default function SnapshotProvider({ const { imc, isReady } = useIMC(receiverHandlerMap); - const [states, setStates] = useState<{ [key: string]: any }>({}); - useEffect(() => { if (isReady) { imc?.updateReceiverHandlerMap(receiverHandlerMap); diff --git a/npm-packages/shared-utils/eslint.config.js b/npm-packages/shared-utils/eslint.config.js index 743bfc52..88dc2a6d 100644 --- a/npm-packages/shared-utils/eslint.config.js +++ b/npm-packages/shared-utils/eslint.config.js @@ -1,5 +1,5 @@ -import globals from "globals"; import pluginJs from "@eslint/js"; +import globals from "globals"; import tseslint from "typescript-eslint"; /** @type {import('eslint').Linter.Config[]} */ @@ -21,4 +21,12 @@ export default [ "@typescript-eslint/no-unused-vars": "off", }, }, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), + project: "./tsconfig.json", + }, + }, + }, ]; diff --git a/npm-packages/shared-utils/src/imc/poly-imc.ts b/npm-packages/shared-utils/src/imc/poly-imc.ts index 417524bc..61636894 100644 --- a/npm-packages/shared-utils/src/imc/poly-imc.ts +++ b/npm-packages/shared-utils/src/imc/poly-imc.ts @@ -16,7 +16,7 @@ import { InterModuleCommunication } from "./inter-module-communication"; export class PolyIMC { // A map that maps the other window ID, // and IMC between current window and the other window. - private channels: Map; + private channelsMap: Map; private baseReceiverHandlerMap: ReceiverHandlerMap; private channelReceiverHandlerMapMap: Map; @@ -26,7 +26,7 @@ export class PolyIMC { * E.g. Pulse Editor API handler */ constructor(baseReceiverHandlerMap: ReceiverHandlerMap) { - this.channels = new Map(); + this.channelsMap = new Map(); this.baseReceiverHandlerMap = baseReceiverHandlerMap; this.channelReceiverHandlerMapMap = new Map(); } @@ -37,22 +37,31 @@ export class PolyIMC { payload?: any, abortSignal?: AbortSignal ) { - const channel = this.channels.get(targetWindowId); - if (!channel) { + const channels = this.channelsMap.get(targetWindowId); + if (!channels) { throw new Error("Channel not found for window ID " + targetWindowId); } - return await channel.sendMessage(handlingType, payload, abortSignal); + const results = await Promise.all( + channels.map( + async (channel) => + await channel.sendMessage(handlingType, payload, abortSignal) + ) + ); + + return results; } public updateBaseReceiverHandlerMap(handlerMap: ReceiverHandlerMap) { this.baseReceiverHandlerMap = handlerMap; - this.channels.forEach((channel, key) => { + this.channelsMap.forEach((channels, key) => { const combinedMap = this.getCombinedHandlerMap( this.baseReceiverHandlerMap, this.channelReceiverHandlerMapMap.get(key) ); - channel.updateReceiverHandlerMap(combinedMap); + channels.forEach((channel) => { + channel.updateReceiverHandlerMap(combinedMap); + }); }); } @@ -60,8 +69,8 @@ export class PolyIMC { targetWindowId: string, handlerMap: ReceiverHandlerMap ) { - const channel = this.channels.get(targetWindowId); - if (!channel) { + const channels = this.channelsMap.get(targetWindowId); + if (!channels) { throw new Error("Channel not found for window ID " + targetWindowId); } @@ -70,7 +79,9 @@ export class PolyIMC { handlerMap ); - channel.updateReceiverHandlerMap(combinedMap); + channels.forEach((channel) => { + channel.updateReceiverHandlerMap(combinedMap); + }); this.channelReceiverHandlerMapMap.set(targetWindowId, handlerMap); } @@ -85,7 +96,11 @@ export class PolyIMC { channel.initThisWindow(window, targetWindowId); await channel.initOtherWindow(targetWindow); - this.channels.set(targetWindowId, channel); + const newChannels = this.channelsMap.get(targetWindowId) ?? []; + // Add to existing channels + newChannels.push(channel); + + this.channelsMap.set(targetWindowId, newChannels); // If there is a channel specific receiver handler map, // combine it with the base receiver handler map. @@ -96,26 +111,28 @@ export class PolyIMC { } } - public removeChannel(targetWindowId: string) { - const channel = this.channels.get(targetWindowId); - if (!channel) { + public removeWindowChannels(targetWindowId: string) { + const channels = this.channelsMap.get(targetWindowId); + if (!channels) { throw new Error("Channel not found for window ID " + targetWindowId); } - channel.close(); - this.channels.delete(targetWindowId); + channels.forEach((channel) => { + channel.close(); + }); + this.channelsMap.delete(targetWindowId); this.channelReceiverHandlerMapMap.delete(targetWindowId); } public hasChannel(targetWindowId: string): boolean { - return this.channels.has(targetWindowId); + return this.channelsMap.has(targetWindowId); } public close() { - this.channels.forEach((channel) => { - channel.close(); - }); - this.channels.clear(); + this.channelsMap.forEach((channels) => + channels.forEach((channel) => channel.close()) + ); + this.channelsMap.clear(); this.channelReceiverHandlerMapMap.clear(); } diff --git a/npm-packages/shared-utils/src/types/types.ts b/npm-packages/shared-utils/src/types/types.ts index e7e7f08b..ce892a7f 100644 --- a/npm-packages/shared-utils/src/types/types.ts +++ b/npm-packages/shared-utils/src/types/types.ts @@ -32,6 +32,7 @@ export enum IMCMessageTypeEnum { EditorRunAgentMethod = "editor-run-agent-method", // Get theme EditorThemeUpdate = "editor-theme-update", + EditorAppRequestTheme = "editor-app-request-theme", // Send notification EditorShowNotification = "editor-show-notification", // Get environment variables @@ -39,6 +40,8 @@ export enum IMCMessageTypeEnum { // App state snapshot upon importing & exporting EditorAppStateSnapshotRestore = "editor-app-state-snapshot-restore", EditorAppStateSnapshotSave = "editor-app-state-snapshot-save", + // Handle editor file selection or drop + EditorAppReceiveFileUri = "editor-app-receive-file-uri", // #endregion // #region Platform API interaction messages (require OS-like environment) @@ -48,6 +51,8 @@ export enum IMCMessageTypeEnum { PlatformWriteFile = "platform-write-file", // Request view file PlatformReadFile = "platform-read-file", + // File update (file watch notification from platform to view) + PlatformFileUpdate = "platform-file-update", // #endregion // #region Signal messages @@ -123,7 +128,7 @@ export type AppConfig = { id: string; version: string; libVersion?: string; - visibility?: string; + visibility?: "public" | "private" | "unlisted"; author?: string; displayName?: string; description?: string; diff --git a/web/app/(extension-layout)/extension/layout.tsx b/web/app/(extension-layout)/extension/layout.tsx index 18f21c9c..6573b6e4 100644 --- a/web/app/(extension-layout)/extension/layout.tsx +++ b/web/app/(extension-layout)/extension/layout.tsx @@ -1,15 +1,18 @@ import RemoteModuleProvider from "@/components/providers/remote-module-provider"; -import { ReactNode, Suspense } from "react"; import { Analytics } from "@vercel/analytics/next"; +import { ThemeProvider } from "next-themes"; +import { ReactNode, Suspense } from "react"; export default function ExtensionLayout({ children }: { children: ReactNode }) { return ( - + - - {children} - + + + {children} + + ); diff --git a/web/components/app-loaders/sandbox-app-loader.tsx b/web/components/app-loaders/sandbox-app-loader.tsx index 1485f449..4c9bf97b 100644 --- a/web/components/app-loaders/sandbox-app-loader.tsx +++ b/web/components/app-loaders/sandbox-app-loader.tsx @@ -1,5 +1,5 @@ import BaseAppLoader from "@/components/app-loaders/base-app-loader"; -import Loading from "@/components/interface/loading"; +import Loading from "@/components/interface/status-screens/loading"; import { EditorContext } from "@/components/providers/editor-context-provider"; import { IMCContext } from "@/components/providers/imc-provider"; import { PlatformEnum } from "@/lib/enums"; @@ -15,7 +15,6 @@ import { ViewModel, } from "@pulse-editor/shared-utils"; import { useTheme } from "next-themes"; -import path from "path"; import { useContext, useEffect, useRef, useState } from "react"; /** @@ -46,7 +45,7 @@ export default function SandboxAppLoader({ const [isLoadingExtension, setIsLoadingExtension] = useState(false); const clRef = useRef(null); - const [isConnected, setIsConnected] = useState(false); + // const [isConnected, setIsConnected] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const { resolvedTheme } = useTheme(); @@ -58,7 +57,7 @@ export default function SandboxAppLoader({ // remove the old IMC channel and create a new one if (viewModel.viewId !== currentViewId) { if (currentViewId && imcContext?.hasChannel(currentViewId)) { - imcContext.removeChannel(currentViewId); + imcContext.removeViewChannels(currentViewId); } setCurrentViewId(viewModel.viewId); } @@ -128,6 +127,16 @@ export default function SandboxAppLoader({ isInitialized, ]); + // Remove IMC and listener upon unmount + useEffect(() => { + return () => { + clRef.current?.close(); + if (currentViewId && imcContext?.hasChannel(currentViewId)) { + imcContext?.removeViewChannels(currentViewId); + } + }; + }, []); + // Set is loading extension to true when current extension changes useEffect(() => { if (currentExtension) { @@ -137,16 +146,6 @@ export default function SandboxAppLoader({ } }, [currentExtension]); - // When IMC is connected, remove the connection listener - useEffect(() => { - if (isConnected && clRef.current) { - console.log(`[${currentViewId}]: App connected.`); - // Close the connection listener - clRef.current.close(); - clRef.current = null; - } - }, [isConnected]); - // Send theme update to the extension when theme changes useEffect(() => { if (currentViewId && imcContext?.polyIMC?.hasChannel(currentViewId)) { @@ -166,7 +165,7 @@ export default function SandboxAppLoader({ } // Update the IMC receiver handler map - if (isConnected && imcContext?.polyIMC?.hasChannel(viewModel.viewId)) { + if (imcContext?.polyIMC?.hasChannel(viewModel.viewId)) { imcContext.polyIMC.updateChannelReceiverHandlerMap( viewModel.viewId, getHandlerMap(viewModel), @@ -226,15 +225,14 @@ export default function SandboxAppLoader({ editorContext?.persistSettings?.projectHomePath + "/" + editorContext?.editorStates.project; - const absoluteUri = path.join(projectPath, uri); // Prevent writing to path outside the project path - if (!absoluteUri.startsWith(projectPath)) { + if (!uri.startsWith(projectPath)) { throw new Error( "Cannot write to path outside the project directory.", ); } - const newFile = new File([content], absoluteUri); + const newFile = new File([content], uri); await platformApi?.writeFile(newFile, content); } }, @@ -252,14 +250,15 @@ export default function SandboxAppLoader({ editorContext?.persistSettings?.projectHomePath + "/" + editorContext?.editorStates.project; - const absoluteUri = path.join(projectPath, uri); // Prevent reading path outside the project path - if (!absoluteUri.startsWith(projectPath)) { - throw new Error("Cannot read file outside the project directory."); + if (!uri.startsWith(projectPath)) { + throw new Error( + "Cannot read file outside the project directory: " + uri, + ); } - const file = await platformApi?.readFile(absoluteUri); + const file = await platformApi?.readFile(uri); return file; }, ); @@ -293,15 +292,13 @@ export default function SandboxAppLoader({ } function listenForExtensionConnection() { - setIsConnected(false); - // Create IMC channel if (imcContext?.polyIMC) { const cl = new ConnectionListener( imcContext.polyIMC, getHandlerMap(viewModel), (senderWindow: Window, message: IMCMessage) => { - setIsConnected((prev) => true); + console.log(`[${currentViewId}]: App connected.`); }, viewModel.viewId, ); diff --git a/web/components/explorer/file-system/fs-explorer.tsx b/web/components/explorer/file-system/fs-explorer.tsx index b630f51c..dd10950a 100644 --- a/web/components/explorer/file-system/fs-explorer.tsx +++ b/web/components/explorer/file-system/fs-explorer.tsx @@ -1,28 +1,30 @@ "use client"; +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 { getPlatform } from "@/lib/platform-api/platform-checker"; import { TreeViewGroupRef } from "@/lib/types"; -import { Button } from "@heroui/react"; -import { ViewModeEnum } from "@pulse-editor/shared-utils"; +import { addToast, Button } from "@heroui/react"; +import { IMCMessageTypeEnum, ViewModeEnum } from "@pulse-editor/shared-utils"; import { useContext, useEffect, useRef } from "react"; import toast from "react-hot-toast"; -import { v4 } from "uuid"; import Icon from "../../misc/icon"; import { EditorContext } from "../../providers/editor-context-provider"; import TreeViewGroup from "./tree-view"; -import { PlatformEnum } from "@/lib/enums"; export default function FileSystemExplorer({ setIsMenuOpen, }: { setIsMenuOpen: (isOpen: boolean) => void; }) { - const platform = getPlatform(); const editorContext = useContext(EditorContext); + const imcContext = useContext(IMCContext); + + const platform = getPlatform(); const { platformApi } = usePlatformApi(); - const { openFileInView } = useTabViewManager(); + const { activeTabView, closeAllTabViews } = useTabViewManager(); const rootGroupRef = useRef(null); @@ -36,14 +38,56 @@ export default function FileSystemExplorer({ } }, [editorContext?.editorStates.explorerSelectedNodeRefs]); - function viewFile(uri: string, viewMode: ViewModeEnum) { - platformApi?.readFile(uri).then((file) => { - openFileInView(file, viewMode).then(() => { - if (platform === PlatformEnum.Capacitor) { - setIsMenuOpen(false); - } + async function viewFile(uri: string) { + // Simply send uri to selected app node or app view + if (activeTabView?.type === ViewModeEnum.App) { + // Send uri to app view + await imcContext?.polyIMC?.sendMessage( + activeTabView.config.viewId, + IMCMessageTypeEnum.EditorAppReceiveFileUri, + { + uri, + }, + ); + + if (platform === PlatformEnum.Capacitor) { + setIsMenuOpen(false); + } + } else if (activeTabView?.type === ViewModeEnum.Canvas) { + // Get selected node and send to that node + const selectedViewIds = editorContext?.editorStates.selectedViewIds ?? []; + + if (selectedViewIds.length === 0) { + addToast({ + title: "No app selected to handle this file.", + description: "Please select a node in canvas to view the file", + color: "danger", + }); + return; + } + + // For each selected view Id, send selected file uri + for (const viewId of selectedViewIds) { + await imcContext?.polyIMC?.sendMessage( + viewId, + IMCMessageTypeEnum.EditorAppReceiveFileUri, + { + uri, + }, + ); + } + + if (platform === PlatformEnum.Capacitor) { + setIsMenuOpen(false); + } + } else { + addToast({ + title: "No app selected or opened to handle this file.", + description: + "Please open an app or select a node in canvas to view the file", + color: "danger", }); - }); + } } function startCreatingNewFolder() { @@ -128,7 +172,26 @@ export default function FileSystemExplorer({
- + {/* + */}
diff --git a/web/components/explorer/file-system/tree-view.tsx b/web/components/explorer/file-system/tree-view.tsx index ef406237..3d9d08a2 100644 --- a/web/components/explorer/file-system/tree-view.tsx +++ b/web/components/explorer/file-system/tree-view.tsx @@ -3,6 +3,7 @@ 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 { AbstractPlatformAPI } from "@/lib/platform-api/abstract-platform-api"; import { getPlatform } from "@/lib/platform-api/platform-checker"; import { @@ -12,9 +13,7 @@ import { TreeViewGroupRef, TreeViewNodeRef, } from "@/lib/types"; -import { PlatformEnum } from "@/lib/enums"; import { Button, Input } from "@heroui/react"; -import { ViewModeEnum } from "@pulse-editor/shared-utils"; import { forwardRef, Ref, @@ -64,7 +63,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( parentGroupRef, }: { object: FileSystemObject; - viewFile: (uri: string, viewMode: ViewModeEnum) => void; + viewFile: (uri: string) => Promise; platformApi?: AbstractPlatformAPI; parentGroupRef: RefObject; }, @@ -282,11 +281,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( } } // check if ctrl is down - if (isCtrlDown()) { - viewFile(object.uri, ViewModeEnum.Canvas); - } else { - viewFile(object.uri, ViewModeEnum.App); - } + viewFile(object.uri); }} onContextMenu={handleOnContextMenu} > @@ -328,7 +323,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( onPress={(e) => { setContextMenuState({ x: 0, y: 0, isOpen: false }); - viewFile(object.uri, ViewModeEnum.Canvas); + viewFile(object.uri); }} >

Open In Canvas

@@ -342,7 +337,7 @@ const TreeViewNode = forwardRef(function TreeViewNode( onPress={(e) => { setContextMenuState({ x: 0, y: 0, isOpen: false }); - viewFile(object.uri, ViewModeEnum.App); + viewFile(object.uri); }} >

Open In App

@@ -375,7 +370,7 @@ function TreeViewNodeWrapper({ parentGroupRef, }: { object: FileSystemObject; - viewFile: (uri: string, viewMode: ViewModeEnum) => void; + viewFile: (uri: string) => Promise; platformApi?: AbstractPlatformAPI; parentGroupRef: RefObject; }) { @@ -400,7 +395,7 @@ const TreeViewGroup = forwardRef(function TreeViewGroup( platformApi, }: { objects: FileSystemObject[]; - viewFile: (uri: string, viewMode: ViewModeEnum) => void; + viewFile: (uri: string) => Promise; folderUri: string; platformApi?: AbstractPlatformAPI; }, diff --git a/web/components/explorer/project/project-item.tsx b/web/components/explorer/project/project-item.tsx index 25f47925..b732f8d2 100644 --- a/web/components/explorer/project/project-item.tsx +++ b/web/components/explorer/project/project-item.tsx @@ -1,4 +1,6 @@ +import { PlatformEnum, SideMenuTabEnum } from "@/lib/enums"; import { usePlatformApi } from "@/lib/hooks/use-platform-api"; +import { getPlatform, isWeb } from "@/lib/platform-api/platform-checker"; import { ContextMenuState, ProjectInfo } from "@/lib/types"; import { Button } from "@heroui/react"; import { useContext, useState } from "react"; @@ -15,7 +17,9 @@ export default function ProjectItem({ setSettingsProject: (project: ProjectInfo) => void; }) { const editorContext = useContext(EditorContext); + const { platformApi } = usePlatformApi(); + const [contextMenuState, setContextMenuState] = useState({ x: 0, y: 0, @@ -35,23 +39,26 @@ export default function ProjectItem({ }; }); - // TODO: move this to when workspace is loaded - // const uri = - // editorContext?.persistSettings?.projectHomePath + "/" + projectName; - // platformApi - // ?.listPathContent(uri, { - // include: "all", - // isRecursive: true, - // }) - // .then((objects) => { - // editorContext?.setEditorStates((prev) => { - // return { - // ...prev, - // project: projectName, - // projectContent: objects, - // }; - // }); - // }); + if (getPlatform() === PlatformEnum.Electron) { + const uri = + editorContext?.persistSettings?.projectHomePath + "/" + projectName; + platformApi + ?.listPathContent(uri, { + include: "all", + isRecursive: true, + }) + .then((objects) => { + editorContext?.setEditorStates((prev) => { + return { + ...prev, + project: projectName, + projectContent: objects, + }; + }); + }); + } else if (isWeb()) { + // TODO: move this to when workspace is loaded + } } function formatDateTime(date: Date) { diff --git a/web/components/explorer/workspace/workspace-explorer.tsx b/web/components/explorer/workspace/workspace-explorer.tsx new file mode 100644 index 00000000..bd2f35a8 --- /dev/null +++ b/web/components/explorer/workspace/workspace-explorer.tsx @@ -0,0 +1,5 @@ +import WIP from "@/components/interface/status-screens/wip"; + +export default function WorkspaceExplorer() { + return ; +} diff --git a/web/components/interface/navigation/nav-side-menu.tsx b/web/components/interface/navigation/nav-side-menu.tsx index 213c1397..d0cbe378 100644 --- a/web/components/interface/navigation/nav-side-menu.tsx +++ b/web/components/interface/navigation/nav-side-menu.tsx @@ -1,8 +1,11 @@ import AppExplorer from "@/components/explorer/app/app-explorer"; +import FileSystemExplorer from "@/components/explorer/file-system/fs-explorer"; import ProjectExplorer from "@/components/explorer/project/project-explorer"; +import WorkspaceExplorer from "@/components/explorer/workspace/workspace-explorer"; import Tabs from "@/components/misc/tabs"; import ProjectSettingsModal from "@/components/modals/project-settings-modal"; import { EditorContext } from "@/components/providers/editor-context-provider"; +import { SideMenuTabEnum } from "@/lib/enums"; import useExplorer from "@/lib/hooks/use-explorer"; import { useScreenSize } from "@/lib/hooks/use-screen-size"; import { isWeb } from "@/lib/platform-api/platform-checker"; @@ -10,7 +13,6 @@ import { TabItem } from "@/lib/types"; import { Button } from "@heroui/react"; import { AnimatePresence, motion } from "framer-motion"; import { useContext, useState } from "react"; -import FileSystemExplorer from "../../explorer/file-system/fs-explorer"; import Icon from "../../misc/icon"; export default function NavSideMenu({ @@ -114,22 +116,32 @@ function PanelContent({ const tabItems: TabItem[] = [ { - name: "Projects", + name: SideMenuTabEnum.Projects, description: "List of projects", icon: "folder", }, { - name: "Apps", + name: SideMenuTabEnum.Apps, description: "List of apps", icon: "apps", }, { - name: "Workspace", + name: SideMenuTabEnum.Workspaces, description: "Project workspace", icon: "folder", }, ]; - const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const selectedTab = + editorContext?.editorStates.sideMenuTab ?? SideMenuTabEnum.Projects; + function setSelectedTab(tab: SideMenuTabEnum) { + editorContext?.setEditorStates((prev) => { + return { + ...prev, + sideMenuTab: tab, + }; + }); + } // Choose project home path if (!isWeb() && !editorContext?.persistSettings?.projectHomePath) { @@ -157,35 +169,41 @@ function PanelContent({
tab.name === selectedTab)} setSelectedItem={(item) => { const index = tabItems.findIndex( (tab) => tab.name === item?.name, ); - setSelectedTabIndex(index !== -1 ? index : 0); + setSelectedTab(item?.name as SideMenuTabEnum); }} isClosable={false} />
- {tabItems[selectedTabIndex]?.name === "Apps" ? ( + {selectedTab === SideMenuTabEnum.Apps ? ( - ) : tabItems[selectedTabIndex]?.name === "Workspace" ? ( - + ) : selectedTab === SideMenuTabEnum.Workspaces ? ( + // + ) : ( -
-

View Projects

- - -
+ selectedTab === SideMenuTabEnum.Projects && + (editorContext?.editorStates.project ? ( + + ) : ( +
+

View Projects

+ + +
+ )) )}
{ @@ -29,7 +29,7 @@ export default function ProjectIndicator() { }); // Clear view manager - closeAllFileViews(); + closeAllTabViews(); } function handleProjectMenu(key: Key) { diff --git a/web/components/interface/loading.tsx b/web/components/interface/status-screens/loading.tsx similarity index 100% rename from web/components/interface/loading.tsx rename to web/components/interface/status-screens/loading.tsx diff --git a/web/components/interface/not-authorized.tsx b/web/components/interface/status-screens/not-authorized.tsx similarity index 100% rename from web/components/interface/not-authorized.tsx rename to web/components/interface/status-screens/not-authorized.tsx diff --git a/web/components/interface/status-screens/wip.tsx b/web/components/interface/status-screens/wip.tsx new file mode 100644 index 00000000..9257e768 --- /dev/null +++ b/web/components/interface/status-screens/wip.tsx @@ -0,0 +1,8 @@ +export default function WIP() { + return ( +
+

🚧

+

This feature is coming soon!

+
+ ); +} diff --git a/web/components/marketplace/app/app-gallery.tsx b/web/components/marketplace/app/app-gallery.tsx index 0b2197e4..e2f3a23a 100644 --- a/web/components/marketplace/app/app-gallery.tsx +++ b/web/components/marketplace/app/app-gallery.tsx @@ -7,7 +7,7 @@ import { Select, SelectItem } from "@heroui/react"; import { useContext, useEffect, useState } from "react"; import { compare } from "semver"; import useSWR from "swr"; -import Loading from "../../interface/loading"; +import Loading from "../../interface/status-screens/loading"; import AppPreviewCard from "./app-preview-card"; export default function AppGallery() { diff --git a/web/components/marketplace/workflow/workflow-gallery.tsx b/web/components/marketplace/workflow/workflow-gallery.tsx index a9bce5e6..94f546aa 100644 --- a/web/components/marketplace/workflow/workflow-gallery.tsx +++ b/web/components/marketplace/workflow/workflow-gallery.tsx @@ -5,7 +5,7 @@ import { Select, SelectItem } from "@heroui/react"; import { useEffect, useState } from "react"; import { compare } from "semver"; import useSWR from "swr"; -import Loading from "../../interface/loading"; +import Loading from "../../interface/status-screens/loading"; import WorkflowPreviewCard from "./workflow-preview-card"; export default function WorkflowGallery() { diff --git a/web/components/misc/qr-display.tsx b/web/components/misc/qr-display.tsx index edc73c5a..e76c534f 100644 --- a/web/components/misc/qr-display.tsx +++ b/web/components/misc/qr-display.tsx @@ -1,6 +1,6 @@ import { generateQR } from "@/lib/share/qr-gen"; import { useEffect, useState } from "react"; -import Loading from "../interface/loading"; +import Loading from "../interface/status-screens/loading"; import { Button } from "@heroui/react"; import toast from "react-hot-toast"; import Icon from "./icon"; diff --git a/web/components/providers/imc-provider.tsx b/web/components/providers/imc-provider.tsx index 2ed5ef17..84da5acc 100644 --- a/web/components/providers/imc-provider.tsx +++ b/web/components/providers/imc-provider.tsx @@ -27,6 +27,7 @@ import { STTConfig, TTSConfig, } from "@pulse-editor/shared-utils"; +import { useTheme } from "next-themes"; import { createContext, useContext, useEffect, useRef } from "react"; import { EditorContext } from "./editor-context-provider"; @@ -38,6 +39,7 @@ export default function InterModuleCommunicationProvider({ children: React.ReactNode; }) { const editorContext = useContext(EditorContext); + const { resolvedTheme } = useTheme(); const polyIMCRef = useRef(undefined); const imcInitializedMapRef = useRef>(new Map()); @@ -113,9 +115,9 @@ export default function InterModuleCommunicationProvider({ return polyIMCRef.current.hasChannel(viewId); } - function removeChannel(viewId: string) { + function removeViewChannels(viewId: string) { if (!polyIMCRef.current) return; - polyIMCRef.current.removeChannel(viewId); + polyIMCRef.current.removeWindowChannels(viewId); imcInitializedMapRef.current.delete(viewId); delete imcInitializedResolvePromisesRef.current[viewId]; } @@ -488,6 +490,16 @@ export default function InterModuleCommunicationProvider({ markActionRegistered(action); }, ], + [ + IMCMessageTypeEnum.EditorAppRequestTheme, + async ( + senderWindow: Window, + message: IMCMessage, + abortSignal?: AbortSignal, + ) => { + return resolvedTheme ?? "light"; + }, + ], ]); return newMap; @@ -500,7 +512,7 @@ export default function InterModuleCommunicationProvider({ markIMCInitialized, resolveWhenActionRegistered, hasChannel, - removeChannel, + removeViewChannels, }} > {children} diff --git a/web/components/views/base/base-app-view.tsx b/web/components/views/base/base-app-view.tsx index 149eb67b..80e62895 100644 --- a/web/components/views/base/base-app-view.tsx +++ b/web/components/views/base/base-app-view.tsx @@ -1,5 +1,5 @@ -import Loading from "@/components/interface/loading"; -import NotAuthorized from "@/components/interface/not-authorized"; +import Loading from "@/components/interface/status-screens/loading"; +import NotAuthorized from "@/components/interface/status-screens/not-authorized"; import { EditorContext } from "@/components/providers/editor-context-provider"; import { IMCContext } from "@/components/providers/imc-provider"; import useExtensionManager from "@/lib/hooks/use-extension-manager"; diff --git a/web/lib/enums.ts b/web/lib/enums.ts index 994a6770..08b9e416 100644 --- a/web/lib/enums.ts +++ b/web/lib/enums.ts @@ -12,4 +12,10 @@ export enum MarketplaceCategoryEnum { Workflows = "Workflows", Apps = "Apps", } + +export enum SideMenuTabEnum { + Projects = "Projects", + Apps = "Apps", + Workspaces = "Workspaces", +} // #endregion diff --git a/web/lib/hooks/use-canvas-workflow.ts b/web/lib/hooks/use-canvas-workflow.ts index e1a0571a..adf49008 100644 --- a/web/lib/hooks/use-canvas-workflow.ts +++ b/web/lib/hooks/use-canvas-workflow.ts @@ -1,3 +1,4 @@ +import { EditorContext } from "@/components/providers/editor-context-provider"; import { IMCContext } from "@/components/providers/imc-provider"; import { addToast } from "@heroui/react"; import { IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; @@ -15,6 +16,7 @@ import useScopedActions from "./use-scoped-actions"; export default function useCanvasWorkflow( initialWorkflowContent?: WorkflowContent, ) { + const editorContext = useContext(EditorContext); const imcContext = useContext(IMCContext); const { runAction } = useScopedActions(); @@ -40,9 +42,92 @@ export default function useCanvasWorkflow( const [isRestored, setIsRestored] = useState(false); + const debouncedGetEntryPoint = useDebouncedCallback(() => { + const entry = localNodes.find((node) => node.selected) ?? defaultEntryPoint; + setEntryPoint(entry); + }, 200); + + const debounceSetSelectedViews = useDebouncedCallback(() => { + const viewIds = localNodes + .filter((n) => n.selected) + .map((n) => n.data.config.viewId); + + editorContext?.setEditorStates((prev) => ({ + ...prev, + selectedViewIds: viewIds, + })); + }, 200); + + const updateWorkflowNodeData = useCallback( + (nodeViewId: string, data: Partial) => { + setLocalNodes((prev) => { + const index = prev.findIndex((n) => n.id === nodeViewId); + if (index === -1) return prev; + const node = prev[index]; + const newNode = { + ...node, + data: { + ...node.data, + ...data, + }, + }; + const newNodes = [...prev]; + newNodes[index] = newNode; + return newNodes; + }); + }, + [localNodes], + ); + + const updateWorkflowNodes = useCallback( + ( + updater: ( + oldNodes: ReactFlowNode[], + ) => ReactFlowNode[], + ) => { + const updatedNodes = updater(localNodes ?? []); + setLocalNodes(updatedNodes); + }, + [localNodes], + ); + const updateWorkflowEdges = useCallback( + (updater: (oldEdges: ReactFlowEdge[]) => ReactFlowEdge[]) => { + const updatedEdges = updater(localEdges ?? []); + setLocalEdges(updatedEdges); + }, + [localEdges], + ); + + const exportWorkflow = useCallback(async () => { + const blob = new Blob( + [ + JSON.stringify( + { + nodes: localNodes, + edges: localEdges, + defaultEntryPoint: defaultEntryPoint, + snapshotStates: await saveAppsSnapshotStates(), + } as WorkflowContent, + null, + 2, + ), + ], + { + type: "application/json", + }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "workflow.json"; + a.click(); + URL.revokeObjectURL(url); + }, [localNodes, localEdges, defaultEntryPoint]); + // Update entry points useEffect(() => { debouncedGetEntryPoint(); + debounceSetSelectedViews(); }, [localNodes]); // Restore snapshot states upon loading a workflow @@ -313,77 +398,6 @@ export default function useCanvasWorkflow( setPendingNodes([entryPoint]); } - const debouncedGetEntryPoint = useDebouncedCallback(() => { - const entry = localNodes.find((node) => node.selected) ?? defaultEntryPoint; - setEntryPoint(entry); - }, 200); - - const updateWorkflowNodeData = useCallback( - (nodeViewId: string, data: Partial) => { - setLocalNodes((prev) => { - const index = prev.findIndex((n) => n.id === nodeViewId); - if (index === -1) return prev; - const node = prev[index]; - const newNode = { - ...node, - data: { - ...node.data, - ...data, - }, - }; - const newNodes = [...prev]; - newNodes[index] = newNode; - return newNodes; - }); - }, - [localNodes], - ); - - const updateWorkflowNodes = useCallback( - ( - updater: ( - oldNodes: ReactFlowNode[], - ) => ReactFlowNode[], - ) => { - const updatedNodes = updater(localNodes ?? []); - setLocalNodes(updatedNodes); - }, - [localNodes], - ); - const updateWorkflowEdges = useCallback( - (updater: (oldEdges: ReactFlowEdge[]) => ReactFlowEdge[]) => { - const updatedEdges = updater(localEdges ?? []); - setLocalEdges(updatedEdges); - }, - [localEdges], - ); - - const exportWorkflow = useCallback(async () => { - const blob = new Blob( - [ - JSON.stringify( - { - nodes: localNodes, - edges: localEdges, - defaultEntryPoint: defaultEntryPoint, - snapshotStates: await saveAppsSnapshotStates(), - } as WorkflowContent, - null, - 2, - ), - ], - { - type: "application/json", - }, - ); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "workflow.json"; - a.click(); - URL.revokeObjectURL(url); - }, [localNodes, localEdges, defaultEntryPoint]); - async function saveAppsSnapshotStates() { const apps = localNodes.map((node) => node.data.config); @@ -395,10 +409,17 @@ export default function useCanvasWorkflow( return await Promise.race([ new Promise((resolve) => setTimeout(() => resolve(null), 2000)), (async () => { - const { states } = await imcContext?.polyIMC?.sendMessage( + // All IMC channels' states + const channelsStates = await imcContext?.polyIMC?.sendMessage( app.viewId, IMCMessageTypeEnum.EditorAppStateSnapshotSave, ); + + // Consolidate states from all channels into one for this view ID + const states = channelsStates?.reduce((acc, curr) => { + return { ...acc, ...curr }; + }, {}); + return { appId: app.viewId, states: states }; })(), ]); diff --git a/web/lib/hooks/use-tab-view-manager.ts b/web/lib/hooks/use-tab-view-manager.ts index 9551e2c3..97206f8e 100644 --- a/web/lib/hooks/use-tab-view-manager.ts +++ b/web/lib/hooks/use-tab-view-manager.ts @@ -211,10 +211,10 @@ export function useTabViewManager() { if (view.type === ViewModeEnum.Canvas) { // Remove the app nodes' view IDs from IMC context (view.config as CanvasViewConfig).appConfigs?.forEach((appConfig) => { - imcContext?.removeChannel(appConfig.viewId); + imcContext?.removeViewChannels(appConfig.viewId); }); } else if (view.type === ViewModeEnum.App) { - imcContext?.removeChannel((view.config as AppViewConfig).viewId); + imcContext?.removeViewChannels((view.config as AppViewConfig).viewId); } } @@ -237,10 +237,10 @@ export function useTabViewManager() { tabViews.forEach((view) => { if (view.type === ViewModeEnum.Canvas) { (view.config as CanvasViewConfig).appConfigs?.forEach((appConfig) => { - imcContext?.removeChannel(appConfig.viewId); + imcContext?.removeViewChannels(appConfig.viewId); }); } else if (view.type === ViewModeEnum.App) { - imcContext?.removeChannel( + imcContext?.removeViewChannels( (view.config as AppViewConfig).viewId, ); } diff --git a/web/lib/types.ts b/web/lib/types.ts index 3a8f941e..43e84634 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -7,6 +7,7 @@ import { } from "@pulse-editor/shared-utils"; import { Edge as ReactFlowEdge, Node as ReactFlowNode } from "@xyflow/react"; import { Dispatch, RefObject, SetStateAction } from "react"; +import { SideMenuTabEnum } from "./enums"; import { BaseLLM } from "./modalities/llm/llm"; import { BaseSTT } from "./modalities/stt/stt"; import { BaseTTS } from "./modalities/tts/tts"; @@ -86,6 +87,10 @@ export type EditorStates = { // Side menu panel isSideMenuOpen?: boolean; isMarketplaceOpen?: boolean; + sideMenuTab?: SideMenuTabEnum; + + // Selected views + selectedViewIds?: string[]; }; /** @@ -307,7 +312,7 @@ export type IMCContextType = { markIMCInitialized: (viewId: string) => void; resolveWhenActionRegistered: (action: Action) => Promise; hasChannel: (viewId: string) => boolean; - removeChannel: (viewId: string) => void; + removeViewChannels: (viewId: string) => void; }; // #endregion @@ -343,7 +348,7 @@ export type AppMetaData = { org: { name: string; }; - visibility: string; + visibility: "public" | "private" | "unlisted"; thumbnail?: string; }; // #endregion