mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-11-24 03:35:17 +03:00
Merge branch 'wasp-ai' of github.com:wasp-lang/wasp into wasp-ai
This commit is contained in:
commit
934bb77ec8
@ -3,7 +3,13 @@ app waspAi {
|
||||
version: "^0.10.6"
|
||||
},
|
||||
title: "wasp-ai",
|
||||
dependencies: [("uuid", "^9.0.0"), ("prismjs", "^1.29.0")],
|
||||
dependencies: [
|
||||
("uuid", "^9.0.0"),
|
||||
("prismjs", "^1.29.0"),
|
||||
("react-accessible-treeview", "2.6.1"),
|
||||
("react-icons", "4.9.0"),
|
||||
("@zip.js/zip.js", "2.7.16")
|
||||
],
|
||||
client: {
|
||||
rootComponent: import { RootComponent } from "@client/RootComponent.jsx",
|
||||
}
|
||||
|
@ -3,14 +3,15 @@
|
||||
@tailwind utilities;
|
||||
|
||||
.button {
|
||||
@apply bg-yellow-400 text-white font-bold py-2 px-4 rounded;
|
||||
@apply bg-yellow-400 text-yellow-900 font-bold py-2 px-4 rounded;
|
||||
}
|
||||
.button.gray {
|
||||
@apply bg-gray-300 text-gray-700;
|
||||
@apply bg-slate-200 text-slate-600;
|
||||
}
|
||||
.button:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
input, textarea {
|
||||
@apply shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none;
|
||||
input,
|
||||
textarea {
|
||||
@apply border border-gray-300 rounded py-2 px-4 bg-slate-50;
|
||||
}
|
@ -13,14 +13,14 @@ export function CodeHighlight(props = {}) {
|
||||
...others
|
||||
} = props;
|
||||
const langCls = language ? `language-${language}` : "";
|
||||
async function highlight() {
|
||||
function highlight() {
|
||||
if (codeRef.current) {
|
||||
Prism.highlightElement(codeRef.current);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
highlight();
|
||||
}, [language, source]);
|
||||
});
|
||||
return (
|
||||
<pre
|
||||
className={`${prefixCls} ${className || ""} ${langCls}`}
|
||||
|
42
wasp-ai/src/client/components/FileTree.css
Normal file
42
wasp-ai/src/client/components/FileTree.css
Normal file
@ -0,0 +1,42 @@
|
||||
.directory {
|
||||
@apply p-5 font-mono text-base bg-slate-900 text-white rounded;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.directory .tree,
|
||||
.directory .tree-node,
|
||||
.directory .tree-node-group {
|
||||
@apply p-0 m-0 list-none;
|
||||
}
|
||||
|
||||
.directory .tree-branch-wrapper,
|
||||
.directory .tree-node__leaf {
|
||||
outline: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.directory .tree-node {
|
||||
cursor: pointer;
|
||||
@apply py-1;
|
||||
}
|
||||
|
||||
.directory .tree-node:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.directory .tree .tree-node--focused {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.directory .tree .tree-node--selected {
|
||||
@apply text-white rounded-sm bg-slate-700;
|
||||
}
|
||||
|
||||
.tree-node__branch, .tree-node__leaf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.directory .icon {
|
||||
@apply w-6 h-6 pr-2;
|
||||
}
|
110
wasp-ai/src/client/components/FileTree.jsx
Normal file
110
wasp-ai/src/client/components/FileTree.jsx
Normal file
@ -0,0 +1,110 @@
|
||||
// @ts-check
|
||||
import React, { useMemo } from "react";
|
||||
import "./FileTree.css";
|
||||
|
||||
import TreeView, { flattenTree } from "react-accessible-treeview";
|
||||
import { DiCss3, DiJavascript, DiNpm, DiReact } from "react-icons/di";
|
||||
import { FaList, FaRegFolder, FaRegFolderOpen } from "react-icons/fa";
|
||||
import { WaspIcon } from "./WaspIcon";
|
||||
|
||||
export function FileTree({ paths, activeFilePath, onActivePathSelect }) {
|
||||
const tree = useMemo(() => {
|
||||
const root = { name: "", children: [] };
|
||||
paths.forEach((path) => {
|
||||
const pathParts = path.split("/");
|
||||
let currentLevel = root;
|
||||
pathParts.forEach((part, index) => {
|
||||
const existingPath = currentLevel.children.find(
|
||||
(child) => child.name === part
|
||||
);
|
||||
if (existingPath) {
|
||||
currentLevel = existingPath;
|
||||
} else {
|
||||
const metadata = index === pathParts.length - 1 ? { path } : {};
|
||||
const newPart = { name: part, children: [], metadata };
|
||||
currentLevel.children.push(newPart);
|
||||
currentLevel = newPart;
|
||||
}
|
||||
});
|
||||
});
|
||||
return root;
|
||||
}, [paths]);
|
||||
|
||||
return (
|
||||
<DirectoryTreeView
|
||||
tree={tree}
|
||||
onNodeSelect={(props) => {
|
||||
if (props.element.metadata.path) {
|
||||
onActivePathSelect(props.element.metadata.path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryTreeView({ tree, onNodeSelect }) {
|
||||
const data = useMemo(() => {
|
||||
return flattenTree(tree);
|
||||
}, [tree]);
|
||||
const allIds = useMemo(() => {
|
||||
return data.map((node) => node.id);
|
||||
}, [data]);
|
||||
return (
|
||||
<div>
|
||||
<div className="directory">
|
||||
<TreeView
|
||||
data={data}
|
||||
defaultExpandedIds={allIds}
|
||||
aria-label="directory tree"
|
||||
onNodeSelect={onNodeSelect}
|
||||
nodeRenderer={({
|
||||
element,
|
||||
isBranch,
|
||||
isExpanded,
|
||||
getNodeProps,
|
||||
level,
|
||||
}) => (
|
||||
<div {...getNodeProps()} style={{ paddingLeft: `calc(0.5rem + ${20 * (level - 1)}px)` }}>
|
||||
{isBranch ? (
|
||||
<FolderIcon isOpen={isExpanded} />
|
||||
) : (
|
||||
<FileIcon filename={element.name} />
|
||||
)}
|
||||
|
||||
{element.name}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FolderIcon = ({ isOpen }) =>
|
||||
isOpen ? (
|
||||
<FaRegFolderOpen color="e8a87c" className="icon" />
|
||||
) : (
|
||||
<FaRegFolder color="e8a87c" className="icon" />
|
||||
);
|
||||
|
||||
const FileIcon = ({ filename }) => {
|
||||
const extension = filename.slice(filename.lastIndexOf(".") + 1);
|
||||
switch (extension) {
|
||||
case "js":
|
||||
case "ts":
|
||||
return <DiJavascript color="yellow" className="icon" />;
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
return <DiReact color="turquoise" className="icon" />;
|
||||
case "css":
|
||||
return <DiCss3 color="turquoise" className="icon" />;
|
||||
case "json":
|
||||
return <FaList color="yellow" className="icon" />;
|
||||
case "npmignore":
|
||||
return <DiNpm color="red" className="icon" />;
|
||||
case "wasp":
|
||||
return <WaspIcon className="icon" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
64
wasp-ai/src/client/components/Loader.css
Normal file
64
wasp-ai/src/client/components/Loader.css
Normal file
@ -0,0 +1,64 @@
|
||||
:root {
|
||||
--loader-size: 20px;
|
||||
}
|
||||
.loader-1 {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
animation: loader-1-1 4.8s linear infinite;
|
||||
}
|
||||
@keyframes loader-1-1 {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.loader-1 span {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
clip: rect(0, 20px, 20px, 10px);
|
||||
animation: loader-1-2 1.2s linear infinite;
|
||||
}
|
||||
@keyframes loader-1-2 {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(220deg);
|
||||
}
|
||||
}
|
||||
.loader-1 span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
clip: rect(0, 20px, 20px, 10px);
|
||||
border: 3px solid;
|
||||
@apply border-slate-800;
|
||||
border-radius: 50%;
|
||||
animation: loader-1-3 1.2s cubic-bezier(0.77, 0, 0.175, 1) infinite;
|
||||
}
|
||||
@keyframes loader-1-3 {
|
||||
0% {
|
||||
transform: rotate(-140deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-160deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(140deg);
|
||||
}
|
||||
}
|
9
wasp-ai/src/client/components/Loader.jsx
Normal file
9
wasp-ai/src/client/components/Loader.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
import "./Loader.css";
|
||||
|
||||
export function Loader() {
|
||||
return (
|
||||
<div className="loader-1">
|
||||
<span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
60
wasp-ai/src/client/components/WaspIcon.jsx
Normal file
60
wasp-ai/src/client/components/WaspIcon.jsx
Normal file
@ -0,0 +1,60 @@
|
||||
export function WaspIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
viewBox="0 0 161 161"
|
||||
width="1em"
|
||||
height="1em"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<style>{".cls-2{fill-rule:evenodd}"}</style>
|
||||
</defs>
|
||||
<g id="Page-1">
|
||||
<g id="Group-23">
|
||||
<circle
|
||||
id="Oval"
|
||||
cx={80.5}
|
||||
cy={80.5}
|
||||
r={79}
|
||||
style={{
|
||||
fill: "#f5cc05",
|
||||
}}
|
||||
/>
|
||||
<g id="Group-36">
|
||||
<g id="_" data-name="}">
|
||||
<path
|
||||
id="path-2"
|
||||
d="M88.67 114.33h2.91q6 0 7.87-1.89c1.22-1.25 1.83-3.9 1.83-7.93V93.89c0-4.46.65-7.7 1.93-9.73s3.51-3.43 6.67-4.2q-4.69-1.08-6.65-4.12c-1.3-2-2-5.28-2-9.77V55.44q0-6-1.83-7.93t-7.87-1.88h-2.86V39.5h2.65q10.65 0 14.24 3.15t3.59 12.62v10.29c0 4.28.77 7.24 2.29 8.87s4.3 2.44 8.32 2.44h2.74V83h-2.74q-6 0-8.32 2.49c-1.52 1.65-2.29 4.64-2.29 9v10.25q0 9.47-3.59 12.64t-14.24 3.12h-2.65Z"
|
||||
className="cls-2"
|
||||
/>
|
||||
<path
|
||||
id="path-2-2"
|
||||
d="M88.67 114.33h2.91q6 0 7.87-1.89c1.22-1.25 1.83-3.9 1.83-7.93V93.89c0-4.46.65-7.7 1.93-9.73s3.51-3.43 6.67-4.2q-4.69-1.08-6.65-4.12c-1.3-2-2-5.28-2-9.77V55.44q0-6-1.83-7.93t-7.87-1.88h-2.86V39.5h2.65q10.65 0 14.24 3.15t3.59 12.62v10.29c0 4.28.77 7.24 2.29 8.87s4.3 2.44 8.32 2.44h2.74V83h-2.74q-6 0-8.32 2.49c-1.52 1.65-2.29 4.64-2.29 9v10.25q0 9.47-3.59 12.64t-14.24 3.12h-2.65Z"
|
||||
className="cls-2"
|
||||
data-name="path-2"
|
||||
/>
|
||||
</g>
|
||||
<g id="text831">
|
||||
<g id="_2" data-name="=">
|
||||
<path
|
||||
id="path-3"
|
||||
d="M38.5 85.15h37.33v7.58H38.5Zm0-17.88h37.33v7.49H38.5Z"
|
||||
className="cls-2"
|
||||
/>
|
||||
<path
|
||||
id="path-3-2"
|
||||
d="M38.5 85.15h37.33v7.58H38.5Zm0-17.88h37.33v7.49H38.5Z"
|
||||
className="cls-2"
|
||||
data-name="path-3"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -4,6 +4,9 @@ import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp";
|
||||
import getAppGenerationResult from "@wasp/queries/getAppGenerationResult";
|
||||
import { useQuery } from "@wasp/queries";
|
||||
import { CodeHighlight } from "../components/CodeHighlight";
|
||||
import { FileTree } from "../components/FileTree";
|
||||
import { Loader } from "../components/Loader";
|
||||
import { createFilesAndDownloadZip } from "../zip/zipHelpers";
|
||||
|
||||
const MainPage = () => {
|
||||
const [appName, setAppName] = useState("");
|
||||
@ -28,7 +31,9 @@ const MainPage = () => {
|
||||
|
||||
const logs = appGenerationResult?.messages
|
||||
.filter((m) => m.type === "log")
|
||||
.map((m) => m.text);
|
||||
.map((m) => m.text)
|
||||
.reverse();
|
||||
|
||||
let files = {};
|
||||
{
|
||||
appGenerationResult?.messages
|
||||
@ -68,6 +73,35 @@ const MainPage = () => {
|
||||
}
|
||||
}, [activeFilePath]);
|
||||
|
||||
const interestingFilePaths = useMemo(() => {
|
||||
if (files) {
|
||||
return Object.keys(files)
|
||||
.filter(
|
||||
(path) =>
|
||||
path !== ".env.server" &&
|
||||
path !== ".env.client" &&
|
||||
path !== "src/client/vite-env.d.ts" &&
|
||||
path !== "src/client/tsconfig.json" &&
|
||||
path !== "src/server/tsconfig.json" &&
|
||||
path !== "src/shared/tsconfig.json" &&
|
||||
path !== ".gitignore" &&
|
||||
path !== "src/.waspignore" &&
|
||||
path !== ".wasproot"
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.endsWith(".wasp") ? 0 : 1) - (b.endsWith(".wasp") ? 0 : 1)
|
||||
);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
function downloadZip() {
|
||||
const safeAppName = appName.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
createFilesAndDownloadZip(files, safeAppName);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div
|
||||
@ -111,10 +145,9 @@ const MainPage = () => {
|
||||
disabled={appId}
|
||||
/>
|
||||
</div>
|
||||
<button className="button" disabled={appId}>
|
||||
<button className="button mr-2" disabled={appId}>
|
||||
Generate
|
||||
</button>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={appId}
|
||||
@ -123,79 +156,99 @@ const MainPage = () => {
|
||||
>
|
||||
Fill in with example app details
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{appId && !generationDone && (
|
||||
<div className="mt-8 mb-4 p-4 bg-slate-100 rounded">Generating...</div>
|
||||
)}
|
||||
|
||||
{logs && logs.length > 0 && (
|
||||
{interestingFilePaths.length > 0 && (
|
||||
<>
|
||||
<h2
|
||||
<header
|
||||
className="
|
||||
text-xl
|
||||
font-bold
|
||||
text-gray-800
|
||||
mt-8
|
||||
mb-4
|
||||
mb-2
|
||||
flex
|
||||
justify-between
|
||||
items-center
|
||||
"
|
||||
>
|
||||
Logs
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{logs.map((log, i) => (
|
||||
<pre key={i} className="p-4 bg-slate-100 rounded">
|
||||
{log}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{files && Object.keys(files).length > 0 && (
|
||||
<>
|
||||
<h2
|
||||
className="
|
||||
text-xl
|
||||
font-bold
|
||||
text-gray-800
|
||||
mt-8
|
||||
mb-4
|
||||
"
|
||||
>
|
||||
Files
|
||||
</h2>
|
||||
<div className="grid gap-4 grid-cols-[300px_minmax(900px,_1fr)_100px]">
|
||||
<aside className="bg-slate-100 p-4 rounded flex flex-col gap-2 sticky top-0">
|
||||
{Object.keys(files).map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className={
|
||||
"px-4 py-2 bg-slate-200 rounded cursor-pointer " +
|
||||
(activeFilePath === path ? "bg-yellow-400" : "")
|
||||
}
|
||||
onClick={() => setActiveFilePath(path)}
|
||||
className="
|
||||
flex
|
||||
items-center
|
||||
|
||||
"
|
||||
>
|
||||
<div className="font-bold">{path}</div>
|
||||
<h2
|
||||
className="
|
||||
text-xl
|
||||
font-bold
|
||||
text-gray-800
|
||||
mr-2
|
||||
"
|
||||
>
|
||||
{appName}
|
||||
</h2>
|
||||
{appId && !generationDone && <Loader />}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button
|
||||
className="button"
|
||||
disabled={!generationDone}
|
||||
onClick={downloadZip}
|
||||
>
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid gap-4 grid-cols-[300px_minmax(900px,_1fr)_100px]">
|
||||
<aside>
|
||||
<FileTree
|
||||
paths={interestingFilePaths}
|
||||
activeFilePath={activeFilePath}
|
||||
onActivePathSelect={setActiveFilePath}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{activeFilePath && (
|
||||
<main className="flex flex-col gap-4">
|
||||
<div
|
||||
key={activeFilePath}
|
||||
className="px-4 py-2 bg-slate-100 rounded"
|
||||
>
|
||||
<main className="flex flex-col gap-2">
|
||||
<div className="font-bold">{activeFilePath}:</div>
|
||||
<div key={activeFilePath} className="py-4 bg-slate-100 rounded">
|
||||
<CodeHighlight language={language}>
|
||||
{files[activeFilePath]}
|
||||
{files[activeFilePath].trim()}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
{!activeFilePath && (
|
||||
<main className="p-8 bg-slate-100 rounded grid place-content-center">
|
||||
<div className="text-center">
|
||||
<div className="font-bold">Select a file to view</div>
|
||||
<div className="text-gray-500 text-sm">
|
||||
(click on a file in the file tree)
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{logs && logs.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-8">
|
||||
{logs.map((log, i) => (
|
||||
/*
|
||||
If log contains "generated" or "Generated"
|
||||
make it green, otherwise make it gray.
|
||||
*/
|
||||
<pre
|
||||
key={i}
|
||||
className={`p-3 rounded text-sm ${
|
||||
log.toLowerCase().includes("generated")
|
||||
? "bg-green-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{log}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
24
wasp-ai/src/client/zip/zipHelpers.js
Normal file
24
wasp-ai/src/client/zip/zipHelpers.js
Normal file
@ -0,0 +1,24 @@
|
||||
import * as zip from "@zip.js/zip.js";
|
||||
|
||||
// Input: object with file paths as keys and file contents as values
|
||||
export function createFilesAndDownloadZip(files, zipName) {
|
||||
const zipWriter = new zip.ZipWriter(new zip.BlobWriter("application/zip"), {
|
||||
bufferedWrite: true,
|
||||
useCompressionStream: false,
|
||||
});
|
||||
|
||||
// Create a file in the zip for each file in the input
|
||||
for (let [path, contents] of Object.entries(files)) {
|
||||
zipWriter.add(path, new zip.TextReader(contents));
|
||||
}
|
||||
|
||||
// Close the zip and get a blob containing the zip contents
|
||||
zipWriter.close().then((blob) => {
|
||||
// Download the zip
|
||||
const zipUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = zipUrl;
|
||||
link.download = `${zipName}.zip`;
|
||||
link.click();
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user