diff --git a/aiprompts/tsunami-builder.md b/aiprompts/tsunami-builder.md new file mode 100644 index 0000000000..eb84289563 --- /dev/null +++ b/aiprompts/tsunami-builder.md @@ -0,0 +1,261 @@ +# Tsunami AI Builder - V1 Architecture + +## Overview + +A split-screen builder for creating Tsunami applications: chat interface on left, tabbed preview/code/files on right. Users describe what they want, AI edits the code iteratively. + +## UI Layout + +### Left Panel + +- **💬 Chat** - Conversation with AI + +### Right Panel + +**Top Section - Tabs:** +- **👁️ Preview** (default) - Live preview of running Tsunami app, updates automatically after successful compilation +- **📝 Code** - Monaco editor for manual edits to app.go +- **📁 Files** - Static assets browser (images, etc) + +**Bottom Section - Build Panel (closable):** +- Shows compilation status and output (like VSCode's terminal panel) +- Displays success messages or errors with line numbers +- Auto-runs after AI edits +- For manual Code tab edits: auto-reruns or user clicks build button +- Can be manually closed/reopened by user + +### Top Bar + +- Current AppTitle (extracted from app.go) +- **Publish** button - Moves draft → published version +- **Revert** button - Copies published → draft (discards draft changes) + +## Version Management + +**Draft mode**: Auto-saved on every edit, persists when builder closes +**Published version**: What runs in main Wave Terminal, only updates on explicit "Publish" + +Flow: + +1. Edit in builder (always editing draft) +2. Click "Publish" when ready (copies draft → published) +3. Continue editing draft OR click "Revert" to abandon changes + +## Context Structure + +Every AI request includes: + +``` +[System Instructions] + - General system prompt + - Full system.md (Tsunami framework guide) + +[Conversation History] + - Recent messages (with prompt caching) + +[Current Context] (injected fresh each turn, removed from previous turns) + - Current app.go content + - Compilation results (success or errors with line numbers) + - Static files listing (e.g., "/static/logo.png") +``` + +**Context cleanup**: Old "current context" blocks are removed from previous messages and replaced with "[OLD CONTEXT REMOVED]" to save tokens. Only the latest app.go + compile results stay in context. + +## AI Tools + +### edit_appgo (str_replace) + +**Primary editing tool** + +- `old_str` - Unique string to find in app.go +- `new_str` - Replacement string +- `description` - What this change does + +**Backend behavior**: + +1. Apply string replacement to app.go +2. Immediately run `go build` +3. Return tool result: + - ✓ Success: "Edit applied, compilation successful" + - ✗ Failure: "Edit applied, compilation failed: [error details]" + +AI can make multiple edits in one response, getting compile feedback after each. + +### create_appgo + +**Bootstrap new apps** + +- `content` - Full app.go file content +- Only used for initial app creation or total rewrites + +Same compilation behavior as str_replace. + +### web_search + +**Look up APIs, docs, examples** + +- Implemented via provider backend (OpenAI/Anthropic) +- AI can research before making edits + +### read_file + +**Read user-provided documentation** + +- `path` - Path to file (e.g., "/docs/api-spec.md") +- User can upload docs/examples for AI to reference + +## User Actions (Not AI Tools) + +### Manage Static Assets + +- Upload via drag & drop into Files tab or file picker +- Delete files from Files tab +- Rename files from Files tab +- Appear in `/static/` directory +- Auto-injected into AI context as available files + +### Share Screenshot + +- User clicks "📷 Share preview with AI" button +- Captures current preview state +- Attaches to user's next message +- Useful for debugging layout/visual issues + +### Manual Code Editing + +- User can switch to Code tab +- Edit app.go directly in Monaco editor +- Changes auto-compile +- AI sees manual edits in next chat turn + +## Compilation Pipeline + +After every code change (AI or user): + +``` +1. Write app.go to disk +2. Run: go build app.go +3. Show build output in build panel +4. If success: + - Start/restart app process + - Update preview iframe + - Show success message in build panel +5. If failure: + - Parse error output (line numbers, messages) + - Show error in build panel (bottom of right side) + - Inject into AI context for next turn +``` + +**Auto-retry**: AI can fix its own compilation errors within the same response (up to 3 attempts). + +## Error Handling + +### Compilation Errors + +Shown in build panel at bottom of right side. + +Format for AI: + +``` +COMPILATION FAILED + +Error at line 45: + 43 | func(props TodoProps) any { + 44 | return vdom.H("div", nil +> 45 | vdom.H("span", nil, "test") + | ^ missing closing parenthesis + 46 | ) + +Message: expected ')', found 'vdom' +``` + +### Runtime Errors + +- Shown in preview tab (not errors panel) +- User can screenshot and report to AI +- Not auto-injected (v1 simplification) + +### Linting (Future) + +- Could add custom Tsunami-specific linting +- Would inject warnings alongside compile results +- Not required for v1 + +## Secrets/Configuration + +Apps can declare secrets using Tsunami's ConfigAtom: + +```go +var apiKeyAtom = app.ConfigAtom("api_key", "", &app.AtomMeta{ + Desc: "OpenAI API Key", + Secret: true, +}) +``` + +Builder detects these and shows input fields in UI for user to fill in. + +## Conversation Limits + +**V1 approach**: No summarization, no smart handling. + +When context limit hit: Show message "You've hit the conversation limit. Click 'Start Fresh' to continue editing this app in a new chat." + +Starting fresh uses current app.go as the beginning state. + +## Token Optimization + +- System.md + early messages benefit from prompt caching +- Only pay per-turn for: current app.go + new messages +- Old context blocks removed to prevent bloat +- Estimated: 10-20k tokens per turn (very manageable) + +## Example Flow + +``` +User: "Create a counter app" +AI: [calls create_appgo with full counter app] +Backend: ✓ Compiled successfully +Preview: Shows counter app + +User: "Add a reset button" +AI: [calls str_replace to add reset button] +Backend: ✓ Compiled successfully +Preview: Updates with reset button + +User: "Make buttons bigger" +AI: [calls str_replace to update button classes] +Backend: ✓ Compiled successfully +Preview: Updates with larger buttons + +User: [switches to Code tab, tweaks color manually] +Backend: ✓ Compiled successfully +Preview: Updates + +User: "Add a chart showing count over time" +AI: [calls web_search for "go charting library"] +AI: [calls str_replace to add chart] +Backend: ✗ Compilation failed - missing import +AI: [calls str_replace to add import] +Backend: ✓ Compiled successfully +Preview: Shows chart +``` + +## Out of Scope (V1) + +- Version history / snapshots +- Multiple files / project structure +- Collaboration / sharing +- Advanced linting +- Runtime error auto-injection +- Conversation summarization +- Component-specific editing tools + +These can be added in v2+ based on user feedback. + +## Success Criteria + +- User can create functional Tsunami app through chat in <5 minutes +- AI successfully fixes its own compilation errors 80%+ of the time +- Iteration cycle (message → edit → preview) takes <10 seconds +- Users can publish working apps to Wave Terminal +- Draft state persists across sessions diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts new file mode 100644 index 0000000000..4219183c25 --- /dev/null +++ b/emain/emain-builder.ts @@ -0,0 +1,114 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ClientService } from "@/app/store/services"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { randomUUID } from "crypto"; +import { BrowserWindow } from "electron"; +import { globalEvents } from "emain/emain-events"; +import path from "path"; +import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; +import { calculateWindowBounds, MinWindowHeight, MinWindowWidth } from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; + +export type BuilderWindowType = BrowserWindow & { + builderId: string; + savedInitOpts: BuilderInitOpts; +}; + +const builderWindows: BuilderWindowType[] = []; +export let focusedBuilderWindow: BuilderWindowType = null; + +export function getBuilderWindowById(builderId: string): BuilderWindowType { + return builderWindows.find((win) => win.builderId === builderId); +} + +export function getBuilderWindowByWebContentsId(webContentsId: number): BuilderWindowType { + return builderWindows.find((win) => win.webContents.id === webContentsId); +} + +export function getAllBuilderWindows(): BuilderWindowType[] { + return builderWindows; +} + +export async function createBuilderWindow(appId: string): Promise { + const builderId = randomUUID(); + + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + const clientData = await ClientService.GetClientData(); + const clientId = clientData?.oid; + const windowId = randomUUID(); + + const winBounds = calculateWindowBounds(undefined, undefined, fullConfig.settings); + + const builderWindow = new BrowserWindow({ + x: winBounds.x, + y: winBounds.y, + width: winBounds.width, + height: winBounds.height, + minWidth: MinWindowWidth, + minHeight: MinWindowHeight, + titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "default", + icon: + unamePlatform === "linux" + ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") + : undefined, + show: false, + backgroundColor: "#222222", + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + }); + + if (isDevVite) { + await builderWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); + } else { + await builderWindow.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); + } + + const initOpts: BuilderInitOpts = { + builderId, + clientId, + windowId, + appId, + }; + + const typedBuilderWindow = builderWindow as BuilderWindowType; + typedBuilderWindow.builderId = builderId; + typedBuilderWindow.savedInitOpts = initOpts; + + console.log("sending builder-init", initOpts); + typedBuilderWindow.webContents.send("builder-init", initOpts); + + typedBuilderWindow.on("focus", () => { + focusedBuilderWindow = typedBuilderWindow; + console.log("builder window focused", builderId); + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + + typedBuilderWindow.on("blur", () => { + if (focusedBuilderWindow === typedBuilderWindow) { + focusedBuilderWindow = null; + } + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + + typedBuilderWindow.on("closed", () => { + console.log("builder window closed", builderId); + const index = builderWindows.indexOf(typedBuilderWindow); + if (index !== -1) { + builderWindows.splice(index, 1); + } + if (focusedBuilderWindow === typedBuilderWindow) { + focusedBuilderWindow = null; + } + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + + builderWindows.push(typedBuilderWindow); + typedBuilderWindow.show(); + + console.log("created builder window", builderId, appId); + return typedBuilderWindow; +} diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts new file mode 100644 index 0000000000..a3d8fab758 --- /dev/null +++ b/emain/emain-ipc.ts @@ -0,0 +1,423 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { FastAverageColor } from "fast-average-color"; +import fs from "fs"; +import * as child_process from "node:child_process"; +import * as path from "path"; +import { PNG } from "pngjs"; +import { Readable } from "stream"; +import { RpcApi } from "../frontend/app/store/wshclientapi"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; +import * as keyutil from "../frontend/util/keyutil"; +import { fireAndForget, parseDataUrl } from "../frontend/util/util"; +import { createBuilderWindow, getBuilderWindowByWebContentsId } from "./emain-builder"; +import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; +import { getWaveTabViewByWebContentsId } from "./emain-tabview"; +import { handleCtrlShiftState } from "./emain-util"; +import { getWaveVersion } from "./emain-wavesrv"; +import { createNewWaveWindow, focusedWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; + +const electronApp = electron.app; + +let webviewFocusId: number = null; +let webviewKeys: string[] = []; + +type UrlInSessionResult = { + stream: Readable; + mimeType: string; + fileName: string; +}; + +function getSingleHeaderVal(headers: Record, key: string): string { + const val = headers[key]; + if (val == null) { + return null; + } + if (Array.isArray(val)) { + return val[0]; + } + return val; +} + +function cleanMimeType(mimeType: string): string { + if (mimeType == null) { + return null; + } + const parts = mimeType.split(";"); + return parts[0].trim(); +} + +function getFileNameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const filename = pathname.substring(pathname.lastIndexOf("/") + 1); + return filename; + } catch (e) { + return null; + } +} + +function getUrlInSession(session: Electron.Session, url: string): Promise { + return new Promise((resolve, reject) => { + if (url.startsWith("data:")) { + try { + const parsed = parseDataUrl(url); + const buffer = Buffer.from(parsed.buffer); + const readable = Readable.from(buffer); + resolve({ stream: readable, mimeType: parsed.mimeType, fileName: "image" }); + } catch (err) { + return reject(err); + } + return; + } + const request = electron.net.request({ + url, + method: "GET", + session, + }); + const readable = new Readable({ + read() {}, + }); + request.on("response", (response) => { + const statusCode = response.statusCode; + if (statusCode < 200 || statusCode >= 300) { + readable.destroy(); + request.abort(); + reject(new Error(`HTTP request failed with status ${statusCode}: ${response.statusMessage || ""}`)); + return; + } + + const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type")); + const fileName = getFileNameFromUrl(url) || "image"; + response.on("data", (chunk) => { + readable.push(chunk); + }); + response.on("end", () => { + readable.push(null); + resolve({ stream: readable, mimeType, fileName }); + }); + response.on("error", (err) => { + readable.destroy(err); + reject(err); + }); + }); + request.on("error", (err) => { + readable.destroy(err); + reject(err); + }); + request.end(); + }); +} + +function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) { + if (defaultFileName == null || defaultFileName == "") { + defaultFileName = "image"; + } + const ww = focusedWaveWindow; + if (ww == null) { + return; + } + const mimeToExtension: { [key: string]: string } = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/tiff": "tiff", + "image/heic": "heic", + "image/svg+xml": "svg", + }; + function addExtensionIfNeeded(fileName: string, mimeType: string): string { + const extension = mimeToExtension[mimeType]; + if (!path.extname(fileName) && extension) { + return `${fileName}.${extension}`; + } + return fileName; + } + defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); + electron.dialog + .showSaveDialog(ww, { + title: "Save Image", + defaultPath: defaultFileName, + filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], + }) + .then((file) => { + if (file.canceled) { + return; + } + const writeStream = fs.createWriteStream(file.filePath); + readStream.pipe(writeStream); + writeStream.on("finish", () => { + console.log("saved file", file.filePath); + }); + writeStream.on("error", (err) => { + console.log("error saving file (writeStream)", err); + readStream.destroy(); + }); + readStream.on("error", (err) => { + console.error("error saving file (readStream)", err); + writeStream.destroy(); + }); + }) + .catch((err) => { + console.log("error trying to save file", err); + }); +} + +export function initIpcHandlers() { + electron.ipcMain.on("open-external", (event, url) => { + if (url && typeof url === "string") { + fireAndForget(() => + callWithOriginalXdgCurrentDesktopAsync(() => + electron.shell.openExternal(url).catch((err) => { + console.error(`Failed to open URL ${url}:`, err); + }) + ) + ); + } else { + console.error("Invalid URL received in open-external event:", url); + } + }); + + electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { + const menu = new electron.Menu(); + const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id); + if (win == null) { + return; + } + menu.append( + new electron.MenuItem({ + label: "Save Image", + click: () => { + const resultP = getUrlInSession(event.sender.session, payload.src); + resultP + .then((result) => { + saveImageFileWithNativeDialog(result.fileName, result.mimeType, result.stream); + }) + .catch((e) => { + console.log("error getting image", e); + }); + }, + }) + ); + menu.popup(); + }); + + electron.ipcMain.on("download", (event, payload) => { + const baseName = encodeURIComponent(path.basename(payload.filePath)); + const streamingUrl = + getWebServerEndpoint() + "/wave/stream-file/" + baseName + "?path=" + encodeURIComponent(payload.filePath); + event.sender.downloadURL(streamingUrl); + }); + + electron.ipcMain.on("get-cursor-point", (event) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null) { + event.returnValue = null; + return; + } + const screenPoint = electron.screen.getCursorScreenPoint(); + const windowRect = tabView.getBounds(); + const retVal: Electron.Point = { + x: screenPoint.x - windowRect.x, + y: screenPoint.y - windowRect.y, + }; + event.returnValue = retVal; + }); + + electron.ipcMain.handle("capture-screenshot", async (event, rect) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (!tabView) { + throw new Error("No tab view found for the given webContents id"); + } + const image = await tabView.webContents.capturePage(rect); + const base64String = image.toPNG().toString("base64"); + return `data:image/png;base64,${base64String}`; + }); + + electron.ipcMain.on("get-env", (event, varName) => { + event.returnValue = process.env[varName] ?? null; + }); + + electron.ipcMain.on("get-about-modal-details", (event) => { + event.returnValue = getWaveVersion() as AboutModalDetails; + }); + + electron.ipcMain.on("get-zoom-factor", (event) => { + event.returnValue = event.sender.getZoomFactor(); + }); + + const hasBeforeInputRegisteredMap = new Map(); + + electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { + webviewFocusId = focusedId; + console.log("webview-focus", focusedId); + if (focusedId == null) { + return; + } + const parentWc = event.sender; + const webviewWc = electron.webContents.fromId(focusedId); + if (webviewWc == null) { + webviewFocusId = null; + return; + } + if (!hasBeforeInputRegisteredMap.get(focusedId)) { + hasBeforeInputRegisteredMap.set(focusedId, true); + webviewWc.on("before-input-event", (e, input) => { + let waveEvent = keyutil.adaptFromElectronKeyEvent(input); + handleCtrlShiftState(parentWc, waveEvent); + if (webviewFocusId != focusedId) { + return; + } + if (input.type != "keyDown") { + return; + } + for (let keyDesc of webviewKeys) { + if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + parentWc.send("reinject-key", waveEvent); + console.log("webview reinject-key", keyDesc); + return; + } + } + }); + webviewWc.on("destroyed", () => { + hasBeforeInputRegisteredMap.delete(focusedId); + }); + } + }); + + electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { + webviewKeys = keys ?? []; + }); + + electron.ipcMain.on("set-keyboard-chord-mode", (event) => { + event.returnValue = null; + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + tabView?.setKeyboardChordMode(true); + }); + + if (unamePlatform !== "darwin") { + const fac = new FastAverageColor(); + + electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + if (fullConfig.settings["window:nativetitlebar"]) return; + + const zoomFactor = event.sender.getZoomFactor(); + const electronRect: Electron.Rectangle = { + x: rect.left * zoomFactor, + y: rect.top * zoomFactor, + height: rect.height * zoomFactor, + width: rect.width * zoomFactor, + }; + const overlay = await event.sender.capturePage(electronRect); + const overlayBuffer = overlay.toPNG(); + const png = PNG.sync.read(overlayBuffer); + const color = fac.prepareResult(fac.getColorFromArray4(png.data)); + const ww = getWaveWindowByWebContentsId(event.sender.id); + ww.setTitleBarOverlay({ + color: unamePlatform === "linux" ? color.rgba : "#00000000", + symbolColor: color.isDark ? "white" : "black", + }); + }); + } + + electron.ipcMain.on("quicklook", (event, filePath: string) => { + if (unamePlatform == "darwin") { + child_process.execFile("/usr/bin/qlmanage", ["-p", filePath], (error, stdout, stderr) => { + if (error) { + console.error(`Error opening Quick Look: ${error}`); + return; + } + }); + } + }); + + electron.ipcMain.handle("clear-webview-storage", async (event, webContentsId: number) => { + try { + const wc = electron.webContents.fromId(webContentsId); + if (wc && wc.session) { + await wc.session.clearStorageData(); + console.log("Cleared cookies and storage for webContentsId:", webContentsId); + } + } catch (e) { + console.error("Failed to clear cookies and storage:", e); + throw e; + } + }); + + electron.ipcMain.on("open-native-path", (event, filePath: string) => { + console.log("open-native-path", filePath); + filePath = filePath.replace("~", electronApp.getPath("home")); + fireAndForget(() => + callWithOriginalXdgCurrentDesktopAsync(() => + electron.shell.openPath(filePath).then((excuse) => { + if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); + }) + ) + ); + }); + + electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView != null && tabView.initResolve != null) { + if (status === "ready") { + tabView.initResolve(); + if (tabView.savedInitOpts) { + console.log("savedInitOpts calling wave-init", tabView.waveTabId); + tabView.webContents.send("wave-init", tabView.savedInitOpts); + } + } else if (status === "wave-ready") { + tabView.waveReadyResolve(); + } + return; + } + + const builderWindow = getBuilderWindowByWebContentsId(event.sender.id); + if (builderWindow != null) { + if (status === "ready") { + if (builderWindow.savedInitOpts) { + console.log("savedInitOpts calling builder-init", builderWindow.savedInitOpts.builderId); + builderWindow.webContents.send("builder-init", builderWindow.savedInitOpts); + } + } + return; + } + + console.log("set-window-init-status: no window found for webContentsId", event.sender.id); + }); + + electron.ipcMain.on("fe-log", (event, logStr: string) => { + console.log("fe-log", logStr); + }); + + electron.ipcMain.on("open-builder", (event, appId?: string) => { + fireAndForget(() => createBuilderWindow(appId || "")); + }); + + electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); + + electron.ipcMain.on("close-builder-window", async (event) => { + const bw = getBuilderWindowByWebContentsId(event.sender.id); + if (bw == null) { + return; + } + const builderId = bw.builderId; + if (builderId) { + try { + await RpcApi.SetRTInfoCommand(ElectronWshClient, { + oref: `builder:${builderId}`, + data: {} as ObjRTInfo, + delete: true, + }); + } catch (e) { + console.error("Error deleting builder rtinfo:", e); + } + } + bw.destroy(); + }); +} diff --git a/emain/log.ts b/emain/emain-log.ts similarity index 98% rename from emain/log.ts rename to emain/emain-log.ts index 34fada4b36..91241b522a 100644 --- a/emain/log.ts +++ b/emain/emain-log.ts @@ -5,7 +5,7 @@ import fs from "fs"; import path from "path"; import { format } from "util"; import winston from "winston"; -import { getWaveDataDir, isDev } from "./platform"; +import { getWaveDataDir, isDev } from "./emain-platform"; const oldConsoleLog = console.log; diff --git a/emain/menu.ts b/emain/emain-menu.ts similarity index 93% rename from emain/menu.ts rename to emain/emain-menu.ts index 5453c41ee2..20450b55d2 100644 --- a/emain/menu.ts +++ b/emain/emain-menu.ts @@ -5,6 +5,8 @@ import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; import { fireAndForget } from "../frontend/util/util"; +import { createBuilderWindow, focusedBuilderWindow } from "./emain-builder"; +import { isDev, unamePlatform } from "./emain-platform"; import { clearTabCache } from "./emain-tabview"; import { createNewWaveWindow, @@ -16,7 +18,6 @@ import { WaveBrowserWindow, } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; -import { unamePlatform } from "./platform"; import { updater } from "./updater"; type AppMenuCallbacks = { @@ -28,10 +29,14 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents if (window == null) { return null; } - if (window instanceof electron.BaseWindow) { - const waveWin = window as WaveBrowserWindow; - if (waveWin.activeTabView) { - return waveWin.activeTabView.webContents; + // Check BrowserWindow first (for Tsunami Builder windows) + if (window instanceof electron.BrowserWindow) { + return window.webContents; + } + // Check WaveBrowserWindow (for main Wave windows with tab views) + if (window instanceof WaveBrowserWindow) { + if (window.activeTabView) { + return window.activeTabView.webContents; } return null; } @@ -81,12 +86,18 @@ async function getAppMenu( }, { role: "close", - accelerator: "", // clear the accelerator + accelerator: "", click: () => { focusedWaveWindow?.close(); }, }, ]; + if (isDev) { + fileMenu.splice(1, 0, { + label: "New WaveApp Builder Window", + click: () => fireAndForget(() => createBuilderWindow("")), + }); + } if (numWaveWindows == 0) { fileMenu.push({ label: "New Window (hidden-1)", @@ -180,9 +191,10 @@ async function getAppMenu( ]; const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Shift+I"; + const isBuilderWindowFocused = focusedBuilderWindow != null; const viewMenu: Electron.MenuItemConstructorOptions[] = [ { - label: "Reload Tab", + label: isBuilderWindowFocused ? "Reload Window" : "Reload Tab", accelerator: "Shift+CommandOrControl+R", click: (_, window) => { getWindowWebContents(window ?? ww)?.reloadIgnoringCache(); @@ -317,7 +329,7 @@ async function getAppMenu( submenu: viewMenu, }, ]; - if (workspaceMenu != null) { + if (workspaceMenu != null && !isBuilderWindowFocused) { menuTemplate.push({ label: "Workspace", id: "workspace-menu", diff --git a/emain/platform.ts b/emain/emain-platform.ts similarity index 100% rename from emain/platform.ts rename to emain/emain-platform.ts diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 0c7d651b4f..5855198ff5 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -9,9 +9,9 @@ import { getWaveWindowById } from "emain/emain-window"; import path from "path"; import { configureAuthKeyRequestInjection } from "./authkey"; import { setWasActive } from "./emain-activity"; +import { getElectronAppBasePath, isDevVite } from "./emain-platform"; import { handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler } from "./emain-util"; import { ElectronWshClient } from "./emain-wsh"; -import { getElectronAppBasePath, isDevVite } from "./platform"; function computeBgColor(fullConfig: FullConfigType): string { const settings = fullConfig?.settings; diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts index 686ee87f76..18f8c2eb18 100644 --- a/emain/emain-wavesrv.ts +++ b/emain/emain-wavesrv.ts @@ -7,7 +7,6 @@ import * as readline from "readline"; import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; import { AuthKey, WaveAuthKeyEnv } from "./authkey"; import { setForceQuit } from "./emain-activity"; -import { WaveAppPathVarName, WaveAppElectronExecPath, getElectronExecPath } from "./emain-util"; import { getElectronAppUnpackedBasePath, getWaveConfigDir, @@ -17,7 +16,8 @@ import { getXdgCurrentDesktop, WaveConfigHomeVarName, WaveDataHomeVarName, -} from "./platform"; +} from "./emain-platform"; +import { getElectronExecPath, WaveAppElectronExecPath, WaveAppPathVarName } from "./emain-util"; import { updater } from "./updater"; let isWaveSrvDead = false; diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 6aa3b9e31c..da2d90efeb 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -15,11 +15,11 @@ import { setWasActive, setWasInFg, } from "./emain-activity"; +import { log } from "./emain-log"; +import { getElectronAppBasePath, unamePlatform } from "./emain-platform"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; import { ElectronWshClient } from "./emain-wsh"; -import { log } from "./log"; -import { getElectronAppBasePath, unamePlatform } from "./platform"; import { updater } from "./updater"; export type WindowOpts = { @@ -27,8 +27,67 @@ export type WindowOpts = { isPrimaryStartupWindow?: boolean; }; -const MIN_WINDOW_WIDTH = 800; -const MIN_WINDOW_HEIGHT = 500; +export const MinWindowWidth = 800; +export const MinWindowHeight = 500; + +export function calculateWindowBounds( + winSize?: { width?: number; height?: number }, + pos?: { x?: number; y?: number }, + settings?: any +): { x: number; y: number; width: number; height: number } { + let winWidth = winSize?.width; + let winHeight = winSize?.height; + let winPosX = pos?.x ?? 100; + let winPosY = pos?.y ?? 100; + + if ((winWidth == null || winWidth === 0 || winHeight == null || winHeight === 0) && settings?.["window:dimensions"]) { + const dimensions = settings["window:dimensions"]; + const match = dimensions.match(/^(\d+)[xX](\d+)$/); + + if (match) { + const [, dimensionWidth, dimensionHeight] = match; + const parsedWidth = parseInt(dimensionWidth, 10); + const parsedHeight = parseInt(dimensionHeight, 10); + + if ((!winWidth || winWidth === 0) && Number.isFinite(parsedWidth) && parsedWidth > 0) { + winWidth = parsedWidth; + } + if ((!winHeight || winHeight === 0) && Number.isFinite(parsedHeight) && parsedHeight > 0) { + winHeight = parsedHeight; + } + } else { + console.warn('Invalid window:dimensions format. Expected "widthxheight".'); + } + } + + if (winWidth == null || winWidth == 0) { + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + winWidth = width - winPosX - 100; + if (winWidth > 2000) { + winWidth = 2000; + } + } + if (winHeight == null || winHeight == 0) { + const primaryDisplay = screen.getPrimaryDisplay(); + const { height } = primaryDisplay.workAreaSize; + winHeight = height - winPosY - 100; + if (winHeight > 1200) { + winHeight = 1200; + } + } + + winWidth = Math.max(winWidth, MinWindowWidth); + winHeight = Math.max(winHeight, MinWindowHeight); + + let winBounds = { + x: winPosX, + y: winPosY, + width: winWidth, + height: winHeight, + }; + return ensureBoundsAreVisible(winBounds); +} export const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow @@ -85,61 +144,7 @@ export class WaveBrowserWindow extends BaseWindow { const settings = fullConfig?.settings; console.log("create win", waveWindow.oid); - let winWidth = waveWindow?.winsize?.width; - let winHeight = waveWindow?.winsize?.height; - let winPosX = waveWindow.pos.x; - let winPosY = waveWindow.pos.y; - - if ( - (winWidth == null || winWidth === 0 || winHeight == null || winHeight === 0) && - settings?.["window:dimensions"] - ) { - const dimensions = settings["window:dimensions"]; - const match = dimensions.match(/^(\d+)[xX](\d+)$/); - - if (match) { - const [, dimensionWidth, dimensionHeight] = match; - const parsedWidth = parseInt(dimensionWidth, 10); - const parsedHeight = parseInt(dimensionHeight, 10); - - if ((!winWidth || winWidth === 0) && Number.isFinite(parsedWidth) && parsedWidth > 0) { - winWidth = parsedWidth; - } - if ((!winHeight || winHeight === 0) && Number.isFinite(parsedHeight) && parsedHeight > 0) { - winHeight = parsedHeight; - } - } else { - console.warn('Invalid window:dimensions format. Expected "widthxheight".'); - } - } - - if (winWidth == null || winWidth == 0) { - const primaryDisplay = screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - winWidth = width - winPosX - 100; - if (winWidth > 2000) { - winWidth = 2000; - } - } - if (winHeight == null || winHeight == 0) { - const primaryDisplay = screen.getPrimaryDisplay(); - const { height } = primaryDisplay.workAreaSize; - winHeight = height - winPosY - 100; - if (winHeight > 1200) { - winHeight = 1200; - } - } - // Ensure dimensions meet minimum requirements - winWidth = Math.max(winWidth, MIN_WINDOW_WIDTH); - winHeight = Math.max(winHeight, MIN_WINDOW_HEIGHT); - - let winBounds = { - x: winPosX, - y: winPosY, - width: winWidth, - height: winHeight, - }; - winBounds = ensureBoundsAreVisible(winBounds); + const winBounds = calculateWindowBounds(waveWindow.winsize, waveWindow.pos, settings); const winOpts: BaseWindowConstructorOptions = { titleBarStyle: opts.unamePlatform === "darwin" @@ -158,8 +163,8 @@ export class WaveBrowserWindow extends BaseWindow { y: winBounds.y, width: winBounds.width, height: winBounds.height, - minWidth: MIN_WINDOW_WIDTH, - minHeight: MIN_WINDOW_HEIGHT, + minWidth: MinWindowWidth, + minHeight: MinWindowHeight, icon: opts.unamePlatform == "linux" ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") @@ -250,9 +255,10 @@ export class WaveBrowserWindow extends BaseWindow { fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); setWasInFg(true); setWasActive(true); + setTimeout(() => globalEvents.emit("windows-updated"), 50); }); this.on("blur", () => { - // nothing for now + setTimeout(() => globalEvents.emit("windows-updated"), 50); }); this.on("close", (e) => { if (this.canClose) { @@ -350,7 +356,14 @@ export class WaveBrowserWindow extends BaseWindow { } async setActiveTab(tabId: string, setInBackend: boolean, primaryStartupTab = false) { - console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend, primaryStartupTab ? "(primary startup)" : ""); + console.log( + "setActiveTab", + tabId, + this.waveWindowId, + this.workspaceId, + setInBackend, + primaryStartupTab ? "(primary startup)" : "" + ); await this._queueActionInternal({ op: "switchtab", tabId, setInBackend, primaryStartupTab }); } @@ -371,7 +384,11 @@ export class WaveBrowserWindow extends BaseWindow { tabView.savedInitOpts.activate = false; delete tabView.savedInitOpts.primaryTabStartup; let startTime = Date.now(); - console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId, primaryStartupTab ? "(primary startup)" : ""); + console.log( + "before wave ready, init tab, sending wave-init", + tabView.waveTabId, + primaryStartupTab ? "(primary startup)" : "" + ); tabView.webContents.send("wave-init", initOpts); await tabView.waveReadyPromise; console.log("wave-ready init time", Date.now() - startTime + "ms"); @@ -548,7 +565,7 @@ export class WaveBrowserWindow extends BaseWindow { return; } const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); - const primaryStartupTabFlag = entry.op === "switchtab" ? entry.primaryStartupTab ?? false : false; + const primaryStartupTabFlag = entry.op === "switchtab" ? (entry.primaryStartupTab ?? false) : false; await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag); } catch (e) { console.log("error caught in processActionQueue", e); @@ -820,10 +837,15 @@ export async function relaunchBrowserWindows() { continue; } const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId; - console.log("relaunch -- creating window", windowId, windowData, isPrimaryStartupWindow ? "(primary startup)" : ""); + console.log( + "relaunch -- creating window", + windowId, + windowData, + isPrimaryStartupWindow ? "(primary startup)" : "" + ); const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform, - isPrimaryStartupWindow + isPrimaryStartupWindow, }); wins.push(win); } diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index 2b171e7b21..aaf330038a 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -6,9 +6,9 @@ import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { Notification } from "electron"; import { getResolvedUpdateChannel } from "emain/updater"; +import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; -import { unamePlatform } from "./platform"; export class ElectronWshClientType extends WshClient { constructor() { diff --git a/emain/emain.ts b/emain/emain.ts index 81c3c529df..02854cb27a 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -3,21 +3,15 @@ import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; +import { focusedBuilderWindow, getAllBuilderWindows } from "emain/emain-builder"; import { globalEvents } from "emain/emain-events"; -import { FastAverageColor } from "fast-average-color"; -import fs from "fs"; -import * as child_process from "node:child_process"; -import * as path from "path"; -import { PNG } from "pngjs"; import { sprintf } from "sprintf-js"; -import { Readable } from "stream"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base"; -import { getWebServerEndpoint } from "../frontend/util/endpoints"; -import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, sleep } from "../frontend/util/util"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { initDocsite } from "./docsite"; +import { initIpcHandlers } from "./emain-ipc"; import { getActivityState, getForceQuit, @@ -25,11 +19,23 @@ import { setForceQuit, setGlobalIsQuitting, setGlobalIsStarting, -setWasActive, + setWasActive, setWasInFg, } from "./emain-activity"; -import { ensureHotSpareTab, getWaveTabViewByWebContentsId, setMaxTabCacheSize } from "./emain-tabview"; -import { handleCtrlShiftState } from "./emain-util"; +import { log } from "./emain-log"; +import { makeAppMenu, makeDockTaskbar } from "./emain-menu"; +import { + callWithOriginalXdgCurrentDesktopAsync, + checkIfRunningUnderARM64Translation, + getElectronAppBasePath, + getElectronAppUnpackedBasePath, + getWaveConfigDir, + getWaveDataDir, + isDev, + unameArch, + unamePlatform, +} from "./emain-platform"; +import { ensureHotSpareTab, setMaxTabCacheSize } from "./emain-tabview"; import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv"; import { createBrowserWindow, @@ -45,19 +51,6 @@ import { } from "./emain-window"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; -import { log } from "./log"; -import { makeAppMenu, makeDockTaskbar } from "./menu"; -import { - callWithOriginalXdgCurrentDesktopAsync, - checkIfRunningUnderARM64Translation, - getElectronAppBasePath, - getElectronAppUnpackedBasePath, - getWaveConfigDir, - getWaveDataDir, - isDev, - unameArch, - unamePlatform, -} from "./platform"; import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; @@ -67,9 +60,6 @@ const waveConfigDir = getWaveConfigDir(); electron.nativeTheme.themeSource = "dark"; -let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) -let webviewKeys: string[] = []; // the keys to trap when webview has focus - console.log = log; console.log( sprintf( @@ -124,360 +114,6 @@ function handleWSEvent(evtMsg: WSEventType) { }); } -// Listen for the open-external event from the renderer process -electron.ipcMain.on("open-external", (event, url) => { - if (url && typeof url === "string") { - fireAndForget(() => - callWithOriginalXdgCurrentDesktopAsync(() => - electron.shell.openExternal(url).catch((err) => { - console.error(`Failed to open URL ${url}:`, err); - }) - ) - ); - } else { - console.error("Invalid URL received in open-external event:", url); - } -}); - -type UrlInSessionResult = { - stream: Readable; - mimeType: string; - fileName: string; -}; - -function getSingleHeaderVal(headers: Record, key: string): string { - const val = headers[key]; - if (val == null) { - return null; - } - if (Array.isArray(val)) { - return val[0]; - } - return val; -} - -function cleanMimeType(mimeType: string): string { - if (mimeType == null) { - return null; - } - const parts = mimeType.split(";"); - return parts[0].trim(); -} - -function getFileNameFromUrl(url: string): string { - try { - const pathname = new URL(url).pathname; - const filename = pathname.substring(pathname.lastIndexOf("/") + 1); - return filename; - } catch (e) { - return null; - } -} - -function getUrlInSession(session: Electron.Session, url: string): Promise { - return new Promise((resolve, reject) => { - // Handle data URLs directly - if (url.startsWith("data:")) { - const parts = url.split(","); - if (parts.length < 2) { - return reject(new Error("Invalid data URL")); - } - const header = parts[0]; // Get the data URL header (e.g., data:image/png;base64) - const base64Data = parts[1]; // Get the base64 data part - const mimeType = header.split(";")[0].slice(5); // Extract the MIME type (after "data:") - const buffer = Buffer.from(base64Data, "base64"); - const readable = Readable.from(buffer); - resolve({ stream: readable, mimeType, fileName: "image" }); - return; - } - const request = electron.net.request({ - url, - method: "GET", - session, // Attach the session directly to the request - }); - const readable = new Readable({ - read() {}, // No-op, we'll push data manually - }); - request.on("response", (response) => { - const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type")); - const fileName = getFileNameFromUrl(url) || "image"; - response.on("data", (chunk) => { - readable.push(chunk); // Push data to the readable stream - }); - response.on("end", () => { - readable.push(null); // Signal the end of the stream - resolve({ stream: readable, mimeType, fileName }); - }); - }); - request.on("error", (err) => { - readable.destroy(err); // Destroy the stream on error - reject(err); - }); - request.end(); - }); -} - -electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { - const menu = new electron.Menu(); - const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id); - if (win == null) { - return; - } - menu.append( - new electron.MenuItem({ - label: "Save Image", - click: () => { - const resultP = getUrlInSession(event.sender.session, payload.src); - resultP - .then((result) => { - saveImageFileWithNativeDialog(result.fileName, result.mimeType, result.stream); - }) - .catch((e) => { - console.log("error getting image", e); - }); - }, - }) - ); - const { x, y } = electron.screen.getCursorScreenPoint(); - const windowPos = win.getPosition(); - menu.popup(); -}); - -electron.ipcMain.on("download", (event, payload) => { - const baseName = encodeURIComponent(path.basename(payload.filePath)); - const streamingUrl = - getWebServerEndpoint() + "/wave/stream-file/" + baseName + "?path=" + encodeURIComponent(payload.filePath); - event.sender.downloadURL(streamingUrl); -}); - -electron.ipcMain.on("get-cursor-point", (event) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (tabView == null) { - event.returnValue = null; - return; - } - const screenPoint = electron.screen.getCursorScreenPoint(); - const windowRect = tabView.getBounds(); - const retVal: Electron.Point = { - x: screenPoint.x - windowRect.x, - y: screenPoint.y - windowRect.y, - }; - event.returnValue = retVal; -}); - -electron.ipcMain.handle("capture-screenshot", async (event, rect) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (!tabView) { - throw new Error("No tab view found for the given webContents id"); - } - const image = await tabView.webContents.capturePage(rect); - const base64String = image.toPNG().toString("base64"); - return `data:image/png;base64,${base64String}`; -}); - -electron.ipcMain.on("get-env", (event, varName) => { - event.returnValue = process.env[varName] ?? null; -}); - -electron.ipcMain.on("get-about-modal-details", (event) => { - event.returnValue = getWaveVersion() as AboutModalDetails; -}); - -electron.ipcMain.on("get-zoom-factor", (event) => { - event.returnValue = event.sender.getZoomFactor(); -}); - -const hasBeforeInputRegisteredMap = new Map(); - -electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { - webviewFocusId = focusedId; - console.log("webview-focus", focusedId); - if (focusedId == null) { - return; - } - const parentWc = event.sender; - const webviewWc = electron.webContents.fromId(focusedId); - if (webviewWc == null) { - webviewFocusId = null; - return; - } - if (!hasBeforeInputRegisteredMap.get(focusedId)) { - hasBeforeInputRegisteredMap.set(focusedId, true); - webviewWc.on("before-input-event", (e, input) => { - let waveEvent = keyutil.adaptFromElectronKeyEvent(input); - // console.log(`WEB ${focusedId}`, waveEvent.type, waveEvent.code); - handleCtrlShiftState(parentWc, waveEvent); - if (webviewFocusId != focusedId) { - return; - } - if (input.type != "keyDown") { - return; - } - for (let keyDesc of webviewKeys) { - if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { - e.preventDefault(); - parentWc.send("reinject-key", waveEvent); - console.log("webview reinject-key", keyDesc); - return; - } - } - }); - webviewWc.on("destroyed", () => { - hasBeforeInputRegisteredMap.delete(focusedId); - }); - } -}); - -electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { - webviewKeys = keys ?? []; -}); - -electron.ipcMain.on("set-keyboard-chord-mode", (event) => { - event.returnValue = null; - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - tabView?.setKeyboardChordMode(true); -}); - -if (unamePlatform !== "darwin") { - const fac = new FastAverageColor(); - - electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { - // Bail out if the user requests the native titlebar - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - if (fullConfig.settings["window:nativetitlebar"]) return; - - const zoomFactor = event.sender.getZoomFactor(); - const electronRect: Electron.Rectangle = { - x: rect.left * zoomFactor, - y: rect.top * zoomFactor, - height: rect.height * zoomFactor, - width: rect.width * zoomFactor, - }; - const overlay = await event.sender.capturePage(electronRect); - const overlayBuffer = overlay.toPNG(); - const png = PNG.sync.read(overlayBuffer); - const color = fac.prepareResult(fac.getColorFromArray4(png.data)); - const ww = getWaveWindowByWebContentsId(event.sender.id); - ww.setTitleBarOverlay({ - color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. - symbolColor: color.isDark ? "white" : "black", - }); - }); -} - -electron.ipcMain.on("quicklook", (event, filePath: string) => { - if (unamePlatform == "darwin") { - child_process.execFile("/usr/bin/qlmanage", ["-p", filePath], (error, stdout, stderr) => { - if (error) { - console.error(`Error opening Quick Look: ${error}`); - return; - } - }); - } -}); - -electron.ipcMain.handle("clear-webview-storage", async (event, webContentsId: number) => { - try { - const wc = electron.webContents.fromId(webContentsId); - if (wc && wc.session) { - await wc.session.clearStorageData(); - console.log("Cleared cookies and storage for webContentsId:", webContentsId); - } - } catch (e) { - console.error("Failed to clear cookies and storage:", e); - throw e; - } -}); - -electron.ipcMain.on("open-native-path", (event, filePath: string) => { - console.log("open-native-path", filePath); - filePath = filePath.replace("~", electronApp.getPath("home")); - fireAndForget(() => - callWithOriginalXdgCurrentDesktopAsync(() => - electron.shell.openPath(filePath).then((excuse) => { - if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); - }) - ) - ); -}); - -electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (tabView == null || tabView.initResolve == null) { - return; - } - if (status === "ready") { - tabView.initResolve(); - if (tabView.savedInitOpts) { - // this handles the "reload" case. we'll re-send the init opts to the frontend - console.log("savedInitOpts calling wave-init", tabView.waveTabId); - tabView.webContents.send("wave-init", tabView.savedInitOpts); - } - } else if (status === "wave-ready") { - tabView.waveReadyResolve(); - } -}); - -electron.ipcMain.on("fe-log", (event, logStr: string) => { - console.log("fe-log", logStr); -}); - -function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) { - if (defaultFileName == null || defaultFileName == "") { - defaultFileName = "image"; - } - const ww = focusedWaveWindow; - if (ww == null) { - return; - } - const mimeToExtension: { [key: string]: string } = { - "image/png": "png", - "image/jpeg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/bmp": "bmp", - "image/tiff": "tiff", - "image/heic": "heic", - }; - function addExtensionIfNeeded(fileName: string, mimeType: string): string { - const extension = mimeToExtension[mimeType]; - if (!path.extname(fileName) && extension) { - return `${fileName}.${extension}`; - } - return fileName; - } - defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); - electron.dialog - .showSaveDialog(ww, { - title: "Save Image", - defaultPath: defaultFileName, - filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], - }) - .then((file) => { - if (file.canceled) { - return; - } - const writeStream = fs.createWriteStream(file.filePath); - readStream.pipe(writeStream); - writeStream.on("finish", () => { - console.log("saved file", file.filePath); - }); - writeStream.on("error", (err) => { - console.log("error saving file (writeStream)", err); - readStream.destroy(); - }); - readStream.on("error", (err) => { - console.error("error saving file (readStream)", err); - writeStream.destroy(); // Stop the write stream - }); - }) - .catch((err) => { - console.log("error trying to save file", err); - }); -} - -electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); - // we try to set the primary display as index [0] function getActivityDisplays(): ActivityDisplayType[] { const displays = electron.screen.getAllDisplays(); @@ -531,7 +167,7 @@ function logActiveState() { const ww = focusedWaveWindow; const activeTabView = ww?.activeTabView; const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; - + if (astate.wasInFg) { activity.fgminutes = 1; } @@ -539,20 +175,20 @@ function logActiveState() { activity.activeminutes = 1; } activity.displays = getActivityDisplays(); - + const props: TEventProps = { "activity:activeminutes": activity.activeminutes, "activity:fgminutes": activity.fgminutes, "activity:openminutes": activity.openminutes, }; - + if (astate.wasActive && isWaveAIOpen) { props["activity:waveaiactiveminutes"] = 1; } if (astate.wasInFg && isWaveAIOpen) { props["activity:waveaifgminutes"] = 1; } - + try { await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); await RpcApi.RecordTEventCommand( @@ -618,6 +254,10 @@ electronApp.on("before-quit", (e) => { for (const window of allWindows) { hideWindowWithCatch(window); } + const allBuilders = getAllBuilderWindows(); + for (const builder of allBuilders) { + builder.hide(); + } if (getIsWaveSrvDead()) { console.log("wavesrv is dead, quitting immediately"); setForceQuit(true); @@ -663,13 +303,16 @@ process.on("uncaughtException", (error) => { }); let lastWaveWindowCount = 0; +let lastIsBuilderWindowActive = false; globalEvents.on("windows-updated", () => { const wwCount = getAllWaveWindows().length; - if (wwCount == lastWaveWindowCount) { + const isBuilderActive = focusedBuilderWindow != null; + if (wwCount == lastWaveWindowCount && isBuilderActive == lastIsBuilderWindowActive) { return; } lastWaveWindowCount = wwCount; - console.log("windows-updated", wwCount); + lastIsBuilderWindowActive = isBuilderActive; + console.log("windows-updated", wwCount, "builder-active:", isBuilderActive); makeAppMenu(); }); @@ -696,6 +339,7 @@ async function appMain() { console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); await electronApp.whenReady(); configureAuthKeyRequestInjection(electron.session.defaultSession); + initIpcHandlers(); await sleep(10); // wait a bit for wavesrv to be ready try { diff --git a/emain/launchsettings.ts b/emain/launchsettings.ts index 987d014371..238c3a04ae 100644 --- a/emain/launchsettings.ts +++ b/emain/launchsettings.ts @@ -3,7 +3,7 @@ import fs from "fs"; import path from "path"; -import { getWaveConfigDir } from "./platform"; +import { getWaveConfigDir } from "./emain-platform"; /** * Get settings directly from the Wave Home directory on launch. diff --git a/emain/preload.ts b/emain/preload.ts index 6545769fa3..bdde36096f 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -51,6 +51,7 @@ contextBridge.exposeInMainWorld("api", { closeTab: (workspaceId, tabId) => ipcRenderer.send("close-tab", workspaceId, tabId), setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), + onBuilderInit: (callback) => ipcRenderer.on("builder-init", (_event, initOpts) => callback(initOpts)), sendLog: (log) => ipcRenderer.send("fe-log", log), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), @@ -58,6 +59,7 @@ contextBridge.exposeInMainWorld("api", { setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), + closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), }); // Custom event for "new-window" diff --git a/frontend/app/aipanel/aifeedbackbuttons.tsx b/frontend/app/aipanel/aifeedbackbuttons.tsx index 916a4cdc89..926a232f24 100644 --- a/frontend/app/aipanel/aifeedbackbuttons.tsx +++ b/frontend/app/aipanel/aifeedbackbuttons.tsx @@ -1,10 +1,9 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { cn, makeIconClass } from "@/util/util"; import { memo, useState } from "react"; +import { WaveAIModel } from "./waveai-model"; interface AIFeedbackButtonsProps { messageText: string; @@ -21,12 +20,7 @@ export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) setThumbsDownClicked(false); } if (!thumbsUpClicked) { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "waveai:feedback", - props: { - "waveai:feedback": "good", - }, - }); + WaveAIModel.getInstance().handleAIFeedback("good"); } }; @@ -36,12 +30,7 @@ export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) setThumbsUpClicked(false); } if (!thumbsDownClicked) { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "waveai:feedback", - props: { - "waveai:feedback": "bad", - }, - }); + WaveAIModel.getInstance().handleAIFeedback("bad"); } }; diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 6a91d48b7b..384c99ff78 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -4,11 +4,8 @@ import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; import { ErrorBoundary } from "@/app/element/errorboundary"; import { ContextMenuModel } from "@/app/store/contextmenu"; -import { focusManager } from "@/app/store/focusManager"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; -import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { getWebServerEndpoint } from "@/util/endpoints"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isMacOS } from "@/util/platformutil"; import { cn } from "@/util/util"; @@ -165,6 +162,24 @@ const AIWelcomeMessage = memo(() => { AIWelcomeMessage.displayName = "AIWelcomeMessage"; +const AIBuilderWelcomeMessage = memo(() => { + return ( +
+
+ +

