diff --git a/.changeset/crisp-impalas-search.md b/.changeset/crisp-impalas-search.md new file mode 100644 index 00000000..92f01900 --- /dev/null +++ b/.changeset/crisp-impalas-search.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Revert debug information diff --git a/.changeset/cuddly-books-send.md b/.changeset/cuddly-books-send.md new file mode 100644 index 00000000..289c2277 --- /dev/null +++ b/.changeset/cuddly-books-send.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Add ignore signal and improve multi-channel polyimc handling diff --git a/.changeset/green-laws-lose.md b/.changeset/green-laws-lose.md new file mode 100644 index 00000000..3fc04f35 --- /dev/null +++ b/.changeset/green-laws-lose.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Fix polyIMC any channel would handle all channels under same window (view) ID diff --git a/.changeset/modern-stamps-learn.md b/.changeset/modern-stamps-learn.md new file mode 100644 index 00000000..ff0cbe91 --- /dev/null +++ b/.changeset/modern-stamps-learn.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Fix platformAPI for FS diff --git a/.changeset/pre.json b/.changeset/pre.json index 3e93fba9..925bc9e3 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,16 +1,13 @@ { "mode": "pre", - "tag": "alpha", + "tag": "beta", "initialVersions": { - "pulse-editor-desktop": "0.0.1", - "pulse-editor-mobile": "0.0.1", - "@pulse-editor/react-api": "0.1.1-beta.1", - "@pulse-editor/shared-utils": "0.1.1-beta.1", - "pulse-editor-vscode": "0.0.3", - "@pulse-editor/web": "0.1.1-beta.1", - "@pulse-editor/desktop": "0.0.1", + "@pulse-editor/capacitor-plugin": "0.0.1", "@pulse-editor/mobile": "0.0.1", - "@pulse-editor/capacitor-plugin": "0.0.1" + "@pulse-editor/react-api": "0.1.1-alpha.54", + "@pulse-editor/shared-utils": "0.1.1-alpha.54", + "pulse-editor-vscode": "0.0.3", + "@pulse-editor/web": "0.1.1-alpha.13" }, "changesets": [ "angry-llamas-smash", @@ -22,8 +19,10 @@ "clean-mangos-swim", "cold-shrimps-give", "crazy-cities-stand", + "crisp-impalas-search", "cruel-waves-double", "cruel-zoos-play", + "cuddly-books-send", "curvy-places-wash", "cute-foxes-wink", "dirty-swans-rescue", @@ -35,11 +34,13 @@ "fruity-goats-look", "full-beans-stop", "fuzzy-sheep-pay", + "green-laws-lose", "hot-symbols-fry", "hot-windows-march", "large-moose-tap", "lazy-zebras-mate", "mighty-ghosts-crash", + "modern-stamps-learn", "odd-hounds-enjoy", "petite-memes-fix", "polite-lines-dance", @@ -51,16 +52,19 @@ "sharp-memes-give", "shiny-doodles-jump", "silent-glasses-kick", + "slick-olives-wink", "slick-roses-fix", "social-donkeys-cross", "soft-cases-share", "stale-groups-poke", + "sunny-symbols-flash", "tender-jeans-occur", "tender-phones-ring", "tough-aliens-appear", "true-suits-fly", "vast-places-rhyme", "weak-beers-watch", - "wicked-spoons-fry" + "wicked-spoons-fry", + "young-jeans-behave" ] } diff --git a/.changeset/slick-olives-wink.md b/.changeset/slick-olives-wink.md new file mode 100644 index 00000000..86f63285 --- /dev/null +++ b/.changeset/slick-olives-wink.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Release BETA packages diff --git a/.changeset/sunny-symbols-flash.md b/.changeset/sunny-symbols-flash.md new file mode 100644 index 00000000..73e449e2 --- /dev/null +++ b/.changeset/sunny-symbols-flash.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Fix imc intent typo diff --git a/.changeset/young-jeans-behave.md b/.changeset/young-jeans-behave.md new file mode 100644 index 00000000..abc5bc6f --- /dev/null +++ b/.changeset/young-jeans-behave.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Fix IMC handshake async bug diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..13654e53 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "plugins": [ + "prettier-plugin-organize-imports", + "prettier-plugin-tailwindcss" + ] +} \ No newline at end of file diff --git a/desktop/forge.config.ts b/desktop/forge.config.ts index 97f3c21d..cbb3ebe1 100644 --- a/desktop/forge.config.ts +++ b/desktop/forge.config.ts @@ -1,12 +1,12 @@ import type { ForgeConfig } from "@electron-forge/shared-types"; -import path from "path"; import fs from "fs-extra"; +import path from "path"; -function moveModule(moduleList: string[], resourcePath: string) { +function copyModule(moduleList: string[], resourcePath: string) { moduleList.forEach((module) => { - fs.moveSync( + fs.copySync( path.join("./node_modules", module), - path.join(resourcePath, "app/node_modules", module) + path.join(resourcePath, "app/node_modules", module), ); }); } @@ -27,11 +27,11 @@ const config: ForgeConfig = { // We need electron-serve to exist inside the electron build's node_modules. // All other modules from nextjs are not needed and can be removed. if (platform === "win32") { - moveModule(electronModules, path.join(extractPath, "resources")); + copyModule(electronModules, path.join(extractPath, "resources")); } else if (platform === "darwin") { - moveModule( + copyModule( electronModules, - path.join(extractPath, "pulse-editor.app/Contents/Resources") + path.join(extractPath, "pulse-editor.app/Contents/Resources"), ); } else if (platform === "linux") { } @@ -47,7 +47,7 @@ const config: ForgeConfig = { options: { icon: path.join( __dirname, - "../shared-assets/icons/electron/pulse_logo_round" + "../shared-assets/icons/electron/pulse_logo_round", ), }, }, diff --git a/desktop/lib/node-pty-server.js b/desktop/lib/node-pty-server.js index 6fbcaf0b..0296b9ae 100644 --- a/desktop/lib/node-pty-server.js +++ b/desktop/lib/node-pty-server.js @@ -1,7 +1,7 @@ import http from "http"; -import { WebSocketServer } from "ws"; -import os from "os"; import pty from "node-pty"; +import os from "os"; +import { WebSocketServer } from "ws"; let sharedPtyProcess = null; let sharedTerminalMode = false; @@ -25,14 +25,20 @@ const setSharedTerminalMode = (useSharedTerminal) => { const handleTerminalConnection = (ws) => { let ptyProcess = sharedTerminalMode ? sharedPtyProcess : spawnShell(); - ws.on("message", (command) => { - const processedCommand = commandProcessor(command); - ptyProcess.write(processedCommand); + ws.on("message", (data) => { + const dataObj = JSON.parse(data); + + if (dataObj.type === "input") { + const command = dataObj.payload; + ptyProcess.write(command); + } else if (dataObj.type === "resize") { + const { cols, rows } = dataObj.payload; + ptyProcess.resize(cols, rows); + } }); ptyProcess.on("data", (rawOutput) => { - const processedOutput = outputProcessor(rawOutput); - ws.send(processedOutput); + ws.send(JSON.stringify({ type: "output", payload: rawOutput })); }); ws.on("close", () => { @@ -42,16 +48,6 @@ const handleTerminalConnection = (ws) => { }); }; -// Utility function to process commands -const commandProcessor = (command) => { - return command; -}; - -// Utility function to process output -const outputProcessor = (output) => { - return output; -}; - /* Host ws node-pty server */ setSharedTerminalMode(false); // Set this to false to allow a shared session const port = 6060; diff --git a/desktop/main.mjs b/desktop/main.mjs index a2b92d89..8cf17e1f 100644 --- a/desktop/main.mjs +++ b/desktop/main.mjs @@ -1,17 +1,17 @@ -import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, session } from "electron"; import serve from "electron-serve"; import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; -import { createTerminalServer } from "./lib/node-pty-server.js"; import ignore from "ignore"; +import { createTerminalServer } from "./lib/node-pty-server.js"; // Change path to "Pulse Editor" app.setName("Pulse Editor"); app.setPath( "userData", - app.getPath("userData").replace("pulse-editor-desktop", "Pulse Editor") + app.getPath("userData").replace("pulse-editor-desktop", "Pulse Editor"), ); // Get the file path of the current module @@ -34,7 +34,9 @@ serve({ scheme: "extension", }); -function createWindow() { +let mainWindow = null; + +function createMainWindow() { const win = new BrowserWindow({ width: 960, height: 600, @@ -47,6 +49,7 @@ function createWindow() { }, icon: path.join(__dirname, "../shared-assets/icons/electron/pulse_editor"), }); + mainWindow = win; win.menuBarVisible = false; @@ -134,7 +137,7 @@ async function listPathContent(uri, options, baseUri = undefined) { (file) => (options?.include === "folders" && file.isDirectory()) || (options?.include === "files" && file.isFile()) || - options?.include === "all" + options?.include === "all", ) // Filter by gitignore .filter((file) => { @@ -263,6 +266,77 @@ function handleGetInstallationPath(event) { return uri; } +async function handleLogin(event) { + const cookieName = "pulse-editor.session-token"; + + // Use the default session so cookies are shared automatically + const loginWindow = new BrowserWindow({ + width: 600, + height: 700, + show: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + session: session.defaultSession, // ← important + }, + }); + + const signinUrl = "https://pulse-editor.com/api/auth/signin"; + await loginWindow.loadURL(signinUrl); + + const loginSession = loginWindow.webContents.session; + + const interval = setInterval(async () => { + try { + const cookies = await loginSession.cookies.get({ name: cookieName }); + if (cookies.length > 0) { + clearInterval(interval); + + // Login successful + loginWindow.close(); + console.log(`Login successful, cookie "${cookieName}" present.`); + + // Reload main window + if (mainWindow) { + mainWindow.reload(); + } + } + } catch (err) { + console.error("Error checking cookie:", err); + } + }, 1000); +} + +async function handleLogout() { + const mainSession = session.defaultSession; + const cookieName = "pulse-editor.session-token"; + + try { + const cookies = await mainSession.cookies.get({ name: cookieName }); + + for (const cookie of cookies) { + const url = cookie.domain.startsWith(".") + ? `https://${cookie.domain.slice(1)}${cookie.path}` + : `https://${cookie.domain}${cookie.path}`; + + await mainSession.cookies.remove(url, cookie.name); + } + + console.log(`Cookie "${cookieName}" removed. Logout successful.`); + + // Reload main window + if (mainWindow) { + mainWindow.reload(); + } + + // Return success to renderer + return { success: true }; + } catch (err) { + console.error("Error during logout:", err); + return { success: false, error: err.message }; + } +} + let isCreatedTerminal = false; function handleCreateTerminal(event) { if (!isCreatedTerminal) { @@ -303,7 +377,10 @@ app.whenReady().then(() => { ipcMain.handle("create-terminal", handleCreateTerminal); - createWindow(); + ipcMain.handle("login", handleLogin); + ipcMain.handle("logout", handleLogout); + + createMainWindow(); }); app.on("window-all-closed", () => { diff --git a/desktop/package-lock.json b/desktop/package-lock.json index b4da5eb6..c992641e 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -731,9 +731,9 @@ } }, "node_modules/@electron/node-gyp/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1050,9 +1050,9 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1974,9 +1974,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2070,9 +2070,9 @@ } }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2585,9 +2585,9 @@ "license": "MIT" }, "node_modules/electron": { - "version": "36.3.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-36.3.2.tgz", - "integrity": "sha512-v0/j7n22CL3OYv9BIhq6JJz2+e1HmY9H4bjTk8/WzVT9JwVX/T/21YNdR7xuQ6XDSEo9gP5JnqmjOamE+CUY8Q==", + "version": "36.9.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-36.9.5.tgz", + "integrity": "sha512-1UCss2IqxqujSzg/2jkRjuiT3G+EEXgd6UKB5kUekwQW1LJ6d4QCr8YItfC3Rr9VIGRDJ29eOERmnRNO1Eh+NA==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/desktop/preload.mjs b/desktop/preload.mjs index fe74b1be..2f49128d 100644 --- a/desktop/preload.mjs +++ b/desktop/preload.mjs @@ -31,4 +31,7 @@ contextBridge.exposeInMainWorld("electronAPI", { getInstallationPath: () => ipcRenderer.invoke("get-installation-path"), createTerminal: () => ipcRenderer.invoke("create-terminal"), + + login: () => ipcRenderer.invoke("login"), + logout: () => ipcRenderer.invoke("logout"), }); diff --git a/npm-packages/cli/package.json b/npm-packages/cli/package.json index b4786724..46768f7a 100644 --- a/npm-packages/cli/package.json +++ b/npm-packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-editor/cli", - "version": "0.1.0-beta.4", + "version": "0.1.0-beta.5", "license": "MIT", "bin": { "pulse": "./dist/cli.js" diff --git a/npm-packages/cli/source/components/commands/create.tsx b/npm-packages/cli/source/components/commands/create.tsx index 4ff67f8f..8ae10632 100644 --- a/npm-packages/cli/source/components/commands/create.tsx +++ b/npm-packages/cli/source/components/commands/create.tsx @@ -57,9 +57,14 @@ export default function Create({cli}: {cli: Result}) { useEffect(() => { if (framework) { - setIsShowProjectNameInput(true); + const name = cli.flags.name; + if (name) { + setProjectName(name); + } else { + setIsShowProjectNameInput(true); + } } - }, [framework]); + }, [framework, cli]); useEffect(() => { if (projectName) { @@ -77,9 +82,14 @@ export default function Create({cli}: {cli: Result}) { return; } - setIsShowVisibilitySelect(true); + const visibility = cli.flags.visibility; + if (visibility) { + setVisibility(visibility); + } else { + setIsShowVisibilitySelect(true); + } } - }, [projectName]); + }, [projectName, cli]); useEffect(() => { if (visibility && projectName) { diff --git a/npm-packages/cli/source/lib/cli-flags.ts b/npm-packages/cli/source/lib/cli-flags.ts index 038036ee..aedaacc9 100644 --- a/npm-packages/cli/source/lib/cli-flags.ts +++ b/npm-packages/cli/source/lib/cli-flags.ts @@ -26,6 +26,14 @@ export const flags = defineFlags({ type: 'boolean', default: false, }, + name: { + type: 'string', + shortFlag: 'n', + }, + visibility: { + type: 'string', + shortFlag: 'v', + }, }); export type Flags = typeof flags; diff --git a/npm-packages/cli/source/lib/manual.ts b/npm-packages/cli/source/lib/manual.ts index fe00d6dc..8f980970 100644 --- a/npm-packages/cli/source/lib/manual.ts +++ b/npm-packages/cli/source/lib/manual.ts @@ -38,6 +38,11 @@ const create = `\ The framework to use for the new app. Currently available options: react. Future options: vue, angular, etc. + --name, -n [project-name] + The name of the new project. + --visibility, -v [visibility] + The visibility of the new project. Options are private, + public, and unlisted. `; export const commandsManual: Record = { diff --git a/npm-packages/react-api/CHANGELOG.md b/npm-packages/react-api/CHANGELOG.md index 889d6f76..83fdb858 100644 --- a/npm-packages/react-api/CHANGELOG.md +++ b/npm-packages/react-api/CHANGELOG.md @@ -1,5 +1,61 @@ # @pulse-editor/react-api +## 0.1.1-beta.55 + +### Patch Changes + +- Release BETA packages +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-beta.55 + +## 0.1.1-alpha.54 + +### Patch Changes + +- Fix imc intent typo +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.54 + +## 0.1.1-alpha.53 + +### Patch Changes + +- Fix IMC handshake async bug +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.53 + +## 0.1.1-alpha.52 + +### Patch Changes + +- Add ignore signal and improve multi-channel polyimc handling +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.52 + +## 0.1.1-alpha.51 + +### Patch Changes + +- Fix polyIMC any channel would handle all channels under same window (view) ID +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.51 + +## 0.1.1-alpha.50 + +### Patch Changes + +- Revert debug information +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.50 + +## 0.1.1-alpha.49 + +### Patch Changes + +- Fix platformAPI for FS +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.49 + ## 0.1.1-alpha.48 ### Patch Changes diff --git a/npm-packages/react-api/package.json b/npm-packages/react-api/package.json index 246fa6da..a6baa072 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-alpha.48", + "version": "0.1.1-beta.55", "main": "dist/main.js", "files": [ "dist" @@ -21,7 +21,6 @@ "@eslint/js": "^9.25.0", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@types/react": "^19.1.2", "eslint": "^9.25.0", @@ -38,8 +37,8 @@ "typescript-eslint": "^8.30.1" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-alpha.48", + "@pulse-editor/shared-utils": "0.1.1-beta.55", "react": "^19.0.0", "react-dom": "^19.0.0" } -} \ No newline at end of file +} diff --git a/npm-packages/react-api/rollup.config.mjs b/npm-packages/react-api/rollup.config.mjs index d15b1094..890d9239 100644 --- a/npm-packages/react-api/rollup.config.mjs +++ b/npm-packages/react-api/rollup.config.mjs @@ -2,7 +2,6 @@ import resolve from "@rollup/plugin-node-resolve"; import peerDepsExternal from "rollup-plugin-peer-deps-external"; import babel from "@rollup/plugin-babel"; import typescript from "@rollup/plugin-typescript"; -import terser from "@rollup/plugin-terser"; // rollup.config.mjs export default { @@ -12,6 +11,7 @@ export default { file: "dist/main.js", format: "es", exports: "named", + sourcemap: true, }, ], plugins: [ @@ -29,6 +29,5 @@ export default { rootDir: "src", exclude: ["node_modules/**"], }), - terser(), ], }; 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 8c7df5c7..a77a2aaf 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 @@ -12,7 +12,7 @@ export default function useAgentTools() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc } = useIMC(receiverHandlerMap); + const { imc } = useIMC(receiverHandlerMap, "agent-tools"); return { }; } 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 50c43f87..075b2828 100644 --- a/npm-packages/react-api/src/hooks/agent/use-agents.ts +++ b/npm-packages/react-api/src/hooks/agent/use-agents.ts @@ -12,12 +12,12 @@ export default function useAgents() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "agents"); async function runAgentMethod( agentName: string, methodName: string, - parameters: Record, + args: Record, abortSignal?: AbortSignal, llmConfig?: LLMConfig ): Promise { @@ -31,7 +31,7 @@ export default function useAgents() { { agentName, methodName, - parameters, + args, llmConfig, }, abortSignal 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 5c1b6a05..7ee6b309 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 @@ -11,7 +11,7 @@ export default function useImageGen() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "image-gen"); /** * 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 dac9d930..320f1b39 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 @@ -11,7 +11,7 @@ export default function useLLM() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "llm"); async function runLLM( prompt: string, 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 555a1534..0ba26f54 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 @@ -8,7 +8,7 @@ export default function useOCR() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc } = useIMC(receiverHandlerMap); + const { imc } = useIMC(receiverHandlerMap, "ocr"); /** * 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 f4c08db9..eb6358d4 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 @@ -12,7 +12,7 @@ export default function useSpeech2Speech() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "speech2speech"); const [userInput, setUserInput] = useState(""); const [isUserStopped, setIsUserStopped] = useState(false); 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 da5b1709..545f43dd 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 @@ -11,7 +11,7 @@ export default function useSTT() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "stt"); async function runSTT( audio: Uint8Array, 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 c6b653c3..8bc09b05 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 @@ -11,7 +11,7 @@ export default function useTTS() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "tts"); async function runTTS( text: string, 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 8b05e4aa..9f767e26 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 @@ -11,7 +11,7 @@ export default function useVideoGen() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "video-gen"); /** * 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 3f673ca0..4368c91a 100644 --- a/npm-packages/react-api/src/hooks/editor/use-env.ts +++ b/npm-packages/react-api/src/hooks/editor/use-env.ts @@ -8,7 +8,7 @@ export default function usePulseEnv() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "env"); const [envs, setEnvs] = useState>({}); useEffect(() => { diff --git a/npm-packages/react-api/src/hooks/editor/use-file.ts b/npm-packages/react-api/src/hooks/editor/use-file.ts index 9264e80e..66d0f6e0 100644 --- a/npm-packages/react-api/src/hooks/editor/use-file.ts +++ b/npm-packages/react-api/src/hooks/editor/use-file.ts @@ -18,7 +18,7 @@ export default function useFile(uri: string | undefined) { ], ]); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "file"); const saveFile = useCallback( (fileContent: string) => { @@ -39,12 +39,12 @@ export default function useFile(uri: string | undefined) { }); } }, - [uri, file, isReady] + [uri, file, isReady], ); // Read file when uri changes useEffect(() => { - if (isReady) { + if (isReady && uri) { imc ?.sendMessage(IMCMessageTypeEnum.PlatformReadFile, { uri, 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 f5269722..0eb61696 100644 --- a/npm-packages/react-api/src/hooks/editor/use-loading.ts +++ b/npm-packages/react-api/src/hooks/editor/use-loading.ts @@ -8,7 +8,7 @@ export default function useLoading() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "loading"); const [isLoading, setIsLoading] = useState(true); useEffect(() => { 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 d45802af..0774875d 100644 --- a/npm-packages/react-api/src/hooks/editor/use-notification.ts +++ b/npm-packages/react-api/src/hooks/editor/use-notification.ts @@ -12,7 +12,7 @@ export default function useNotification() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc } = useIMC(receiverHandlerMap); + const { imc } = useIMC(receiverHandlerMap, "notification"); function openNotification(text: string, type: NotificationTypeEnum) { if (!imc) { diff --git a/npm-packages/react-api/src/hooks/editor/use-owned-app-view.ts b/npm-packages/react-api/src/hooks/editor/use-owned-app-view.ts new file mode 100644 index 00000000..3b0a3cc0 --- /dev/null +++ b/npm-packages/react-api/src/hooks/editor/use-owned-app-view.ts @@ -0,0 +1,49 @@ +import { + IMCMessage, + IMCMessageTypeEnum, + ViewModel, +} from "@pulse-editor/shared-utils"; +import { useCallback } from "react"; +import useIMC from "../imc/use-imc"; + +export default function useOwnedAppView() { + const receiverHandlerMap = new Map< + IMCMessageTypeEnum, + (senderWindow: Window, message: IMCMessage) => Promise + >([]); + + const { imc, isReady } = useIMC(receiverHandlerMap, "owned-app"); + + const runAppAction = useCallback( + async (ownedAppViewModel: ViewModel, actionName: string, args: any) => { + if (isReady) { + const appViewId = ownedAppViewModel.viewId; + const preRegisteredActions = + ownedAppViewModel.appConfig.preRegisteredActions || []; + + const action = preRegisteredActions.find((a) => a.name === actionName); + if (!action) { + throw new Error( + `Action ${actionName} not found in owned app ${ownedAppViewModel.appConfig.id}`, + ); + } + + const result = await imc?.sendMessage( + IMCMessageTypeEnum.EditorAppUseOwnedApp, + { + viewId: appViewId, + actionName, + args, + }, + ); + return result; + } + return undefined; + }, + [imc, isReady], + ); + + return { + runAppAction, + }; +} 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 36ae295d..e56d26b9 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 @@ -31,12 +31,13 @@ export default function useRegisterAction( }, callbackHandler: (args: any) => Promise, deps: DependencyList, - isExtReady: boolean = true + isExtReady: boolean = true, ) { - const { isReady, imc } = useIMC(getReceiverHandlerMap()); + const { isReady, imc } = useIMC(getReceiverHandlerMap(), "register-action"); // Queue to hold commands until extension is ready const commandQueue = useRef<{ args: any; resolve: (v: any) => void }[]>([]); + const isCommandExecuting = useRef(false); const [action, setAction] = useState({ name: actionInfo.name, @@ -48,13 +49,23 @@ export default function useRegisterAction( // Flush queued commands when isExtReady becomes true useEffect(() => { - if (isExtReady && commandQueue.current.length > 0) { - const pending = [...commandQueue.current]; + async function runQueuedCommands() { + const pendingCMDs = [...commandQueue.current]; commandQueue.current = []; - pending.forEach(async ({ args, resolve }) => { + for (const cmd of pendingCMDs) { + const { args, resolve } = cmd; + if (isCommandExecuting.current) { + return; + } + isCommandExecuting.current = true; const res = await executeAction(args); + isCommandExecuting.current = false; resolve(res); - }); + } + } + + if (isExtReady && commandQueue.current.length > 0) { + runQueuedCommands(); } }, [isExtReady]); @@ -95,6 +106,7 @@ export default function useRegisterAction( if (!action.handler) return; const res = await action.handler(args); + return res; } @@ -106,40 +118,48 @@ export default function useRegisterAction( const { name: requestedName, args }: { name: string; args: any } = message.payload; - if (actionInfo.name === requestedName) { - // Validate parameters - const actionParams = actionInfo.parameters ?? {}; - if (Object.keys(args).length !== Object.keys(actionParams).length) { - throw new Error( - `Invalid number of parameters: expected ${ - Object.keys(actionParams).length - }, got ${Object.keys(args).length}` - ); - } + if (actionInfo.name !== requestedName) { + throw new Error("Message ignored by receiver"); + } + // Validate parameters + const actionParams = actionInfo.parameters ?? {}; + if (Object.keys(args).length !== Object.keys(actionParams).length) { + throw new Error( + `Invalid number of parameters: expected ${ + Object.keys(actionParams).length + }, got ${Object.keys(args).length}`, + ); + } - for (const [key, value] of Object.entries(args)) { - if (actionParams[key] === undefined) { - throw new Error(`Invalid parameter: ${key}`); - } - if (typeof value !== actionParams[key].type) { - throw new Error( - `Invalid type for parameter ${key}: expected ${ - actionParams[key].type - }, got ${typeof value}. Value received: ${value}` - ); - } + // Check types + for (const [key, value] of Object.entries(args)) { + if (actionParams[key] === undefined) { + throw new Error(`Invalid parameter: ${key}`); } - - // If extension is ready, execute immediately - if (isExtReady) { - return await executeAction(args); + if ( + typeof value !== actionParams[key].type && + // Allow object for "app-instance" + (actionParams[key].type !== "app-instance" || + typeof value !== "object") + ) { + throw new Error( + `Invalid type for parameter ${key}: expected ${ + actionParams[key].type + }, got ${typeof value}. Value received: ${value}`, + ); } + } - // Otherwise, queue the command and return when executed - return new Promise((resolve) => { - commandQueue.current.push({ args, resolve }); - }); + // If extension is ready, execute immediately + if (isExtReady) { + const result = await executeAction(args); + return result; } + + // Otherwise, queue the command and return when executed + return new Promise((resolve) => { + commandQueue.current.push({ args, resolve }); + }); }, ], ]); 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 00de0108..c8903545 100644 --- a/npm-packages/react-api/src/hooks/editor/use-theme.ts +++ b/npm-packages/react-api/src/hooks/editor/use-theme.ts @@ -17,7 +17,7 @@ export default function useTheme() { } ); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "theme"); // Upon initial load, request theme from main app useEffect(() => { @@ -25,7 +25,6 @@ export default function useTheme() { imc ?.sendMessage(IMCMessageTypeEnum.EditorAppRequestTheme) .then((result) => { - console.log("Received theme from main app:", result); setTheme((prev) => result); }); } diff --git a/npm-packages/react-api/src/hooks/imc/use-imc.tsx b/npm-packages/react-api/src/hooks/imc/use-imc.tsx index 2f0dcabf..87db13d6 100644 --- a/npm-packages/react-api/src/hooks/imc/use-imc.tsx +++ b/npm-packages/react-api/src/hooks/imc/use-imc.tsx @@ -4,8 +4,9 @@ import { ReceiverHandlerMap, } from "@pulse-editor/shared-utils"; import { useEffect, useState } from "react"; +import { v4 } from "uuid"; -export default function useIMC(handlerMap: ReceiverHandlerMap) { +export default function useIMC(handlerMap: ReceiverHandlerMap, intent: string) { const [imc, setImc] = useState( undefined ); @@ -29,13 +30,16 @@ export default function useIMC(handlerMap: ReceiverHandlerMap) { if (!isMounted) return; else if (imc !== undefined) return; - const newImc = new InterModuleCommunication(); + const newImc = new InterModuleCommunication(intent, v4()); newImc.initThisWindow(window); newImc.updateReceiverHandlerMap(handlerMap); await newImc.initOtherWindow(targetWindow); setImc(newImc); - await newImc.sendMessage(IMCMessageTypeEnum.AppReady); + await newImc.sendMessage(IMCMessageTypeEnum.AppReady, { + intent, + channelId: newImc.channelId, + }); setIsReady(true); } 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 1ff94f2b..f8c62b8e 100644 --- a/npm-packages/react-api/src/hooks/terminal/use-terminal.ts +++ b/npm-packages/react-api/src/hooks/terminal/use-terminal.ts @@ -8,7 +8,7 @@ export default function useTerminal() { (senderWindow: Window, message: IMCMessage) => Promise >(); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "terminal"); const [websocketUrl, setWebsocketUrl] = useState( undefined ); diff --git a/npm-packages/react-api/src/main.ts b/npm-packages/react-api/src/main.ts index a4949fd9..6c685690 100644 --- a/npm-packages/react-api/src/main.ts +++ b/npm-packages/react-api/src/main.ts @@ -14,6 +14,7 @@ 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 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 useTerminal from "./hooks/terminal/use-terminal"; @@ -31,6 +32,7 @@ export { useLoading, useNotification, useOCR, + useOwnedAppView, usePulseEnv, useReceiveFile, useRegisterAction, diff --git a/npm-packages/react-api/src/providers/receive-file-provider.tsx b/npm-packages/react-api/src/providers/receive-file-provider.tsx index f660af5a..aa7bef2e 100644 --- a/npm-packages/react-api/src/providers/receive-file-provider.tsx +++ b/npm-packages/react-api/src/providers/receive-file-provider.tsx @@ -32,7 +32,7 @@ export default function ReceiveFileProvider({ }, ], ]); - useIMC(receiverHandlerMap); + useIMC(receiverHandlerMap, "receive-file-provider"); return ( diff --git a/npm-packages/react-api/src/providers/snapshot-provider.tsx b/npm-packages/react-api/src/providers/snapshot-provider.tsx index 63094913..f198c1bd 100644 --- a/npm-packages/react-api/src/providers/snapshot-provider.tsx +++ b/npm-packages/react-api/src/providers/snapshot-provider.tsx @@ -48,7 +48,7 @@ export default function SnapshotProvider({ ], ]); - const { imc, isReady } = useIMC(receiverHandlerMap); + const { imc, isReady } = useIMC(receiverHandlerMap, "snapshot-provider"); useEffect(() => { if (isReady) { diff --git a/npm-packages/shared-utils/CHANGELOG.md b/npm-packages/shared-utils/CHANGELOG.md index a80202f8..a048847d 100644 --- a/npm-packages/shared-utils/CHANGELOG.md +++ b/npm-packages/shared-utils/CHANGELOG.md @@ -1,5 +1,47 @@ # @pulse-editor/shared-utils +## 0.1.1-beta.55 + +### Patch Changes + +- Release BETA packages + +## 0.1.1-alpha.54 + +### Patch Changes + +- Fix imc intent typo + +## 0.1.1-alpha.53 + +### Patch Changes + +- Fix IMC handshake async bug + +## 0.1.1-alpha.52 + +### Patch Changes + +- Add ignore signal and improve multi-channel polyimc handling + +## 0.1.1-alpha.51 + +### Patch Changes + +- Fix polyIMC any channel would handle all channels under same window (view) ID + +## 0.1.1-alpha.50 + +### Patch Changes + +- Revert debug information + +## 0.1.1-alpha.49 + +### Patch Changes + +- Fix platformAPI for FS + ## 0.1.1-alpha.48 ### Patch Changes diff --git a/npm-packages/shared-utils/package.json b/npm-packages/shared-utils/package.json index 43600fe2..164977f7 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-alpha.48", + "version": "0.1.1-beta.55", "main": "dist/main.js", "files": [ "dist" @@ -22,7 +22,6 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@types/node": "^22.13.1", "eslint": "^9.25.0", @@ -31,4 +30,4 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.30.1" } -} \ No newline at end of file +} diff --git a/npm-packages/shared-utils/rollup.config.mjs b/npm-packages/shared-utils/rollup.config.mjs index 55703db0..e5ac3a68 100644 --- a/npm-packages/shared-utils/rollup.config.mjs +++ b/npm-packages/shared-utils/rollup.config.mjs @@ -1,7 +1,6 @@ import babel from "@rollup/plugin-babel"; import json from "@rollup/plugin-json"; import resolve from "@rollup/plugin-node-resolve"; -import terser from "@rollup/plugin-terser"; import typescript from "@rollup/plugin-typescript"; // rollup.config.mjs @@ -12,6 +11,7 @@ export default { file: "dist/main.js", format: "es", exports: "named", + sourcemap: true, }, ], plugins: [ @@ -28,7 +28,6 @@ export default { rootDir: "src", exclude: ["node_modules/**"], }), - terser(), json(), ], }; diff --git a/npm-packages/shared-utils/src/imc/inter-module-communication.ts b/npm-packages/shared-utils/src/imc/inter-module-communication.ts index 28d3abad..79f02c50 100644 --- a/npm-packages/shared-utils/src/imc/inter-module-communication.ts +++ b/npm-packages/shared-utils/src/imc/inter-module-communication.ts @@ -23,6 +23,14 @@ export class InterModuleCommunication { private messageRecords: Map | undefined; + public intent: string | undefined; + public channelId: string | undefined; + + constructor(intent: string | undefined, channelId: string | undefined) { + this.intent = intent; + this.channelId = channelId; + } + /** * Initialize a receiver to receive message. * @param window The current window. @@ -42,30 +50,43 @@ export class InterModuleCommunication { const receiver = new MessageReceiver( this.receiverHandlerMap, - this.thisWindowId + this.thisWindowId, + this.intent ); this.receiver = receiver; this.messageRecords = new Map(); this.listener = (event: MessageEvent) => { - const messageId = event.data.id; - const type = event.data.type; + const message = event.data; + const messageId = message.messageId; + const channelId = message.channelId; + const type = message.type; + + // Return if the channel ID exists but does not match the current channel ID + // (channel ID can be undefined during initial handshake) + if ( + this.channelId !== undefined && + channelId !== undefined && + channelId !== this.channelId + ) { + return; + } if ( this.messageRecords?.has(messageId) && - type !== IMCMessageTypeEnum.SignalGetWindowId + type !== IMCMessageTypeEnum.SignalRequestOtherWindowId ) { console.warn( `[${ this.thisWindowId }]: Duplicate message received with message ID: ${messageId}. Ignoring this message. Message: ${JSON.stringify( - event.data + message )}` ); return; } - this.messageRecords?.set(messageId, event.data); + this.messageRecords?.set(messageId, message); if (!receiver) { throw new Error( @@ -73,8 +94,7 @@ export class InterModuleCommunication { ); } - const message = event.data; - if (message.from !== undefined) { + if (message.from !== undefined && message.type !== IMCMessageTypeEnum.SignalIgnore) { console.log( `Module ${this.thisWindowId} received message from module ${ message.from @@ -107,13 +127,14 @@ export class InterModuleCommunication { } const message = event.data; - const otherWindowId = message.windowId; + const otherWindowId = message.payload; this.otherWindowId = otherWindowId; const sender = new MessageSender( window, messageTimeout, - this.thisWindowId + this.thisWindowId, + this.channelId ); this.sender = sender; @@ -133,7 +154,7 @@ export class InterModuleCommunication { this.otherWindow = window; this.otherWindow.postMessage( { - type: IMCMessageTypeEnum.SignalGetWindowId, + type: IMCMessageTypeEnum.SignalRequestOtherWindowId, from: this.thisWindowId, }, "*" @@ -197,7 +218,9 @@ export class InterModuleCommunication { this.receiverHandlerMap?.set( IMCMessageTypeEnum.SignalAcknowledge, async (senderWindow: Window, message: IMCMessage) => { - const pendingMessage = this.sender?.getPendingMessage(message.id); + const pendingMessage = this.sender?.getPendingMessage( + message.messageId + ); if (pendingMessage) { pendingMessage.resolve(message.payload); } @@ -208,7 +231,9 @@ export class InterModuleCommunication { this.receiverHandlerMap?.set( IMCMessageTypeEnum.SignalError, async (senderWindow: Window, message: IMCMessage) => { - const pendingMessage = this.sender?.getPendingMessage(message.id); + const pendingMessage = this.sender?.getPendingMessage( + message.messageId + ); if (pendingMessage) { pendingMessage.reject(new Error(message.payload)); } @@ -217,7 +242,7 @@ export class InterModuleCommunication { // Set get window ID handler in the receiver handler map. this.receiverHandlerMap?.set( - IMCMessageTypeEnum.SignalGetWindowId, + IMCMessageTypeEnum.SignalRequestOtherWindowId, async (senderWindow: Window, message: IMCMessage) => { console.log( "Received window ID request. Sending current window ID to other window: " @@ -226,15 +251,24 @@ export class InterModuleCommunication { if (!id) { throw new Error("This window ID is not defined."); } - const msg: IMCMessage = { - id: message.id, - type: IMCMessageTypeEnum.SignalReturnWindowId, - payload: { - windowId: id, - }, - from: id, - }; - senderWindow.postMessage(msg, "*"); + + return id; + } + ); + + // Handle ignore signal + this.receiverHandlerMap?.set( + IMCMessageTypeEnum.SignalIgnore, + async (senderWindow: Window, message: IMCMessage) => { + console.warn( + `Message ignored by receiver. Message ID: ${message.messageId}, Payload: ${message.payload}` + ); + const pendingMessage = this.sender?.getPendingMessage( + message.messageId + ); + if (pendingMessage) { + pendingMessage.reject(new Error("Message ignored by receiver")); + } } ); } diff --git a/npm-packages/shared-utils/src/imc/message-receiver.ts b/npm-packages/shared-utils/src/imc/message-receiver.ts index da9f9194..27e79c55 100644 --- a/npm-packages/shared-utils/src/imc/message-receiver.ts +++ b/npm-packages/shared-utils/src/imc/message-receiver.ts @@ -13,11 +13,17 @@ export class MessageReceiver { } >; private windowId: string; + private intent: string | undefined; - constructor(listenerMap: ReceiverHandlerMap, windowId: string) { + constructor( + listenerMap: ReceiverHandlerMap, + windowId: string, + intent: string | undefined + ) { this.handlerMap = listenerMap; this.pendingTasks = new Map(); this.windowId = windowId; + this.intent = intent; } public receiveMessage(senderWindow: Window, message: IMCMessage) { @@ -26,7 +32,7 @@ export class MessageReceiver { // Abort the task if the message type is Abort if (message.type === IMCMessageTypeEnum.SignalAbort) { - const id = message.id; + const id = message.messageId; const pendingTask = this.pendingTasks.get(id); if (pendingTask) { @@ -39,56 +45,99 @@ export class MessageReceiver { } const handler = this.handlerMap.get(message.type); - if (handler) { - // Create abort controller to listen for abort signal from sender. - // Then save the message id and abort controller to the pending tasks. - const controller = new AbortController(); - const signal = controller.signal; - this.pendingTasks.set(message.id, { - controller, - }); - const promise = handler(senderWindow, message, signal); - promise - .then((result) => { - // Don't send the result if the task has been aborted - if (signal.aborted) return; - - // Acknowledge the sender with the result if the message type is not Acknowledge - if (message.type !== IMCMessageTypeEnum.SignalAcknowledge) { - this.acknowledgeSender(senderWindow, message.id, result); - } - }) - .catch((error) => { - // Send the error message to the sender - const errMsg: IMCMessage = { - id: message.id, - type: IMCMessageTypeEnum.SignalError, - payload: error.message, - from: this.windowId, - }; - - console.error("Error handling message:", error); - - senderWindow.postMessage(errMsg, "*"); - }) - .finally(() => { - this.pendingTasks.delete(message.id); - }); + if (!handler) { + if (this.intent === "connection-listener") { + // Ignore missing handler for connection listener, + // as it handles connection related messages only. + // There should be another channel created to handle other messages. + return; + } + + console.warn(`No handler found for message type: ${message.type}`); + + // Ignore the message if no handler is found + this.ignoreSender(senderWindow, message); + + return; } + + // Create abort controller to listen for abort signal from sender. + // Then save the message id and abort controller to the pending tasks. + const controller = new AbortController(); + const signal = controller.signal; + this.pendingTasks.set(message.messageId, { + controller, + }); + + const promise = handler(senderWindow, message, signal); + promise + .then((result) => { + // Don't send the result if the task has been aborted + if (signal.aborted) return; + + // Acknowledge the sender with the result if the message type is not Acknowledge + if (message.type !== IMCMessageTypeEnum.SignalAcknowledge) { + this.acknowledgeSender( + senderWindow, + message.messageId, + message.channelId, + result + ); + } + }) + .catch((error) => { + if (error.message === "Message ignored by receiver") { + // Ignore the message if no handler is found + this.ignoreSender(senderWindow, message); + return; + } + + // Send the error message to the sender + const errMsg: IMCMessage = { + messageId: message.messageId, + channelId: message.channelId, + type: IMCMessageTypeEnum.SignalError, + payload: error.message, + from: this.windowId, + }; + + console.error("Error handling message:", error); + + senderWindow.postMessage(errMsg, "*"); + }) + .finally(() => { + this.pendingTasks.delete(message.messageId); + }); } private acknowledgeSender( senderWindow: Window, - id: string, + messageId: string, + channelId: string | undefined, payload: any ): void { const message: IMCMessage = { - id, + messageId: messageId, + channelId: channelId, type: IMCMessageTypeEnum.SignalAcknowledge, payload: payload, from: this.windowId, }; senderWindow.postMessage(message, "*"); } + + private ignoreSender(senderWindow: Window, message: IMCMessage) { + // Ignore the message if no handler is found + senderWindow.postMessage( + { + messageId: message.messageId, + channelId: message.channelId, + type: IMCMessageTypeEnum.SignalIgnore, + payload: `No handler for message type: ${message.type}`, + from: this.windowId, + } as IMCMessage, + "*" + ); + } } diff --git a/npm-packages/shared-utils/src/imc/message-sender.ts b/npm-packages/shared-utils/src/imc/message-sender.ts index eaaf9134..2793e015 100644 --- a/npm-packages/shared-utils/src/imc/message-sender.ts +++ b/npm-packages/shared-utils/src/imc/message-sender.ts @@ -11,13 +11,20 @@ export class MessageSender { >; private moduleId: string; + private channelId: string | undefined; - constructor(targetWindow: Window, timeout: number, moduleId: string) { + constructor( + targetWindow: Window, + timeout: number, + moduleId: string, + channelId?: string + ) { this.targetWindow = targetWindow; this.timeout = timeout; this.pendingMessages = new Map(); this.moduleId = moduleId; + this.channelId = channelId; } public async sendMessage( @@ -28,7 +35,8 @@ export class MessageSender { // Generate a unique id for the message using timestamp const id = v4() + new Date().getTime().toString(); const message: IMCMessage = { - id, + messageId: id, + channelId: this.channelId, type: handlingType, payload: payload, from: this.moduleId, @@ -79,6 +87,7 @@ export class MessageSender { this.pendingMessages.delete(id); reject(reason); }; + this.pendingMessages.set(id, { resolve: onResolve, reject: onReject, diff --git a/npm-packages/shared-utils/src/imc/poly-imc.ts b/npm-packages/shared-utils/src/imc/poly-imc.ts index 61636894..607e90f3 100644 --- a/npm-packages/shared-utils/src/imc/poly-imc.ts +++ b/npm-packages/shared-utils/src/imc/poly-imc.ts @@ -42,11 +42,33 @@ export class PolyIMC { throw new Error("Channel not found for window ID " + targetWindowId); } - const results = await Promise.all( - channels.map( - async (channel) => - await channel.sendMessage(handlingType, payload, abortSignal) - ) + // - some channels here are not returning results + // - and the result now is an array instead of a single value + + const results: any[] = []; + + await Promise.all( + channels.map(async (channel) => { + try { + const result = await channel.sendMessage( + handlingType, + payload, + abortSignal + ); + results.push(result); + } catch (error: any) { + // TODO: better ignore handling + if (error.message === "Message ignored by receiver") { + console.warn( + `Message ignored by receiver (window ID ${targetWindowId})`, + error + ); + // nothing returned, nothing pushed — completely skipped + return; + } + throw error; // rethrow real errors + } + }) ); return results; @@ -89,10 +111,12 @@ export class PolyIMC { public async createChannel( targetWindow: Window, targetWindowId: string, + intent: string, + channelId?: string, receiverHandlerMap?: ReceiverHandlerMap ) { console.log("Creating channel for window ID: " + targetWindowId); - const channel = new InterModuleCommunication(); + const channel = new InterModuleCommunication(intent, channelId); channel.initThisWindow(window, targetWindowId); await channel.initOtherWindow(targetWindow); @@ -179,7 +203,10 @@ export class ConnectionListener { this.newConnectionReceiverHandlerMap = newConnectionReceiverHandlerMap; this.onConnection = onConnection; - const listener = new InterModuleCommunication(); + const listener = new InterModuleCommunication( + "connection-listener", + undefined + ); this.listener = listener; listener.initThisWindow(window, expectedOtherWindowId); @@ -192,7 +219,7 @@ export class ConnectionListener { message: IMCMessage, abortSignal?: AbortSignal ) => { - this.handleExtReady(senderWindow, message, abortSignal); + await this.handleExtReady(senderWindow, message, abortSignal); }, ], ]) @@ -215,22 +242,19 @@ export class ConnectionListener { abortSignal?: AbortSignal ) { const targetWindowId = message.from; + const { intent, channelId }: { intent: string; channelId?: string } = + message.payload; - if (this.polyIMC.hasChannel(targetWindowId)) { - // Channel already exists for the target window ID, - // so we don't need to create a new one. - console.log( - "Channel already exists for window ID " + - targetWindowId + - ". Re-using the existing channel." - ); - return; + if (!channelId) { + throw new Error("Channel ID is missing in AppReady message."); } // Create a new channel for the incoming connection await this.polyIMC.createChannel( senderWindow, targetWindowId, + intent, + channelId, this.newConnectionReceiverHandlerMap ); diff --git a/npm-packages/shared-utils/src/types/types.ts b/npm-packages/shared-utils/src/types/types.ts index ce892a7f..b2df2cb2 100644 --- a/npm-packages/shared-utils/src/types/types.ts +++ b/npm-packages/shared-utils/src/types/types.ts @@ -42,6 +42,8 @@ export enum IMCMessageTypeEnum { EditorAppStateSnapshotSave = "editor-app-state-snapshot-save", // Handle editor file selection or drop EditorAppReceiveFileUri = "editor-app-receive-file-uri", + // App uses owned app + EditorAppUseOwnedApp = "editor-app-use-owned-app", // #endregion // #region Platform API interaction messages (require OS-like environment) @@ -56,8 +58,7 @@ export enum IMCMessageTypeEnum { // #endregion // #region Signal messages - SignalGetWindowId = "signal-get-window-id", - SignalReturnWindowId = "signal-return-window-id", + SignalRequestOtherWindowId = "signal-request-other-window-id", // A message to notify sender that the message // has been received and finished processing SignalAcknowledge = "signal-acknowledge", @@ -65,11 +66,14 @@ export enum IMCMessageTypeEnum { SignalAbort = "signal-abort", // Error SignalError = "signal-error", + // Ignore + SignalIgnore = "signal-ignore", // #endregion } export type IMCMessage = { - id: string; + messageId: string; + channelId?: string; from: string; type: IMCMessageTypeEnum; payload?: any; @@ -78,7 +82,7 @@ export type IMCMessage = { export type ReceiverHandler = ( senderWindow: Window, message: IMCMessage, - abortSignal?: AbortSignal + abortSignal?: AbortSignal, ) => Promise; // IMC receiver handler map @@ -94,7 +98,7 @@ export type TextFileSelection = { export type ViewModel = { viewId: string; - appConfig?: AppConfig; + appConfig: AppConfig; }; export enum ViewModeEnum { @@ -208,6 +212,10 @@ export type TypedVariableType = | "number" | "boolean" | "any" + // An app instance is a reference to another app. + // This instance could be possessed by the owner, + // or it can be initialized by the caller. + | "app-instance" | TypedVariableObjectType | TypedVariableArrayType; @@ -218,13 +226,13 @@ export type TypedVariableObjectType = { export type TypedVariableArrayType = [TypedVariableType]; export function isArrayType( - value: TypedVariableType + value: TypedVariableType, ): value is TypedVariableArrayType { return Array.isArray(value) && value.length === 1; } export function isObjectType( - value: TypedVariableType + value: TypedVariableType, ): value is TypedVariableObjectType { return typeof value === "object" && !Array.isArray(value); } diff --git a/package-lock.json b/package-lock.json index 1945c873..91cd85a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,10 @@ "npm-packages/shared-utils" ], "devDependencies": { - "@changesets/cli": "^2.29.4" + "@changesets/cli": "^2.29.4", + "prettier": "^3.6.2", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-tailwindcss": "^0.6.14" } }, "capacitor-plugin": { @@ -83,20 +86,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "capacitor-plugin/node_modules/prettier": { - "version": "3.5.3", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "capacitor-plugin/node_modules/prettier-plugin-java": { "version": "2.6.7", "dev": true, @@ -2117,6 +2106,22 @@ "semver": "^7.5.3" } }, + "node_modules/@changesets/apply-release-plan/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@changesets/assemble-release-plan": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.9.tgz", @@ -2362,6 +2367,22 @@ "prettier": "^2.7.1" } }, + "node_modules/@changesets/write/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -13151,7 +13172,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -20992,16 +21012,16 @@ } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -21024,6 +21044,93 @@ } } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -25722,14 +25829,13 @@ }, "npm-packages/react-api": { "name": "@pulse-editor/react-api", - "version": "0.1.1-alpha.48", + "version": "0.1.1-beta.55", "devDependencies": { "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", "@eslint/js": "^9.25.0", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@types/react": "^19.1.2", "eslint": "^9.25.0", @@ -25746,7 +25852,7 @@ "typescript-eslint": "^8.30.1" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-alpha.48", + "@pulse-editor/shared-utils": "0.1.1-beta.55", "react": "^19.0.0", "react-dom": "^19.0.0" } @@ -25952,7 +26058,7 @@ }, "npm-packages/shared-utils": { "name": "@pulse-editor/shared-utils", - "version": "0.1.1-alpha.48", + "version": "0.1.1-beta.55", "devDependencies": { "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", @@ -25960,7 +26066,6 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@types/node": "^22.13.1", "eslint": "^9.25.0", @@ -26435,7 +26540,7 @@ "@langchain/core": "^0.3.66", "@langchain/openai": "^0.6.3", "@pulse-editor/capacitor-plugin": "file:../capacitor-plugin", - "@pulse-editor/shared-utils": "^0.1.1-alpha.48", + "@pulse-editor/shared-utils": "^0.1.1-beta.55", "@ricky0123/vad-web": "^0.0.28", "@vercel/analytics": "^1.5.0", "@xyflow/react": "^12.8.6", @@ -26474,9 +26579,6 @@ "eslint": "^9", "eslint-config-next": "15.5.5", "postcss": "^8", - "prettier": "^3.6.2", - "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.6.14", "serve": "^14.2.5", "tailwindcss": "^4.1.14", "typescript": "^5", @@ -26707,109 +26809,6 @@ "engines": { "node": ">= 4" } - }, - "web/node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "web/node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.14", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", - "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-hermes": "*", - "@prettier/plugin-oxc": "*", - "@prettier/plugin-pug": "*", - "@shopify/prettier-plugin-liquid": "*", - "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig": "*", - "prettier": "^3.0", - "prettier-plugin-astro": "*", - "prettier-plugin-css-order": "*", - "prettier-plugin-import-sort": "*", - "prettier-plugin-jsdoc": "*", - "prettier-plugin-marko": "*", - "prettier-plugin-multiline-arrays": "*", - "prettier-plugin-organize-attributes": "*", - "prettier-plugin-organize-imports": "*", - "prettier-plugin-sort-imports": "*", - "prettier-plugin-style-order": "*", - "prettier-plugin-svelte": "*" - }, - "peerDependenciesMeta": { - "@ianvs/prettier-plugin-sort-imports": { - "optional": true - }, - "@prettier/plugin-hermes": { - "optional": true - }, - "@prettier/plugin-oxc": { - "optional": true - }, - "@prettier/plugin-pug": { - "optional": true - }, - "@shopify/prettier-plugin-liquid": { - "optional": true - }, - "@trivago/prettier-plugin-sort-imports": { - "optional": true - }, - "@zackad/prettier-plugin-twig": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - }, - "prettier-plugin-css-order": { - "optional": true - }, - "prettier-plugin-import-sort": { - "optional": true - }, - "prettier-plugin-jsdoc": { - "optional": true - }, - "prettier-plugin-marko": { - "optional": true - }, - "prettier-plugin-multiline-arrays": { - "optional": true - }, - "prettier-plugin-organize-attributes": { - "optional": true - }, - "prettier-plugin-organize-imports": { - "optional": true - }, - "prettier-plugin-sort-imports": { - "optional": true - }, - "prettier-plugin-style-order": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index 5c7b08dc..caaa7494 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "capacitor-plugin-build": "npm run build --workspace=capacitor-plugin" }, "devDependencies": { - "@changesets/cli": "^2.29.4" + "@changesets/cli": "^2.29.4", + "prettier": "^3.6.2", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-tailwindcss": "^0.6.14" } } diff --git a/remote-instance/src/servers/node-pty/index.ts b/remote-instance/src/servers/node-pty/index.ts index d2446ed2..807fcd6e 100644 --- a/remote-instance/src/servers/node-pty/index.ts +++ b/remote-instance/src/servers/node-pty/index.ts @@ -1,8 +1,8 @@ import http from "http"; import https from "https"; -import { WebSocket, WebSocketServer } from "ws"; -import os from "os"; import { IPty, spawn } from "node-pty"; +import os from "os"; +import { WebSocket, WebSocketServer } from "ws"; let sharedPtyProcess: IPty | null = null; let sharedTerminalMode = false; diff --git a/remote-instance/tsconfig.json b/remote-instance/tsconfig.json index cebea950..fe93931e 100644 --- a/remote-instance/tsconfig.json +++ b/remote-instance/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", - "moduleResolution": "node", + "module": "node20", + "moduleResolution": "nodenext", "outDir": "dist", "rootDir": "src", "strict": true, diff --git a/web/.prettierrc b/web/.prettierrc deleted file mode 100644 index a9804eb8..00000000 --- a/web/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plugins": [ - "prettier-plugin-tailwindcss", - "prettier-plugin-organize-imports" - ] -} \ No newline at end of file diff --git a/web/components/app-loaders/sandbox-app-loader.tsx b/web/components/app-loaders/sandbox-app-loader.tsx index 4c9bf97b..2176cea9 100644 --- a/web/components/app-loaders/sandbox-app-loader.tsx +++ b/web/components/app-loaders/sandbox-app-loader.tsx @@ -2,12 +2,12 @@ import BaseAppLoader from "@/components/app-loaders/base-app-loader"; 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"; +import { DragEventTypeEnum, PlatformEnum } from "@/lib/enums"; import { usePlatformApi } from "@/lib/hooks/use-platform-api"; import { getPlatform } from "@/lib/platform-api/platform-checker"; -import { ExtensionApp } from "@/lib/types"; +import { ExtensionApp, FileDragData } from "@/lib/types"; +import { addToast } from "@heroui/react"; import { - AppTypeEnum, ConnectionListener, IMCMessage, IMCMessageTypeEnum, @@ -137,6 +137,13 @@ export default function SandboxAppLoader({ }; }, []); + useEffect(() => { + console.log( + "Is dragging over canvas: ", + editorContext?.editorStates.isDraggingOverCanvas, + ); + }, [editorContext?.editorStates.isDraggingOverCanvas]); + // Set is loading extension to true when current extension changes useEffect(() => { if (currentExtension) { @@ -171,7 +178,7 @@ export default function SandboxAppLoader({ getHandlerMap(viewModel), ); } - }, [viewModel]); + }, [viewModel, editorContext?.editorStates, editorContext?.persistSettings]); function getHandlerMap(model: ViewModel) { const newMap = new Map(); @@ -208,85 +215,85 @@ export default function SandboxAppLoader({ ); // The following message handlers require OS-like environment. - // This can be either local environment or remote instance. - if (model.appConfig?.appType === AppTypeEnum.FileView) { - newMap.set( - IMCMessageTypeEnum.PlatformWriteFile, - async ( - senderWindow: Window, - message: IMCMessage, - abortSignal?: AbortSignal, - ) => { - if (message.payload) { - const { uri, content }: { uri: string; content: string } = - message.payload; - - const projectPath = - editorContext?.persistSettings?.projectHomePath + - "/" + - editorContext?.editorStates.project; - - // Prevent writing to path outside the project path - if (!uri.startsWith(projectPath)) { - throw new Error( - "Cannot write to path outside the project directory.", - ); - } - const newFile = new File([content], uri); - await platformApi?.writeFile(newFile, content); + // This can be either local environment or remote workspace. + newMap.set( + IMCMessageTypeEnum.PlatformWriteFile, + async ( + senderWindow: Window, + message: IMCMessage, + abortSignal?: AbortSignal, + ) => { + if (message.payload) { + const { uri, file }: { uri: string; file: File | undefined } = + message.payload; + + if (!file) { + throw new Error("File is undefined."); } - }, - ); - newMap.set( - IMCMessageTypeEnum.PlatformReadFile, - async ( - senderWindow: Window, - message: IMCMessage, - abortSignal?: AbortSignal, - ) => { - const { uri }: { uri: string } = message.payload; const projectPath = editorContext?.persistSettings?.projectHomePath + "/" + editorContext?.editorStates.project; - // Prevent reading path outside the project path + // Prevent writing to path outside the project path if (!uri.startsWith(projectPath)) { throw new Error( - "Cannot read file outside the project directory: " + uri, + "Cannot write to path outside the project directory.", ); } + await platformApi?.writeFile(file, uri); + } + }, + ); + newMap.set( + IMCMessageTypeEnum.PlatformReadFile, + async ( + senderWindow: Window, + message: IMCMessage, + abortSignal?: AbortSignal, + ) => { + const { uri }: { uri: string } = message.payload; - const file = await platformApi?.readFile(uri); - return file; - }, - ); - } else if (model.appConfig?.appType === AppTypeEnum.ConsoleView) { - newMap.set( - IMCMessageTypeEnum.PlatformCreateTerminal, - async ( - senderWindow: Window, - message: IMCMessage, - abortSignal?: AbortSignal, - ) => { - const platform = getPlatform(); - // Get a shell terminal from native platform APIs - if (platform === PlatformEnum.Capacitor) { - return { - websocketUrl: editorContext?.persistSettings?.mobileHost, - projectHomePath: `~/storage/shared/${editorContext?.persistSettings?.projectHomePath}`, - }; - } else { - const wsUrl = await platformApi?.createTerminal(); - return { - websocketUrl: wsUrl, - projectHomePath: editorContext?.persistSettings?.projectHomePath, - }; - } - }, - ); - } + const projectPath = + editorContext?.persistSettings?.projectHomePath + + "/" + + editorContext?.editorStates.project; + + // Prevent reading path outside the project path + if (!uri.startsWith(projectPath)) { + throw new Error( + `Cannot read file outside the project directory: ${uri}, project path: ${projectPath}`, + ); + } + + const file = await platformApi?.readFile(uri); + return file; + }, + ); + newMap.set( + IMCMessageTypeEnum.PlatformCreateTerminal, + async ( + senderWindow: Window, + message: IMCMessage, + abortSignal?: AbortSignal, + ) => { + const platform = getPlatform(); + // Get a shell terminal from native platform APIs + if (platform === PlatformEnum.Capacitor) { + return { + websocketUrl: editorContext?.persistSettings?.mobileHost, + projectHomePath: `~/storage/shared/${editorContext?.persistSettings?.projectHomePath}`, + }; + } else { + const wsUrl = await platformApi?.createTerminal(); + return { + websocketUrl: wsUrl, + projectHomePath: editorContext?.persistSettings?.projectHomePath, + }; + } + }, + ); return newMap; } @@ -307,7 +314,51 @@ export default function SandboxAppLoader({ } return ( -
+
{ + e.stopPropagation(); + const types = e.dataTransfer.types; + if ( + types.includes(`application/${DragEventTypeEnum.File.toLowerCase()}`) + ) { + e.preventDefault(); // allow drop + e.dataTransfer.dropEffect = "move"; + } else { + e.dataTransfer.dropEffect = "none"; + } + }} + onDrop={async (e) => { + const dataText = e.dataTransfer.getData( + `application/${DragEventTypeEnum.File.toLowerCase()}`, + ); + if (!dataText) { + return; + } + console.log("Dropped item:", dataText); + try { + const data = JSON.parse(dataText) as FileDragData; + + e.preventDefault(); + const uri = data.uri; + + // Send uri to app view + await imcContext?.polyIMC?.sendMessage( + viewModel.viewId, + IMCMessageTypeEnum.EditorAppReceiveFileUri, + { + uri, + }, + ); + } catch (error) { + addToast({ + title: "Failed to open file", + description: "The dropped file data is invalid.", + color: "danger", + }); + } + }} + > {isLookingForExtension ? (
@@ -321,7 +372,12 @@ export default function SandboxAppLoader({

) : ( -
+
{isLoadingExtension && (
diff --git a/web/components/explorer/app/app-explorer.tsx b/web/components/explorer/app/app-explorer.tsx index 27c4624a..118333bc 100644 --- a/web/components/explorer/app/app-explorer.tsx +++ b/web/components/explorer/app/app-explorer.tsx @@ -1,8 +1,9 @@ import AppPreviewCard from "@/components/marketplace/app/app-preview-card"; import { EditorContext } from "@/components/providers/editor-context-provider"; +import { DragEventTypeEnum } from "@/lib/enums"; import { useScreenSize } from "@/lib/hooks/use-screen-size"; import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; -import { AppViewConfig } from "@/lib/types"; +import { AppDragData, AppViewConfig } from "@/lib/types"; import { Button } from "@heroui/react"; import { useContext } from "react"; import { v4 } from "uuid"; @@ -21,7 +22,22 @@ export default function AppExplorer() { className="w-full h-fit" draggable onDragStart={(e) => { - e.dataTransfer.setData("text/plain", JSON.stringify(ext)); + editorContext?.setEditorStates((prev) => ({ + ...prev, + isDraggingOverCanvas: true, + })); + e.dataTransfer.setData( + `application/${DragEventTypeEnum.App.toLowerCase()}`, + JSON.stringify({ + app: ext, + } as AppDragData), + ); + }} + onDragEnd={() => { + editorContext?.setEditorStates((prev) => ({ + ...prev, + isDraggingOverCanvas: false, + })); }} >

Tap or drag an extension to open it.

-
+
{previews}
diff --git a/web/components/explorer/file-system/fs-explorer.tsx b/web/components/explorer/file-system/fs-explorer.tsx index dd10950a..3703c721 100644 --- a/web/components/explorer/file-system/fs-explorer.tsx +++ b/web/components/explorer/file-system/fs-explorer.tsx @@ -184,9 +184,6 @@ export default function FileSystemExplorer({ projectContent: [], }; }); - - // Clear view manager - closeAllTabViews(); }} > diff --git a/web/components/explorer/file-system/tree-view.tsx b/web/components/explorer/file-system/tree-view.tsx index 3d9d08a2..57580360 100644 --- a/web/components/explorer/file-system/tree-view.tsx +++ b/web/components/explorer/file-system/tree-view.tsx @@ -3,12 +3,13 @@ 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 { DragEventTypeEnum, PlatformEnum } from "@/lib/enums"; import { AbstractPlatformAPI } from "@/lib/platform-api/abstract-platform-api"; import { getPlatform } from "@/lib/platform-api/platform-checker"; import { ContextMenuState, EditorContextType, + FileDragData, FileSystemObject, TreeViewGroupRef, TreeViewNodeRef, @@ -180,6 +181,26 @@ const TreeViewNode = forwardRef(function TreeViewNode( }); } + const onDragStart = (e: React.DragEvent) => { + editorContext?.setEditorStates((prev) => ({ + ...prev, + isDraggingOverCanvas: true, + })); + e.dataTransfer.setData( + `application/${DragEventTypeEnum.File.toLowerCase()}`, + JSON.stringify({ + uri: object.uri, + } as FileDragData), + ); + }; + + const onDragEnd = () => { + editorContext?.setEditorStates((prev) => ({ + ...prev, + isDraggingOverCanvas: false, + })); + }; + return (
{isRenaming ? ( @@ -220,6 +241,9 @@ const TreeViewNode = forwardRef(function TreeViewNode( <> {object.isFolder ? ( + + +
); } diff --git a/web/components/views/canvas/nodes/app-node/node-handle.tsx b/web/components/views/canvas/nodes/app-node/node-handle.tsx index ba3fdb9f..636c7420 100644 --- a/web/components/views/canvas/nodes/app-node/node-handle.tsx +++ b/web/components/views/canvas/nodes/app-node/node-handle.tsx @@ -14,10 +14,13 @@ export default function NodeHandle({ }) { return (
-

{`${id} (${param?.type.toString()})`}

+
+

{id}

+

({param?.type.toString()})

+
); } + +export const MemoizedStandaloneAppView = memo(StandaloneAppView); diff --git a/web/components/views/view-area.tsx b/web/components/views/view-area.tsx index 49a6d2e9..bf931de0 100644 --- a/web/components/views/view-area.tsx +++ b/web/components/views/view-area.tsx @@ -1,34 +1,19 @@ import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; -import { AppViewConfig, CanvasViewConfig, ExtensionApp } from "@/lib/types"; +import { AppViewConfig, CanvasViewConfig } from "@/lib/types"; import { ViewModeEnum } from "@pulse-editor/shared-utils"; import { ReactFlowProvider } from "@xyflow/react"; import { useSearchParams } from "next/navigation"; -import { memo, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { v4 } from "uuid"; import Tabs from "../misc/tabs"; -import CanvasView from "./canvas/canvas-view"; +import { EditorContext } from "../providers/editor-context-provider"; +import { MemoizedCanvasView } from "./canvas/canvas-view"; import HomeView from "./home/home-view"; -import StandaloneAppView from "./standalone-app/standalone-app-view"; - -const MemoizedStandaloneAppView = memo(StandaloneAppView); -const MemoizedCanvasView = memo( - ({ - config, - isActive, - tabName, - }: { - config: CanvasViewConfig; - isActive: boolean; - tabName: string; - }) => ( - - - - ), -); -MemoizedCanvasView.displayName = "MemoizedCanvasView"; +import { MemoizedStandaloneAppView } from "./standalone-app/standalone-app-view"; export default function ViewArea() { + const editorContext = useContext(EditorContext); + const params = useSearchParams(); const { @@ -96,25 +81,7 @@ export default function ViewArea() { }, [tabViews]); return ( -
{ - e.preventDefault(); - }} - onDrop={(e) => { - e.preventDefault(); - const data = e.dataTransfer.getData("text/plain"); - console.log("Dropped item:", data); - const ext: ExtensionApp = JSON.parse(data); - const config: AppViewConfig = { - app: ext.config.id, - viewId: `${ext.config.id}-${v4()}`, - recommendedHeight: ext.config.recommendedHeight, - recommendedWidth: ext.config.recommendedWidth, - }; - createAppViewInCanvasView(config); - }} - > +
{tabViews.length === 0 ? ( ) : tabIndex < 0 || tabIndex >= tabViews.length ? ( @@ -168,11 +135,13 @@ export default function ViewArea() { config={tabView.config as AppViewConfig} /> ) : tabView.type === ViewModeEnum.Canvas ? ( - + + + ) : (
Unknown view type
)} diff --git a/web/lib/enums.ts b/web/lib/enums.ts index 08b9e416..1f46ee49 100644 --- a/web/lib/enums.ts +++ b/web/lib/enums.ts @@ -18,4 +18,9 @@ export enum SideMenuTabEnum { Apps = "Apps", Workspaces = "Workspaces", } + +export enum DragEventTypeEnum { + File = "File", + App = "App", +} // #endregion diff --git a/web/lib/hooks/use-auth.ts b/web/lib/hooks/use-auth.ts index 41e46c13..cc56e96a 100644 --- a/web/lib/hooks/use-auth.ts +++ b/web/lib/hooks/use-auth.ts @@ -1,10 +1,12 @@ "use client"; +import { EditorContext } from "@/components/providers/editor-context-provider"; import { useContext } from "react"; -import { CreditBalance, Session, Subscription } from "../types"; import useSWR from "swr"; -import { EditorContext } from "@/components/providers/editor-context-provider"; +import { PlatformEnum } from "../enums"; +import { getPlatform } from "../platform-api/platform-checker"; import { fetchAPI, getAPIUrl } from "../pulse-editor-website/backend"; +import { CreditBalance, Session, Subscription } from "../types"; export function useAuth() { const editorContext = useContext(EditorContext); @@ -57,10 +59,16 @@ export function useAuth() { return; } - const url = getAPIUrl(`/api/auth/signin`); - url.searchParams.set("callbackUrl", window.location.href); - - window.location.href = url.toString(); + if (getPlatform() === PlatformEnum.Electron) { + // In Electron, open the sign-in page in the system browser. + // TODO: move this to the platform API layer + // @ts-expect-error window.electronAPI is exposed by the Electron main process + window.electronAPI.login(); + } else { + const url = getAPIUrl(`/api/auth/signin`); + url.searchParams.set("callbackUrl", window.location.href); + window.location.href = url.toString(); + } } // Open a sign-out page if the user is signed in. @@ -69,10 +77,16 @@ export function useAuth() { return; } - const url = getAPIUrl(`/api/auth/signout`); - url.searchParams.set("callbackUrl", window.location.href); - - window.location.href = url.toString(); + if (getPlatform() === PlatformEnum.Electron) { + // In Electron, open the sign-out page in the system browser. + // TODO: move this to the platform API layer + // @ts-expect-error window.electronAPI is exposed by the Electron main process + window.electronAPI.logout(); + } else { + const url = getAPIUrl(`/api/auth/signout`); + url.searchParams.set("callbackUrl", window.location.href); + window.location.href = url.toString(); + } } return { diff --git a/web/lib/hooks/use-canvas-workflow.ts b/web/lib/hooks/use-canvas-workflow.ts index adf49008..13fc641f 100644 --- a/web/lib/hooks/use-canvas-workflow.ts +++ b/web/lib/hooks/use-canvas-workflow.ts @@ -19,7 +19,7 @@ export default function useCanvasWorkflow( const editorContext = useContext(EditorContext); const imcContext = useContext(IMCContext); - const { runAction } = useScopedActions(); + const { runScopedAction } = useScopedActions(); const [pendingNodes, setPendingNodes] = useState< ReactFlowNode[] @@ -236,10 +236,9 @@ export default function useCanvasWorkflow( if (!selectedAction) return; updateWorkflowNodeData(node.id, { isRunning: true }); console.log( - `Running node ${node.id} with action ${selectedAction} and args`, - args, + `Running node ${node.id} with action \n${JSON.stringify(selectedAction)} \nand args \n${JSON.stringify(args)}`, ); - const result = await runAction( + const result = await runScopedAction( { action: selectedAction, viewId: node.id, @@ -249,14 +248,14 @@ export default function useCanvasWorkflow( ); updateWorkflowNodeData(node.id, { isRunning: false }); - return result ? JSON.parse(result) : {}; + return result ?? {}; } async function runSequence(sequence: ReactFlowNode[]) { // Helper to get input args for a node - function getInputArgs(nodeId: string) { - return localEdges - .filter((e) => e.target === nodeId) + function getInputArgs(node: ReactFlowNode) { + const edgeArgs = localEdges + .filter((e) => e.target === node.id) .map((e) => { const sourceHandle = e.sourceHandle; const targetHandle = e.targetHandle; @@ -275,6 +274,13 @@ export default function useCanvasWorkflow( } return acc; }, {}); + + const appInstanceArgs = node.data.ownedAppViews; + + return { + ...edgeArgs, + ...appInstanceArgs, + }; } // Parallel execution using in-degree tracking @@ -315,7 +321,7 @@ export default function useCanvasWorkflow( running.add(nodeId); const node = sequence.find((n) => n.id === nodeId); if (!node) return; - const inputArgs = getInputArgs(nodeId); + const inputArgs = getInputArgs(node); const result = await runNode(node, inputArgs); resultMap.set(nodeId, result); // After running, update children diff --git a/web/lib/hooks/use-platform-ai-assistant.ts b/web/lib/hooks/use-platform-ai-assistant.ts index 950b82ff..7ea67099 100644 --- a/web/lib/hooks/use-platform-ai-assistant.ts +++ b/web/lib/hooks/use-platform-ai-assistant.ts @@ -36,7 +36,7 @@ export default function usePlatformAIAssistant() { const { runSpeech2Speech, stopSpeech2Speech, isRunning } = useSpeech2Speech(); const { readText, playAudio } = useTTS(); - const { runAction, actions } = useScopedActions(); + const { runScopedAction, actions } = useScopedActions(); const { activeTabView } = useTabViewManager(); const [pendingAnalysis, setPendingAnalysis] = useState(""); @@ -119,16 +119,16 @@ export default function usePlatformAIAssistant() { thinkingText: "Executing command...", })); - const command = actions.find((cmd) => cmd.action.name === suggestedCmd); - if (!command) { + const action = actions.find((cmd) => cmd.action.name === suggestedCmd); + if (!action) { toast.error(`Agent suggested command ${suggestedCmd} not found.`); return; } - const cmdResult = await runAction(command, args); + const actionResult = await runScopedAction(action, args); if (process.env.NODE_ENV === "development") { - console.log("Command result:", cmdResult); + console.log("Command result:", actionResult); } const previousMessage = history[history.length - 1].message.content.text; @@ -141,7 +141,7 @@ export default function usePlatformAIAssistant() { userMessage: userVoiceMessage, suggestedCmd: suggestedCmd, previousSuggestion: response, - commandResult: cmdResult, + commandResult: actionResult, }, (chunk) => { if (!chunk.analysis) { @@ -190,7 +190,7 @@ export default function usePlatformAIAssistant() { userMessage: userVoiceMessage, suggestedCmd: suggestedCmd, previousSuggestion: response, - commandResult: cmdResult, + commandResult: actionResult, }, ); diff --git a/web/lib/hooks/use-scoped-actions.ts b/web/lib/hooks/use-scoped-actions.ts index 01618e9b..a8db9ba9 100644 --- a/web/lib/hooks/use-scoped-actions.ts +++ b/web/lib/hooks/use-scoped-actions.ts @@ -1,5 +1,6 @@ import { EditorContext } from "@/components/providers/editor-context-provider"; import { IMCContext } from "@/components/providers/imc-provider"; +import { addToast } from "@heroui/react"; import { Action, IMCMessageTypeEnum, @@ -111,7 +112,7 @@ export default function useScopedActions(appName?: string) { ]); }, [editorContext?.persistSettings?.extensions, keyword, appName]); - async function runAction(action: ScopedAction, args: any) { + async function runScopedAction(action: ScopedAction, args: any) { console.log(`Running action "${action.action.name}"`); if (action.type === "editor") { const editorAction = editorActions.find( @@ -142,18 +143,27 @@ export default function useScopedActions(appName?: string) { return; } - const appInView = findAppInTabView(ext.config.id); + const appInView = findAppInTabView(ext.config.id, action.viewId); console.log("App in view for static Action:", appInView); if (appInView) { // App is already in the view, execute Action in the app's context. - const result = await imcContext?.polyIMC?.sendMessage( - appInView.viewId, - IMCMessageTypeEnum.EditorRunAppAction, - { name: action.action.name, args }, - ); - return result; + const result = + (await imcContext?.polyIMC?.sendMessage( + appInView.viewId, + IMCMessageTypeEnum.EditorRunAppAction, + { name: action.action.name, args }, + )) ?? []; + + if (result?.length !== 1) { + addToast({ + title: `Unexpected result when running action "${action.action.name}"`, + description: `Expected single result but got ${result?.length} results.`, + color: "warning", + }); + } + return result[0]; } else { // Create an instance of the app that provides the static Action, // then execute Action in the app's context. @@ -177,12 +187,22 @@ export default function useScopedActions(appName?: string) { // Wait for the action to be ready await waitForActionReady(action); - const result = await imcContext?.polyIMC?.sendMessage( - viewId, - IMCMessageTypeEnum.EditorRunAppAction, - { name: action.action.name, args }, - ); - return result; + // App is already in the view, execute Action in the app's context. + const result = + (await imcContext?.polyIMC?.sendMessage( + viewId, + IMCMessageTypeEnum.EditorRunAppAction, + { name: action.action.name, args }, + )) ?? []; + + if (result?.length !== 1) { + addToast({ + title: `Unexpected result when running action "${action.action.name}"`, + description: `Expected single result but got ${result?.length} results.`, + color: "warning", + }); + } + return result[0]; } } } @@ -210,7 +230,7 @@ export default function useScopedActions(appName?: string) { } return { - runAction, + runScopedAction, actions, setKeywordFilter, }; diff --git a/web/lib/hooks/use-tab-view-manager.ts b/web/lib/hooks/use-tab-view-manager.ts index 97206f8e..9a9066e3 100644 --- a/web/lib/hooks/use-tab-view-manager.ts +++ b/web/lib/hooks/use-tab-view-manager.ts @@ -240,9 +240,7 @@ export function useTabViewManager() { imcContext?.removeViewChannels(appConfig.viewId); }); } else if (view.type === ViewModeEnum.App) { - imcContext?.removeViewChannels( - (view.config as AppViewConfig).viewId, - ); + imcContext?.removeViewChannels((view.config as AppViewConfig).viewId); } }); } @@ -413,7 +411,10 @@ export function useTabViewManager() { return aId === bId; } - function findAppInTabView(appId: string): AppViewConfig | undefined { + function findAppInTabView( + appId: string, + viewId?: string, + ): AppViewConfig | undefined { if (!editorContext) { throw new Error("Editor context is not available"); } @@ -440,7 +441,11 @@ export function useTabViewManager() { isAppNameMatched(app.app, appId), ); if ((appInstances?.length ?? 0) > 1) { - throw new Error("Multiple instances of the same app found in canvas"); + if (!viewId) { + throw new Error("Multiple instances of the same app found in canvas"); + } + const appInstance = appInstances?.find((app) => app.viewId === viewId); + return appInstance; } const appInstance = appInstances?.[0]; return appInstance; diff --git a/web/lib/types.ts b/web/lib/types.ts index 43e84634..e7eea679 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -4,6 +4,7 @@ import { AppConfig, PolyIMC, ViewModeEnum, + ViewModel, } from "@pulse-editor/shared-utils"; import { Edge as ReactFlowEdge, Node as ReactFlowNode } from "@xyflow/react"; import { Dispatch, RefObject, SetStateAction } from "react"; @@ -91,6 +92,9 @@ export type EditorStates = { // Selected views selectedViewIds?: string[]; + + // Drag control + isDraggingOverCanvas?: boolean; }; /** @@ -374,8 +378,18 @@ export type AppNodeData = { selectedAction: Action | undefined; isRunning: boolean; isShowingWorkflowConnector: boolean; + ownedAppViews: { + [key: string]: ViewModel; + }; +}; + +export type FileDragData = { + uri: string; }; +export type AppDragData = { + app: ExtensionApp; +}; // #endregion // #region Action diff --git a/web/package.json b/web/package.json index 98c7af18..5bdc18f7 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@langchain/core": "^0.3.66", "@langchain/openai": "^0.6.3", "@pulse-editor/capacitor-plugin": "file:../capacitor-plugin", - "@pulse-editor/shared-utils": "^0.1.1-alpha.48", + "@pulse-editor/shared-utils": "^0.1.1-beta.55", "@ricky0123/vad-web": "^0.0.28", "@vercel/analytics": "^1.5.0", "@xyflow/react": "^12.8.6", @@ -66,9 +66,6 @@ "eslint": "^9", "eslint-config-next": "15.5.5", "postcss": "^8", - "prettier": "^3.6.2", - "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.6.14", "serve": "^14.2.5", "tailwindcss": "^4.1.14", "typescript": "^5",