mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 13:10:24 +08:00
* 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
233 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|