diff --git a/.vscode/settings.json b/.vscode/settings.json index 07a68e5252..6f732e999e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -61,5 +61,6 @@ "QF1003": false }, "directoryFilters": ["-tsunami/frontend/scaffold"] - } + }, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore" } diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index a3d8fab758..ebc17363f9 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -396,7 +396,7 @@ export function initIpcHandlers() { }); electron.ipcMain.on("open-builder", (event, appId?: string) => { - fireAndForget(() => createBuilderWindow(appId || "")); + fireAndForget(() => createBuilderWindow(appId || "")); }); electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 20450b55d2..f98a8d7ffd 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -95,7 +95,7 @@ async function getAppMenu( if (isDev) { fileMenu.splice(1, 0, { label: "New WaveApp Builder Window", - click: () => fireAndForget(() => createBuilderWindow("")), + click: () => fireAndForget(() => createBuilderWindow("")), }); } if (numWaveWindows == 0) { diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 384c99ff78..96b8540726 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -232,6 +232,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { }; if (windowType === "builder") { body.builderid = globalStore.get(atoms.builderId); + body.builderappid = globalStore.get(atoms.builderAppId); } else { body.tabid = globalStore.get(atoms.staticTabId); } diff --git a/frontend/app/modals/modal.scss b/frontend/app/modals/modal.scss index 4471ca5d9a..e52c62286b 100644 --- a/frontend/app/modals/modal.scss +++ b/frontend/app/modals/modal.scss @@ -14,7 +14,7 @@ .modal-backdrop { position: fixed; - top: 0; + top: 36px; left: 0; right: 0; bottom: 0; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index f6f37432e8..3db4cc14af 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -17,7 +17,7 @@ import { import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { setPlatform } from "@/util/platformutil"; -import { deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util"; +import { base64ToString, deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; @@ -54,6 +54,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom; + const builderAppIdAtom = atom(null) as PrimitiveAtom; const waveWindowTypeAtom = atom((get) => { const builderId = get(builderIdAtom); return builderId != null ? "builder" : "tab"; @@ -172,6 +173,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { // initialized in wave.ts (will not be null inside of application) clientId: clientIdAtom, builderId: builderIdAtom, + builderAppId: builderAppIdAtom, waveWindowType: waveWindowTypeAtom, uiContext: uiContextAtom, client: clientAtom, @@ -547,7 +549,7 @@ async function fetchWaveFile( if (fileInfo64 == null) { throw new Error(`missing zone file info for ${zoneId}:${fileName}`); } - const fileInfo = JSON.parse(atob(fileInfo64)); + const fileInfo = JSON.parse(base64ToString(fileInfo64)); const data = await resp.arrayBuffer(); return { data: new Uint8Array(data), fileInfo }; } diff --git a/frontend/app/store/tabrpcclient.ts b/frontend/app/store/tabrpcclient.ts index f0c6da55ce..caab82ac82 100644 --- a/frontend/app/store/tabrpcclient.ts +++ b/frontend/app/store/tabrpcclient.ts @@ -5,7 +5,7 @@ import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { getApi } from "@/app/store/global"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { getLayoutModelForStaticTab } from "@/layout/index"; -import { base64ToArray } from "@/util/util"; +import { base64ToArrayBuffer } from "@/util/util"; import { RpcResponseHelper, WshClient } from "./wshclient"; export class TabClient extends WshClient { @@ -74,7 +74,7 @@ export class TabClient extends WshClient { if (data.files && data.files.length > 0) { for (const fileData of data.files) { - const decodedData = base64ToArray(fileData.data64); + const decodedData = base64ToArrayBuffer(fileData.data64); const blob = new Blob([decodedData], { type: fileData.type }); const file = new File([blob], fileData.name, { type: fileData.type }); await model.addFile(file); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index e2220ea827..18fc68fe15 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -112,6 +112,11 @@ class RpcApiType { return client.wshRpcCall("createsubblock", data, opts); } + // command "deleteappfile" [call] + DeleteAppFileCommand(client: WshClient, data: CommandDeleteAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("deleteappfile", data, opts); + } + // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deleteblock", data, opts); @@ -297,6 +302,16 @@ class RpcApiType { return client.wshRpcCall("getwaveairatelimit", null, opts); } + // command "listallappfiles" [call] + ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise { + return client.wshRpcCall("listallappfiles", data, opts); + } + + // command "listalleditableapps" [call] + ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("listalleditableapps", null, opts); + } + // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { return client.wshRpcCall("message", data, opts); @@ -312,6 +327,11 @@ class RpcApiType { return client.wshRpcCall("path", data, opts); } + // command "readappfile" [call] + ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("readappfile", data, opts); + } + // command "recordtevent" [call] RecordTEventCommand(client: WshClient, data: TEvent, opts?: RpcOpts): Promise { return client.wshRpcCall("recordtevent", data, opts); @@ -387,6 +407,11 @@ class RpcApiType { return client.wshRpcCall("remotewritefile", data, opts); } + // command "renameappfile" [call] + RenameAppFileCommand(client: WshClient, data: CommandRenameAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("renameappfile", data, opts); + } + // command "resolveids" [call] ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { return client.wshRpcCall("resolveids", data, opts); @@ -517,6 +542,11 @@ class RpcApiType { return client.wshRpcCall("workspacelist", null, opts); } + // command "writeappfile" [call] + WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writeappfile", data, opts); + } + // command "wshactivity" [call] WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { return client.wshRpcCall("wshactivity", data, opts); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 932c1b219f..7a58ac7e78 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -8,7 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { base64ToArray, fireAndForget } from "@/util/util"; +import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -259,7 +259,7 @@ function handleOsc16162Command(data: string, blockId: string, loaded: boolean, t globalStore.set(termWrap.lastCommandAtom, rtInfo["shell:lastcmd"]); } else { try { - const decodedCmd = atob(cmd.data.cmd64); + const decodedCmd = base64ToString(cmd.data.cmd64); rtInfo["shell:lastcmd"] = decodedCmd; globalStore.set(termWrap.lastCommandAtom, decodedCmd); } catch (e) { @@ -364,9 +364,7 @@ export class TermWrap { this.hasResized = false; this.lastUpdated = Date.now(); this.promptMarkers = []; - this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom< - "ready" | "running-command" | null - >; + this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<"ready" | "running-command" | null>; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); @@ -460,25 +458,25 @@ export class TermWrap { } this.mainFileSubject = getFileSubject(this.blockId, TermFileName); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); - + try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), }); - + if (rtInfo["shell:integration"]) { const shellState = rtInfo["shell:state"] as ShellIntegrationStatus; globalStore.set(this.shellIntegrationStatusAtom, shellState || null); } else { globalStore.set(this.shellIntegrationStatusAtom, null); } - + const lastCmd = rtInfo["shell:lastcmd"]; globalStore.set(this.lastCommandAtom, lastCmd || null); } catch (e) { console.log("Error loading runtime info:", e); } - + try { await this.loadInitialTerminalData(); } finally { diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx new file mode 100644 index 0000000000..247230b011 --- /dev/null +++ b/frontend/builder/app-selection-modal.tsx @@ -0,0 +1,207 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { FlexiModal } from "@/app/modals/modal"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms, globalStore } from "@/store/global"; +import * as WOS from "@/store/wos"; +import { useEffect, useState } from "react"; + +const MaxAppNameLength = 50; +const AppNameRegex = /^[a-zA-Z0-9_-]+$/; + +export function AppSelectionModal() { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [newAppName, setNewAppName] = useState(""); + const [error, setError] = useState(""); + const [inputError, setInputError] = useState(""); + + useEffect(() => { + loadApps(); + }, []); + + const validateAppName = (name: string) => { + if (!name.trim()) { + setInputError(""); + return false; + } + if (name.length > MaxAppNameLength) { + setInputError(`Name must be ${MaxAppNameLength} characters or less`); + return false; + } + if (!AppNameRegex.test(name)) { + setInputError("Only letters, numbers, hyphens, and underscores allowed"); + return false; + } + setInputError(""); + return true; + }; + + const loadApps = async () => { + try { + const appList = await RpcApi.ListAllEditableAppsCommand(TabRpcClient); + setApps(appList || []); + } catch (err) { + console.error("Failed to load apps:", err); + setError("Failed to load apps"); + } finally { + setLoading(false); + } + }; + + const handleSelectApp = async (appId: string) => { + const builderId = globalStore.get(atoms.builderId); + const oref = WOS.makeORef("builder", builderId); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref, + data: { "builder:appid": appId }, + }); + globalStore.set(atoms.builderAppId, appId); + }; + + const handleCreateNew = async () => { + const trimmedName = newAppName.trim(); + + if (!trimmedName) { + setError("WaveApp name cannot be empty"); + return; + } + + if (trimmedName.length > MaxAppNameLength) { + setError(`WaveApp name must be ${MaxAppNameLength} characters or less`); + return; + } + + if (!AppNameRegex.test(trimmedName)) { + setError("WaveApp name can only contain letters, numbers, hyphens, and underscores"); + return; + } + + const draftAppId = `draft/${trimmedName}`; + const builderId = globalStore.get(atoms.builderId); + const oref = WOS.makeORef("builder", builderId); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref, + data: { "builder:appid": draftAppId }, + }); + globalStore.set(atoms.builderAppId, draftAppId); + }; + + const isDraftApp = (appId: string) => { + return appId.startsWith("draft/"); + }; + + const getAppDisplayName = (appId: string) => { + const parts = appId.split("/"); + if (parts.length === 2) { + const isDraft = parts[0] === "draft"; + return isDraft ? `${parts[1]} (draft)` : parts[1]; + } + return appId; + }; + + if (loading) { + return ( + +
Loading apps...
+
+ ); + } + + return ( + +
+

Select a WaveApp to Edit

+ + {error && ( +
+
+ + {error} +
+
+ )} + + {apps.length > 0 && ( +
+

Existing WaveApps

+
+ {apps.map((appId) => ( + + ))} +
+
+ )} + + {apps.length > 0 && ( +
+
+ or +
+
+ )} + +
+

Create New WaveApp

+
+
+ { + const value = e.target.value; + setNewAppName(value); + validateAppName(value); + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !e.nativeEvent.isComposing && + newAppName.trim() && + !inputError + ) { + handleCreateNew(); + } + }} + placeholder="my-app" + maxLength={MaxAppNameLength} + className={`flex-1 px-3 py-2 bg-panel border rounded-l focus:outline-none transition-colors ${ + inputError ? "border-error" : "border-border focus:border-accent" + }`} + autoFocus + /> + +
+ {inputError && ( +
+ + {inputError} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index dadf4919c5..af5c7acd28 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -1,11 +1,13 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; -import { globalStore } from "@/store/global"; +import { atoms, globalStore } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; import * as keyutil from "@/util/keyutil"; -import { Provider } from "jotai"; +import { isBlank } from "@/util/util"; +import { Provider, useAtomValue } from "jotai"; import { useEffect } from "react"; type BuilderAppProps = { @@ -25,6 +27,25 @@ const BuilderKeyHandlers = () => { return null; }; +function BuilderAppInner() { + const builderAppId = useAtomValue(atoms.builderAppId); + + return ( +
+ +
+
+ WaveApp Builder{!isBlank(builderAppId) && ` (${builderAppId})`} +
+
+ {isBlank(builderAppId) ? : } +
+ ); +} + export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { useEffect(() => { onFirstRender(); @@ -32,16 +53,7 @@ export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { return ( -
- -
- {/* Title bar - draggable area */} -
- -
+
); } diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 3b7bbabf0b..44d6be68c1 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -1,15 +1,17 @@ // 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 { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; +import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; +import { ErrorBoundary } from "@/element/errorboundary"; +import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useState } from "react"; - -type TabType = "preview" | "files" | "code"; +import { memo, useCallback, useRef } from "react"; type TabButtonProps = { label: string; @@ -38,46 +40,131 @@ const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick }: Tab TabButton.displayName = "TabButton"; const BuilderAppPanel = memo(() => { - const [activeTab, setActiveTab] = useState("preview"); + const model = BuilderAppPanelModel.getInstance(); + const focusElemRef = useRef(null); + const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; + const saveNeeded = useAtomValue(model.saveNeededAtom); + const builderAppId = useAtomValue(atoms.builderAppId); + + if (focusElemRef.current) { + model.setFocusElemRef(focusElemRef.current); + } const handleTabClick = (tab: TabType) => { - setActiveTab(tab); + model.setActiveTab(tab); BuilderFocusManager.getInstance().setAppFocused(); + 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]'); + + if (isInteractive) { + return; + } + + const hasSelection = builderAppHasSelection(); + if (hasSelection) { + BuilderFocusManager.getInstance().setAppFocused(); + return; + } + + setTimeout(() => { + if (!builderAppHasSelection()) { + BuilderFocusManager.getInstance().setAppFocused(); + model.giveFocus(); + } + }, 0); + }, [model]); + + const handleSave = useCallback(() => { + if (builderAppId) { + model.saveAppFile(builderAppId); + } + }, [builderAppId, model]); + return ( -
-
-
- handleTabClick("preview")} - /> - handleTabClick("files")} - /> - handleTabClick("code")} - /> +
+
+ {}} + /> +
+
+
+
+ handleTabClick("preview")} + /> + handleTabClick("code")} + /> + handleTabClick("files")} + /> +
+ {activeTab === "code" && ( + + )}
-
- {activeTab === "preview" && } - {activeTab === "files" && } - {activeTab === "code" && } +
+
+ + + +
+
+ + + +
+
+ + + +
); @@ -85,4 +172,4 @@ const BuilderAppPanel = memo(() => { BuilderAppPanel.displayName = "BuilderAppPanel"; -export { BuilderAppPanel }; \ No newline at end of file +export { BuilderAppPanel }; diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 16f3fd1b59..6b1f10d1fe 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { debounce } from "throttle-debounce"; -const DEFAULT_LAYOUT = { +const DefaultLayoutPercentages = { chat: 50, app: 80, build: 20, @@ -29,7 +29,7 @@ const BuilderWorkspace = memo(() => { useEffect(() => { const loadLayout = async () => { if (!builderId) { - setLayout(DEFAULT_LAYOUT); + setLayout(DefaultLayoutPercentages); setIsLoading(false); return; } @@ -41,11 +41,11 @@ const BuilderWorkspace = memo(() => { if (rtInfo?.["builder:layout"]) { setLayout(rtInfo["builder:layout"] as Record); } else { - setLayout(DEFAULT_LAYOUT); + setLayout(DefaultLayoutPercentages); } } catch (error) { console.error("Failed to load builder layout:", error); - setLayout(DEFAULT_LAYOUT); + setLayout(DefaultLayoutPercentages); } finally { setIsLoading(false); } diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builderAppPanelModel.ts new file mode 100644 index 0000000000..c982cfa098 --- /dev/null +++ b/frontend/builder/store/builderAppPanelModel.ts @@ -0,0 +1,114 @@ +// 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 60ed2e4acb..31c80826a3 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -1,23 +1,89 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; -import { memo, useRef } from "react"; +import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; +import { atoms } from "@/store/global"; +import * as keyutil from "@/util/keyutil"; +import { useAtomValue } from "jotai"; +import { memo, useEffect } from "react"; const BuilderCodeTab = memo(() => { - const focusElemRef = useRef(null); + const model = BuilderAppPanelModel.getInstance(); + const builderAppId = useAtomValue(atoms.builderAppId); + const codeContent = useAtomValue(model.codeContentAtom); + const isLoading = useAtomValue(model.isLoadingAtom); + const error = useAtomValue(model.errorAtom); - const handleClick = () => { - focusElemRef.current?.focus(); - BuilderFocusManager.getInstance().setAppFocused(); + 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); }; - return ( -
-
- {}} /> + const handleEditorMount = (editor: any) => { + model.setMonacoEditorRef(editor); + return () => { + model.setMonacoEditorRef(null); + }; + }; + + const handleKeyDown = keyutil.keydownWrapper((waveEvent: WaveKeyboardEvent) => { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:s")) { + if (builderAppId) { + model.saveAppFile(builderAppId); + } + return true; + } + return false; + }); + + if (isLoading) { + return ( +
+
Loading app.go...
+
+ ); + } + + if (error) { + return ( +
+
{error}
-

Code Tab

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

Files Tab

); diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 0aede8b477..0071e0a3db 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -1,28 +1,11 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; -import { memo, useRef } from "react"; +import { memo } from "react"; const BuilderPreviewTab = memo(() => { - const focusElemRef = useRef(null); - - const handleClick = () => { - focusElemRef.current?.focus(); - BuilderFocusManager.getInstance().setAppFocused(); - }; - return ( -
-
- {}} - /> -
+

Preview Tab

); diff --git a/frontend/builder/utils/builder-focus-utils.ts b/frontend/builder/utils/builder-focus-utils.ts new file mode 100644 index 0000000000..add1987fbe --- /dev/null +++ b/frontend/builder/utils/builder-focus-utils.ts @@ -0,0 +1,59 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export function findBuilderAppPanel(element: HTMLElement): HTMLElement | null { + let current: HTMLElement = element; + while (current) { + if (current.hasAttribute("data-builder-app-panel")) { + return current; + } + current = current.parentElement; + } + return null; +} + +export function builderAppHasFocusWithin(focusTarget?: Element | null): boolean { + if (focusTarget !== undefined) { + if (focusTarget instanceof HTMLElement) { + return findBuilderAppPanel(focusTarget) != null; + } + return false; + } + + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const appPanel = findBuilderAppPanel(focused); + if (appPanel) return true; + } + + const sel = document.getSelection(); + if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const appPanel = findBuilderAppPanel(anchor); + if (appPanel) return true; + } + } + + return false; +} + +export function builderAppHasSelection(): boolean { + const sel = document.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + return false; + } + + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + return findBuilderAppPanel(anchor) != null; + } + + return false; +} \ No newline at end of file diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index bdc29620db..3c5c26650a 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -9,6 +9,7 @@ declare global { type GlobalAtomsType = { clientId: jotai.Atom; // readonly builderId: jotai.PrimitiveAtom; // readonly (for builder mode) + builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode waveWindowType: jotai.Atom<"tab" | "builder">; // derived from builderId client: jotai.Atom; // driven from WOS uiContext: jotai.Atom; // driven from windowId, tabId diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1578d57c66..1d215086f4 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -195,6 +195,12 @@ declare global { blockdef: BlockDef; }; + // wshrpc.CommandDeleteAppFileData + type CommandDeleteAppFileData = { + appid: string; + filename: string; + }; + // wshrpc.CommandDeleteBlockData type CommandDeleteBlockData = { blockid: string; @@ -240,12 +246,41 @@ declare global { chatid: string; }; + // wshrpc.CommandListAllAppFilesData + type CommandListAllAppFilesData = { + appid: string; + }; + + // wshrpc.CommandListAllAppFilesRtnData + type CommandListAllAppFilesRtnData = { + path: string; + absolutepath: string; + parentdir?: string; + entries: DirEntryOut[]; + entrycount: number; + totalentries: number; + truncated?: boolean; + }; + // wshrpc.CommandMessageData type CommandMessageData = { oref: ORef; message: string; }; + // wshrpc.CommandReadAppFileData + type CommandReadAppFileData = { + appid: string; + filename: string; + }; + + // wshrpc.CommandReadAppFileRtnData + type CommandReadAppFileRtnData = { + data64: string; + notfound?: boolean; + modts?: number; + }; + // wshrpc.CommandRemoteListEntriesData type CommandRemoteListEntriesData = { path: string; @@ -269,6 +304,13 @@ declare global { opts?: FileCopyOpts; }; + // wshrpc.CommandRenameAppFileData + type CommandRenameAppFileData = { + appid: string; + fromfilename: string; + tofilename: string; + }; + // wshrpc.CommandResolveIdsData type CommandResolveIdsData = { blockid: string; @@ -354,6 +396,13 @@ declare global { opts?: WebSelectorOpts; }; + // wshrpc.CommandWriteAppFileData + type CommandWriteAppFileData = { + appid: string; + filename: string; + data64: string; + }; + // wconfig.ConfigError type ConfigError = { file: string; @@ -436,6 +485,17 @@ declare global { count: number; }; + // wshrpc.DirEntryOut + type DirEntryOut = { + name: string; + dir?: boolean; + symlink?: boolean; + size?: number; + mode: string; + modified: string; + modifiedtime: string; + }; + // vdom.DomRect type DomRect = { top: number; @@ -722,6 +782,7 @@ declare global { "shell:lastcmd"?: string; "shell:lastcmdexitcode"?: number; "builder:layout"?: {[key: string]: number}; + "builder:appid"?: string; "waveai:chatid"?: string; }; diff --git a/frontend/util/util.ts b/frontend/util/util.ts index f16da53a7a..f940614712 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -28,28 +28,16 @@ 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)); - for (let i = 0; i < rawStr.length; i++) { - rtnArr[i] = rawStr.charCodeAt(i); - } - return rtnArr; +function base64ToArray(b64: string): Uint8Array { + const cleanB64 = b64.replace(/\s+/g, ""); + return base64.toByteArray(cleanB64); } -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 base64ToArrayBuffer(b64: string): ArrayBuffer { + const cleanB64 = b64.replace(/\s+/g, ""); + const u8 = base64.toByteArray(cleanB64); // Uint8Array + // Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues) + return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer; } function boundNumber(num: number, min: number, max: number): number { @@ -454,7 +442,7 @@ function parseDataUrl(dataUrl: string): ParsedDataUrl { let buffer: Uint8Array; if (isBase64) { - buffer = decodeBase64ToBytes(data); + buffer = base64ToArray(data); } else { // assume text const decoded = decodeURIComponent(data); @@ -468,6 +456,7 @@ export { atomWithDebounce, atomWithThrottle, base64ToArray, + base64ToArrayBuffer, base64ToString, boundNumber, cn, diff --git a/frontend/wave.ts b/frontend/wave.ts index dd9d3b471a..9020012427 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -288,6 +288,19 @@ async function initBuilder(initOpts: BuilderInitOpts) { (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); + + let appIdToUse = initOpts.appId; + try { + const oref = WOS.makeORef("builder", initOpts.builderId); + const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref }); + if (rtInfo && rtInfo["builder:appid"]) { + appIdToUse = rtInfo["builder:appid"]; + } + } catch (e) { + console.log("Could not load saved builder appId from rtinfo:", e); + } + + globalStore.set(atoms.builderAppId, appIdToUse); const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); diff --git a/pkg/aiusechat/anthropic/anthropic-convertmessage.go b/pkg/aiusechat/anthropic/anthropic-convertmessage.go index 07e9575024..f49ef3f2fd 100644 --- a/pkg/aiusechat/anthropic/anthropic-convertmessage.go +++ b/pkg/aiusechat/anthropic/anthropic-convertmessage.go @@ -72,6 +72,23 @@ func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage } } + // inject chatOpts.AppGoFile as a "text" block at the END of the LAST "user" message found (append to Content) + if chatOpts.AppGoFile != "" { + // Find the last "user" message + for i := len(convertedMsgs) - 1; i >= 0; i-- { + if convertedMsgs[i].Role == "user" { + // Create a text block with the AppGoFile content wrapped in XML tag + appGoFileBlock := anthropicMessageContentBlock{ + Type: "text", + Text: "\n" + chatOpts.AppGoFile + "\n", + } + // Append to the Content of this message + convertedMsgs[i].Content = append(convertedMsgs[i].Content, appGoFileBlock) + break + } + } + } + // Build request body reqBody := &anthropicStreamRequest{ Model: opts.Model, diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index ee61cf71d8..e3505e8938 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -194,6 +194,23 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes. } } + // Inject chatOpts.AppGoFile as a text block at the end of the last "user" message + if chatOpts.AppGoFile != "" { + // Find the last "user" message + for i := len(inputs) - 1; i >= 0; i-- { + if msg, ok := inputs[i].(OpenAIMessage); ok && msg.Role == "user" { + // Add AppGoFile wrapped in XML tag + appGoFileBlock := OpenAIMessageContent{ + Type: "input_text", + Text: "\n" + chatOpts.AppGoFile + "\n", + } + msg.Content = append(msg.Content, appGoFileBlock) + inputs[i] = msg + break + } + } + } + // Build request body reqBody := &OpenAIRequest{ Model: opts.Model, diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 1924b1bf43..4e36cb2dd4 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -10,6 +10,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/wps" ) const BuilderAppFileName = "app.go" @@ -68,14 +69,16 @@ func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { return nil, err } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAppAppGoUpdated, + Scopes: []string{appId}, + }) + return map[string]any{ "success": true, "message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName), }, nil }, - ToolApproval: func(input any) string { - return uctypes.ApprovalNeedsApproval - }, } } @@ -105,7 +108,9 @@ 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), + Description: "Edit the app.go file for this app using precise search and replace. " + + "Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " + + "All edits are applied atomically - if any single edit fails, the entire operation fails and no changes are made.", ToolLogName: "builder:edit_app", Strict: false, InputSchema: map[string]any{ @@ -113,13 +118,13 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { "properties": map[string]any{ "edits": map[string]any{ "type": "array", - "description": "Array of edit specifications with old and new strings", + "description": "Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.", "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", + "description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, the entire edit operation will fail.", }, "new_str": map[string]any{ "type": "string", @@ -127,7 +132,7 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { }, "desc": map[string]any{ "type": "string", - "description": "Description of the edit", + "description": "Description of what this edit does", }, }, "required": []string{"old_str", "new_str"}, @@ -155,14 +160,16 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { return nil, err } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAppAppGoUpdated, + Scopes: []string{appId}, + }) + 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 - }, } } @@ -189,8 +196,5 @@ func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { return result, nil }, - ToolApproval: func(input any) string { - return uctypes.ApprovalAutoApproved - }, } -} \ No newline at end of file +} diff --git a/tsunami/prompts/global-keyboard-handling.md b/pkg/aiusechat/tsunami/global-keyboard-handling.md similarity index 100% rename from tsunami/prompts/global-keyboard-handling.md rename to pkg/aiusechat/tsunami/global-keyboard-handling.md diff --git a/tsunami/prompts/graphing.md b/pkg/aiusechat/tsunami/graphing.md similarity index 100% rename from tsunami/prompts/graphing.md rename to pkg/aiusechat/tsunami/graphing.md diff --git a/tsunami/prompts/system.md b/pkg/aiusechat/tsunami/system.md similarity index 100% rename from tsunami/prompts/system.md rename to pkg/aiusechat/tsunami/system.md diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 7f478e945e..19a1aefa5d 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -418,20 +418,25 @@ func (m *UIMessage) GetContent() string { } type WaveChatOpts struct { - ChatId string - ClientId string - Config AIOptsType - Tools []ToolDefinition - SystemPrompt []string - TabStateGenerator func() (string, []ToolDefinition, string, error) - WidgetAccess bool - RegisterToolApproval func(string) - AllowNativeWebSearch bool + ChatId string + ClientId string + Config AIOptsType + Tools []ToolDefinition + SystemPrompt []string + TabStateGenerator func() (string, []ToolDefinition, string, error) + BuilderAppGenerator func() (string, string, error) + WidgetAccess bool + RegisterToolApproval func(string) + AllowNativeWebSearch bool + BuilderId string + BuilderAppId string // ephemeral to the step - TabState string - TabTools []ToolDefinition - TabId string + TabState string + TabTools []ToolDefinition + TabId string + AppGoFile string + AppBuildStatus string } func (opts *WaveChatOpts) GetToolDefinition(toolName string) *ToolDefinition { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index fc894a7d84..f470984d35 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -5,6 +5,7 @@ package aiusechat import ( "context" + _ "embed" "encoding/json" "fmt" "log" @@ -24,6 +25,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/web/sse" "github.com/wavetermdev/waveterm/pkg/wps" @@ -38,6 +40,10 @@ const ( const DefaultAPI = APIType_OpenAI const DefaultAIEndpoint = "https://cfapi.waveterm.dev/api/waveai" const DefaultMaxTokens = 4 * 1024 +const BuilderMaxTokens = 24 * 1024 + +//go:embed tsunami/system.md +var tsunamiSystemDoc string var ( globalRateLimitInfo = &uctypes.RateLimitInfo{Unknown: true} @@ -71,6 +77,7 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `User-attached directories use the tag JSON DirInfo.`, `If multiple attached files exist, treat each as a separate source file with its own file_name.`, `When the user refers to these files, use their inline content directly; do NOT call any read_text_file or file-access tools to re-read them unless asked.`, + `The current "app.go" file will be provided with the tag \ncontent\n (use this as the basis for your app.go file edits)`, // Output & formatting `When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`, @@ -93,16 +100,70 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `You have NO API access to widgets or Wave unless provided via an explicit tool.`, }, " ") -func getWaveAISettings(premium bool) (*uctypes.AIOptsType, error) { +var BuilderSystemPromptText_OpenAI = strings.Join([]string{ + `# Wave AI - WaveApp Builder`, + ``, + `You are Wave AI, a specialized AI assistant designed exclusively to help users build Tsunami framework widgets for Wave Terminal.`, + ``, + `**Core Directives:**`, + `- ONLY respond to requests related to building Tsunami/wave widgets`, + `- Follow the Tsunami framework documentation strictly`, + `- Generate complete, working Go code that compiles and runs in Wave Terminal`, + `- Politely decline requests to write other types of code, answer general questions, or go off-topic`, + `- If a user asks something unrelated, respond: "I'm Wave AI, specialized in building wave widgets for Wave Terminal. I can only help with creating WaveApps. What would you like to build?"`, + ``, + `**Behavior:**`, + `- Be concise and direct. Prefer determinism over speculation.`, + `- Never fabricate data, APIs, or framework features. If unsure, say so and reference the documentation.`, + `- If a brief clarifying question eliminates guesswork, ask it.`, + ``, + `**Attached Files:**`, + `- User-attached text files appear inline as \ncontent\n`, + `- User-attached directories use JSON DirInfo`, + `- When users refer to attached files, use their inline content directly; do NOT attempt to read them again`, + ``, + `**Code Output:**`, + `- Do NOT output code in fenced code blocks or inline code snippets`, + `- The file editing tools handle all code generation—users see the code in the editor pane`, + `- After using a tool to write code, provide a brief summary of what you implemented`, + `- Only use code formatting when explaining specific concepts or showing small examples that are NOT the main app code`, + ``, + `**Safety & Guardrails:**`, + `- Build widgets that perform powerful actions, but include appropriate safeguards:`, + ` - Require explicit user interaction (button clicks, form submissions) before destructive operations`, + ` - Add confirmation dialogs for destructive actions (file deletion, system commands, data modifications)`, + ` - Use visual indicators: red/warning colors, warning icons, clear action labels ("Delete", "Remove", "Overwrite")`, + ` - Never trigger dangerous operations automatically on widget load or in render cycles`, + ` - For bulk operations, preview what will be affected and require confirmation`, + `- Example: A file manager widget should have red "Delete" buttons that show a dialog: "Delete 3 files? [Cancel] [Delete]"`, + ``, + `**Your Workflow:**`, + `1. Understand what the user wants to build`, + `2. Reference the Tsunami framework documentation below`, + `3. Use the file editing tools to modify app.go with your implementation`, + `4. Compilation/linting results will be returned after each edit`, + `5. If there are errors, analyze them and use the tools again to fix the issues`, + `6. Continue iterating until the code compiles cleanly and runs successfully`, + `7. Verify the widget follows framework conventions and meets the user's requirements`, + ``, + `-----------`, + ``, +}, "\n") + +func getWaveAISettings(premium bool, builderMode bool) (*uctypes.AIOptsType, error) { baseUrl := DefaultAIEndpoint if os.Getenv("WAVETERM_WAVEAI_ENDPOINT") != "" { baseUrl = os.Getenv("WAVETERM_WAVEAI_ENDPOINT") } + maxTokens := DefaultMaxTokens + if builderMode { + maxTokens = BuilderMaxTokens + } if DefaultAPI == APIType_Anthropic { return &uctypes.AIOptsType{ APIType: APIType_Anthropic, Model: uctypes.DefaultAnthropicModel, - MaxTokens: DefaultMaxTokens, + MaxTokens: maxTokens, ThinkingLevel: uctypes.ThinkingLevelMedium, BaseURL: baseUrl, }, nil @@ -116,7 +177,7 @@ func getWaveAISettings(premium bool) (*uctypes.AIOptsType, error) { return &uctypes.AIOptsType{ APIType: APIType_OpenAI, Model: model, - MaxTokens: DefaultMaxTokens, + MaxTokens: maxTokens, ThinkingLevel: thinkingLevel, BaseURL: baseUrl, }, nil @@ -378,6 +439,13 @@ func RunAIChat(ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctyp chatOpts.TabId = tabId } } + if chatOpts.BuilderAppGenerator != nil { + appGoFile, appBuildStatus, appErr := chatOpts.BuilderAppGenerator() + if appErr == nil { + chatOpts.AppGoFile = appGoFile + chatOpts.AppBuildStatus = appBuildStatus + } + } stopReason, rtnMessage, err := runAIChatStep(ctx, sseHandler, chatOpts, cont) metrics.RequestCount++ if chatOpts.Config.IsPremiumModel() { @@ -572,6 +640,7 @@ func sendAIMetricsTelemetry(ctx context.Context, metrics *uctypes.AIMetrics) { type PostMessageRequest struct { TabId string `json:"tabid,omitempty"` BuilderId string `json:"builderid,omitempty"` + BuilderAppId string `json:"builderappid,omitempty"` ChatID string `json:"chatid"` Msg uctypes.AIMessage `json:"msg"` WidgetAccess bool `json:"widgetaccess,omitempty"` @@ -603,7 +672,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { // Get WaveAI settings premium := shouldUsePremium() - aiOpts, err := getWaveAISettings(premium) + builderMode := req.BuilderId != "" + aiOpts, err := getWaveAISettings(premium, builderMode) if err != nil { http.Error(w, fmt.Sprintf("WaveAI configuration error: %v", err), http.StatusInternalServerError) return @@ -624,16 +694,44 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { WidgetAccess: req.WidgetAccess, RegisterToolApproval: RegisterToolApproval, AllowNativeWebSearch: true, + BuilderId: req.BuilderId, + BuilderAppId: req.BuilderAppId, } if chatOpts.Config.APIType == APIType_OpenAI { - chatOpts.SystemPrompt = []string{SystemPromptText_OpenAI} + if chatOpts.BuilderId != "" { + chatOpts.SystemPrompt = []string{BuilderSystemPromptText_OpenAI + tsunamiSystemDoc} + } else { + chatOpts.SystemPrompt = []string{SystemPromptText_OpenAI} + } } else { chatOpts.SystemPrompt = []string{SystemPromptText} } - chatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) { - tabState, tabTools, err := GenerateTabStateAndTools(r.Context(), req.TabId, req.WidgetAccess) - return tabState, tabTools, req.TabId, err + if req.TabId != "" { + chatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) { + tabState, tabTools, err := GenerateTabStateAndTools(r.Context(), req.TabId, req.WidgetAccess) + return tabState, tabTools, req.TabId, err + } + } + + if req.BuilderAppId != "" { + chatOpts.BuilderAppGenerator = func() (string, string, error) { + fileData, err := waveappstore.ReadAppFile(req.BuilderAppId, "app.go") + if err != nil { + return "", "", err + } + appGoFile := string(fileData.Contents) + appBuildStatus := "" + return appGoFile, appBuildStatus, nil + } + } + + if req.BuilderAppId != "" { + chatOpts.Tools = append(chatOpts.Tools, + GetBuilderWriteAppFileToolDefinition(req.BuilderAppId), + GetBuilderEditAppFileToolDefinition(req.BuilderAppId), + GetBuilderListFilesToolDefinition(req.BuilderAppId), + ) } // Validate the message diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index 8e21cd33b1..a1f90569c9 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -28,13 +28,12 @@ import ( ) type TsunamiAppProc struct { - Cmd *exec.Cmd - StdoutBuffer *utilds.ReaderLineBuffer - StderrBuffer *utilds.ReaderLineBuffer // May be nil if stderr was consumed for port detection - StdinWriter io.WriteCloser - Port int // Port the tsunami app is listening on - WaitCh chan struct{} // Channel that gets closed when cmd.Wait() returns - WaitRtn error // Error returned by cmd.Wait() + Cmd *exec.Cmd + LineBuffer *utilds.MultiReaderLineBuffer + StdinWriter io.WriteCloser + Port int // Port the tsunami app is listening on + WaitCh chan struct{} // Channel that gets closed when cmd.Wait() returns + WaitRtn error // Error returned by cmd.Wait() } type TsunamiController struct { @@ -90,30 +89,30 @@ func (c *TsunamiController) fetchAndSetSchemas(port int) { client := &http.Client{ Timeout: 10 * time.Second, } - + resp, err := client.Get(url) if err != nil { log.Printf("TsunamiController: failed to fetch schemas from %s: %v", url, err) return } defer resp.Body.Close() - + if resp.StatusCode != http.StatusOK { log.Printf("TsunamiController: received non-200 status %d from %s", resp.StatusCode, url) return } - + var schemas any if err := json.NewDecoder(resp.Body).Decode(&schemas); err != nil { log.Printf("TsunamiController: failed to decode schemas response: %v", err) return } - + blockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId) wstore.SetRTInfo(blockRef, map[string]any{ "tsunami:schemas": schemas, }) - + log.Printf("TsunamiController: successfully fetched and cached schemas for block %s", c.blockId) } @@ -380,14 +379,19 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, appName := build.GetAppName(appPath) - stdoutBuffer := utilds.MakeReaderLineBuffer(stdoutPipe, 1000) - stdoutBuffer.SetLineCallback(func(line string) { - log.Printf("[tsunami:%s] %s\n", appName, line) - }) + lineBuffer := utilds.MakeMultiReaderLineBuffer(1000) + portChan := make(chan int, 1) + portFound := false - stderrBuffer := utilds.MakeReaderLineBuffer(stderrPipe, 1000) - stderrBuffer.SetLineCallback(func(line string) { + lineBuffer.SetLineCallback(func(line string) { log.Printf("[tsunami:%s] %s\n", appName, line) + + if !portFound { + if port := build.ParseTsunamiPort(line); port > 0 { + portFound = true + portChan <- port + } + } }) err = cmd.Start() @@ -398,11 +402,10 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, // Create wait channel and tsunami proc first waitCh := make(chan struct{}) tsunamiProc := &TsunamiAppProc{ - Cmd: cmd, - StdoutBuffer: stdoutBuffer, - StderrBuffer: stderrBuffer, - StdinWriter: stdinPipe, - WaitCh: waitCh, + Cmd: cmd, + LineBuffer: lineBuffer, + StdinWriter: stdinPipe, + WaitCh: waitCh, } // Start goroutine to handle cmd.Wait() @@ -427,29 +430,12 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, close(waitCh) }() - go stdoutBuffer.ReadAll() - - // Monitor stderr for port information - portChan := make(chan int, 1) - errChan := make(chan error, 1) - - go func() { - for { - line, err := stderrBuffer.ReadLine() - if err != nil { - errChan <- fmt.Errorf("stderr buffer error: %w", err) - return - } - - port := build.ParseTsunamiPort(line) - if port > 0 { - portChan <- port - return - } - } - }() + // Start reading both stdout and stderr + go lineBuffer.ReadAll(stdoutPipe) + go lineBuffer.ReadAll(stderrPipe) // Wait for either port detection, process death, or context timeout + errChan := make(chan error, 1) go func() { <-tsunamiProc.WaitCh select { @@ -462,9 +448,6 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, select { case port := <-portChan: - // Start the stderr ReadAll goroutine now that we have the port - go stderrBuffer.ReadAll() - tsunamiProc.Port = port return tsunamiProc, nil case err := <-errChan: diff --git a/pkg/utilds/multireaderlinebuffer.go b/pkg/utilds/multireaderlinebuffer.go new file mode 100644 index 0000000000..657d3649a2 --- /dev/null +++ b/pkg/utilds/multireaderlinebuffer.go @@ -0,0 +1,90 @@ +package utilds + +import ( + "bufio" + "io" + "sync" +) + +type MultiReaderLineBuffer struct { + lock sync.Mutex + lines []string + maxLines int + totalLineCount int + lineCallback func(string) +} + +func MakeMultiReaderLineBuffer(maxLines int) *MultiReaderLineBuffer { + if maxLines <= 0 { + maxLines = 1000 + } + + return &MultiReaderLineBuffer{ + lines: make([]string, 0, maxLines), + maxLines: maxLines, + totalLineCount: 0, + } +} + +// callback is synchronous. will block the consuming of lines and +// guaranteed to run in order. it is also guaranteed only one callback +// will be running at a time (protected by the internal line lock) +func (mrlb *MultiReaderLineBuffer) SetLineCallback(callback func(string)) { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + mrlb.lineCallback = callback +} + +func (mrlb *MultiReaderLineBuffer) ReadAll(r io.Reader) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + mrlb.addLine(line) + mrlb.callLineCallback(line) + } +} + +func (mrlb *MultiReaderLineBuffer) callLineCallback(line string) { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + if mrlb.lineCallback != nil { + mrlb.lineCallback(line) + } +} + +func (mrlb *MultiReaderLineBuffer) addLine(line string) { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + mrlb.totalLineCount++ + + if len(mrlb.lines) >= mrlb.maxLines { + mrlb.lines = append(mrlb.lines[1:], line) + } else { + mrlb.lines = append(mrlb.lines, line) + } +} + +func (mrlb *MultiReaderLineBuffer) GetLines() []string { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + result := make([]string, len(mrlb.lines)) + copy(result, mrlb.lines) + return result +} + +func (mrlb *MultiReaderLineBuffer) GetLineCount() int { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + return len(mrlb.lines) +} + +func (mrlb *MultiReaderLineBuffer) GetTotalLineCount() int { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + return mrlb.totalLineCount +} diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index 6328339fc4..1da4c59278 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -17,6 +17,9 @@ import ( const ( AppNSLocal = "local" AppNSDraft = "draft" + + MaxNamespaceLen = 30 + MaxAppNameLen = 50 ) var ( @@ -24,6 +27,11 @@ var ( appNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ) +type FileData struct { + Contents []byte + ModTs int64 +} + func MakeAppId(appNS string, appName string) string { return appNS + "/" + appName } @@ -46,6 +54,12 @@ func ValidateAppId(appId string) error { if err != nil { return err } + if len(appNS) > MaxNamespaceLen { + return fmt.Errorf("namespace too long: max %d characters", MaxNamespaceLen) + } + if len(appName) > MaxAppNameLen { + return fmt.Errorf("app name too long: max %d characters", MaxAppNameLen) + } if !namespaceRegex.MatchString(appNS) { return fmt.Errorf("invalid namespace: must match pattern @?[a-z0-9-]+") } @@ -264,6 +278,37 @@ func WriteAppFile(appId string, fileName string, contents []byte) error { return nil } +func ReadAppFile(appId string, fileName string) (*FileData, 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 + } + + filePath, err := validateAndResolveFilePath(appDir, fileName) + if err != nil { + return nil, err + } + + fileInfo, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + + contents, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return &FileData{ + Contents: contents, + ModTs: fileInfo.ModTime().UnixMilli(), + }, nil +} + func DeleteAppFile(appId string, fileName string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index 1ce2929de3..655d5a6c8c 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -19,6 +19,7 @@ type ObjRTInfo struct { ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"` BuilderLayout map[string]float64 `json:"builder:layout,omitempty"` + BuilderAppId string `json:"builder:appid,omitempty"` WaveAIChatId string `json:"waveai:chatid,omitempty"` } diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index b99e7898c6..245f71c0d4 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -6,17 +6,18 @@ package wps import "github.com/wavetermdev/waveterm/pkg/util/utilfn" const ( - Event_BlockClose = "blockclose" - Event_ConnChange = "connchange" - Event_SysInfo = "sysinfo" - Event_ControllerStatus = "controllerstatus" - Event_WaveObjUpdate = "waveobj:update" - Event_BlockFile = "blockfile" - Event_Config = "config" - Event_UserInput = "userinput" - Event_RouteGone = "route:gone" - Event_WorkspaceUpdate = "workspace:update" - Event_WaveAIRateLimit = "waveai:ratelimit" + Event_BlockClose = "blockclose" + Event_ConnChange = "connchange" + Event_SysInfo = "sysinfo" + Event_ControllerStatus = "controllerstatus" + Event_WaveObjUpdate = "waveobj:update" + Event_BlockFile = "blockfile" + Event_Config = "config" + Event_UserInput = "userinput" + Event_RouteGone = "route:gone" + Event_WorkspaceUpdate = "workspace:update" + Event_WaveAIRateLimit = "waveai:ratelimit" + Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 47af277974..5c8ec2582c 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -143,6 +143,12 @@ func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockD return resp, err } +// command "deleteappfile", wshserver.DeleteAppFileCommand +func DeleteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteAppFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deleteappfile", data, opts) + return err +} + // command "deleteblock", wshserver.DeleteBlockCommand func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) @@ -362,6 +368,18 @@ func GetWaveAIRateLimitCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*uctype return resp, err } +// command "listallappfiles", wshserver.ListAllAppFilesCommand +func ListAllAppFilesCommand(w *wshutil.WshRpc, data wshrpc.CommandListAllAppFilesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandListAllAppFilesRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandListAllAppFilesRtnData](w, "listallappfiles", data, opts) + return resp, err +} + +// command "listalleditableapps", wshserver.ListAllEditableAppsCommand +func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "listalleditableapps", nil, opts) + return resp, err +} + // command "message", wshserver.MessageCommand func MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "message", data, opts) @@ -380,6 +398,12 @@ func PathCommand(w *wshutil.WshRpc, data wshrpc.PathCommandData, opts *wshrpc.Rp return resp, err } +// command "readappfile", wshserver.ReadAppFileCommand +func ReadAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandReadAppFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandReadAppFileRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandReadAppFileRtnData](w, "readappfile", data, opts) + return resp, err +} + // command "recordtevent", wshserver.RecordTEventCommand func RecordTEventCommand(w *wshutil.WshRpc, data telemetrydata.TEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "recordtevent", data, opts) @@ -466,6 +490,12 @@ func RemoteWriteFileCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrp return err } +// command "renameappfile", wshserver.RenameAppFileCommand +func RenameAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRenameAppFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "renameappfile", data, opts) + return err +} + // command "resolveids", wshserver.ResolveIdsCommand func ResolveIdsCommand(w *wshutil.WshRpc, data wshrpc.CommandResolveIdsData, opts *wshrpc.RpcOpts) (wshrpc.CommandResolveIdsRtnData, error) { resp, err := sendRpcRequestCallHelper[wshrpc.CommandResolveIdsRtnData](w, "resolveids", data, opts) @@ -617,6 +647,12 @@ func WorkspaceListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.Wor return resp, err } +// command "writeappfile", wshserver.WriteAppFileCommand +func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "writeappfile", data, opts) + return err +} + // command "wshactivity", wshserver.WshActivityCommand func WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "wshactivity", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index bb121e0c67..a3e3f02ed4 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -151,6 +151,14 @@ const ( Command_SetRTInfo = "setrtinfo" Command_TermGetScrollbackLines = "termgetscrollbacklines" + + // builder + Command_ListAllEditableApps = "listalleditableapps" + Command_ListAllAppFiles = "listallappfiles" + Command_ReadAppFile = "readappfile" + Command_WriteAppFile = "writeappfile" + Command_DeleteAppFile = "deleteappfile" + Command_RenameAppFile = "renameappfile" ) type RespOrErrorUnion[T any] struct { @@ -286,6 +294,14 @@ type WshRpcInterface interface { // terminal TermGetScrollbackLinesCommand(ctx context.Context, data CommandTermGetScrollbackLinesData) (*CommandTermGetScrollbackLinesRtnData, error) + // builder + ListAllEditableAppsCommand(ctx context.Context) ([]string, error) + ListAllAppFilesCommand(ctx context.Context, data CommandListAllAppFilesData) (*CommandListAllAppFilesRtnData, error) + ReadAppFileCommand(ctx context.Context, data CommandReadAppFileData) (*CommandReadAppFileRtnData, error) + WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error + DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error + RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error + // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse] @@ -877,3 +893,56 @@ type CommandTermGetScrollbackLinesRtnData struct { Lines []string `json:"lines"` LastUpdated int64 `json:"lastupdated"` } + +// builder +type CommandListAllAppFilesData struct { + AppId string `json:"appid"` +} + +type CommandListAllAppFilesRtnData struct { + Path string `json:"path"` + AbsolutePath string `json:"absolutepath"` + ParentDir string `json:"parentdir,omitempty"` + Entries []DirEntryOut `json:"entries"` + EntryCount int `json:"entrycount"` + TotalEntries int `json:"totalentries"` + Truncated bool `json:"truncated,omitempty"` +} + +type DirEntryOut struct { + Name string `json:"name"` + Dir bool `json:"dir,omitempty"` + Symlink bool `json:"symlink,omitempty"` + Size int64 `json:"size,omitempty"` + Mode string `json:"mode"` + Modified string `json:"modified"` + ModifiedTime string `json:"modifiedtime"` +} + +type CommandReadAppFileData struct { + AppId string `json:"appid"` + FileName string `json:"filename"` +} + +type CommandReadAppFileRtnData struct { + Data64 string `json:"data64"` + NotFound bool `json:"notfound,omitempty"` + ModTs int64 `json:"modts,omitempty"` +} + +type CommandWriteAppFileData struct { + AppId string `json:"appid"` + FileName string `json:"filename"` + Data64 string `json:"data64"` +} + +type CommandDeleteAppFileData struct { + AppId string `json:"appid"` + FileName string `json:"filename"` +} + +type CommandRenameAppFileData struct { + AppId string `json:"appid"` + FromFileName string `json:"fromfilename"` + ToFileName string `json:"tofilename"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index d4bbf367e9..8364329efc 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -9,9 +9,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io/fs" "log" + "os" "path/filepath" "regexp" "strings" @@ -40,6 +42,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/util/wavefileutil" "github.com/wavetermdev/waveterm/pkg/waveai" + "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcloud" @@ -890,6 +893,70 @@ func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.Workspa return rtn, nil } +func (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]string, error) { + return waveappstore.ListAllEditableApps() +} + +func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.CommandListAllAppFilesData) (*wshrpc.CommandListAllAppFilesRtnData, error) { + result, err := waveappstore.ListAllAppFiles(data.AppId) + if err != nil { + return nil, err + } + entries := make([]wshrpc.DirEntryOut, len(result.Entries)) + for i, entry := range result.Entries { + entries[i] = wshrpc.DirEntryOut{ + Name: entry.Name, + Dir: entry.Dir, + Symlink: entry.Symlink, + Size: entry.Size, + Mode: entry.Mode, + Modified: entry.Modified, + ModifiedTime: entry.ModifiedTime, + } + } + return &wshrpc.CommandListAllAppFilesRtnData{ + Path: result.Path, + AbsolutePath: result.AbsolutePath, + ParentDir: result.ParentDir, + Entries: entries, + EntryCount: result.EntryCount, + TotalEntries: result.TotalEntries, + Truncated: result.Truncated, + }, nil +} + +func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) { + fileData, err := waveappstore.ReadAppFile(data.AppId, data.FileName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &wshrpc.CommandReadAppFileRtnData{ + NotFound: true, + }, nil + } + return nil, fmt.Errorf("failed to read app file: %w", err) + } + return &wshrpc.CommandReadAppFileRtnData{ + Data64: base64.StdEncoding.EncodeToString(fileData.Contents), + ModTs: fileData.ModTs, + }, nil +} + +func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.CommandWriteAppFileData) error { + contents, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return fmt.Errorf("failed to decode data64: %w", err) + } + return waveappstore.WriteAppFile(data.AppId, data.FileName, contents) +} + +func (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error { + return waveappstore.DeleteAppFile(data.AppId, data.FileName) +} + +func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.CommandRenameAppFileData) error { + return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil {