mirror of
https://github.com/khairul169/code-share.git
synced 2026-10-24 11:53:58 +07:00
feat: add sandbox
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import Ansi from "ansi-to-react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useProjectContext } from "../context/project";
|
||||
import { api } from "~/lib/api";
|
||||
import Spinner from "~/components/ui/spinner";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BASE_URL } from "~/lib/consts";
|
||||
import { useSSE } from "~/hooks/useSSE";
|
||||
import Divider from "~/components/ui/divider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { FaCopy, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||
import ActionButton from "~/components/ui/action-button";
|
||||
import { copy, getUrl } from "~/lib/utils";
|
||||
|
||||
const APIManager = () => {
|
||||
const { project } = useProjectContext();
|
||||
|
||||
const stats = useQuery({
|
||||
queryKey: ["sandbox/stats", project.slug],
|
||||
queryFn: () => api(`/sandbox/${project.slug}/stats`),
|
||||
refetchInterval: 5000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: () => api(`/sandbox/${project.slug}/start`, { method: "POST" }),
|
||||
onSuccess: () => stats.refetch(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stats.error && (stats.error as any).code === 404 && start.isIdle) {
|
||||
start.mutate();
|
||||
}
|
||||
}, [stats.error, start.isIdle]);
|
||||
|
||||
const onRetry = () => {
|
||||
if (start.isError) {
|
||||
start.mutate();
|
||||
} else if (!stats.data) {
|
||||
stats.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
if (stats.isLoading || start.isPending) {
|
||||
return (
|
||||
<div className="p-8 h-full flex flex-col items-center justify-center">
|
||||
<Spinner />
|
||||
<p>
|
||||
{start.isPending
|
||||
? "Starting up development sandbox..."
|
||||
: "Please wait..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats.data || start.isError) {
|
||||
return (
|
||||
<div className="p-8 h-full flex flex-col items-center justify-center">
|
||||
<p>Cannot load dev sandbox :(</p>
|
||||
{start.error?.message ? (
|
||||
<p className="text-sm mt-2">{start.error.message}</p>
|
||||
) : null}
|
||||
|
||||
<Button onClick={onRetry} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-2 h-full flex flex-col">
|
||||
<div className="flex gap-4 items-start">
|
||||
<Stats data={stats.data.result} />
|
||||
<Actions stats={stats} />
|
||||
</div>
|
||||
<Divider className="my-2" />
|
||||
|
||||
<p className="text-sm mb-1">Output:</p>
|
||||
<Logs />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Actions = ({ stats }: any) => {
|
||||
const { project } = useProjectContext();
|
||||
const restart = useMutation({
|
||||
mutationFn: () => {
|
||||
return api(`/sandbox/${project.slug}/restart`, { method: "POST" });
|
||||
},
|
||||
onSuccess: () => stats.refetch(),
|
||||
});
|
||||
const proxyUrl = `/api/sandbox/${project.slug}/proxy`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => restart.mutate()}
|
||||
isLoading={restart.isPending}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<ActionButton
|
||||
icon={FaCopy}
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => copy(proxyUrl)}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={FaExternalLinkAlt}
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => window.open(getUrl(proxyUrl), "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Stats = ({ data }: any) => {
|
||||
const { cpu, mem, memUsage, network, status, addr } = data;
|
||||
const [memUsed, memTotal] = memUsage || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-sm flex-1">
|
||||
<p>Status: {status}</p>
|
||||
<p>Address: {addr}</p>
|
||||
<p>CPU: {cpu}%</p>
|
||||
<p>
|
||||
Memory: {memUsed != null ? `${memUsed} / ${memTotal} (${mem}%)` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Logs = () => {
|
||||
const { project } = useProjectContext();
|
||||
const url = BASE_URL + `/api/sandbox/${project.slug}/logs`;
|
||||
const [logs, setLogs] = useState<{ log: string; time: number; id: string }[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
function onData(data: any) {
|
||||
setLogs((l) => [
|
||||
{ ...data, log: data.log.replace(/[^\x00-\x7F]/g, ""), id: createId() },
|
||||
...l,
|
||||
]);
|
||||
}
|
||||
|
||||
useSSE(url, onData);
|
||||
useEffect(() => {
|
||||
setLogs([]);
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 shrink-0 bg-gray-900 p-4 overflow-y-auto flex flex-col-reverse gap-2 rounded-lg text-sm relative">
|
||||
<ActionButton
|
||||
icon={FaTimes}
|
||||
className="absolute top-1 right-2"
|
||||
onClick={() => setLogs([])}
|
||||
/>
|
||||
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="border-t last:border-t-0 border-t-gray-800 pt-2"
|
||||
>
|
||||
{log.log.split("\n").map((line, idx) => (
|
||||
<Ansi key={idx} className="block">
|
||||
{line}
|
||||
</Ansi>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIManager;
|
||||
@@ -13,6 +13,7 @@ import FormErrorMessage from "../../../../components/ui/form-error-message";
|
||||
import trpc from "~/lib/trpc";
|
||||
import type { FileSchema } from "~/server/db/schema/file";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useProjectContext } from "../context/project";
|
||||
|
||||
type Props = {
|
||||
disclose: UseDiscloseReturn<CreateFileSchema>;
|
||||
@@ -33,6 +34,7 @@ const defaultValues: z.infer<typeof fileSchema> = {
|
||||
};
|
||||
|
||||
const CreateFileDialog = ({ disclose, onSuccess }: Props) => {
|
||||
const { project } = useProjectContext();
|
||||
const form = useForm(fileSchema, disclose.data || defaultValues);
|
||||
const isDir = useWatch({ name: "isDirectory", control: form.control });
|
||||
|
||||
@@ -55,9 +57,9 @@ const CreateFileDialog = ({ disclose, onSuccess }: Props) => {
|
||||
}
|
||||
|
||||
if (values.id) {
|
||||
update.mutate({ id: values.id!, ...values });
|
||||
update.mutate({ ...values, id: values.id!, projectId: project.id });
|
||||
} else {
|
||||
create.mutate(values);
|
||||
create.mutate({ ...values, projectId: project.id });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import { useData } from "~/renderer/hooks";
|
||||
import { Data } from "../+data";
|
||||
import { useBreakpoint } from "~/hooks/useBreakpoint";
|
||||
import StatusBar from "./status-bar";
|
||||
import { FiTerminal } from "react-icons/fi";
|
||||
import { FiServer, FiTerminal } from "react-icons/fi";
|
||||
import SettingsDialog from "./settings-dialog";
|
||||
import FileIcon from "~/components/ui/file-icon";
|
||||
import APIManager from "./api-manager";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
const Editor = () => {
|
||||
const { project, initialFiles } = useData<Data>();
|
||||
@@ -71,6 +73,11 @@ const Editor = () => {
|
||||
}
|
||||
}, [openedFilesData.data]);
|
||||
|
||||
useEffect(() => {
|
||||
// start API sandbox
|
||||
api(`/sandbox/${project.slug}/start`, { method: "POST" }).catch(() => {});
|
||||
}, [project]);
|
||||
|
||||
const onOpenFile = useCallback(
|
||||
(fileId: number, autoSwitchTab = true) => {
|
||||
const idx = curOpenFiles.indexOf(fileId);
|
||||
@@ -147,6 +154,13 @@ const Editor = () => {
|
||||
});
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
title: "API",
|
||||
icon: <FiServer />,
|
||||
render: () => <APIManager />,
|
||||
locked: true,
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}, [curOpenFiles, openedFiles, breakpoint]);
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
return updateFile.mutate({
|
||||
projectId: file.projectId,
|
||||
id: file.id,
|
||||
isPinned: !file.isPinned,
|
||||
});
|
||||
|
||||
@@ -5,12 +5,14 @@ import { useData } from "~/renderer/hooks";
|
||||
import { Data } from "../+data";
|
||||
import Spinner from "~/components/ui/spinner";
|
||||
import { previewStore } from "../stores/web-preview";
|
||||
import { useProjectContext } from "../context/project";
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
const FileViewer = ({ id }: Props) => {
|
||||
const { project } = useProjectContext();
|
||||
const { initialFiles } = useData<Data>();
|
||||
const initialData = initialFiles.find((i) => i.id === id);
|
||||
|
||||
@@ -48,7 +50,9 @@ const FileViewer = ({ id }: Props) => {
|
||||
lang={ext}
|
||||
value={data?.content || ""}
|
||||
formatOnSave
|
||||
onChange={(val) => updateFileContent.mutate({ id, content: val })}
|
||||
onChange={(val) =>
|
||||
updateFileContent.mutate({ projectId: project.id, id, content: val })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Panel from "~/components/ui/panel";
|
||||
import { ComponentProps, useCallback, useEffect, useRef } from "react";
|
||||
import { useProjectContext } from "../context/project";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { FaRedo } from "react-icons/fa";
|
||||
import { FaExternalLinkAlt, FaRedo } from "react-icons/fa";
|
||||
import { previewStore } from "../stores/web-preview";
|
||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||
import useCommandKey from "~/hooks/useCommandKey";
|
||||
@@ -75,6 +75,13 @@ const WebPreview = ({ url, ...props }: WebPreviewProps) => {
|
||||
>
|
||||
<FaRedo />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="dark:hover:bg-slate-700"
|
||||
onClick={() => window.open(url || "#", "_blank")}
|
||||
>
|
||||
<FaExternalLinkAlt />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{url != null ? (
|
||||
|
||||
Reference in New Issue
Block a user