WaveApp Builder

+
+
+

+ The WaveApp builder helps create wave widgets that integrate seamlessly into Wave Terminal. +

+
+
+ ); +}); + +AIBuilderWelcomeMessage.displayName = "AIBuilderWelcomeMessage"; + interface AIErrorMessageProps { errorMessage: string; onClear: () => void; @@ -200,24 +215,27 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { const errorMessage = jotai.useAtomValue(model.errorMessage); const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; - const focusType = jotai.useAtomValue(focusManager.focusType); - const isFocused = focusType === "waveai"; + const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const isPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); const { messages, sendMessage, status, setMessages, error, stop } = useChat({ transport: new DefaultChatTransport({ - api: `${getWebServerEndpoint()}/api/post-chat-message`, + api: model.getUseChatEndpointUrl(), prepareSendMessagesRequest: (opts) => { const msg = model.getAndClearMessage(); - return { - body: { - msg, - chatid: globalStore.get(model.chatId), - widgetaccess: globalStore.get(model.widgetAccessAtom), - tabid: globalStore.get(atoms.staticTabId), - }, + const windowType = globalStore.get(atoms.waveWindowType); + const body: any = { + msg, + chatid: globalStore.get(model.chatId), + widgetaccess: globalStore.get(model.widgetAccessAtom), }; + if (windowType === "builder") { + body.builderid = globalStore.get(atoms.builderId); + } else { + body.tabid = globalStore.get(atoms.staticTabId); + } + return { body }; }, }), onError: (error) => { @@ -258,7 +276,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { useEffect(() => { const loadChat = async () => { - await model.uiLoadChat(); + await model.uiLoadInitialChat(); setInitialLoadDone(true); }; loadChat(); @@ -362,10 +380,13 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { } }; - const handleFocusCapture = useCallback((event: React.FocusEvent) => { - // console.log("Wave AI focus capture", getElemAsStr(event.target)); - focusManager.requestWaveAIFocus(); - }, []); + const handleFocusCapture = useCallback( + (event: React.FocusEvent) => { + // console.log("Wave AI focus capture", getElemAsStr(event.target)); + model.requestWaveAIFocus(); + }, + [model] + ); const handleClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -377,7 +398,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { const hasSelection = waveAIHasSelection(); if (hasSelection) { - focusManager.requestWaveAIFocus(); + model.requestWaveAIFocus(); return; } @@ -428,14 +449,15 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { ref={containerRef} data-waveai-panel="true" className={cn( - "bg-gray-900 flex flex-col relative h-[calc(100%-4px)] mt-1", + "bg-gray-900 flex flex-col relative h-[calc(100%-4px)]", + model.inBuilder ? "mt-0" : "mt-1", className, isDragOver && "bg-gray-800 border-accent", isFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ - borderTopRightRadius: 10, - borderBottomRightRadius: 10, + borderTopRightRadius: model.inBuilder ? 0 : 10, + borderBottomRightRadius: model.inBuilder ? 0 : 10, borderBottomLeftRadius: 10, }} onFocusCapture={handleFocusCapture} @@ -458,7 +480,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { <> {messages.length === 0 && initialLoadDone ? (
- + {model.inBuilder ? : }
) : ( { const widgetAccess = useAtomValue(model.widgetAccessAtom); + const inBuilder = model.inBuilder; const handleKebabClick = (e: React.MouseEvent) => { const menu: ContextMenuItem[] = [ @@ -42,35 +43,37 @@ export const AIPanelHeader = memo(({ onClose, model, onClearChat }: AIPanelHeade
-
- Context - Widget Context - -
+ + + {widgetAccess ? "ON" : "OFF"} + + +
+ )} + ); +}); + +TabButton.displayName = "TabButton"; + +const BuilderAppPanel = memo(() => { + const [activeTab, setActiveTab] = useState("preview"); + const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); + const isAppFocused = focusType === "app"; + + const handleTabClick = (tab: TabType) => { + setActiveTab(tab); + BuilderFocusManager.getInstance().setAppFocused(); + }; + + return ( +
+
+
+ handleTabClick("preview")} + /> + handleTabClick("files")} + /> + handleTabClick("code")} + /> +
+
+
+ {activeTab === "preview" && } + {activeTab === "files" && } + {activeTab === "code" && } +
+
+ ); +}); + +BuilderAppPanel.displayName = "BuilderAppPanel"; + +export { BuilderAppPanel }; \ No newline at end of file diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx new file mode 100644 index 0000000000..16f3fd1b59 --- /dev/null +++ b/frontend/builder/builder-workspace.tsx @@ -0,0 +1,132 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { AIPanel } from "@/app/aipanel/aipanel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { BuilderAppPanel } from "@/builder/builder-apppanel"; +import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { atoms } from "@/store/global"; +import { cn } from "@/util/util"; +import { useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useState } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { debounce } from "throttle-debounce"; + +const DEFAULT_LAYOUT = { + chat: 50, + app: 80, + build: 20, +}; + +const BuilderWorkspace = memo(() => { + const builderId = useAtomValue(atoms.builderId); + const [layout, setLayout] = useState>(null); + const [isLoading, setIsLoading] = useState(true); + const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); + const isAppFocused = focusType === "app"; + + useEffect(() => { + const loadLayout = async () => { + if (!builderId) { + setLayout(DEFAULT_LAYOUT); + setIsLoading(false); + return; + } + + try { + const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { + oref: `builder:${builderId}`, + }); + if (rtInfo?.["builder:layout"]) { + setLayout(rtInfo["builder:layout"] as Record); + } else { + setLayout(DEFAULT_LAYOUT); + } + } catch (error) { + console.error("Failed to load builder layout:", error); + setLayout(DEFAULT_LAYOUT); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + }, [builderId]); + + const saveLayout = useCallback( + debounce(500, (newLayout: Record) => { + if (!builderId) return; + + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: `builder:${builderId}`, + data: { + "builder:layout": newLayout, + }, + }).catch((error) => { + console.error("Failed to save builder layout:", error); + }); + }), + [builderId] + ); + + const handleHorizontalLayout = useCallback( + (sizes: number[]) => { + const newLayout = { ...layout, chat: sizes[0] }; + setLayout(newLayout); + saveLayout(newLayout); + }, + [layout, saveLayout] + ); + + const handleVerticalLayout = useCallback( + (sizes: number[]) => { + const newLayout = { ...layout, app: sizes[0], build: sizes[1] }; + setLayout(newLayout); + saveLayout(newLayout); + }, + [layout, saveLayout] + ); + + if (isLoading || !layout) { + return null; + } + + return ( +
+ + + + + + +
+ + + + + + +
+ Build Panel +
+
+
+
+
+
+
+ ); +}); + +BuilderWorkspace.displayName = "BuilderWorkspace"; + +export { BuilderWorkspace }; diff --git a/frontend/builder/store/builderFocusManager.ts b/frontend/builder/store/builderFocusManager.ts new file mode 100644 index 0000000000..f360663cc9 --- /dev/null +++ b/frontend/builder/store/builderFocusManager.ts @@ -0,0 +1,34 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { atom, type PrimitiveAtom } from "jotai"; + +export type BuilderFocusType = "waveai" | "app"; + +export class BuilderFocusManager { + private static instance: BuilderFocusManager | null = null; + + focusType: PrimitiveAtom = atom("app"); + + private constructor() {} + + static getInstance(): BuilderFocusManager { + if (!BuilderFocusManager.instance) { + BuilderFocusManager.instance = new BuilderFocusManager(); + } + return BuilderFocusManager.instance; + } + + setWaveAIFocused() { + globalStore.set(this.focusType, "waveai"); + } + + setAppFocused() { + globalStore.set(this.focusType, "app"); + } + + getFocusType(): BuilderFocusType { + return globalStore.get(this.focusType); + } +} diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx new file mode 100644 index 0000000000..60ed2e4acb --- /dev/null +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -0,0 +1,27 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { memo, useRef } from "react"; + +const BuilderCodeTab = memo(() => { + const focusElemRef = useRef(null); + + const handleClick = () => { + focusElemRef.current?.focus(); + BuilderFocusManager.getInstance().setAppFocused(); + }; + + return ( +
+
+ {}} /> +
+

Code Tab

+
+ ); +}); + +BuilderCodeTab.displayName = "BuilderCodeTab"; + +export { BuilderCodeTab }; diff --git a/frontend/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx new file mode 100644 index 0000000000..b70796f6d8 --- /dev/null +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -0,0 +1,33 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { memo, useRef } from "react"; + +const BuilderFilesTab = memo(() => { + const focusElemRef = useRef(null); + + const handleClick = () => { + focusElemRef.current?.focus(); + BuilderFocusManager.getInstance().setAppFocused(); + }; + + return ( +
+
+ {}} + /> +
+

Files Tab

+
+ ); +}); + +BuilderFilesTab.displayName = "BuilderFilesTab"; + +export { BuilderFilesTab }; \ No newline at end of file diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx new file mode 100644 index 0000000000..0aede8b477 --- /dev/null +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -0,0 +1,33 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { memo, useRef } from "react"; + +const BuilderPreviewTab = memo(() => { + const focusElemRef = useRef(null); + + const handleClick = () => { + focusElemRef.current?.focus(); + BuilderFocusManager.getInstance().setAppFocused(); + }; + + return ( +
+
+ {}} + /> +
+

Preview Tab

+
+ ); +}); + +BuilderPreviewTab.displayName = "BuilderPreviewTab"; + +export { BuilderPreviewTab }; \ No newline at end of file diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index c031022afd..e066afa27a 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { focusManager } from "@/app/store/focusManager"; +import { FocusManager } from "@/app/store/focusManager"; import { getSettingsKeyAtom } from "@/app/store/global"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; @@ -594,13 +594,13 @@ export class LayoutModel { case LayoutTreeActionType.InsertNode: insertNode(this.treeState, action as LayoutTreeInsertNodeAction); if ((action as LayoutTreeInsertNodeAction).focused) { - focusManager.requestNodeFocus(); + FocusManager.getInstance().requestNodeFocus(); } break; case LayoutTreeActionType.InsertNodeAtIndex: insertNodeAtIndex(this.treeState, action as LayoutTreeInsertNodeAtIndexAction); if ((action as LayoutTreeInsertNodeAtIndexAction).focused) { - focusManager.requestNodeFocus(); + FocusManager.getInstance().requestNodeFocus(); } break; case LayoutTreeActionType.DeleteNode: @@ -636,11 +636,11 @@ export class LayoutModel { } case LayoutTreeActionType.FocusNode: focusNode(this.treeState, action as LayoutTreeFocusNodeAction); - focusManager.requestNodeFocus(); + FocusManager.getInstance().requestNodeFocus(); break; case LayoutTreeActionType.MagnifyNodeToggle: magnifyNodeToggle(this.treeState, action as LayoutTreeMagnifyNodeToggleAction); - focusManager.requestNodeFocus(); + FocusManager.getInstance().requestNodeFocus(); break; case LayoutTreeActionType.ClearTree: clearTree(this.treeState); @@ -1034,7 +1034,7 @@ export class LayoutModel { isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); const isFocused = treeState.focusedNodeId === nodeid; - const focusType = get(focusManager.focusType); + const focusType = get(FocusManager.getInstance().focusType); return isFocused && focusType === "node"; }), numLeafs: this.numLeafs, diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index e816e548eb..bdc29620db 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -8,6 +8,8 @@ import type * as rxjs from "rxjs"; declare global { type GlobalAtomsType = { clientId: jotai.Atom; // readonly + builderId: jotai.PrimitiveAtom; // readonly (for builder mode) + waveWindowType: jotai.Atom<"tab" | "builder">; // derived from builderId client: jotai.Atom; // driven from WOS uiContext: jotai.Atom; // driven from windowId, tabId waveWindow: jotai.Atom; // driven from WOS @@ -63,6 +65,13 @@ declare global { primaryTabStartup?: boolean; }; + type BuilderInitOpts = { + builderId: string; + clientId: string; + windowId: string; + appId: string; + }; + type ElectronApi = { getAuthKey(): string; // get-auth-key getIsDev(): boolean; // get-is-dev @@ -103,6 +112,7 @@ declare global { closeTab: (workspaceId: string, tabId: string) => void; // close-tab setWindowInitStatus: (status: "ready" | "wave-ready") => void; // set-window-init-status onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; // wave-init + onBuilderInit: (callback: (initOpts: BuilderInitOpts) => void) => void; // builder-init sendLog: (log: string) => void; // fe-log onQuicklook: (filePath: string) => void; // quicklook openNativePath(filePath: string): void; // open-native-path @@ -110,6 +120,7 @@ declare global { setKeyboardChordMode: () => void; // set-keyboard-chord-mode clearWebviewStorage: (webContentsId: number) => Promise; // clear-webview-storage setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open + closeBuilderWindow: () => void; // close-builder-window }; type ElectronContextMenuItem = { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ce2c8f3455..1578d57c66 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -290,6 +290,7 @@ declare global { type CommandSetRTInfoData = { oref: ORef; data: ObjRTInfo; + delete?: boolean; }; // wshrpc.CommandTermGetScrollbackLinesData @@ -720,6 +721,8 @@ declare global { "shell:inputempty"?: boolean; "shell:lastcmd"?: string; "shell:lastcmdexitcode"?: number; + "builder:layout"?: {[key: string]: number}; + "waveai:chatid"?: string; }; // iochantypes.Packet diff --git a/frontend/util/util.ts b/frontend/util/util.ts index b963a20746..f16da53a7a 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -28,6 +28,7 @@ function stringToBase64(input: string): string { return base64.fromByteArray(stringBytes); } +// browser only (uses atob) function base64ToArray(b64: string): Uint8Array { const rawStr = atob(b64); const rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length)); @@ -37,6 +38,20 @@ function base64ToArray(b64: string): Uint8Array { return rtnArr; } +function decodeBase64ToBytes(b64: string): Uint8Array { + // Remove whitespace that some generators insert + const clean = b64.replace(/\s+/g, ""); + if (typeof Buffer !== "undefined") { + // Node/Electron main + return new Uint8Array(Buffer.from(clean, "base64")); + } + // Browser + const raw = atob(clean); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) & 0xff; + return out; +} + function boundNumber(num: number, min: number, max: number): number { if (num == null || typeof num != "number" || isNaN(num)) { return null; @@ -421,6 +436,34 @@ function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +type ParsedDataUrl = { + mimeType: string; + buffer: Uint8Array; +}; + +function parseDataUrl(dataUrl: string): ParsedDataUrl { + if (!dataUrl.startsWith("data:")) throw new Error("Invalid data URL"); + const [header, data] = dataUrl.split(",", 2); + if (data === undefined) throw new Error("Invalid data URL: missing data"); + + const meta = header.slice(5); + let mimeType = "text/plain;charset=US-ASCII"; + const parts = meta.split(";"); + if (parts[0]) mimeType = parts[0]; + const isBase64 = parts.some((p) => p.toLowerCase() === "base64"); + + let buffer: Uint8Array; + if (isBase64) { + buffer = decodeBase64ToBytes(data); + } else { + // assume text + const decoded = decodeURIComponent(data); + buffer = new TextEncoder().encode(decoded); + } + + return { mimeType, buffer }; +} + export { atomWithDebounce, atomWithThrottle, @@ -443,6 +486,7 @@ export { makeExternLink, makeIconClass, mergeMeta, + parseDataUrl, sleep, stringToBase64, useAtomValueSafe, diff --git a/frontend/wave.ts b/frontend/wave.ts index ec1a14b08a..dd9d3b471a 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -4,14 +4,17 @@ import { App } from "@/app/app"; import { globalRefocus, + registerBuilderGlobalKeys, registerControlShiftStateUpdateHandler, registerElectronReinjectKeyHandler, registerGlobalKeys, } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; +import { makeBuilderRouteId, makeTabRouteId } from "@/app/store/wshrouter"; import { initWshrpc, TabRpcClient } from "@/app/store/wshrpcutil"; import { loadMonaco } from "@/app/view/codeeditor/codeeditor"; +import { BuilderApp } from "@/builder/builder-app"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { atoms, @@ -36,6 +39,7 @@ import { createRoot } from "react-dom/client"; const platform = getApi().getPlatform(); document.title = `Wave Terminal`; let savedInitOpts: WaveInitOpts = null; +let savedBuilderInitOpts: BuilderInitOpts = null; (window as any).WOS = WOS; (window as any).globalStore = globalStore; @@ -62,6 +66,7 @@ async function initBare() { document.body.style.opacity = "0"; document.body.classList.add("is-transparent"); getApi().onWaveInit(initWaveWrap); + getApi().onBuilderInit(initBuilderWrap); setKeyUtilPlatform(platform); loadFonts(); updateZoomFactor(getApi().getZoomFactor()); @@ -169,7 +174,7 @@ async function initWave(initOpts: WaveInitOpts) { (window as any).globalAtoms = atoms; // Init WPS event handlers - const globalWS = initWshrpc(initOpts.tabId); + const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId)); (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); @@ -211,3 +216,98 @@ async function initWave(initOpts: WaveInitOpts) { console.log("Wave First Render Done"); getApi().setWindowInitStatus("wave-ready"); } + +async function initBuilderWrap(initOpts: BuilderInitOpts) { + try { + if (savedBuilderInitOpts) { + await reinitBuilder(); + return; + } + savedBuilderInitOpts = initOpts; + await initBuilder(initOpts); + } catch (e) { + getApi().sendLog("Error in initBuilder " + e.message + "\n" + e.stack); + console.error("Error in initBuilder", e); + } finally { + document.body.style.visibility = null; + document.body.style.opacity = null; + document.body.classList.remove("is-transparent"); + } +} + +async function reinitBuilder() { + console.log("Reinit Builder"); + getApi().sendLog("Reinit Builder"); + + // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. + document.body.classList.add("nohover"); + requestAnimationFrame(() => + setTimeout(() => { + document.body.classList.remove("nohover"); + }, 100) + ); + + await WOS.reloadWaveObject(WOS.makeORef("client", savedBuilderInitOpts.clientId)); + document.title = `Tsunami Builder - ${savedBuilderInitOpts.appId}`; + getApi().setWindowInitStatus("wave-ready"); + globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1); + globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus()); + setTimeout(() => { + globalRefocus(); + }, 50); +} + +async function initBuilder(initOpts: BuilderInitOpts) { + getApi().sendLog("Init Builder " + JSON.stringify(initOpts)); + console.log( + "Tsunami Builder Init", + "builderid", + initOpts.builderId, + "clientid", + initOpts.clientId, + "windowid", + initOpts.windowId, + "appid", + initOpts.appId, + "platform", + platform + ); + + document.title = `Tsunami Builder - ${initOpts.appId}`; + + initGlobal({ + clientId: initOpts.clientId, + windowId: initOpts.windowId, + platform, + environment: "renderer", + builderId: initOpts.builderId, + }); + (window as any).globalAtoms = atoms; + + const globalWS = initWshrpc(makeBuilderRouteId(initOpts.builderId)); + (window as any).globalWS = globalWS; + (window as any).TabRpcClient = TabRpcClient; + await loadConnStatus(); + + const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); + + registerBuilderGlobalKeys(); + registerElectronReinjectKeyHandler(); + await loadMonaco(); + const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); + console.log("fullconfig", fullConfig); + globalStore.set(atoms.fullConfigAtom, fullConfig); + + console.log("Tsunami Builder First Render"); + let firstRenderResolveFn: () => void = null; + let firstRenderPromise = new Promise((resolve) => { + firstRenderResolveFn = resolve; + }); + const reactElem = createElement(BuilderApp, { initOpts, onFirstRender: firstRenderResolveFn }, null); + const elem = document.getElementById("main"); + const root = createRoot(elem); + root.render(reactElem); + await firstRenderPromise; + console.log("Tsunami Builder First Render Done"); + getApi().setWindowInitStatus("wave-ready"); +} diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go new file mode 100644 index 0000000000..1924b1bf43 --- /dev/null +++ b/pkg/aiusechat/tools_builder.go @@ -0,0 +1,196 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package aiusechat + +import ( + "fmt" + + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/fileutil" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveappstore" +) + +const BuilderAppFileName = "app.go" + +type builderWriteAppFileParams struct { + Contents string `json:"contents"` +} + +func parseBuilderWriteAppFileInput(input any) (*builderWriteAppFileParams, error) { + result := &builderWriteAppFileParams{} + + if input == nil { + return nil, fmt.Errorf("input is required") + } + + if err := utilfn.ReUnmarshal(result, input); err != nil { + return nil, fmt.Errorf("invalid input format: %w", err) + } + + if result.Contents == "" { + return nil, fmt.Errorf("missing contents parameter") + } + + return result, nil +} + +func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { + return uctypes.ToolDefinition{ + Name: "builder_write_app_file", + DisplayName: "Write App File", + Description: fmt.Sprintf("Write the app.go file for app %s", appId), + ToolLogName: "builder:write_app", + Strict: false, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "contents": map[string]any{ + "type": "string", + "description": "The contents to write to app.go", + }, + }, + "required": []string{"contents"}, + "additionalProperties": false, + }, + ToolInputDesc: func(input any) string { + return fmt.Sprintf("writing app.go for %s", appId) + }, + ToolAnyCallback: func(input any) (any, error) { + params, err := parseBuilderWriteAppFileInput(input) + if err != nil { + return nil, err + } + + err = waveappstore.WriteAppFile(appId, BuilderAppFileName, []byte(params.Contents)) + if err != nil { + return nil, err + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName), + }, nil + }, + ToolApproval: func(input any) string { + return uctypes.ApprovalNeedsApproval + }, + } +} + +type builderEditAppFileParams struct { + Edits []fileutil.EditSpec `json:"edits"` +} + +func parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error) { + result := &builderEditAppFileParams{} + + if input == nil { + return nil, fmt.Errorf("input is required") + } + + if err := utilfn.ReUnmarshal(result, input); err != nil { + return nil, fmt.Errorf("invalid input format: %w", err) + } + + if len(result.Edits) == 0 { + return nil, fmt.Errorf("missing edits parameter") + } + + return result, nil +} + +func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { + return uctypes.ToolDefinition{ + Name: "builder_edit_app_file", + DisplayName: "Edit App File", + Description: fmt.Sprintf("Edit the app.go file for app %s using search and replace", appId), + ToolLogName: "builder:edit_app", + Strict: false, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "edits": map[string]any{ + "type": "array", + "description": "Array of edit specifications with old and new strings", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "old_str": map[string]any{ + "type": "string", + "description": "The exact string to find and replace", + }, + "new_str": map[string]any{ + "type": "string", + "description": "The string to replace with", + }, + "desc": map[string]any{ + "type": "string", + "description": "Description of the edit", + }, + }, + "required": []string{"old_str", "new_str"}, + }, + }, + }, + "required": []string{"edits"}, + "additionalProperties": false, + }, + ToolInputDesc: func(input any) string { + params, err := parseBuilderEditAppFileInput(input) + if err != nil { + return fmt.Sprintf("error parsing input: %v", err) + } + return fmt.Sprintf("editing app.go for %s (%d edits)", appId, len(params.Edits)) + }, + ToolAnyCallback: func(input any) (any, error) { + params, err := parseBuilderEditAppFileInput(input) + if err != nil { + return nil, err + } + + err = waveappstore.ReplaceInAppFile(appId, BuilderAppFileName, params.Edits) + if err != nil { + return nil, err + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("Successfully edited %s with %d changes", BuilderAppFileName, len(params.Edits)), + }, nil + }, + ToolApproval: func(input any) string { + return uctypes.ApprovalNeedsApproval + }, + } +} + +func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { + return uctypes.ToolDefinition{ + Name: "builder_list_files", + DisplayName: "List App Files", + Description: fmt.Sprintf("List all files in app %s", appId), + ToolLogName: "builder:list_files", + Strict: false, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{}, + "additionalProperties": false, + }, + ToolInputDesc: func(input any) string { + return fmt.Sprintf("listing files for %s", appId) + }, + ToolAnyCallback: func(input any) (any, error) { + result, err := waveappstore.ListAllAppFiles(appId) + if err != nil { + return nil, err + } + + return result, nil + }, + ToolApproval: func(input any) string { + return uctypes.ApprovalAutoApproved + }, + } +} \ No newline at end of file diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index f3c45b8ae7..fc894a7d84 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -570,7 +570,8 @@ func sendAIMetricsTelemetry(ctx context.Context, metrics *uctypes.AIMetrics) { // PostMessageRequest represents the request body for posting a message type PostMessageRequest struct { - TabId string `json:"tabid"` + TabId string `json:"tabid,omitempty"` + BuilderId string `json:"builderid,omitempty"` ChatID string `json:"chatid"` Msg uctypes.AIMessage `json:"msg"` WidgetAccess bool `json:"widgetaccess,omitempty"` diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index 33751d4449..f2570e10f3 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -27,18 +27,18 @@ type WSEventType struct { type WindowWatchData struct { WindowWSCh chan any - TabId string + RouteId string } var globalLock = &sync.Mutex{} var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData -func RegisterWSChannel(connId string, tabId string, ch chan any) { +func RegisterWSChannel(connId string, routeId string, ch chan any) { globalLock.Lock() defer globalLock.Unlock() wsMap[connId] = &WindowWatchData{ WindowWSCh: ch, - TabId: tabId, + RouteId: routeId, } } @@ -53,7 +53,7 @@ func getWindowWatchesForWindowId(windowId string) []*WindowWatchData { defer globalLock.Unlock() var watches []*WindowWatchData for _, wdata := range wsMap { - if wdata.TabId == windowId { + if wdata.RouteId == windowId { watches = append(watches, wdata) } } diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go index 9b958a1aab..670da2a765 100644 --- a/pkg/util/fileutil/fileutil.go +++ b/pkg/util/fileutil/fileutil.go @@ -4,6 +4,7 @@ package fileutil import ( + "fmt" "io" "io/fs" "mime" @@ -249,3 +250,57 @@ func ToFsFileInfo(fi *wshrpc.FileInfo) FsFileInfo { IsDirInternal: fi.IsDir, } } + +const ( + MaxEditFileSize = 5 * 1024 * 1024 // 5MB +) + +type EditSpec struct { + OldStr string + NewStr string + Desc string +} + +func ReplaceInFile(filePath string, edits []EditSpec) error { + fileInfo, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + if !fileInfo.Mode().IsRegular() { + return fmt.Errorf("not a regular file: %s", filePath) + } + + if fileInfo.Size() > MaxEditFileSize { + return fmt.Errorf("file too large for editing: %d bytes (max: %d)", fileInfo.Size(), MaxEditFileSize) + } + + contents, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + modifiedContents := string(contents) + + for i, edit := range edits { + if edit.OldStr == "" { + return fmt.Errorf("edit %d (%s): OldStr cannot be empty", i, edit.Desc) + } + + count := strings.Count(modifiedContents, edit.OldStr) + if count == 0 { + return fmt.Errorf("edit %d (%s): OldStr not found in file", i, edit.Desc) + } + if count > 1 { + return fmt.Errorf("edit %d (%s): OldStr appears %d times, must appear exactly once", i, edit.Desc, count) + } + + modifiedContents = strings.Replace(modifiedContents, edit.OldStr, edit.NewStr, 1) + } + + if err := os.WriteFile(filePath, []byte(modifiedContents), fileInfo.Mode()); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/pkg/util/fileutil/readdir.go b/pkg/util/fileutil/readdir.go index 0f419b919c..9a4b0ba4a7 100644 --- a/pkg/util/fileutil/readdir.go +++ b/pkg/util/fileutil/readdir.go @@ -133,5 +133,102 @@ func ReadDir(path string, maxEntries int) (*ReadDirResult, error) { result.ParentDir = parentDir } + return result, nil +} + +func ReadDirRecursive(path string, maxEntries int) (*ReadDirResult, error) { + expandedPath, err := wavebase.ExpandHomeDir(path) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + fileInfo, err := os.Stat(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to stat path: %w", err) + } + + if !fileInfo.IsDir() { + return nil, fmt.Errorf("path is not a directory") + } + + var allEntries []DirEntryOut + isDirMap := make(map[string]bool) + var truncated bool + + err = filepath.WalkDir(expandedPath, func(fullPath string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + if fullPath == expandedPath { + return nil + } + + if len(allEntries) >= maxEntries { + truncated = true + return fs.SkipAll + } + + relativePath, _ := filepath.Rel(expandedPath, fullPath) + + isSymlink := d.Type()&fs.ModeSymlink != 0 + + info, infoErr := d.Info() + if infoErr != nil { + return nil + } + + isDir := d.IsDir() + isDirMap[relativePath] = isDir + + entryData := DirEntryOut{ + Name: relativePath, + Dir: isDir, + Symlink: isSymlink, + Mode: info.Mode().String(), + Modified: utilfn.FormatRelativeTime(info.ModTime()), + ModifiedTime: info.ModTime().UTC().Format(time.RFC3339), + } + + if !isDir { + entryData.Size = info.Size() + } + + allEntries = append(allEntries, entryData) + + if isSymlink && isDir { + return fs.SkipDir + } + + return nil + }) + + if err != nil && err != fs.SkipAll { + return nil, fmt.Errorf("failed to walk directory: %w", err) + } + + sort.Slice(allEntries, func(i, j int) bool { + iIsDir := isDirMap[allEntries[i].Name] + jIsDir := isDirMap[allEntries[j].Name] + if iIsDir != jIsDir { + return iIsDir + } + return allEntries[i].Name < allEntries[j].Name + }) + + result := &ReadDirResult{ + Path: path, + AbsolutePath: expandedPath, + Entries: allEntries, + EntryCount: len(allEntries), + TotalEntries: 0, + Truncated: truncated, + } + + parentDir := filepath.Dir(expandedPath) + if parentDir != expandedPath { + result.ParentDir = parentDir + } + return result, nil } \ No newline at end of file diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go new file mode 100644 index 0000000000..6328339fc4 --- /dev/null +++ b/pkg/waveappstore/waveappstore.go @@ -0,0 +1,478 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveappstore + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/wavetermdev/waveterm/pkg/util/fileutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +const ( + AppNSLocal = "local" + AppNSDraft = "draft" +) + +var ( + namespaceRegex = regexp.MustCompile(`^@?[a-z0-9-]+$`) + appNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +) + +func MakeAppId(appNS string, appName string) string { + return appNS + "/" + appName +} + +func ParseAppId(appId string) (appNS string, appName string, err error) { + parts := strings.Split(appId, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid appId format: must be namespace/name") + } + appNS = parts[0] + appName = parts[1] + if appNS == "" || appName == "" { + return "", "", fmt.Errorf("invalid appId: namespace and name cannot be empty") + } + return appNS, appName, nil +} + +func ValidateAppId(appId string) error { + appNS, appName, err := ParseAppId(appId) + if err != nil { + return err + } + if !namespaceRegex.MatchString(appNS) { + return fmt.Errorf("invalid namespace: must match pattern @?[a-z0-9-]+") + } + if !appNameRegex.MatchString(appName) { + return fmt.Errorf("invalid app name: must match pattern [a-zA-Z0-9_-]+") + } + return nil +} + +func GetAppDir(appId string) (string, error) { + if err := ValidateAppId(appId); err != nil { + return "", err + } + appNS, appName, _ := ParseAppId(appId) + homeDir := wavebase.GetHomeDir() + return filepath.Join(homeDir, "waveapps", appNS, appName), nil +} + +func copyDir(src, dst string) error { + if err := os.RemoveAll(dst); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove existing directory: %w", err) + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dstPath, data, info.Mode()) + }) +} + +func PublishDraft(draftAppId string) (string, error) { + if err := ValidateAppId(draftAppId); err != nil { + return "", fmt.Errorf("invalid appId: %w", err) + } + + appNS, appName, _ := ParseAppId(draftAppId) + if appNS != AppNSDraft { + return "", fmt.Errorf("appId must be in draft namespace, got: %s", appNS) + } + + draftDir, err := GetAppDir(draftAppId) + if err != nil { + return "", err + } + + if _, err := os.Stat(draftDir); os.IsNotExist(err) { + return "", fmt.Errorf("draft app does not exist: %s", draftDir) + } + + localAppId := MakeAppId(AppNSLocal, appName) + localDir, err := GetAppDir(localAppId) + if err != nil { + return "", err + } + + if err := copyDir(draftDir, localDir); err != nil { + return "", err + } + + return localAppId, nil +} + +func RevertDraft(draftAppId string) error { + if err := ValidateAppId(draftAppId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appNS, appName, _ := ParseAppId(draftAppId) + if appNS != AppNSDraft { + return fmt.Errorf("appId must be in draft namespace, got: %s", appNS) + } + + draftDir, err := GetAppDir(draftAppId) + if err != nil { + return err + } + + localAppId := MakeAppId(AppNSLocal, appName) + localDir, err := GetAppDir(localAppId) + if err != nil { + return err + } + + if _, err := os.Stat(localDir); os.IsNotExist(err) { + return fmt.Errorf("local app does not exist: %s", localDir) + } + + return copyDir(localDir, draftDir) +} + +func MakeDraftFromLocal(localAppId string) (string, error) { + if err := ValidateAppId(localAppId); err != nil { + return "", fmt.Errorf("invalid appId: %w", err) + } + + appNS, appName, _ := ParseAppId(localAppId) + if appNS != AppNSLocal { + return "", fmt.Errorf("appId must be in local namespace, got: %s", appNS) + } + + localDir, err := GetAppDir(localAppId) + if err != nil { + return "", err + } + + if _, err := os.Stat(localDir); os.IsNotExist(err) { + return "", fmt.Errorf("local app does not exist: %s", localDir) + } + + draftAppId := MakeAppId(AppNSDraft, appName) + draftDir, err := GetAppDir(draftAppId) + if err != nil { + return "", err + } + + if _, err := os.Stat(draftDir); err == nil { + // draft already exists, don't overwrite (that's what RevertDraft is for) + return draftAppId, nil + } else if !os.IsNotExist(err) { + return "", err + } + + if err := copyDir(localDir, draftDir); err != nil { + return "", err + } + + return draftAppId, nil +} + +func DeleteApp(appId string) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + if err := os.RemoveAll(appDir); err != nil { + return fmt.Errorf("failed to delete app directory: %w", err) + } + + return nil +} + +func validateAndResolveFilePath(appDir string, fileName string) (string, error) { + if filepath.IsAbs(fileName) { + return "", fmt.Errorf("fileName must be relative, got absolute path: %s", fileName) + } + + cleanPath := filepath.Clean(fileName) + if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, string(filepath.Separator)+"..") { + return "", fmt.Errorf("path traversal not allowed: %s", fileName) + } + + fullPath := filepath.Join(appDir, cleanPath) + resolvedPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + + resolvedAppDir, err := filepath.Abs(appDir) + if err != nil { + return "", fmt.Errorf("failed to resolve app directory: %w", err) + } + + if !strings.HasPrefix(resolvedPath, resolvedAppDir+string(filepath.Separator)) && resolvedPath != resolvedAppDir { + return "", fmt.Errorf("path escapes app directory: %s", fileName) + } + + return resolvedPath, nil +} + +func WriteAppFile(appId string, fileName string, contents []byte) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + filePath, err := validateAndResolveFilePath(appDir, fileName) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(filePath, contents, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +func DeleteAppFile(appId string, fileName string) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + filePath, err := validateAndResolveFilePath(appDir, fileName) + if err != nil { + return err + } + + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to delete file: %w", err) + } + + return nil +} + +func ReplaceInAppFile(appId string, fileName string, edits []fileutil.EditSpec) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + filePath, err := validateAndResolveFilePath(appDir, fileName) + if err != nil { + return err + } + + return fileutil.ReplaceInFile(filePath, edits) +} + +func RenameAppFile(appId string, fromFileName string, toFileName string) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + fromPath, err := validateAndResolveFilePath(appDir, fromFileName) + if err != nil { + return fmt.Errorf("invalid source path: %w", err) + } + + toPath, err := validateAndResolveFilePath(appDir, toFileName) + if err != nil { + return fmt.Errorf("invalid destination path: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(toPath), 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + if err := os.Rename(fromPath, toPath); err != nil { + return fmt.Errorf("failed to rename file: %w", err) + } + + return nil +} + +func ListAllAppFiles(appId string) (*fileutil.ReadDirResult, error) { + if err := ValidateAppId(appId); err != nil { + return nil, fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return nil, err + } + + if _, err := os.Stat(appDir); os.IsNotExist(err) { + return nil, fmt.Errorf("app directory does not exist: %s", appDir) + } + + return fileutil.ReadDirRecursive(appDir, 10000) +} + +func ListAllApps() ([]string, error) { + homeDir := wavebase.GetHomeDir() + waveappsDir := filepath.Join(homeDir, "waveapps") + + if _, err := os.Stat(waveappsDir); os.IsNotExist(err) { + return []string{}, nil + } + + namespaces, err := os.ReadDir(waveappsDir) + if err != nil { + return nil, fmt.Errorf("failed to read waveapps directory: %w", err) + } + + var appIds []string + + for _, ns := range namespaces { + if !ns.IsDir() { + continue + } + + namespace := ns.Name() + nsPath := filepath.Join(waveappsDir, namespace) + + apps, err := os.ReadDir(nsPath) + if err != nil { + continue + } + + for _, app := range apps { + if !app.IsDir() { + continue + } + + appName := app.Name() + appId := MakeAppId(namespace, appName) + + if err := ValidateAppId(appId); err == nil { + appIds = append(appIds, appId) + } + } + } + + return appIds, nil +} +func ListAllEditableApps() ([]string, error) { + homeDir := wavebase.GetHomeDir() + waveappsDir := filepath.Join(homeDir, "waveapps") + + if _, err := os.Stat(waveappsDir); os.IsNotExist(err) { + return []string{}, nil + } + + localApps := make(map[string]bool) + draftApps := make(map[string]bool) + + localPath := filepath.Join(waveappsDir, AppNSLocal) + if localEntries, err := os.ReadDir(localPath); err == nil { + for _, app := range localEntries { + if app.IsDir() { + appName := app.Name() + appId := MakeAppId(AppNSLocal, appName) + if err := ValidateAppId(appId); err == nil { + localApps[appName] = true + } + } + } + } + + draftPath := filepath.Join(waveappsDir, AppNSDraft) + if draftEntries, err := os.ReadDir(draftPath); err == nil { + for _, app := range draftEntries { + if app.IsDir() { + appName := app.Name() + appId := MakeAppId(AppNSDraft, appName) + if err := ValidateAppId(appId); err == nil { + draftApps[appName] = true + } + } + } + } + + allAppNames := make(map[string]bool) + for appName := range localApps { + allAppNames[appName] = true + } + for appName := range draftApps { + allAppNames[appName] = true + } + + var appIds []string + for appName := range allAppNames { + if localApps[appName] { + appIds = append(appIds, MakeAppId(AppNSLocal, appName)) + } else { + appIds = append(appIds, MakeAppId(AppNSDraft, appName)) + } + } + + return appIds, nil +} + + +func DraftHasLocalVersion(draftAppId string) (bool, error) { + if err := ValidateAppId(draftAppId); err != nil { + return false, fmt.Errorf("invalid appId: %w", err) + } + + appNS, appName, _ := ParseAppId(draftAppId) + if appNS != AppNSDraft { + return false, fmt.Errorf("appId must be in draft namespace, got: %s", appNS) + } + + localAppId := MakeAppId(AppNSLocal, appName) + localDir, err := GetAppDir(localAppId) + if err != nil { + return false, err + } + + if _, err := os.Stat(localDir); os.IsNotExist(err) { + return false, nil + } + + return true, nil +} diff --git a/pkg/waveobj/blockrtinfo.go b/pkg/waveobj/objrtinfo.go similarity index 87% rename from pkg/waveobj/blockrtinfo.go rename to pkg/waveobj/objrtinfo.go index 1a5a1f7b5f..1ce2929de3 100644 --- a/pkg/waveobj/blockrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -17,4 +17,8 @@ type ObjRTInfo struct { ShellInputEmpty bool `json:"shell:inputempty,omitempty"` ShellLastCmd string `json:"shell:lastcmd,omitempty"` ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"` + + BuilderLayout map[string]float64 `json:"builder:layout,omitempty"` + + WaveAIChatId string `json:"waveai:chatid,omitempty"` } diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 1498c44a09..fbd63876b3 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -29,6 +29,7 @@ const ( OType_LayoutState = "layout" OType_Block = "block" OType_Temp = "temp" + OType_Builder = "builder" // not persisted to DB ) var ValidOTypes = map[string]bool{ @@ -39,6 +40,7 @@ var ValidOTypes = map[string]bool{ OType_LayoutState: true, OType_Block: true, OType_Temp: true, + OType_Builder: true, } type WaveObjUpdate struct { diff --git a/pkg/web/ws.go b/pkg/web/ws.go index fcf110556d..84928d5d08 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -268,9 +268,9 @@ func unregisterConn(wsConnId string, routeId string) { } func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { - tabId := r.URL.Query().Get("tabid") - if tabId == "" { - return fmt.Errorf("tabid is required") + routeId := r.URL.Query().Get("routeid") + if routeId == "" { + return fmt.Errorf("routeid is required") } err := authkey.ValidateIncomingRequest(r) if err != nil { @@ -287,14 +287,8 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { wsConnId := uuid.New().String() outputCh := make(chan any, 100) closeCh := make(chan any) - var routeId string - if tabId == wshutil.ElectronRoute { - routeId = wshutil.ElectronRoute - } else { - routeId = wshutil.MakeTabRouteId(tabId) - } - log.Printf("[websocket] new connection: tabid:%s connid:%s routeid:%s\n", tabId, wsConnId, routeId) - eventbus.RegisterWSChannel(wsConnId, tabId, outputCh) + log.Printf("[websocket] new connection: connid:%s routeid:%s\n", wsConnId, routeId) + eventbus.RegisterWSChannel(wsConnId, routeId, outputCh) defer eventbus.UnregisterWSChannel(wsConnId) wproxy := wshutil.MakeRpcProxy() // we create a wshproxy to handle rpc messages to/from the window defer close(wproxy.ToRemoteCh) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index b4cd905633..bb121e0c67 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -860,8 +860,9 @@ type CommandGetRTInfoData struct { } type CommandSetRTInfoData struct { - ORef waveobj.ORef `json:"oref"` - Data map[string]any `json:"data" tstype:"ObjRTInfo"` + ORef waveobj.ORef `json:"oref"` + Data map[string]any `json:"data" tstype:"ObjRTInfo"` + Delete bool `json:"delete,omitempty"` } type CommandTermGetScrollbackLinesData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index a27a89a84e..d4bbf367e9 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -167,6 +167,10 @@ func (ws *WshServer) GetRTInfoCommand(ctx context.Context, data wshrpc.CommandGe } func (ws *WshServer) SetRTInfoCommand(ctx context.Context, data wshrpc.CommandSetRTInfoData) error { + if data.Delete { + wstore.DeleteRTInfo(data.ORef) + return nil + } wstore.SetRTInfo(data.ORef, data.Data) return nil } diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index d4bb92c84b..17185dd607 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -30,6 +30,7 @@ const ( RoutePrefix_Proc = "proc:" RoutePrefix_Tab = "tab:" RoutePrefix_FeBlock = "feblock:" + RoutePrefix_Builder = "builder:" ) // this works like a network switch @@ -77,6 +78,10 @@ func MakeFeBlockRouteId(blockId string) string { return "feblock:" + blockId } +func MakeBuilderRouteId(builderId string) string { + return "builder:" + builderId +} + var DefaultRouter = NewWshRouter() func NewWshRouter() *WshRouter { diff --git a/pkg/wstore/blockrtinfo.go b/pkg/wstore/wstore_rtinfo.go similarity index 65% rename from pkg/wstore/blockrtinfo.go rename to pkg/wstore/wstore_rtinfo.go index 3f76d701bf..372d1db813 100644 --- a/pkg/wstore/blockrtinfo.go +++ b/pkg/wstore/wstore_rtinfo.go @@ -12,21 +12,21 @@ import ( ) var ( - blockRTInfoStore = make(map[waveobj.ORef]*waveobj.ObjRTInfo) - blockRTInfoMutex sync.RWMutex + rtInfoStore = make(map[waveobj.ORef]*waveobj.ObjRTInfo) + rtInfoMutex sync.RWMutex ) -// SetRTInfo merges the provided info map into the BlockRTInfo for the given ORef. -// Only updates fields that exist in the BlockRTInfo struct. +// SetRTInfo merges the provided info map into the ObjRTInfo for the given ORef. +// Only updates fields that exist in the ObjRTInfo struct. // Removes fields that have nil values. func SetRTInfo(oref waveobj.ORef, info map[string]any) { - blockRTInfoMutex.Lock() - defer blockRTInfoMutex.Unlock() + rtInfoMutex.Lock() + defer rtInfoMutex.Unlock() - rtInfo, exists := blockRTInfoStore[oref] + rtInfo, exists := rtInfoStore[oref] if !exists { rtInfo = &waveobj.ObjRTInfo{} - blockRTInfoStore[oref] = rtInfo + rtInfoStore[oref] = rtInfo } rtInfoValue := reflect.ValueOf(rtInfo).Elem() @@ -77,6 +77,19 @@ func SetRTInfo(oref waveobj.ORef, info map[string]any) { case float64: fieldValue.SetInt(int64(v)) } + } else if fieldValue.Kind() == reflect.Map { + // Handle map[string]float64 fields + if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.Float64 { + if inputMap, ok := value.(map[string]any); ok { + outputMap := make(map[string]float64) + for k, v := range inputMap { + if floatVal, ok := v.(float64); ok { + outputMap[k] = floatVal + } + } + fieldValue.Set(reflect.ValueOf(outputMap)) + } + } } else if fieldValue.Kind() == reflect.Interface { // Handle any/interface{} fields fieldValue.Set(reflect.ValueOf(value)) @@ -85,12 +98,12 @@ func SetRTInfo(oref waveobj.ORef, info map[string]any) { } } -// GetRTInfo returns the BlockRTInfo for the given ORef, or nil if not found +// GetRTInfo returns the ObjRTInfo for the given ORef, or nil if not found func GetRTInfo(oref waveobj.ORef) *waveobj.ObjRTInfo { - blockRTInfoMutex.RLock() - defer blockRTInfoMutex.RUnlock() + rtInfoMutex.RLock() + defer rtInfoMutex.RUnlock() - if rtInfo, exists := blockRTInfoStore[oref]; exists { + if rtInfo, exists := rtInfoStore[oref]; exists { // Return a copy to avoid external modification copy := *rtInfo return © @@ -98,10 +111,10 @@ func GetRTInfo(oref waveobj.ORef) *waveobj.ObjRTInfo { return nil } -// DeleteRTInfo removes the BlockRTInfo for the given ORef +// DeleteRTInfo removes the ObjRTInfo for the given ORef func DeleteRTInfo(oref waveobj.ORef) { - blockRTInfoMutex.Lock() - defer blockRTInfoMutex.Unlock() + rtInfoMutex.Lock() + defer rtInfoMutex.Unlock() - delete(blockRTInfoStore, oref) + delete(rtInfoStore, oref) } diff --git a/tsconfig.json b/tsconfig.json index 950b6fecb6..cb02488e63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "baseUrl": "./", "paths": { "@/app/*": ["frontend/app/*"], + "@/builder/*": ["frontend/builder/*"], "@/util/*": ["frontend/util/*"], "@/layout/*": ["frontend/layout/*"], "@/store/*": ["frontend/app/store/*"],