Merge branch 'wasp-ai' of github.com:wasp-lang/wasp into wasp-ai

This commit is contained in:
Martin Sosic 2023-06-20 18:34:43 +02:00
commit 934bb77ec8
10 changed files with 443 additions and 74 deletions

View File

@ -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",
}

View File

@ -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;
}

View File

@ -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}`}

View 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;
}

View 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;
}
};

View 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);
}
}

View File

@ -0,0 +1,9 @@
import "./Loader.css";
export function Loader() {
return (
<div className="loader-1">
<span></span>
</div>
);
}

View 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>
);
}

View File

@ -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>

View 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();
});
}