waveterm/frontend/builder/app-selection-modal.tsx
Mike Sawka 347eef289d
more builder updates (#2553)
* load manifest metadata into the FE
* builder only edits draft/ apps (convert local => draft)
* gofmt app.go after saving (AI tools and manual user save)
* dont open duplicate builder windows
* remix app context menu in waveapp
* add icon/iconcolor in appmeta and implement in the wave block frame
2025-11-13 22:47:46 -08:00

233 lines
9.2 KiB
TypeScript

// 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, getApi, globalStore } from "@/store/global";
import * as WOS from "@/store/wos";
import { formatRelativeTime } from "@/util/util";
import { useEffect, useState } from "react";
const MaxAppNameLength = 50;
const AppNameRegex = /^[a-zA-Z0-9_-]+$/;
function CreateNewWaveApp({ onCreateApp }: { onCreateApp: (appName: string) => Promise<void> }) {
const [newAppName, setNewAppName] = useState("");
const [inputError, setInputError] = useState("");
const [isCreating, setIsCreating] = useState(false);
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 handleCreate = async () => {
const trimmedName = newAppName.trim();
if (!validateAppName(trimmedName)) {
return;
}
setIsCreating(true);
try {
await onCreateApp(trimmedName);
} finally {
setIsCreating(false);
}
};
return (
<div className="min-h-[80px]">
<h3 className="text-base font-medium mb-1 text-muted-foreground">Create New WaveApp</h3>
<div className="relative">
<div className="flex w-full">
<input
type="text"
value={newAppName}
onChange={(e) => {
const value = e.target.value;
setNewAppName(value);
validateAppName(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing && newAppName.trim() && !inputError) {
handleCreate();
}
}}
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
disabled={isCreating}
/>
<button
onClick={handleCreate}
disabled={!newAppName.trim() || !!inputError || isCreating}
className={`px-4 py-2 rounded-r transition-colors font-medium whitespace-nowrap ${
!newAppName.trim() || inputError || isCreating
? "bg-panel border border-l-0 border-border text-muted cursor-not-allowed"
: "bg-accent text-black hover:bg-accent-hover cursor-pointer"
}`}
>
Create
</button>
</div>
{inputError && (
<div className="absolute left-0 top-full mt-1 text-xs text-error flex items-center gap-1.5 whitespace-nowrap">
<i className="fa-solid fa-circle-exclamation"></i>
<span>{inputError}</span>
</div>
)}
</div>
</div>
);
}
export function AppSelectionModal() {
const [apps, setApps] = useState<AppInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadApps();
}, []);
const loadApps = async () => {
try {
const appList = await RpcApi.ListAllEditableAppsCommand(TabRpcClient);
const sortedApps = (appList || []).sort((a, b) => b.modtime - a.modtime);
setApps(sortedApps);
} catch (err) {
console.error("Failed to load apps:", err);
setError("Failed to load apps");
} finally {
setLoading(false);
}
};
const handleSelectApp = async (appId: string) => {
let appIdToUse = appId;
// If selecting a local app, convert it to a draft first
if (appId.startsWith("local/")) {
try {
const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId });
appIdToUse = result.draftappid;
} catch (err) {
console.error("Failed to create draft from local app:", err);
setError(`Failed to create draft from ${appId}: ${err.message || String(err)}`);
return;
}
}
const builderId = globalStore.get(atoms.builderId);
const oref = WOS.makeORef("builder", builderId);
await RpcApi.SetRTInfoCommand(TabRpcClient, {
oref,
data: { "builder:appid": appIdToUse },
});
globalStore.set(atoms.builderAppId, appIdToUse);
document.title = `WaveApp Builder (${appIdToUse})`;
getApi().setBuilderWindowAppId(appIdToUse);
};
const handleCreateNew = async (appName: string) => {
const draftAppId = `draft/${appName}`;
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);
document.title = `WaveApp Builder (${draftAppId})`;
getApi().setBuilderWindowAppId(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 (
<FlexiModal className="min-w-[600px] w-[600px]">
<div className="text-center py-8">Loading apps...</div>
</FlexiModal>
);
}
return (
<FlexiModal className="min-w-[600px] w-[600px] max-h-[90vh] overflow-y-auto">
<div className="w-full px-2 pt-0 pb-4">
<h2 className="text-2xl mb-2">Select a WaveApp to Edit</h2>
{error && (
<div className="mb-6 px-4 py-3 bg-panel rounded">
<div className="flex items-center gap-3">
<i className="fa-solid fa-circle-exclamation text-warning"></i>
<span>{error}</span>
</div>
</div>
)}
{apps.length > 0 && (
<div className="mb-2">
<h3 className="text-base font-medium mb-1 text-muted-foreground">Existing WaveApps</h3>
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{apps.map((appInfo) => (
<button
key={appInfo.appid}
onClick={() => handleSelectApp(appInfo.appid)}
className="w-full text-left px-4 py-1.5 bg-panel hover:bg-hover border border-border rounded transition-colors cursor-pointer"
>
<div className="flex items-center gap-3">
<i className="fa-solid fa-cube self-center"></i>
<div className="flex flex-col">
<span>{getAppDisplayName(appInfo.appid)}</span>
<span className="text-[11px] text-muted mt-0.5">
Last updated: {formatRelativeTime(appInfo.modtime)}
</span>
</div>
</div>
</button>
))}
</div>
</div>
)}
{apps.length > 0 && (
<div className="flex items-center gap-4 my-2">
<div className="flex-1 border-t border-border"></div>
<span className="text-muted-foreground text-sm">or</span>
<div className="flex-1 border-t border-border"></div>
</div>
)}
<CreateNewWaveApp onCreateApp={handleCreateNew} />
</div>
</FlexiModal>
);
}