diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index a83a80b3b7..0ac189a328 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -34,6 +34,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - For element variants use class-variance-authority + - Do NOT create private fields in classes (they are impossible to inspect) - **Component Practices**: - Make sure to add cursor-pointer to buttons/links and clickable items - NEVER use cursor-help (it looks terrible) diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 427e6ad2e0..9a1314012c 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -353,6 +353,11 @@ func main() { log.Printf("error ensuring wave presets dir: %v\n", err) return } + err = wavebase.EnsureWaveCachesDir() + if err != nil { + log.Printf("error ensuring wave caches dir: %v\n", err) + return + } waveLock, err := wavebase.AcquireWaveLock() if err != nil { log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts index 4219183c25..a073d99c77 100644 --- a/emain/emain-builder.ts +++ b/emain/emain-builder.ts @@ -103,6 +103,7 @@ export async function createBuilderWindow(appId: string): Promise globalEvents.emit("windows-updated"), 50); }); diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 2184ae3a63..98942100b9 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -14,7 +14,7 @@ import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { getWebServerEndpoint } from "@/util/endpoints"; import { ChatStatus } from "ai"; import * as jotai from "jotai"; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 18fc68fe15..c10f3547b7 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -122,6 +122,11 @@ class RpcApiType { return client.wshRpcCall("deleteblock", data, opts); } + // command "deletebuilder" [call] + DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("deletebuilder", data, opts); + } + // command "deletesubblock" [call] DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deletesubblock", data, opts); @@ -262,6 +267,16 @@ class RpcApiType { return client.wshRpcCall("focuswindow", data, opts); } + // command "getbuilderoutput" [call] + GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("getbuilderoutput", data, opts); + } + + // command "getbuilderstatus" [call] + GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("getbuilderstatus", data, opts); + } + // command "getfullconfig" [call] GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("getfullconfig", null, opts); @@ -462,6 +477,11 @@ class RpcApiType { return client.wshRpcCall("setview", data, opts); } + // command "startbuilder" [call] + StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise { + return client.wshRpcCall("startbuilder", data, opts); + } + // command "streamcpudata" [responsestream] StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { return client.wshRpcStream("streamcpudata", data, opts); diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 44d6be68c1..ba940ef277 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -1,9 +1,10 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderAppPanelModel, type TabType } from "@/builder/store/builderAppPanelModel"; -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; +import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; +import { BuilderEnvTab } from "@/builder/tabs/builder-envtab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; @@ -11,7 +12,35 @@ import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useCallback, useRef } from "react"; +import { memo, useCallback, useEffect, useRef } from "react"; + +const StatusDot = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const builderStatus = useAtomValue(model.builderStatusAtom); + + const getStatusDotColor = (status: string | null | undefined): string => { + if (!status) return "bg-gray-500"; + switch (status) { + case "init": + case "stopped": + return "bg-gray-500"; + case "building": + return "bg-warning"; + case "running": + return "bg-success"; + case "error": + return "bg-error"; + default: + return "bg-gray-500"; + } + }; + + const statusDotColor = getStatusDotColor(builderStatus?.status); + + return ; +}); + +StatusDot.displayName = "StatusDot"; type TabButtonProps = { label: string; @@ -19,26 +48,54 @@ type TabButtonProps = { isActive: boolean; isAppFocused: boolean; onClick: () => void; + showStatusDot?: boolean; }; -const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick }: TabButtonProps) => { +const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick, showStatusDot }: TabButtonProps) => { return ( ); }); TabButton.displayName = "TabButton"; +const ErrorStrip = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const errorMsg = useAtomValue(model.errorAtom); + + if (!errorMsg) return null; + return ( +
+
+ + {errorMsg} +
+ +
+ ); +}); + +ErrorStrip.displayName = "ErrorStrip"; + const BuilderAppPanel = memo(() => { const model = BuilderAppPanelModel.getInstance(); const focusElemRef = useRef(null); @@ -46,7 +103,13 @@ const BuilderAppPanel = memo(() => { const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; const saveNeeded = useAtomValue(model.saveNeededAtom); + const envSaveNeeded = useAtomValue(model.envVarsDirtyAtom); const builderAppId = useAtomValue(atoms.builderAppId); + const builderId = useAtomValue(atoms.builderId); + + useEffect(() => { + model.initialize(); + }, []); if (focusElemRef.current) { model.setFocusElemRef(focusElemRef.current); @@ -58,34 +121,34 @@ const BuilderAppPanel = memo(() => { model.giveFocus(); }; - const handleFocusCapture = useCallback( - (event: React.FocusEvent) => { - BuilderFocusManager.getInstance().setAppFocused(); - }, - [] - ); - - const handlePanelClick = useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLElement; - const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); + const handleFocusCapture = useCallback((event: React.FocusEvent) => { + BuilderFocusManager.getInstance().setAppFocused(); + }, []); - if (isInteractive) { - return; - } + const handlePanelClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); - const hasSelection = builderAppHasSelection(); - if (hasSelection) { - BuilderFocusManager.getInstance().setAppFocused(); - return; - } + if (isInteractive) { + return; + } - setTimeout(() => { - if (!builderAppHasSelection()) { + const hasSelection = builderAppHasSelection(); + if (hasSelection) { BuilderFocusManager.getInstance().setAppFocused(); - model.giveFocus(); + return; } - }, 0); - }, [model]); + + setTimeout(() => { + if (!builderAppHasSelection()) { + BuilderFocusManager.getInstance().setAppFocused(); + model.giveFocus(); + } + }, 0); + }, + [model] + ); const handleSave = useCallback(() => { if (builderAppId) { @@ -93,9 +156,19 @@ const BuilderAppPanel = memo(() => { } }, [builderAppId, model]); + const handleEnvSave = useCallback(() => { + if (builderId) { + model.saveEnvVars(builderId); + } + }, [builderId, model]); + + const handleRestart = useCallback(() => { + model.restartBuilder(); + }, [model]); + return (
{ isActive={activeTab === "preview"} isAppFocused={isAppFocused} onClick={() => handleTabClick("preview")} + showStatusDot={true} /> { isAppFocused={isAppFocused} onClick={() => handleTabClick("code")} /> + {false && ( + handleTabClick("files")} + /> + )} handleTabClick("files")} + onClick={() => handleTabClick("env")} />
+ {activeTab === "preview" && ( + + )} {activeTab === "code" && ( )} + {activeTab === "env" && ( + + )} +
@@ -165,6 +270,11 @@ const BuilderAppPanel = memo(() => {
+
+ + + +
); diff --git a/frontend/builder/builder-buildpanel.tsx b/frontend/builder/builder-buildpanel.tsx new file mode 100644 index 0000000000..500531b6e5 --- /dev/null +++ b/frontend/builder/builder-buildpanel.tsx @@ -0,0 +1,46 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderBuildPanelModel } from "@/builder/store/builder-buildpanel-model"; +import { useAtomValue } from "jotai"; +import { memo, useEffect, useRef } from "react"; + +const BuilderBuildPanel = memo(() => { + const model = BuilderBuildPanelModel.getInstance(); + const outputLines = useAtomValue(model.outputLines); + const scrollRef = useRef(null); + + useEffect(() => { + model.initialize(); + return () => { + model.dispose(); + }; + }, []); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [outputLines]); + + return ( +
+
+ Build Output +
+
+
+                    {outputLines.length === 0 ? (
+                        Waiting for output...
+                    ) : (
+                        outputLines.join("\n")
+                    )}
+                
+
+
+ ); +}); + +BuilderBuildPanel.displayName = "BuilderBuildPanel"; + +export { BuilderBuildPanel }; diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 6b1f10d1fe..3b1a84e412 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -5,7 +5,8 @@ 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 { BuilderBuildPanel } from "@/builder/builder-buildpanel"; +import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; @@ -115,9 +116,7 @@ const BuilderWorkspace = memo(() => { -
- Build Panel -
+
diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts new file mode 100644 index 0000000000..606cadb513 --- /dev/null +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -0,0 +1,295 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms, WOS } from "@/store/global"; +import { base64ToString, stringToBase64 } from "@/util/util"; +import { atom, type Atom, type PrimitiveAtom } from "jotai"; +import { debounce } from "throttle-debounce"; + +export type TabType = "preview" | "files" | "code" | "env"; + +export type EnvVar = { + name: string; + value: string; + visible?: boolean; +}; + +export class BuilderAppPanelModel { + private static instance: BuilderAppPanelModel | null = null; + + activeTab: PrimitiveAtom = atom("preview"); + codeContentAtom: PrimitiveAtom = atom(""); + originalContentAtom: PrimitiveAtom = atom(""); + envVarsArrayAtom: PrimitiveAtom = atom([]); + envVarIndexAtoms: Atom[] = []; + envVarsDirtyAtom: PrimitiveAtom = atom(false); + isLoadingAtom: PrimitiveAtom = atom(false); + errorAtom: PrimitiveAtom = atom(""); + builderStatusAtom = atom(null) as PrimitiveAtom; + saveNeededAtom!: Atom; + focusElemRef: { current: HTMLInputElement | null } = { current: null }; + monacoEditorRef: { current: any | null } = { current: null }; + statusUnsubFn: (() => void) | null = null; + appGoUpdateUnsubFn: (() => void) | null = null; + debouncedRestart: (() => void) & { cancel: () => void }; + initialized = false; + + private constructor() { + this.debouncedRestart = debounce(800, () => { + this.restartBuilder(); + }); + this.saveNeededAtom = atom((get) => { + return get(this.codeContentAtom) !== get(this.originalContentAtom); + }); + } + + static getInstance(): BuilderAppPanelModel { + if (!BuilderAppPanelModel.instance) { + BuilderAppPanelModel.instance = new BuilderAppPanelModel(); + } + return BuilderAppPanelModel.instance; + } + + setActiveTab(tab: TabType) { + globalStore.set(this.activeTab, tab); + } + + getActiveTab(): TabType { + return globalStore.get(this.activeTab); + } + + setCodeContent(content: string) { + globalStore.set(this.codeContentAtom, content); + } + + async initialize() { + if (this.initialized) return; + this.initialized = true; + + // builderId is set in initialization so is always available + const builderId = globalStore.get(atoms.builderId); + + if (this.statusUnsubFn) { + this.statusUnsubFn(); + } + + this.statusUnsubFn = waveEventSubscribe({ + eventType: "builderstatus", + scope: WOS.makeORef("builder", builderId), + handler: (event) => { + const status: BuilderStatusData = event.data; + const currentStatus = globalStore.get(this.builderStatusAtom); + if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) { + globalStore.set(this.builderStatusAtom, status); + } + }, + }); + + try { + const status = await RpcApi.GetBuilderStatusCommand(TabRpcClient, builderId); + globalStore.set(this.builderStatusAtom, status); + } catch (err) { + console.error("Failed to load builder status:", err); + } + + // the apppanel does not render until appId is set, so this will never be null during initialization + const appId = globalStore.get(atoms.builderAppId); + await this.loadAppFile(appId); + await this.loadEnvVars(builderId); + + this.appGoUpdateUnsubFn = waveEventSubscribe({ + eventType: "waveapp:appgoupdated", + scope: appId, + handler: () => { + this.loadAppFile(appId); + this.debouncedRestart(); + }, + }); + } + + async loadEnvVars(builderId: string) { + try { + const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("builder", builderId), + }); + const envVars = rtInfo?.["builder:env"] || {}; + const envVarsArray = Object.entries(envVars).map(([name, value]) => ({ name, value, visible: false })); + globalStore.set(this.envVarsArrayAtom, envVarsArray); + globalStore.set(this.envVarsDirtyAtom, false); + } catch (err) { + console.error("Failed to load environment variables:", err); + } + } + + async saveEnvVars(builderId: string) { + try { + const envVarsArray = globalStore.get(this.envVarsArrayAtom); + const envVars: Record = {}; + envVarsArray.forEach((v) => { + const trimmedName = v.name.trim(); + if (trimmedName) { + envVars[trimmedName] = v.value; + } + }); + const cleanedArray = Object.entries(envVars).map(([name, value]) => ({ name, value, visible: false })); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("builder", builderId), + data: { + "builder:env": envVars, + }, + }); + globalStore.set(this.envVarsArrayAtom, cleanedArray); + globalStore.set(this.envVarsDirtyAtom, false); + globalStore.set(this.errorAtom, ""); + this.debouncedRestart(); + } catch (err) { + console.error("Failed to save environment variables:", err); + globalStore.set(this.errorAtom, `Failed to save environment variables: ${err.message || "Unknown error"}`); + } + } + + getEnvVarIndexAtom(index: number): Atom { + if (!this.envVarIndexAtoms[index]) { + this.envVarIndexAtoms[index] = atom((get) => { + const array = get(this.envVarsArrayAtom); + return array[index] ?? null; + }); + } + return this.envVarIndexAtoms[index]; + } + + addEnvVar() { + const current = globalStore.get(this.envVarsArrayAtom); + globalStore.set(this.envVarsArrayAtom, [...current, { name: "", value: "", visible: false }]); + globalStore.set(this.envVarsDirtyAtom, true); + } + + removeEnvVar(index: number) { + const current = globalStore.get(this.envVarsArrayAtom); + const newArray = current.filter((_, i) => i !== index); + globalStore.set(this.envVarsArrayAtom, newArray); + globalStore.set(this.envVarsDirtyAtom, true); + } + + setEnvVarAtIndex(index: number, envVar: EnvVar, dirty: boolean) { + const current = globalStore.get(this.envVarsArrayAtom); + const newArray = [...current]; + newArray[index] = envVar; + globalStore.set(this.envVarsArrayAtom, newArray); + if (dirty) { + globalStore.set(this.envVarsDirtyAtom, true); + } + } + + async startBuilder() { + const builderId = globalStore.get(atoms.builderId); + try { + await RpcApi.StartBuilderCommand(TabRpcClient, { + builderid: builderId, + }); + } catch (err) { + console.error("Failed to start builder:", err); + globalStore.set(this.errorAtom, `Failed to start builder: ${err.message || "Unknown error"}`); + } + } + + async restartBuilder() { + const builderId = globalStore.get(atoms.builderId); + try { + await RpcApi.ControllerStopCommand(TabRpcClient, builderId); + await new Promise((resolve) => setTimeout(resolve, 500)); + await this.startBuilder(); + } catch (err) { + console.error("Failed to restart builder:", err); + globalStore.set(this.errorAtom, `Failed to restart builder: ${err.message || "Unknown error"}`); + } + } + + async loadAppFile(appId: string) { + try { + globalStore.set(this.isLoadingAtom, true); + globalStore.set(this.errorAtom, ""); + + const result = await RpcApi.ReadAppFileCommand(TabRpcClient, { + appid: appId, + filename: "app.go", + }); + + if (result.notfound) { + globalStore.set(this.codeContentAtom, ""); + globalStore.set(this.originalContentAtom, ""); + } else { + const decoded = base64ToString(result.data64); + globalStore.set(this.codeContentAtom, decoded); + globalStore.set(this.originalContentAtom, decoded); + + if (decoded.trim() !== "") { + const currentStatus = globalStore.get(this.builderStatusAtom); + if (currentStatus?.status !== "running" && currentStatus?.status !== "building") { + await this.startBuilder(); + } + } + } + } catch (err) { + console.error("Failed to load app.go:", err); + globalStore.set(this.errorAtom, `Failed to load app.go: ${err.message || "Unknown error"}`); + } finally { + globalStore.set(this.isLoadingAtom, false); + } + } + + async saveAppFile(appId: string) { + try { + const content = globalStore.get(this.codeContentAtom); + const encoded = stringToBase64(content); + await RpcApi.WriteAppFileCommand(TabRpcClient, { + appid: appId, + filename: "app.go", + data64: encoded, + }); + globalStore.set(this.originalContentAtom, content); + globalStore.set(this.errorAtom, ""); + this.debouncedRestart(); + } catch (err) { + console.error("Failed to save app.go:", err); + globalStore.set(this.errorAtom, `Failed to save app.go: ${err.message || "Unknown error"}`); + } + } + + clearError() { + globalStore.set(this.errorAtom, ""); + } + + giveFocus() { + const activeTab = globalStore.get(this.activeTab); + if (activeTab === "code" && this.monacoEditorRef.current) { + this.monacoEditorRef.current.focus(); + } else { + this.focusElemRef.current?.focus(); + } + } + + setFocusElemRef(ref: HTMLInputElement | null) { + this.focusElemRef.current = ref; + } + + setMonacoEditorRef(ref: any) { + this.monacoEditorRef.current = ref; + } + + dispose() { + if (this.statusUnsubFn) { + this.statusUnsubFn(); + this.statusUnsubFn = null; + } + if (this.appGoUpdateUnsubFn) { + this.appGoUpdateUnsubFn(); + this.appGoUpdateUnsubFn = null; + } + this.debouncedRestart.cancel(); + } +} diff --git a/frontend/builder/store/builder-buildpanel-model.ts b/frontend/builder/store/builder-buildpanel-model.ts new file mode 100644 index 0000000000..e1e376f160 --- /dev/null +++ b/frontend/builder/store/builder-buildpanel-model.ts @@ -0,0 +1,72 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms, WOS } from "@/store/global"; +import { atom, type PrimitiveAtom } from "jotai"; + +export class BuilderBuildPanelModel { + private static instance: BuilderBuildPanelModel | null = null; + + outputLines: PrimitiveAtom = atom([]); + outputUnsubFn: (() => void) | null = null; + initialized = false; + + private constructor() {} + + static getInstance(): BuilderBuildPanelModel { + if (!BuilderBuildPanelModel.instance) { + BuilderBuildPanelModel.instance = new BuilderBuildPanelModel(); + } + return BuilderBuildPanelModel.instance; + } + + async initialize() { + if (this.initialized) return; + this.initialized = true; + + const builderId = globalStore.get(atoms.builderId); + if (!builderId) return; + + if (this.outputUnsubFn) { + this.outputUnsubFn(); + } + + this.outputUnsubFn = waveEventSubscribe({ + eventType: "builderoutput", + scope: WOS.makeORef("builder", builderId), + handler: (event) => { + const data = event.data as { lines?: string[]; reset?: boolean }; + if (!data) return; + + if (data.reset) { + globalStore.set(this.outputLines, data.lines || []); + } else if (data.lines && data.lines.length > 0) { + globalStore.set(this.outputLines, (prev) => [...prev, ...data.lines]); + } + }, + }); + + try { + const output = await RpcApi.GetBuilderOutputCommand(TabRpcClient, builderId); + globalStore.set(this.outputLines, output || []); + } catch (err) { + console.error("Failed to load builder output:", err); + } + } + + clearOutput() { + globalStore.set(this.outputLines, []); + } + + dispose() { + if (this.outputUnsubFn) { + this.outputUnsubFn(); + this.outputUnsubFn = null; + } + this.initialized = false; + } +} \ No newline at end of file diff --git a/frontend/builder/store/builderFocusManager.ts b/frontend/builder/store/builder-focusmanager.ts similarity index 100% rename from frontend/builder/store/builderFocusManager.ts rename to frontend/builder/store/builder-focusmanager.ts diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builderAppPanelModel.ts deleted file mode 100644 index c982cfa098..0000000000 --- a/frontend/builder/store/builderAppPanelModel.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { globalStore } from "@/app/store/jotaiStore"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { base64ToString, stringToBase64 } from "@/util/util"; -import { atom, type Atom, type PrimitiveAtom } from "jotai"; - -export type TabType = "preview" | "files" | "code"; - -export class BuilderAppPanelModel { - private static instance: BuilderAppPanelModel | null = null; - - activeTab: PrimitiveAtom = atom("preview"); - codeContentAtom: PrimitiveAtom = atom(""); - originalContentAtom: PrimitiveAtom = atom(""); - isLoadingAtom: PrimitiveAtom = atom(false); - errorAtom: PrimitiveAtom = atom(""); - saveNeededAtom!: Atom; - focusElemRef: { current: HTMLInputElement | null } = { current: null }; - monacoEditorRef: { current: any | null } = { current: null }; - - private constructor() { - this.saveNeededAtom = atom((get) => { - return get(this.codeContentAtom) !== get(this.originalContentAtom); - }); - } - - static getInstance(): BuilderAppPanelModel { - if (!BuilderAppPanelModel.instance) { - BuilderAppPanelModel.instance = new BuilderAppPanelModel(); - } - return BuilderAppPanelModel.instance; - } - - setActiveTab(tab: TabType) { - globalStore.set(this.activeTab, tab); - } - - getActiveTab(): TabType { - return globalStore.get(this.activeTab); - } - - setCodeContent(content: string) { - globalStore.set(this.codeContentAtom, content); - } - - async loadAppFile(appId: string) { - if (!appId) { - globalStore.set(this.errorAtom, "No app selected"); - globalStore.set(this.isLoadingAtom, false); - return; - } - - try { - globalStore.set(this.isLoadingAtom, true); - globalStore.set(this.errorAtom, ""); - const result = await RpcApi.ReadAppFileCommand(TabRpcClient, { - appid: appId, - filename: "app.go", - }); - if (result.notfound) { - globalStore.set(this.codeContentAtom, ""); - globalStore.set(this.originalContentAtom, ""); - } else { - const decoded = base64ToString(result.data64); - globalStore.set(this.codeContentAtom, decoded); - globalStore.set(this.originalContentAtom, decoded); - } - } catch (err) { - console.error("Failed to load app.go:", err); - globalStore.set(this.errorAtom, `Failed to load app.go: ${err.message || "Unknown error"}`); - } finally { - globalStore.set(this.isLoadingAtom, false); - } - } - - async saveAppFile(appId: string) { - if (!appId) return; - - try { - const content = globalStore.get(this.codeContentAtom); - const encoded = stringToBase64(content); - await RpcApi.WriteAppFileCommand(TabRpcClient, { - appid: appId, - filename: "app.go", - data64: encoded, - }); - globalStore.set(this.originalContentAtom, content); - globalStore.set(this.errorAtom, ""); - } catch (err) { - console.error("Failed to save app.go:", err); - globalStore.set(this.errorAtom, `Failed to save app.go: ${err.message || "Unknown error"}`); - } - } - - giveFocus() { - const activeTab = globalStore.get(this.activeTab); - if (activeTab === "code" && this.monacoEditorRef.current) { - this.monacoEditorRef.current.focus(); - } else { - this.focusElemRef.current?.focus(); - } - } - - setFocusElemRef(ref: HTMLInputElement | null) { - this.focusElemRef.current = ref; - } - - setMonacoEditorRef(ref: any) { - this.monacoEditorRef.current = ref; - } -} diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index 31c80826a3..37197506cd 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; -import { waveEventSubscribe } from "@/app/store/wps"; -import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { atoms } from "@/store/global"; import * as keyutil from "@/util/keyutil"; import { useAtomValue } from "jotai"; -import { memo, useEffect } from "react"; +import { memo } from "react"; const BuilderCodeTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); @@ -16,26 +15,6 @@ const BuilderCodeTab = memo(() => { const isLoading = useAtomValue(model.isLoadingAtom); const error = useAtomValue(model.errorAtom); - useEffect(() => { - if (builderAppId) { - model.loadAppFile(builderAppId); - } - }, [builderAppId, model]); - - useEffect(() => { - if (!builderAppId) { - return; - } - const unsubscribe = waveEventSubscribe({ - eventType: "waveapp:appgoupdated", - scope: builderAppId, - handler: () => { - model.loadAppFile(builderAppId); - }, - }); - return unsubscribe; - }, [builderAppId, model]); - const handleCodeChange = (newText: string) => { model.setCodeContent(newText); }; @@ -76,7 +55,7 @@ const BuilderCodeTab = memo(() => { return (
{ + const envVar = useAtomValue(model.getEnvVarIndexAtom(index)); + + if (!envVar) { + return null; + } + + const isValueVisible = envVar.visible ?? false; + + return ( +
+ model.setEnvVarAtIndex(index, { ...envVar, name: e.target.value }, true)} + placeholder="Variable Name" + className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" + /> +
+ model.setEnvVarAtIndex(index, { ...envVar, value: e.target.value }, true)} + placeholder="Value" + className="w-full px-3 py-2 pr-10 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" + /> + +
+ +
+ ); +}); + +EnvVarRow.displayName = "EnvVarRow"; + +const BuilderEnvTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const envVars = useAtomValue(model.envVarsArrayAtom); + const error = useAtomValue(model.errorAtom); + + return ( +
+
+

Environment Variables

+ +
+ +
+ These environment variables are transient and only used during builder testing. They are not bundled + with the app. +
+ + {error &&
{error}
} + +
+
+ {envVars.length === 0 ? ( +
+ No environment variables defined. Click "Add Variable" to create one. +
+ ) : ( + envVars.map((_, index) => ) + )} +
+
+
+ ); +}); + +BuilderEnvTab.displayName = "BuilderEnvTab"; + +export { BuilderEnvTab }; diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 0071e0a3db..ceca7273c1 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -1,16 +1,152 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { memo } from "react"; +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; +import { atoms } from "@/store/global"; +import { useAtomValue } from "jotai"; +import { memo, useState } from "react"; + +const EmptyStateView = memo(() => { + return ( +
+
+
🏗️
+
+

No App to Preview

+

+ Get started by using the AI chat interface on the left to create your WaveApp. Describe what you + want to build, and the AI will help you generate the code. +

+
+
+ Your app will appear here once app.go is created +
+
+
+ ); +}); + +EmptyStateView.displayName = "EmptyStateView"; + +const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { + const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : "Unknown Error"; + return ( +
+
+
+

Build Error

+
+
{displayMsg}
+
+
+
+
+ ); +}); + +ErrorStateView.displayName = "ErrorStateView"; + +const BuildingStateView = memo(() => { + return ( +
+
+
⚙️
+
+

App is Building...

+

+ Your WaveApp is being compiled and prepared. This may take a few moments. +

+
+
+
+ ); +}); + +BuildingStateView.displayName = "BuildingStateView"; + +const StoppedStateView = memo(({ onStart }: { onStart: () => void }) => { + const [isStarting, setIsStarting] = useState(false); + + const handleStart = () => { + setIsStarting(true); + onStart(); + setTimeout(() => setIsStarting(false), 2000); + }; -const BuilderPreviewTab = memo(() => { return ( -
-

Preview Tab

+
+
+
+

App is Not Running

+

+ Your WaveApp is currently not running. Click the button below to start it. +

+
+ {!isStarting && ( + + )} + {isStarting &&
Starting...
} +
); }); +StoppedStateView.displayName = "StoppedStateView"; + +const BuilderPreviewTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const isLoading = useAtomValue(model.isLoadingAtom); + const originalContent = useAtomValue(model.originalContentAtom); + const builderStatus = useAtomValue(model.builderStatusAtom); + const builderId = useAtomValue(atoms.builderId); + + const fileExists = originalContent.length > 0; + + if (isLoading) { + return null; + } + + if (builderStatus?.status === "error") { + return ; + } + + if (!fileExists) { + return ; + } + + const status = builderStatus?.status || "init"; + + if (status === "init") { + return null; + } + + if (status === "building") { + return ; + } + + if (status === "stopped") { + return model.startBuilder()} />; + } + + const shouldShowWebView = status === "running" && builderStatus?.port && builderStatus.port !== 0; + + if (shouldShowWebView) { + const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`; + return ( +
+ +
+ ); + } + + return null; +}); + BuilderPreviewTab.displayName = "BuilderPreviewTab"; -export { BuilderPreviewTab }; \ No newline at end of file +export { BuilderPreviewTab }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1d215086f4..93147c67f6 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -110,6 +110,15 @@ declare global { workspaceid?: string; }; + // wshrpc.BuilderStatusData + type BuilderStatusData = { + status: string; + port?: number; + exitcode?: number; + errormsg?: string; + version: number; + }; + // waveobj.Client type Client = WaveObj & { windowids: string[]; @@ -335,6 +344,11 @@ declare global { delete?: boolean; }; + // wshrpc.CommandStartBuilderData + type CommandStartBuilderData = { + builderid: string; + }; + // wshrpc.CommandTermGetScrollbackLinesData type CommandTermGetScrollbackLinesData = { linestart: number; @@ -783,6 +797,7 @@ declare global { "shell:lastcmdexitcode"?: number; "builder:layout"?: {[key: string]: number}; "builder:appid"?: string; + "builder:env"?: {[key: string]: string}; "waveai:chatid"?: string; }; diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index a1f90569c9..ebef637f2c 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -14,11 +14,11 @@ import ( "os/exec" "path/filepath" "runtime" - "strings" "sync" "syscall" "time" + "github.com/wavetermdev/waveterm/pkg/tsunamiutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -48,41 +48,6 @@ type TsunamiController struct { port int } -func getCachesDir() string { - var cacheDir string - appBundle := "waveterm" - if wavebase.IsDevMode() { - appBundle = "waveterm-dev" - } - - switch runtime.GOOS { - case "darwin": - // macOS: ~/Library/Caches/ - homeDir := wavebase.GetHomeDir() - cacheDir = filepath.Join(homeDir, "Library", "Caches", appBundle) - case "linux": - // Linux: XDG_CACHE_HOME or ~/.cache/ - xdgCache := os.Getenv("XDG_CACHE_HOME") - if xdgCache != "" { - cacheDir = filepath.Join(xdgCache, appBundle) - } else { - homeDir := wavebase.GetHomeDir() - cacheDir = filepath.Join(homeDir, ".cache", appBundle) - } - case "windows": - localAppData := os.Getenv("LOCALAPPDATA") - if localAppData != "" { - cacheDir = filepath.Join(localAppData, appBundle, "Cache") - } - } - - if cacheDir == "" { - tmpDir := os.TempDir() - cacheDir = filepath.Join(tmpDir, appBundle) - } - - return cacheDir -} func (c *TsunamiController) fetchAndSetSchemas(port int) { url := fmt.Sprintf("http://localhost:%d/api/schemas", port) @@ -124,31 +89,13 @@ func (c *TsunamiController) clearSchemas() { log.Printf("TsunamiController: cleared schemas for block %s", c.blockId) } -func getTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) { - cachesDir := getCachesDir() - tsunamiCacheDir := filepath.Join(cachesDir, "tsunami-build-cache") - fullAppName := appName + "." + osArch - if strings.HasPrefix(osArch, "windows") { - fullAppName = fullAppName + ".exe" - } - fullPath := filepath.Join(tsunamiCacheDir, scope, fullAppName) - - // Create the directory if it doesn't exist - dirPath := filepath.Dir(fullPath) - err := wavebase.TryMkdirs(dirPath, 0755, "tsunami cache directory") - if err != nil { - return "", fmt.Errorf("failed to create tsunami cache directory: %w", err) - } - - return fullPath, nil -} func isBuildCacheUpToDate(appPath string) (bool, error) { appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH - cachePath, err := getTsunamiAppCachePath("local", appName, osArch) + cachePath, err := tsunamiutil.GetTsunamiAppCachePath("local", appName, osArch) if err != nil { return false, err } @@ -214,7 +161,7 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH - cachePath, err := getTsunamiAppCachePath("local", appName, osArch) + cachePath, err := tsunamiutil.GetTsunamiAppCachePath("local", appName, osArch) if err != nil { return fmt.Errorf("failed to get cache path: %w", err) } diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go new file mode 100644 index 0000000000..c9fe8cab2c --- /dev/null +++ b/pkg/buildercontroller/buildercontroller.go @@ -0,0 +1,458 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package buildercontroller + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/utilds" + "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/tsunami/build" +) + +const ( + BuilderStatus_Init = "init" + BuilderStatus_Building = "building" + BuilderStatus_Running = "running" + BuilderStatus_Error = "error" + BuilderStatus_Stopped = "stopped" +) + +type BuilderProcess struct { + Cmd *exec.Cmd + StdinWriter io.WriteCloser + Port int + WaitCh chan struct{} + WaitRtn error +} + +type BuilderController struct { + lock sync.Mutex + builderId string + appId string + process *BuilderProcess + outputBuffer *utilds.MultiReaderLineBuffer + statusLock sync.Mutex + status string + statusVersion int + port int + exitCode int + errorMsg string +} + +var ( + controllerMap = make(map[string]*BuilderController) // key is builderid + mapLock sync.Mutex +) + +func GetOrCreateController(builderId string) *BuilderController { + mapLock.Lock() + defer mapLock.Unlock() + + bc := controllerMap[builderId] + if bc != nil { + return bc + } + + bc = &BuilderController{ + builderId: builderId, + status: BuilderStatus_Init, + statusVersion: 0, + } + controllerMap[builderId] = bc + + return bc +} + +func DeleteController(builderId string) { + mapLock.Lock() + bc := controllerMap[builderId] + delete(controllerMap, builderId) + mapLock.Unlock() + + if bc != nil { + bc.Stop() + } + + cachesDir := wavebase.GetWaveCachesDir() + builderDir := filepath.Join(cachesDir, "builder", builderId) + if err := os.RemoveAll(builderDir); err != nil { + log.Printf("failed to remove builder cache directory for %s: %v", builderId, err) + } +} + +func GetBuilderAppExecutablePath(builderId string, appName string) (string, error) { + cachesDir := wavebase.GetWaveCachesDir() + builderDir := filepath.Join(cachesDir, "builder", builderId) + + binaryName := appName + if runtime.GOOS == "windows" { + binaryName = binaryName + ".exe" + } + cachePath := filepath.Join(builderDir, binaryName) + + err := wavebase.TryMkdirs(builderDir, 0755, "builder cache directory") + if err != nil { + return "", fmt.Errorf("failed to create builder cache directory: %w", err) + } + + return cachePath, nil +} + +func Shutdown() { + mapLock.Lock() + controllers := make([]*BuilderController, 0, len(controllerMap)) + for _, bc := range controllerMap { + controllers = append(controllers, bc) + } + mapLock.Unlock() + + for _, bc := range controllers { + bc.Stop() + } + + cachesDir := wavebase.GetWaveCachesDir() + builderCacheDir := filepath.Join(cachesDir, "builder") + if err := os.RemoveAll(builderCacheDir); err != nil { + log.Printf("failed to remove builder cache directory: %v", err) + } +} + +func (bc *BuilderController) waitForBuildDone(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + bc.statusLock.Lock() + status := bc.status + bc.statusLock.Unlock() + + if status != BuilderStatus_Building { + return nil + } + + time.Sleep(100 * time.Millisecond) + } +} + +func (bc *BuilderController) Start(ctx context.Context, appId string, builderEnv map[string]string) error { + if err := bc.waitForBuildDone(ctx); err != nil { + return err + } + + bc.lock.Lock() + defer bc.lock.Unlock() + + if bc.appId != appId && bc.process != nil { + log.Printf("BuilderController: stopping previous app %s for builder %s", bc.appId, bc.builderId) + bc.stopProcess_nolock() + } + + bc.appId = appId + bc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000) + bc.setStatus_nolock(BuilderStatus_Building, 0, 0, "") + + bc.publishOutputLine("", true) + + bc.outputBuffer.SetLineCallback(func(line string) { + bc.publishOutputLine(line, false) + }) + + buildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + go func() { + defer cancel() + defer func() { + panichandler.PanicHandler(fmt.Sprintf("buildercontroller[%s].buildAndRun", bc.builderId), recover()) + }() + bc.buildAndRun(buildCtx, appId, builderEnv) + }() + + return nil +} + +func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, builderEnv map[string]string) { + appPath, err := waveappstore.GetAppDir(appId) + if err != nil { + bc.handleBuildError(fmt.Errorf("failed to get app directory: %w", err)) + return + } + + appName := build.GetAppName(appPath) + + cachePath, err := GetBuilderAppExecutablePath(bc.builderId, appName) + if err != nil { + bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err)) + return + } + + nodePath := wavebase.GetWaveAppElectronExecPath() + if nodePath == "" { + bc.handleBuildError(fmt.Errorf("electron executable path not set")) + return + } + + scaffoldPath := os.Getenv("TSUNAMI_SCAFFOLDPATH") + sdkReplacePath := os.Getenv("TSUNAMI_SDKREPLACEPATH") + + outputCapture := build.MakeOutputCapture() + + _, err = build.TsunamiBuildInternal(build.BuildOpts{ + AppPath: appPath, + Verbose: true, + Open: false, + KeepTemp: false, + OutputFile: cachePath, + ScaffoldPath: scaffoldPath, + SdkReplacePath: sdkReplacePath, + NodePath: nodePath, + OutputCapture: outputCapture, + }) + if err != nil { + bc.handleBuildError(fmt.Errorf("build failed: %w", err)) + return + } + + for _, line := range outputCapture.GetLines() { + bc.outputBuffer.AddLine(line) + } + + info, err := os.Stat(cachePath) + if err != nil { + bc.handleBuildError(fmt.Errorf("build output not found: %w", err)) + return + } + + if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { + bc.handleBuildError(fmt.Errorf("build output is not executable")) + return + } + + process, err := bc.runBuilderApp(ctx, cachePath, builderEnv) + if err != nil { + bc.handleBuildError(fmt.Errorf("failed to run app: %w", err)) + return + } + + bc.lock.Lock() + bc.process = process + bc.setStatus_nolock(BuilderStatus_Running, process.Port, 0, "") + bc.lock.Unlock() + + go func() { + <-process.WaitCh + bc.lock.Lock() + if bc.process == process { + bc.process = nil + exitCode := exitCodeFromWaitErr(process.WaitRtn) + bc.setStatus_nolock(BuilderStatus_Stopped, 0, exitCode, "") + } + bc.lock.Unlock() + }() +} + +func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) { + cmd := exec.Command(appBinPath) + cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") + + for key, value := range builderEnv { + cmd.Env = append(cmd.Env, key+"="+value) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdin pipe: %w", err) + } + + portChan := make(chan int, 1) + portFound := false + + bc.outputBuffer.SetLineCallback(func(line string) { + if !portFound { + if port := build.ParseTsunamiPort(line); port > 0 { + portFound = true + portChan <- port + } + } + bc.publishOutputLine(line, false) + }) + + err = cmd.Start() + if err != nil { + return nil, fmt.Errorf("failed to start process: %w", err) + } + + waitCh := make(chan struct{}) + process := &BuilderProcess{ + Cmd: cmd, + StdinWriter: stdinPipe, + WaitCh: waitCh, + } + + go func() { + process.WaitRtn = cmd.Wait() + close(waitCh) + }() + + go bc.outputBuffer.ReadAll(stdoutPipe) + go bc.outputBuffer.ReadAll(stderrPipe) + + errChan := make(chan error, 1) + go func() { + <-process.WaitCh + select { + case <-portChan: + default: + errChan <- fmt.Errorf("process died before emitting port") + } + }() + + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + + select { + case port := <-portChan: + process.Port = port + return process, nil + case err := <-errChan: + cmd.Process.Kill() + return nil, err + case <-timeout.C: + cmd.Process.Kill() + return nil, fmt.Errorf("timeout waiting for port") + case <-ctx.Done(): + cmd.Process.Kill() + return nil, fmt.Errorf("cancelled while waiting for app port: %w", ctx.Err()) + } +} + +func (bc *BuilderController) handleBuildError(err error) { + bc.lock.Lock() + defer bc.lock.Unlock() + bc.setStatus_nolock(BuilderStatus_Error, 0, 1, err.Error()) +} + +func (bc *BuilderController) Stop() error { + if err := bc.waitForBuildDone(context.Background()); err != nil { + return err + } + + bc.lock.Lock() + defer bc.lock.Unlock() + bc.stopProcess_nolock() + bc.setStatus_nolock(BuilderStatus_Stopped, 0, 0, "") + return nil +} + +func (bc *BuilderController) stopProcess_nolock() { + if bc.process == nil { + return + } + + if bc.process.Cmd.Process != nil { + bc.process.Cmd.Process.Kill() + } + + if bc.process.StdinWriter != nil { + bc.process.StdinWriter.Close() + } + + bc.process = nil +} + +func (bc *BuilderController) GetStatus() BuilderStatusData { + bc.statusLock.Lock() + defer bc.statusLock.Unlock() + + bc.statusVersion++ + return BuilderStatusData{ + Status: bc.status, + Port: bc.port, + ExitCode: bc.exitCode, + ErrorMsg: bc.errorMsg, + Version: bc.statusVersion, + } +} + +func (bc *BuilderController) GetOutput() []string { + if bc.outputBuffer == nil { + return []string{} + } + return bc.outputBuffer.GetLines() +} + +func (bc *BuilderController) setStatus_nolock(status string, port int, exitCode int, errorMsg string) { + bc.statusLock.Lock() + bc.status = status + bc.port = port + bc.exitCode = exitCode + bc.errorMsg = errorMsg + bc.statusLock.Unlock() + + go bc.publishStatus() +} + +func (bc *BuilderController) publishStatus() { + status := bc.GetStatus() + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BuilderStatus, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()}, + Data: status, + }) +} + +func (bc *BuilderController) publishOutputLine(line string, reset bool) { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BuilderOutput, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()}, + Data: map[string]any{ + "lines": []string{line}, + "reset": reset, + }, + }) +} + +type BuilderStatusData struct { + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` +} + +func exitCodeFromWaitErr(waitErr error) int { + if waitErr == nil { + return 0 + } + if exitError, ok := waitErr.(*exec.ExitError); ok { + return exitError.ExitCode() + } + return 1 +} + diff --git a/pkg/tsunamiutil/tsunamiutil.go b/pkg/tsunamiutil/tsunamiutil.go new file mode 100644 index 0000000000..85657efb8c --- /dev/null +++ b/pkg/tsunamiutil/tsunamiutil.go @@ -0,0 +1,30 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tsunamiutil + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +func GetTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) { + cachesDir := wavebase.GetWaveCachesDir() + tsunamiCacheDir := filepath.Join(cachesDir, "tsunami-build-cache") + fullAppName := appName + "." + osArch + if strings.HasPrefix(osArch, "windows") { + fullAppName = fullAppName + ".exe" + } + fullPath := filepath.Join(tsunamiCacheDir, scope, fullAppName) + + dirPath := filepath.Dir(fullPath) + err := wavebase.TryMkdirs(dirPath, 0755, "tsunami cache directory") + if err != nil { + return "", fmt.Errorf("failed to create tsunami cache directory: %w", err) + } + + return fullPath, nil +} \ No newline at end of file diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go index 670da2a765..708eb5c725 100644 --- a/pkg/util/fileutil/fileutil.go +++ b/pkg/util/fileutil/fileutil.go @@ -256,9 +256,9 @@ const ( ) type EditSpec struct { - OldStr string - NewStr string - Desc string + OldStr string `json:"old_str"` + NewStr string `json:"new_str"` + Desc string `json:"desc,omitempty"` } func ReplaceInFile(filePath string, edits []EditSpec) error { diff --git a/pkg/utilds/multireaderlinebuffer.go b/pkg/utilds/multireaderlinebuffer.go index 657d3649a2..efc38c13d5 100644 --- a/pkg/utilds/multireaderlinebuffer.go +++ b/pkg/utilds/multireaderlinebuffer.go @@ -53,6 +53,11 @@ func (mrlb *MultiReaderLineBuffer) callLineCallback(line string) { } } +func (mrlb *MultiReaderLineBuffer) AddLine(line string) { + mrlb.addLine(line) + mrlb.callLineCallback(line) +} + func (mrlb *MultiReaderLineBuffer) addLine(line string) { mrlb.lock.Lock() defer mrlb.lock.Unlock() diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index 930b8a5e08..72f346874b 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -69,6 +69,9 @@ const AppPathBinDir = "bin" var baseLock = &sync.Mutex{} var ensureDirCache = map[string]bool{} +var waveCachesDirOnce = &sync.Once{} +var waveCachesDir string + var SupportedWshBinaries = map[string]bool{ "darwin-x64": true, "darwin-arm64": true, @@ -187,6 +190,51 @@ func EnsureWavePresetsDir() error { return CacheEnsureDir(filepath.Join(GetWaveConfigDir(), "presets"), "wavepresets", 0700, "wave presets directory") } +func resolveWaveCachesDir() string { + var cacheDir string + appBundle := "waveterm" + if IsDevMode() { + appBundle = "waveterm-dev" + } + + switch runtime.GOOS { + case "darwin": + homeDir := GetHomeDir() + cacheDir = filepath.Join(homeDir, "Library", "Caches", appBundle) + case "linux": + xdgCache := os.Getenv("XDG_CACHE_HOME") + if xdgCache != "" { + cacheDir = filepath.Join(xdgCache, appBundle) + } else { + homeDir := GetHomeDir() + cacheDir = filepath.Join(homeDir, ".cache", appBundle) + } + case "windows": + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData != "" { + cacheDir = filepath.Join(localAppData, appBundle, "Cache") + } + } + + if cacheDir == "" { + tmpDir := os.TempDir() + cacheDir = filepath.Join(tmpDir, appBundle) + } + + return cacheDir +} + +func GetWaveCachesDir() string { + waveCachesDirOnce.Do(func() { + waveCachesDir = resolveWaveCachesDir() + }) + return waveCachesDir +} + +func EnsureWaveCachesDir() error { + return CacheEnsureDir(GetWaveCachesDir(), "wavecaches", 0700, "wave caches directory") +} + func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { baseLock.Lock() ok := ensureDirCache[cacheKey] diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index 655d5a6c8c..a09ca9c714 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -20,6 +20,7 @@ type ObjRTInfo struct { BuilderLayout map[string]float64 `json:"builder:layout,omitempty"` BuilderAppId string `json:"builder:appid,omitempty"` + BuilderEnv map[string]string `json:"builder:env,omitempty"` WaveAIChatId string `json:"waveai:chatid,omitempty"` } diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 245f71c0d4..cae43908ff 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -10,6 +10,8 @@ const ( Event_ConnChange = "connchange" Event_SysInfo = "sysinfo" Event_ControllerStatus = "controllerstatus" + Event_BuilderStatus = "builderstatus" + Event_BuilderOutput = "builderoutput" Event_WaveObjUpdate = "waveobj:update" Event_BlockFile = "blockfile" Event_Config = "config" diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 5c8ec2582c..82cf14b00d 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -155,6 +155,12 @@ func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, o return err } +// command "deletebuilder", wshserver.DeleteBuilderCommand +func DeleteBuilderCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deletebuilder", data, opts) + return err +} + // command "deletesubblock", wshserver.DeleteSubBlockCommand func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deletesubblock", data, opts) @@ -320,6 +326,18 @@ func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } +// command "getbuilderoutput", wshserver.GetBuilderOutputCommand +func GetBuilderOutputCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "getbuilderoutput", data, opts) + return resp, err +} + +// command "getbuilderstatus", wshserver.GetBuilderStatusCommand +func GetBuilderStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BuilderStatusData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.BuilderStatusData](w, "getbuilderstatus", data, opts) + return resp, err +} + // command "getfullconfig", wshserver.GetFullConfigCommand func GetFullConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.FullConfigType, error) { resp, err := sendRpcRequestCallHelper[wconfig.FullConfigType](w, "getfullconfig", nil, opts) @@ -556,6 +574,12 @@ func SetViewCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockSetViewData, opts return err } +// command "startbuilder", wshserver.StartBuilderCommand +func StartBuilderCommand(w *wshutil.WshRpc, data wshrpc.CommandStartBuilderData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "startbuilder", data, opts) + return err +} + // command "streamcpudata", wshserver.StreamCpuDataCommand func StreamCpuDataCommand(w *wshutil.WshRpc, data wshrpc.CpuDataRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "streamcpudata", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a3e3f02ed4..a15075663d 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -159,6 +159,10 @@ const ( Command_WriteAppFile = "writeappfile" Command_DeleteAppFile = "deleteappfile" Command_RenameAppFile = "renameappfile" + Command_DeleteBuilder = "deletebuilder" + Command_StartBuilder = "startbuilder" + Command_GetBuilderStatus = "getbuilderstatus" + Command_GetBuilderOutput = "getbuilderoutput" ) type RespOrErrorUnion[T any] struct { @@ -301,6 +305,10 @@ type WshRpcInterface interface { WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error + DeleteBuilderCommand(ctx context.Context, builderId string) error + StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error + GetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error) + GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] @@ -946,3 +954,15 @@ type CommandRenameAppFileData struct { FromFileName string `json:"fromfilename"` ToFileName string `json:"tofilename"` } + +type CommandStartBuilderData struct { + BuilderId string `json:"builderid"` +} + +type BuilderStatusData struct { + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 8364329efc..df90065157 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -25,6 +25,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" + "github.com/wavetermdev/waveterm/pkg/buildercontroller" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/panichandler" @@ -898,6 +899,9 @@ func (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]string, } func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.CommandListAllAppFilesData) (*wshrpc.CommandListAllAppFilesRtnData, error) { + if data.AppId == "" { + return nil, fmt.Errorf("must provide an appId to ListAllAppFilesCommand") + } result, err := waveappstore.ListAllAppFiles(data.AppId) if err != nil { return nil, err @@ -926,6 +930,9 @@ func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.Com } func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) { + if data.AppId == "" { + return nil, fmt.Errorf("must provide an appId to ReadAppFileCommand") + } fileData, err := waveappstore.ReadAppFile(data.AppId, data.FileName) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -942,6 +949,9 @@ func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.Command } func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.CommandWriteAppFileData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to WriteAppFileCommand") + } contents, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return fmt.Errorf("failed to decode data64: %w", err) @@ -950,13 +960,66 @@ func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.Comman } func (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to DeleteAppFileCommand") + } return waveappstore.DeleteAppFile(data.AppId, data.FileName) } func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.CommandRenameAppFileData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to RenameAppFileCommand") + } return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) } +func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error { + if builderId == "" { + return fmt.Errorf("must provide a builderId to DeleteBuilderCommand") + } + buildercontroller.DeleteController(builderId) + return nil +} + +func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.CommandStartBuilderData) error { + if data.BuilderId == "" { + return fmt.Errorf("must provide a builderId to StartBuilderCommand") + } + bc := buildercontroller.GetOrCreateController(data.BuilderId) + rtInfo := wstore.GetRTInfo(waveobj.MakeORef("builder", data.BuilderId)) + if rtInfo == nil { + return fmt.Errorf("builder rtinfo not found for builderid: %s", data.BuilderId) + } + appId := rtInfo.BuilderAppId + if appId == "" { + return fmt.Errorf("builder appid not set for builderid: %s", data.BuilderId) + } + return bc.Start(ctx, appId, rtInfo.BuilderEnv) +} + +func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { + if builderId == "" { + return nil, fmt.Errorf("must provide a builderId to GetBuilderStatusCommand") + } + bc := buildercontroller.GetOrCreateController(builderId) + status := bc.GetStatus() + return &wshrpc.BuilderStatusData{ + Status: status.Status, + Port: status.Port, + ExitCode: status.ExitCode, + ErrorMsg: status.ErrorMsg, + Version: status.Version, + }, nil +} + +func (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) { + if builderId == "" { + return nil, fmt.Errorf("must provide a builderId to GetBuilderOutputCommand") + } + bc := buildercontroller.GetOrCreateController(builderId) + return bc.GetOutput(), nil +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { diff --git a/pkg/wstore/wstore_rtinfo.go b/pkg/wstore/wstore_rtinfo.go index 372d1db813..912a3ccac0 100644 --- a/pkg/wstore/wstore_rtinfo.go +++ b/pkg/wstore/wstore_rtinfo.go @@ -16,6 +16,68 @@ var ( rtInfoMutex sync.RWMutex ) +func setFieldValue(fieldValue reflect.Value, value any) { + if value == nil { + fieldValue.Set(reflect.Zero(fieldValue.Type())) + return + } + + if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String { + fieldValue.SetString(valueStr) + return + } + + if valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool { + fieldValue.SetBool(valueBool) + return + } + + if fieldValue.Kind() == reflect.Int { + switch v := value.(type) { + case int: + fieldValue.SetInt(int64(v)) + case int64: + fieldValue.SetInt(v) + case float64: + fieldValue.SetInt(int64(v)) + } + return + } + + if fieldValue.Kind() == reflect.Map { + 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)) + } + return + } + + if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.String { + if inputMap, ok := value.(map[string]any); ok { + outputMap := make(map[string]string) + for k, v := range inputMap { + if strVal, ok := v.(string); ok { + outputMap[k] = strVal + } + } + fieldValue.Set(reflect.ValueOf(outputMap)) + } + return + } + return + } + + if fieldValue.Kind() == reflect.Interface { + fieldValue.Set(reflect.ValueOf(value)) + } +} + // 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. @@ -58,43 +120,7 @@ func SetRTInfo(oref waveobj.ORef, info map[string]any) { continue } - if value == nil { - // Set to zero value (empty string for string fields) - fieldValue.Set(reflect.Zero(fieldValue.Type())) - } else { - // Convert and set the value - if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String { - fieldValue.SetString(valueStr) - } else if valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool { - fieldValue.SetBool(valueBool) - } else if fieldValue.Kind() == reflect.Int { - // Handle int fields - need to convert from various numeric types - switch v := value.(type) { - case int: - fieldValue.SetInt(int64(v)) - case int64: - fieldValue.SetInt(v) - 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)) - } - } + setFieldValue(fieldValue, value) } } diff --git a/tsunami/build/build.go b/tsunami/build/build.go index dfdd0e28dc..e50b7eb084 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -29,6 +29,39 @@ import ( const MinSupportedGoMinorVersion = 22 const TsunamiUIImportPath = "github.com/wavetermdev/waveterm/tsunami/ui" +type OutputCapture struct { + lock sync.Mutex + lines []string +} + +func MakeOutputCapture() *OutputCapture { + return &OutputCapture{ + lines: make([]string, 0), + } +} + +func (oc *OutputCapture) Printf(format string, args ...interface{}) { + if oc == nil { + log.Printf(format, args...) + return + } + line := fmt.Sprintf(format, args...) + oc.lock.Lock() + defer oc.lock.Unlock() + oc.lines = append(oc.lines, line) +} + +func (oc *OutputCapture) GetLines() []string { + if oc == nil { + return nil + } + oc.lock.Lock() + defer oc.lock.Unlock() + result := make([]string, len(oc.lines)) + copy(result, oc.lines) + return result +} + type BuildOpts struct { AppPath string Verbose bool @@ -39,6 +72,7 @@ type BuildOpts struct { SdkReplacePath string NodePath string MoveFileBack bool + OutputCapture *OutputCapture } func GetAppName(appPath string) string { @@ -97,6 +131,8 @@ func findGoExecutable() (string, error) { } func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { + oc := opts.OutputCapture + // Find Go executable using enhanced search goPath, err := findGoExecutable() if err != nil { @@ -113,7 +149,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { // Parse go version output and check for 1.22+ versionStr := strings.TrimSpace(string(output)) if verbose { - log.Printf("Found %s", versionStr) + oc.Printf("Found %s", versionStr) } // Extract version like "go1.22.0" from output @@ -159,7 +195,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { } if verbose { - log.Printf("Using custom node path: %s", opts.NodePath) + oc.Printf("Using custom node path: %s", opts.NodePath) } } else { // Use standard PATH lookup @@ -169,7 +205,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { } if verbose { - log.Printf("Found node in PATH") + oc.Printf("Found node in PATH") } } @@ -180,6 +216,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { } func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose bool) error { + oc := opts.OutputCapture modulePath := fmt.Sprintf("tsunami/app/%s", appName) // Check if go.mod already exists in temp directory (copied from app path) @@ -190,7 +227,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo if _, err := os.Stat(tempGoModPath); err == nil { // go.mod exists in temp dir, parse it if verbose { - log.Printf("Found existing go.mod in temp directory, parsing it") + oc.Printf("Found existing go.mod in temp directory, parsing it") } // Parse the existing go.mod @@ -206,7 +243,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo } else if os.IsNotExist(err) { // go.mod doesn't exist, create new one if verbose { - log.Printf("No existing go.mod found, creating new one") + oc.Printf("No existing go.mod found, creating new one") } modFile = &modfile.File{} @@ -244,9 +281,9 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo } if verbose { - log.Printf("Created go.mod with module path: %s", modulePath) - log.Printf("Added require: github.com/wavetermdev/waveterm/tsunami v0.0.0") - log.Printf("Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s", opts.SdkReplacePath) + oc.Printf("Created go.mod with module path: %s", modulePath) + oc.Printf("Added require: github.com/wavetermdev/waveterm/tsunami v0.0.0") + oc.Printf("Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s", opts.SdkReplacePath) } // Run go mod tidy to clean up dependencies @@ -254,7 +291,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo tidyCmd.Dir = tempDir if verbose { - log.Printf("Running go mod tidy") + oc.Printf("Running go mod tidy") tidyCmd.Stdout = os.Stdout tidyCmd.Stderr = os.Stderr } @@ -264,7 +301,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo } if verbose { - log.Printf("Successfully ran go mod tidy") + oc.Printf("Successfully ran go mod tidy") } return nil @@ -409,6 +446,8 @@ func TsunamiBuild(opts BuildOpts) error { } func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { + oc := opts.OutputCapture + buildEnv, err := verifyEnvironment(opts.Verbose, opts) if err != nil { return nil, err @@ -446,26 +485,26 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { buildEnv.TempDir = tempDir - log.Printf("Building tsunami app from %s\n", opts.AppPath) + oc.Printf("Building tsunami app from %s", opts.AppPath) if opts.Verbose || opts.KeepTemp { - log.Printf("Temp dir: %s\n", tempDir) + oc.Printf("Temp dir: %s", tempDir) } // Copy files from app path (go.mod, go.sum, static/, *.go) - copyStats, err := copyFilesFromAppFS(appFS, opts.AppPath, tempDir, opts.Verbose) + copyStats, err := copyFilesFromAppFS(appFS, opts.AppPath, tempDir, opts.Verbose, oc) if err != nil { return buildEnv, fmt.Errorf("failed to copy files from app path: %w", err) } // Copy scaffold directory contents selectively - scaffoldCount, err := copyScaffoldFS(scaffoldFS, tempDir, opts.Verbose) + scaffoldCount, err := copyScaffoldFS(scaffoldFS, tempDir, opts.Verbose, oc) if err != nil { return buildEnv, fmt.Errorf("failed to copy scaffold directory: %w", err) } if opts.Verbose { - log.Printf("Copied %d go files, %d static files, %d scaffold files (go.mod: %t, go.sum: %t)\n", + oc.Printf("Copied %d go files, %d static files, %d scaffold files (go.mod: %t, go.sum: %t)", copyStats.GoFiles, copyStats.StaticFiles, scaffoldCount, copyStats.GoMod, copyStats.GoSum) } @@ -496,10 +535,10 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { return buildEnv, fmt.Errorf("failed to create ui symlink: %w", err) } if opts.Verbose { - log.Printf("Created UI symlink: %s -> %s", uiLinkPath, uiTargetPath) + oc.Printf("Created UI symlink: %s -> %s", uiLinkPath, uiTargetPath) } } else if opts.Verbose { - log.Printf("Skipping UI symlink creation - no UI package imports found") + oc.Printf("Skipping UI symlink creation - no UI package imports found") } // Generate Tailwind CSS @@ -514,19 +553,19 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { // Move generated files back to original directory if opts.MoveFileBack && canWrite { - if err := moveFilesBack(tempDir, opts.AppPath, opts.Verbose); err != nil { + if err := moveFilesBack(tempDir, opts.AppPath, opts.Verbose, oc); err != nil { return buildEnv, fmt.Errorf("failed to move files back: %w", err) } } else if opts.MoveFileBack && !canWrite { if opts.Verbose { - log.Printf("Skipping move files back - app path is not writable: %s", opts.AppPath) + oc.Printf("Skipping move files back - app path is not writable: %s", opts.AppPath) } } return buildEnv, nil } -func moveFilesBack(tempDir, originalDir string, verbose bool) error { +func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) error { // Move go.mod back to original directory goModSrc := filepath.Join(tempDir, "go.mod") goModDest := filepath.Join(originalDir, "go.mod") @@ -534,7 +573,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to copy go.mod back: %w", err) } if verbose { - log.Printf("Moved go.mod back to %s", goModDest) + oc.Printf("Moved go.mod back to %s", goModDest) } // Move go.sum back to original directory (only if it exists) @@ -545,7 +584,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to copy go.sum back: %w", err) } if verbose { - log.Printf("Moved go.sum back to %s", goSumDest) + oc.Printf("Moved go.sum back to %s", goSumDest) } } @@ -555,7 +594,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to create static directory: %w", err) } if verbose { - log.Printf("Ensured static directory exists at %s", staticDir) + oc.Printf("Ensured static directory exists at %s", staticDir) } // Move tw.css back to original directory @@ -565,13 +604,14 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to copy tw.css back: %w", err) } if verbose { - log.Printf("Moved tw.css back to %s", twCssDest) + oc.Printf("Moved tw.css back to %s", twCssDest) } return nil } func runGoBuild(tempDir string, opts BuildOpts) error { + oc := opts.OutputCapture var outputPath string if opts.OutputFile != "" { // Convert to absolute path resolved against current working directory @@ -603,7 +643,7 @@ func runGoBuild(tempDir string, opts BuildOpts) error { buildCmd.Dir = tempDir if opts.Verbose { - log.Printf("Running: %s", strings.Join(buildCmd.Args, " ")) + oc.Printf("Running: %s", strings.Join(buildCmd.Args, " ")) buildCmd.Stdout = os.Stdout buildCmd.Stderr = os.Stderr } @@ -614,9 +654,9 @@ func runGoBuild(tempDir string, opts BuildOpts) error { if opts.Verbose { if opts.OutputFile != "" { - log.Printf("Application built successfully at %s", outputPath) + oc.Printf("Application built successfully at %s", outputPath) } else { - log.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app")) + oc.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app")) } } @@ -624,6 +664,7 @@ func runGoBuild(tempDir string, opts BuildOpts) error { } func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error { + oc := opts.OutputCapture // tailwind.css is already in tempDir from scaffold copy tailwindOutput := filepath.Join(tempDir, "static", "tw.css") @@ -634,7 +675,7 @@ func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error tailwindCmd.Env = append(os.Environ(), "ELECTRON_RUN_AS_NODE=1") if verbose { - log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) + oc.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) } if err := tailwindCmd.Run(); err != nil { @@ -642,7 +683,7 @@ func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error } if verbose { - log.Printf("Tailwind CSS generated successfully") + oc.Printf("Tailwind CSS generated successfully") } return nil @@ -681,7 +722,7 @@ func copyGoFilesFromFS(fsys fs.FS, destDir string) (int, error) { } // appPath is just used for logging (we do the copies from appFS) -func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*CopyStats, error) { +func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool, oc *OutputCapture) (*CopyStats, error) { stats := &CopyStats{} // Copy go.mod if it exists @@ -692,7 +733,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co } stats.GoMod = copied if copied && verbose { - log.Printf("Copied go.mod from %s", filepath.Join(appPath, "go.mod")) + oc.Printf("Copied go.mod from %s", filepath.Join(appPath, "go.mod")) } // Copy go.sum if it exists @@ -703,7 +744,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co } stats.GoSum = copied if copied && verbose { - log.Printf("Copied go.sum from %s", filepath.Join(appPath, "go.sum")) + oc.Printf("Copied go.sum from %s", filepath.Join(appPath, "go.sum")) } // Copy manifest.json if it exists @@ -713,7 +754,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co return nil, err } if copied && verbose { - log.Printf("Copied manifest.json from %s", filepath.Join(appPath, "manifest.json")) + oc.Printf("Copied manifest.json from %s", filepath.Join(appPath, "manifest.json")) } // Copy static directory @@ -735,6 +776,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co } func TsunamiRun(opts BuildOpts) error { + oc := opts.OutputCapture buildEnv, err := TsunamiBuildInternal(opts) defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose) if err != nil { @@ -747,7 +789,7 @@ func TsunamiRun(opts BuildOpts) error { runCmd := exec.Command(appBinPath) runCmd.Dir = buildEnv.TempDir - log.Printf("Running tsunami app from %s", opts.AppPath) + oc.Printf("Running tsunami app from %s", opts.AppPath) runCmd.Stdin = os.Stdin @@ -841,7 +883,7 @@ func ParseTsunamiPort(line string) int { return port } -func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool) (int, error) { +func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool, oc *OutputCapture) (int, error) { fileCount := 0 // Handle node_modules directory - prefer symlink if possible, otherwise copy @@ -855,7 +897,7 @@ func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool) (int, error) return 0, fmt.Errorf("failed to create symlink for node_modules: %w", err) } if verbose { - log.Printf("Symlinked node_modules directory\n") + oc.Printf("Symlinked node_modules directory") } fileCount++ } else { @@ -865,7 +907,7 @@ func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool) (int, error) return 0, fmt.Errorf("failed to copy node_modules directory: %w", err) } if verbose { - log.Printf("Copied node_modules directory (%d files)\n", dirCount) + oc.Printf("Copied node_modules directory (%d files)", dirCount) } fileCount += dirCount } diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index 5da8dc6262..145b537dd8 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -102,10 +102,10 @@ func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **Compon renderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag) return vdom.ToElems(renderedElem) }) - + // Process atom usage after render r.updateComponentAtomUsage(*comp, vc.UsedAtoms) - + var rtnElem *vdom.VDomElem if len(rtnElemArr) == 0 { rtnElem = nil @@ -250,12 +250,30 @@ func convertPropsToVDom(props map[string]any) map[string]any { vdomProps[k] = vdomFunc continue } + if vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok { + if vdomFuncPtr == nil { + continue // handled typed-nil + } + // ensure Type is set on all VDomFuncs (pointer) + vdomFuncPtr.Type = vdom.ObjectType_Func + vdomProps[k] = vdomFuncPtr + continue + } if vdomRef, ok := v.(vdom.VDomRef); ok { // ensure Type is set on all VDomRefs vdomRef.Type = vdom.ObjectType_Ref vdomProps[k] = vdomRef continue } + if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { + if vdomRefPtr == nil { + continue // handle typed-nil + } + // ensure Type is set on all VDomRefs (pointer) + vdomRefPtr.Type = vdom.ObjectType_Ref + vdomProps[k] = vdomRefPtr + continue + } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs