Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions app/(docs)/@chat/chat/[chatId]/chatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,20 +127,28 @@ export function ChatAreaContent(props: Props) {
<div className="flex-none breadcrumbs text-sm">
<ul className="flex-wrap">
<li>
<Link href={`/${langId}/${langEntry?.pages[0].slug}`}>
{langEntry?.name}
<Link
href={
langEntry?.pages[0]
? `/${langId}/${langEntry.pages[0].slug}`
: `/${langId}/sandbox`
}
>
{langEntry?.name ?? langId}
</Link>
</li>
<li>
<Link href={`/${chatData.section.pagePath}`}>
{pageEntry?.index}. {pageEntry?.name}
</Link>
</li>
<li>
<Link href={`/${chatData.section.pagePath}#${chatData.sectionId}`}>
{targetSection?.title}
{pageEntry ? `${pageEntry.index}. ${pageEntry.name}` : "Sandbox"}
</Link>
</li>
{targetSection?.title && targetSection.title !== "sandbox" && (
<li>
<Link href={`/${chatData.section.pagePath}#${chatData.sectionId}`}>
{targetSection.title}
</Link>
</li>
)}
</ul>
</div>
<div className="flex flex-wrap items-center gap-2">
Expand Down
2 changes: 1 addition & 1 deletion app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function PageContent(props: PageContentProps) {
);
}

