From db0094d6fa1c144d0e4a84458571f70f37b91e00 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 14 Nov 2025 16:49:22 -0800 Subject: [PATCH 1/6] working on a files tab impl... --- frontend/builder/builder-apppanel.tsx | 20 +- frontend/builder/tabs/builder-filestab.tsx | 327 ++++++++++++++++++++- 2 files changed, 332 insertions(+), 15 deletions(-) diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index a0f0523a2a..bc66d87d0d 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, 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, PublishAppModal, RenameFileModal }; diff --git a/frontend/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx index 7596ac912e..33c0260c04 100644 --- a/frontend/builder/tabs/builder-filestab.tsx +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -1,16 +1,335 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { memo } from "react"; +import { Modal } from "@/app/modals/modal"; +import { modalsModel } from "@/app/store/modalmodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms } from "@/store/global"; +import { useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; + +const MaxFileSize = 5 * 1024 * 1024; // 5MB + +type FileEntry = { + name: string; + size: number; + modified: string; + isReadOnly: boolean; +}; + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +const RenameFileModal = memo(({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { + const [newName, setNewName] = useState(fileName); + 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 === fileName) { + modalsModel.popModal(); + return; + } + + setIsRenaming(true); + try { + await RpcApi.RenameAppFileCommand(TabRpcClient, { + appid: appId, + fromfilename: fileName, + tofilename: trimmedName, + }); + onSuccess(); + modalsModel.popModal(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsRenaming(false); + } + }; + + const handleClose = () => { + modalsModel.popModal(); + }; + + return ( + +
+

Rename File

