diff --git a/.vscode/settings.json b/.vscode/settings.json index 5ea5bc64b1..25c1ea49f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,7 +60,7 @@ "analyses": { "QF1003": false }, - "directoryFilters": ["-tsunami/frontend/scaffold", "-dist"] + "directoryFilters": ["-tsunami/frontend/scaffold", "-dist", "-make"] }, "tailwindCSS.lint.suggestCanonicalClasses": "ignore" } diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index 88fc1c7daf..cb67820b6a 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -4,7 +4,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; -import { PublishAppModal } from "@/builder/builder-apppanel"; +import { DeleteFileModal, PublishAppModal, RenameFileModal } from "@/builder/builder-apppanel"; import { AboutModal } from "./about"; import { UserInputModal } from "./userinputmodal"; @@ -15,6 +15,8 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, + [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, + [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index a0f0523a2a..24c1d80041 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -9,7 +9,7 @@ import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-appp 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 { BuilderFilesTab, DeleteFileModal, RenameFileModal } 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"; @@ -291,15 +291,13 @@ const BuilderAppPanel = memo(() => { isAppFocused={isAppFocused} onClick={() => handleTabClick("code")} /> - {false && ( - handleTabClick("files")} - /> - )} + handleTabClick("files")} + /> { BuilderAppPanel.displayName = "BuilderAppPanel"; -export { BuilderAppPanel, PublishAppModal }; +export { BuilderAppPanel, DeleteFileModal, PublishAppModal, RenameFileModal }; diff --git a/frontend/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx index 7596ac912e..1056646b33 100644 --- a/frontend/builder/tabs/builder-filestab.tsx +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -1,16 +1,399 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { memo } from "react"; +import { formatFileSize } from "@/app/aipanel/ai-utils"; +import { Modal } from "@/app/modals/modal"; +import { ContextMenuModel } from "@/app/store/contextmenu"; +import { modalsModel } from "@/app/store/modalmodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { arrayToBase64 } from "@/util/util"; +import { atoms } from "@/store/global"; +import { useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; + +const MaxFileSize = 5 * 1024 * 1024; // 5MB +const ReadOnlyFileNames = ["static/tw.css"]; + +type FileEntry = { + name: string; + size: number; + modified: string; + isReadOnly: boolean; +}; + +const RenameFileModal = memo( + ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { + const displayName = fileName.replace("static/", ""); + const [newName, setNewName] = useState(displayName); + const [error, setError] = useState(""); + const [isRenaming, setIsRenaming] = useState(false); + + const handleRename = async () => { + const trimmedName = newName.trim(); + if (!trimmedName) { + setError("File name cannot be empty"); + return; + } + if (trimmedName.includes("/") || trimmedName.includes("\\")) { + setError("File name cannot contain / or \\"); + return; + } + if (trimmedName === displayName) { + modalsModel.popModal(); + return; + } + + setIsRenaming(true); + try { + await RpcApi.RenameAppFileCommand(TabRpcClient, { + appid: appId, + fromfilename: fileName, + tofilename: `static/${trimmedName}`, + }); + onSuccess(); + modalsModel.popModal(); + } catch (err) { + console.log("Error renaming file:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsRenaming(false); + } + }; + + const handleClose = () => { + modalsModel.popModal(); + }; + + return ( + +
+

Rename File

+
+
+ Current name: {displayName} +
+ { + setNewName(e.target.value); + setError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing && newName.trim() && !error) { + handleRename(); + } + }} + className="px-3 py-2 bg-panel border border-border rounded focus:outline-none focus:border-accent" + autoFocus + disabled={isRenaming} + spellCheck={false} + /> + {error &&
{error}
} +
+
+
+ ); + } +); + +RenameFileModal.displayName = "RenameFileModal"; + +const DeleteFileModal = memo( + ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(""); + + const handleDelete = async () => { + setIsDeleting(true); + setError(""); + try { + await RpcApi.DeleteAppFileCommand(TabRpcClient, { + appid: appId, + filename: fileName, + }); + onSuccess(); + modalsModel.popModal(); + } catch (err) { + console.log("Error deleting file:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsDeleting(false); + } + }; + + const handleClose = () => { + modalsModel.popModal(); + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !isDeleting) { + e.preventDefault(); + handleDelete(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isDeleting]); + + return ( + +
+

Delete File

+

+ Are you sure you want to delete {fileName.replace("static/", "")}? +

+

This action cannot be undone.

+ {error &&
{error}
} +
+
+ ); + } +); + +DeleteFileModal.displayName = "DeleteFileModal"; const BuilderFilesTab = memo(() => { + const builderAppId = useAtomValue(atoms.builderAppId); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; fileName: string } | null>(null); + const fileInputRef = useRef(null); + + const loadFiles = useCallback(async () => { + if (!builderAppId) return; + + setLoading(true); + setError(""); + try { + const result = await RpcApi.ListAllAppFilesCommand(TabRpcClient, { appid: builderAppId }); + const fileEntries: FileEntry[] = result.entries + .filter((entry) => !entry.dir && entry.name.startsWith("static/")) + .map((entry) => ({ + name: entry.name, + size: entry.size || 0, + modified: entry.modified, + isReadOnly: ReadOnlyFileNames.includes(entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + setFiles(fileEntries); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [builderAppId]); + + const handleRefresh = useCallback(async () => { + // Clear files and add delay so UX shows the refresh is happening + setFiles([]); + await new Promise((resolve) => setTimeout(resolve, 100)); + await loadFiles(); + }, [loadFiles]); + + useEffect(() => { + loadFiles(); + }, [loadFiles]); + + useEffect(() => { + const handleClickOutside = () => setContextMenu(null); + if (contextMenu) { + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); + } + }, [contextMenu]); + + const handleFileUpload = async (fileList: FileList) => { + if (!builderAppId || fileList.length === 0) return; + + const file = fileList[0]; + if (file.size > MaxFileSize) { + setError(`File size exceeds maximum allowed size of ${formatFileSize(MaxFileSize)}`); + return; + } + + setError(""); + setLoading(true); + + try { + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + const base64Encoded = arrayToBase64(uint8Array); + + await RpcApi.WriteAppFileCommand(TabRpcClient, { + appid: builderAppId, + filename: `static/${file.name}`, + data64: base64Encoded, + }); + + await loadFiles(); + } catch (err) { + console.error("Error uploading file:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + handleFileUpload(e.dataTransfer.files); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + if (e.target.files) { + handleFileUpload(e.target.files); + } + }; + + const handleContextMenu = (e: React.MouseEvent, fileName: string) => { + const menu: ContextMenuItem[] = [ + { + label: "Rename File", + click: () => { + modalsModel.pushModal("RenameFileModal", { appId: builderAppId, fileName, onSuccess: loadFiles }); + }, + }, + { + type: "separator", + }, + { + label: "Delete File", + click: () => { + modalsModel.pushModal("DeleteFileModal", { appId: builderAppId, fileName, onSuccess: loadFiles }); + }, + }, + ]; + + ContextMenuModel.showContextMenu(menu, e); + }; + return ( -
-

Files Tab

+
+
+

Static Files

+
+ + +
+ +
+ + {error && ( +
+ + {error} +
+ )} + +
+ Drag and drop files here or click "Add File". Maximum file size: {formatFileSize(MaxFileSize)} +
+ +
+ {loading && files.length === 0 ? ( +
Loading files...
+ ) : files.length === 0 ? ( +
+ +

No files yet. Drag and drop files here or click "Add File" to get started.

+
+ ) : ( +
+ {files.map((file) => ( +
!file.isReadOnly && handleContextMenu(e, file.name)} + > + +
+
{file.name.replace("static/", "")}
+
+ {formatFileSize(file.size)} + {file.isReadOnly && ( + + + Generated by framework (read-only) + + )} +
+
+
{file.modified}
+ {!file.isReadOnly && ( + + )} +
+ ))} +
+ )} +
); }); BuilderFilesTab.displayName = "BuilderFilesTab"; -export { BuilderFilesTab }; \ No newline at end of file +export { BuilderFilesTab, DeleteFileModal, RenameFileModal }; diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 2c02dce2cf..0d94f9babf 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -28,6 +28,10 @@ function stringToBase64(input: string): string { return base64.fromByteArray(stringBytes); } +function arrayToBase64(input: Uint8Array): string { + return base64.fromByteArray(input); +} + function base64ToArray(b64: string): Uint8Array { const cleanB64 = b64.replace(/\s+/g, ""); return base64.toByteArray(cleanB64); @@ -476,6 +480,7 @@ function formatRelativeTime(timestamp: number): string { } export { + arrayToBase64, atomWithDebounce, atomWithThrottle, base64ToArray, diff --git a/package-lock.json b/package-lock.json index 6a62724f7c..dfc132373a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.3-beta.1", + "version": "0.12.3-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.3-beta.1", + "version": "0.12.3-beta.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index c7c912c975..604284bbb8 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -70,6 +70,21 @@ func generateDeterministicSuffix(inputs ...string) string { return hex.EncodeToString(hash)[:8] } +// appendToLastUserMessage appends a text block to the last user message in the inputs slice +func appendToLastUserMessage(inputs []any, text string) { + for i := len(inputs) - 1; i >= 0; i-- { + if msg, ok := inputs[i].(OpenAIMessage); ok && msg.Role == "user" { + block := OpenAIMessageContent{ + Type: "input_text", + Text: text, + } + msg.Content = append(msg.Content, block) + inputs[i] = msg + break + } + } +} + // ---------- OpenAI Request Types ---------- type StreamOptionsType struct { @@ -203,38 +218,16 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes. maxTokens = OpenAIDefaultMaxTokens } - // Inject chatOpts.TabState as a text block at the end of the last "user" message if chatOpts.TabState != "" { - // Find the last "user" message - for i := len(inputs) - 1; i >= 0; i-- { - if msg, ok := inputs[i].(OpenAIMessage); ok && msg.Role == "user" { - // Add TabState as a new text block - tabStateBlock := OpenAIMessageContent{ - Type: "input_text", - Text: chatOpts.TabState, - } - msg.Content = append(msg.Content, tabStateBlock) - inputs[i] = msg - break - } - } + appendToLastUserMessage(inputs, chatOpts.TabState) + } + + if chatOpts.AppStaticFiles != "" { + appendToLastUserMessage(inputs, "\n"+chatOpts.AppStaticFiles+"\n") } - // 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 - } - } + appendToLastUserMessage(inputs, "\n"+chatOpts.AppGoFile+"\n") } // Build request body diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index b9fe0092ee..47ada4c7b8 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -461,7 +461,7 @@ type WaveChatOpts struct { TabTools []ToolDefinition TabId string AppGoFile string - AppBuildStatus string + AppStaticFiles string } func (opts *WaveChatOpts) GetToolDefinition(toolName string) *ToolDefinition { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 3e7597f07c..aa7137e72a 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -435,10 +435,10 @@ func RunAIChat(ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctyp } } if chatOpts.BuilderAppGenerator != nil { - appGoFile, appBuildStatus, appErr := chatOpts.BuilderAppGenerator() + appGoFile, appStaticFiles, appErr := chatOpts.BuilderAppGenerator() if appErr == nil { chatOpts.AppGoFile = appGoFile - chatOpts.AppBuildStatus = appBuildStatus + chatOpts.AppStaticFiles = appStaticFiles } } stopReason, rtnMessage, err := runAIChatStep(ctx, sseHandler, chatOpts, cont) @@ -729,13 +729,7 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { 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 + return generateBuilderAppData(req.BuilderAppId) } } @@ -870,3 +864,44 @@ func CreateWriteTextFileDiff(ctx context.Context, chatId string, toolCallId stri modifiedContent := []byte(params.Contents) return originalContent, modifiedContent, nil } + + +type StaticFileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + Modified string `json:"modified"` + ModifiedTime string `json:"modified_time"` +} + +func generateBuilderAppData(appId string) (string, string, error) { + appGoFile := "" + fileData, err := waveappstore.ReadAppFile(appId, "app.go") + if err == nil { + appGoFile = string(fileData.Contents) + } + + staticFilesJSON := "" + allFiles, err := waveappstore.ListAllAppFiles(appId) + if err == nil { + var staticFiles []StaticFileInfo + for _, entry := range allFiles.Entries { + if strings.HasPrefix(entry.Name, "static/") { + staticFiles = append(staticFiles, StaticFileInfo{ + Name: entry.Name, + Size: entry.Size, + Modified: entry.Modified, + ModifiedTime: entry.ModifiedTime, + }) + } + } + + if len(staticFiles) > 0 { + staticFilesBytes, marshalErr := json.Marshal(staticFiles) + if marshalErr == nil { + staticFilesJSON = string(staticFilesBytes) + } + } + } + + return appGoFile, staticFilesJSON, nil +} diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index ae74b169a8..c3cfd7846e 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "os" + "strings" "github.com/wavetermdev/waveterm/tsunami/engine" "github.com/wavetermdev/waveterm/tsunami/util" @@ -186,3 +187,35 @@ func PrintAppManifest() { client := engine.GetDefaultClient() client.PrintAppManifest() } + +// ReadStaticFile reads a file from the embedded static filesystem. +// The path MUST start with "static/" (e.g., "static/config.json"). +// Returns the file contents or an error if the file doesn't exist or can't be read. +func ReadStaticFile(path string) ([]byte, error) { + client := engine.GetDefaultClient() + if client.StaticFS == nil { + return nil, fs.ErrNotExist + } + if !strings.HasPrefix(path, "static/") { + return nil, fs.ErrNotExist + } + // Strip "static/" prefix since the FS is already sub'd to the static directory + relativePath := strings.TrimPrefix(path, "static/") + return fs.ReadFile(client.StaticFS, relativePath) +} + +// OpenStaticFile opens a file from the embedded static filesystem. +// The path MUST start with "static/" (e.g., "static/config.json"). +// Returns an fs.File or an error if the file doesn't exist or can't be opened. +func OpenStaticFile(path string) (fs.File, error) { + client := engine.GetDefaultClient() + if client.StaticFS == nil { + return nil, fs.ErrNotExist + } + if !strings.HasPrefix(path, "static/") { + return nil, fs.ErrNotExist + } + // Strip "static/" prefix since the FS is already sub'd to the static directory + relativePath := strings.TrimPrefix(path, "static/") + return client.StaticFS.Open(relativePath) +}