function ChatListForSection(props: {
export function ChatListForSection(props: {
dynamicMdContent: DynamicMarkdownSection[];
sectionId: SectionId;
chatHistories: ChatWithMessages[];
Expand Down
55 changes: 55 additions & 0 deletions app/(docs)/@docs/[lang]/sandbox/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { SandboxContent } from "./sandboxContent";
import { getChatFromCache, initContext } from "@/lib/chatHistory";
import { getPagesListForLang, getTermDefinitions, LangId, PageSlug } from "@/lib/docs";
import { TermDefinitionProvider } from "@/markdown/term";
import { DocsAutoRedirect } from "../[pageId]/autoRedirect";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: LangId }>;
}): Promise<Metadata> {
const { lang } = await params;
const langEntry = await getPagesListForLang(lang);
if (!langEntry) notFound();

return {
title: `${langEntry.name} - Sandbox`,
description: `${langEntry.name} のインタラクティブなコード実行サンドボックスです。`,
};
}

export default async function Page({
params,
}: {
params: Promise<{ lang: LangId }>;
}) {
const { lang } = await params;

const langEntry = await getPagesListForLang(lang);
if (!langEntry) notFound();

const path = { lang, page: "sandbox" as PageSlug };
const context = await initContext();
const chatHistories = await getChatFromCache(path, context.userId);
const termDefinitions = await getTermDefinitions(lang);

return (
<>
<TermDefinitionProvider
termDefinitions={termDefinitions}
lang={lang}
page={"sandbox" as PageSlug}
>
<SandboxContent
langId={lang}
path={path}
chatHistories={chatHistories}
/>
</TermDefinitionProvider>
<DocsAutoRedirect path={path} />
</>
);
}
280 changes: 280 additions & 0 deletions app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
"use client";

import { useState, FormEvent, useEffect, useMemo, useRef } from "react";
import { Heading } from "@/markdown/heading";
import { langConstants, RuntimeLang } from "@my-code/runtime/languages";
import { ReplTerminal } from "@/terminal/repl";
import { EditorComponent } from "@/terminal/editor";
import { ExecFile } from "@/terminal/exec";
import { sampleConfig } from "@/terminal/sampleConfig";
import {
DynamicMarkdownSection,
LangId,
PagePath,
SectionId,
} from "@/lib/docs";
import { ChatWithMessages } from "@/lib/chatHistory";
import { ChatForm } from "../[pageId]/chatForm";
import { ChatListForSection } from "../[pageId]/pageContent";
import { usePagesListForLang } from "@/pagesListContext";
import { useEmbedContext } from "@/terminal/embedContext";
import { useSidebarMdContext } from "@/sidebar";

interface SandboxContentProps {
langId: LangId;
path: PagePath;
chatHistories: ChatWithMessages[];
}

export function SandboxContent(props: SandboxContentProps) {
const { langId, path, chatHistories } = props;
const langEntry = usePagesListForLang(langId);
const { setSidebarMdContent } = useSidebarMdContext();
const { writeFile } = useEmbedContext();

const runtimeLang = langId as RuntimeLang;
const config = sampleConfig[runtimeLang];

const [userFiles, setUserFiles] = useState<string[]>([]);
const [newFilename, setNewFilename] = useState("");
const [filenameError, setFilenameError] = useState<string | null>(null);
const [isFormVisible, setIsFormVisible] = useState(false);

// サイドバーの目次用セクション定義
const baseSections = useMemo(() => {
const list: Array<{ id: SectionId; title: string; level: number }> = [];
if (config?.repl) {
list.push({ id: "sandbox-repl" as SectionId, title: "REPL", level: 2 });
}
if (config?.editor || userFiles.length > 0) {
list.push({ id: "sandbox-editor" as SectionId, title: "コード", level: 2 });
}
if (config?.exec) {
list.push({ id: "sandbox-exec" as SectionId, title: "実行", level: 2 });
}
if (config?.readonlyFiles && config.readonlyFiles.length > 0) {
list.push({
id: "sandbox-readonly" as SectionId,
title: "出力ファイル",
level: 2,
});
}
return list;
}, [config, userFiles.length]);

const [sectionInView, setSectionInView] = useState<boolean[]>([]);
const sectionRefs = useRef<Map<string, HTMLElement | null>>(new Map());

useEffect(() => {
const handleScroll = () => {
setSectionInView(
baseSections.map((sec) => {
const el = sectionRefs.current.get(sec.id);
if (el) {
const rect = el.getBoundingClientRect();
return (
rect.top < window.innerHeight * 0.9 &&
rect.bottom >= window.innerHeight * 0.1
);
}
return false;
})
);
};
window.addEventListener("scroll", handleScroll);
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [baseSections]);

const dynamicSections: DynamicMarkdownSection[] = useMemo(() => {
return baseSections.map((sec, i) => ({
id: sec.id,
title: sec.title,
level: sec.level,
file: "sandbox.md",
rawContent: "",
md5: "",
replacedContent: "",
replacedRange: [],
inView: sectionInView[i] ?? false,
}));
}, [baseSections, sectionInView]);

useEffect(() => {
setSidebarMdContent(path, dynamicSections);
}, [dynamicSections, path, setSidebarMdContent]);

const handleAddFile = (e: FormEvent) => {
e.preventDefault();
const name = newFilename.trim();
if (!name) return;

// 既存ファイルチェック
const defaultFiles = config?.editor ? Object.keys(config.editor) : [];
const readonlyFiles = config?.readonlyFiles ?? [];
if (
defaultFiles.includes(name) ||
readonlyFiles.includes(name) ||
userFiles.includes(name)
) {
setFilenameError("同名のファイルがすでに存在します。");
return;
}

setFilenameError(null);
setUserFiles((prev) => [...prev, name]);
writeFile({ [name]: "" });
setNewFilename("");
};

const handleRemoveFile = (filename: string) => {
setUserFiles((prev) => prev.filter((f) => f !== filename));
};

return (
<div className="flex-1 p-4 pb-16 flex flex-col max-w-docs mx-auto w-full">
<div className="flex flex-row items-center justify-between mb-4">
<Heading level={1}>{langEntry?.name ?? langId} Sandbox</Heading>
</div>

<div className="flex flex-col gap-6">
{/* 1. REPL */}
{config?.repl && (
<section
id="sandbox-repl"
ref={(el) => {
sectionRefs.current.set("sandbox-repl", el);
}}
>
<Heading level={2}>REPL</Heading>
<ReplTerminal
terminalId={`sandbox-${langId}`}
language={langConstants(runtimeLang)}
initContent={config.replInitContent}
/>
</section>
)}

{/* 2. エディター (既存ファイル + 追加ファイル + 追加ボタン) */}
{(config?.editor || userFiles.length > 0 || config?.supportsMultiFile) && (
<section
id="sandbox-editor"
ref={(el) => {
sectionRefs.current.set("sandbox-editor", el);
}}
>
<Heading level={2}>コード</Heading>
{config?.editor &&
Object.entries(config.editor).map(([filename, initContent]) => (
<EditorComponent
key={filename}
language={langConstants(runtimeLang)}
filename={filename}
initContent={initContent}
/>
))}

{userFiles.map((filename) => (
<EditorComponent
key={filename}
language={langConstants(runtimeLang)}
filename={filename}
initContent=""
onDelete={() => handleRemoveFile(filename)}
/>
))}

{config?.supportsMultiFile && (
<div className="my-2">
<form onSubmit={handleAddFile} className="flex gap-2">
<input
type="text"
className="input input-bordered input-sm flex-1 font-mono"
placeholder="追加するファイル名を入力 (例: helper.py)"
value={newFilename}
onChange={(e) => {
setNewFilename(e.target.value);
setFilenameError(null);
}}
/>
<button type="submit" className="btn btn-sm btn-secondary">
ファイルを追加
</button>
</form>
{filenameError && (
<p className="text-error text-sm mt-1">{filenameError}</p>
)}
</div>
)}
</section>
)}

{/* 3. 実行 */}
{config?.exec && (
<section
id="sandbox-exec"
ref={(el) => {
sectionRefs.current.set("sandbox-exec", el);
}}
>
<Heading level={2}>実行</Heading>
<ExecFile
filenames={config.exec}
language={langConstants(runtimeLang)}
content=""
/>
</section>
)}

{/* 4. 出力ファイル */}
{config?.readonlyFiles && config.readonlyFiles.length > 0 && (
<section
id="sandbox-readonly"
ref={(el) => {
sectionRefs.current.set("sandbox-readonly", el);
}}
>
<Heading level={2}>出力ファイル</Heading>
{config.readonlyFiles.map((filename) => (
<EditorComponent
key={filename}
language={langConstants(runtimeLang)}
filename={filename}
initContent=""
readonly
/>
))}
</section>
)}

<div className="mt-6">
<ChatListForSection
sectionId={"sandbox" as SectionId}
dynamicMdContent={dynamicSections}
chatHistories={chatHistories}
/>
</div>
</div>

{isFormVisible ? (
<div className="fixed bottom-4 right-4 left-4 has-sidebar:left-[calc(var(--container-sidebar)+1rem)] z-40">
<ChatForm
path={path}
langName={langEntry?.name ?? langId}
sectionContent={dynamicSections}
close={() => setIsFormVisible(false)}
/>
</div>
) : (
<button
className="fixed bottom-4 right-4 btn btn-soft btn-secondary rounded-full shadow-md z-50"
onClick={() => setIsFormVisible(true)}
>
AIに質問
</button>
)}
</div>
);
}
Loading
Loading