+
+ { + 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} + /> + {error &&
{error}
} +
+
+
+ ); +}); + +RenameFileModal.displayName = "RenameFileModal"; 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: entry.name === "static/tw.css", + })) + .sort((a, b) => a.name.localeCompare(b.name)); + setFiles(fileEntries); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [builderAppId]); + + 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 base64 = btoa(String.fromCharCode(...uint8Array)); + + await RpcApi.WriteAppFileCommand(TabRpcClient, { + appid: builderAppId, + filename: `static/${file.name}`, + data64: base64, + }); + + await loadFiles(); + } catch (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) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, fileName }); + }; + + const handleDelete = async (fileName: string, isReadOnly: boolean) => { + if (!builderAppId || isReadOnly) return; + + setContextMenu(null); + setError(""); + try { + await RpcApi.DeleteAppFileCommand(TabRpcClient, { + appid: builderAppId, + filename: fileName, + }); + await loadFiles(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleRename = (fileName: string, isReadOnly: boolean) => { + if (isReadOnly) return; + setContextMenu(null); + modalsModel.pushModal("RenameFileModal", { appId: builderAppId, fileName, onSuccess: loadFiles }); + }; + 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}
+
+ ))} +
+ )} +
+ + {contextMenu && ( +
+ + +
+ )}
); }); BuilderFilesTab.displayName = "BuilderFilesTab"; -export { BuilderFilesTab }; \ No newline at end of file +export { BuilderFilesTab, RenameFileModal }; \ No newline at end of file From 9a93871b1f5a2f618065ac00a5bafde6539d9dea Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 14 Nov 2025 16:59:19 -0800 Subject: [PATCH 2/6] add read/open static path --- package-lock.json | 4 ++-- tsunami/app/defaultclient.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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/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) +} From ec05927d8a43df59e5e3bef859262c95ffcc032d Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 14 Nov 2025 18:32:38 -0800 Subject: [PATCH 3/6] inject staticfile info into the chat as well --- pkg/aiusechat/openai/openai-convertmessage.go | 49 ++++++++--------- pkg/aiusechat/uctypes/usechat-types.go | 2 +- pkg/aiusechat/usechat.go | 53 +++++++++++++++---- 3 files changed, 66 insertions(+), 38 deletions(-) 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 +} From 467ddbfd201d94ebd9575a14e39b272b113774e0 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 18 Nov 2025 10:36:30 -0800 Subject: [PATCH 4/6] improved file operations (delete, rename, add). fixed modals and contextmenu --- frontend/app/modals/modalregistry.tsx | 4 +- frontend/builder/builder-apppanel.tsx | 4 +- frontend/builder/tabs/builder-filestab.tsx | 313 ++++++++++++--------- frontend/util/util.ts | 5 + 4 files changed, 193 insertions(+), 133 deletions(-) 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 bc66d87d0d..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, RenameFileModal } 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"; @@ -358,4 +358,4 @@ const BuilderAppPanel = memo(() => { BuilderAppPanel.displayName = "BuilderAppPanel"; -export { BuilderAppPanel, PublishAppModal, RenameFileModal }; +export { BuilderAppPanel, DeleteFileModal, PublishAppModal, RenameFileModal }; diff --git a/frontend/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx index 33c0260c04..205aa54283 100644 --- a/frontend/builder/tabs/builder-filestab.tsx +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 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"; @@ -26,79 +28,158 @@ function formatFileSize(bytes: number): string { return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; } -const RenameFileModal = memo(({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { - const [newName, setNewName] = useState(fileName); - 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 === fileName) { +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; - } - - setIsRenaming(true); - try { - await RpcApi.RenameAppFileCommand(TabRpcClient, { - appid: appId, - fromfilename: fileName, - tofilename: trimmedName, - }); - onSuccess(); - modalsModel.popModal(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setIsRenaming(false); - } - }; + }; + + 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}
} +
+
+
+ ); + } +); - const handleClose = () => { - modalsModel.popModal(); - }; +RenameFileModal.displayName = "RenameFileModal"; - return ( - -
-

Rename File

-
- { - 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} - /> +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}
}
-
- - ); -}); + + ); + } +); -RenameFileModal.displayName = "RenameFileModal"; +DeleteFileModal.displayName = "DeleteFileModal"; const BuilderFilesTab = memo(() => { const builderAppId = useAtomValue(atoms.builderAppId); @@ -111,7 +192,7 @@ const BuilderFilesTab = memo(() => { const loadFiles = useCallback(async () => { if (!builderAppId) return; - + setLoading(true); setError(""); try { @@ -160,16 +241,17 @@ const BuilderFilesTab = memo(() => { try { const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); - const base64 = btoa(String.fromCharCode(...uint8Array)); + const base64Encoded = arrayToBase64(uint8Array); await RpcApi.WriteAppFileCommand(TabRpcClient, { appid: builderAppId, filename: `static/${file.name}`, - data64: base64, + data64: base64Encoded, }); await loadFiles(); } catch (err) { + console.error("Error uploading file:", err); setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); @@ -199,30 +281,25 @@ const BuilderFilesTab = memo(() => { }; const handleContextMenu = (e: React.MouseEvent, fileName: string) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, fileName }); - }; - - const handleDelete = async (fileName: string, isReadOnly: boolean) => { - if (!builderAppId || isReadOnly) return; - - setContextMenu(null); - setError(""); - try { - await RpcApi.DeleteAppFileCommand(TabRpcClient, { - appid: builderAppId, - filename: fileName, - }); - await loadFiles(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; - - const handleRename = (fileName: string, isReadOnly: boolean) => { - if (isReadOnly) return; - setContextMenu(null); - modalsModel.pushModal("RenameFileModal", { appId: builderAppId, fileName, onSuccess: loadFiles }); + 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 ( @@ -244,12 +321,7 @@ const BuilderFilesTab = memo(() => { Add File - +
{error && ( @@ -276,7 +348,7 @@ const BuilderFilesTab = memo(() => { {files.map((file) => (
!file.isReadOnly && handleContextMenu(e, file.name)} > @@ -293,43 +365,24 @@ const BuilderFilesTab = memo(() => {
{file.modified}
+ {!file.isReadOnly && ( + + )} ))} )} - - {contextMenu && ( -
- - -
- )} ); }); BuilderFilesTab.displayName = "BuilderFilesTab"; -export { BuilderFilesTab, RenameFileModal }; \ 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, From 5602abdbced3aee1613feee7baf58d10832f73e6 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 18 Nov 2025 10:40:40 -0800 Subject: [PATCH 5/6] add manual refresh button --- frontend/builder/tabs/builder-filestab.tsx | 33 ++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx index 205aa54283..5775ad75bd 100644 --- a/frontend/builder/tabs/builder-filestab.tsx +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -214,6 +214,13 @@ const BuilderFilesTab = memo(() => { } }, [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]); @@ -313,14 +320,24 @@ const BuilderFilesTab = memo(() => { >

Static Files

- +
+ + +
From 07ce737c549513ddd5cca8f20a08f2f9b02e2e70 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 18 Nov 2025 10:45:45 -0800 Subject: [PATCH 6/6] minor fixes --- .vscode/settings.json | 2 +- frontend/builder/tabs/builder-filestab.tsx | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) 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/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx index 5775ad75bd..1056646b33 100644 --- a/frontend/builder/tabs/builder-filestab.tsx +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -1,6 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +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"; @@ -12,6 +13,7 @@ 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; @@ -20,14 +22,6 @@ type FileEntry = { isReadOnly: boolean; }; -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; -} - const RenameFileModal = memo( ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { const displayName = fileName.replace("static/", ""); @@ -203,7 +197,7 @@ const BuilderFilesTab = memo(() => { name: entry.name, size: entry.size || 0, modified: entry.modified, - isReadOnly: entry.name === "static/tw.css", + isReadOnly: ReadOnlyFileNames.includes(entry.name), })) .sort((a, b) => a.name.localeCompare(b.name)); setFiles(fileEntries);