Layonara Forge — NWN Development IDE
Electron desktop application for Neverwinter Nights module development. Clone, edit, build, and run a complete Layonara NWNX server with only Docker required. - React 19 + Vite frontend with Monaco editor and NWScript LSP - Node.js + Express backend managing Docker sibling containers - Electron shell with Docker availability check and auto-setup - Builder image auto-builds on first use from bundled Dockerfile - Cross-platform: Windows (.exe), macOS (.dmg), Linux (.AppImage) - Gitea Actions CI for automated release builds
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { createServer } from "http";
|
||||
import type { Server } from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocketServer } from "ws";
|
||||
@@ -21,72 +22,82 @@ import { loadTlkIndex } from "./nwscript/tlk-index.js";
|
||||
import { getRepoPath } from "./services/workspace.service.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
export function startServer(port: number): Promise<Server> {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
initWebSocket(server);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
wsClients: getClientCount(),
|
||||
uptime: process.uptime(),
|
||||
initWebSocket(server);
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
wsClients: getClientCount(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api/workspace", workspaceRouter);
|
||||
app.use("/api/docker", dockerRouter);
|
||||
app.use("/api/editor", editorRouter);
|
||||
app.use("/api/terminal", terminalRouter);
|
||||
app.use("/api/build", buildRouter);
|
||||
app.use("/api/server", serverRouter);
|
||||
app.use("/api/toolset", toolsetRouter);
|
||||
app.use("/api/github", githubRouter);
|
||||
app.use("/api/repos", reposRouter);
|
||||
app.use("/api/workspace", workspaceRouter);
|
||||
app.use("/api/docker", dockerRouter);
|
||||
app.use("/api/editor", editorRouter);
|
||||
app.use("/api/terminal", terminalRouter);
|
||||
app.use("/api/build", buildRouter);
|
||||
app.use("/api/server", serverRouter);
|
||||
app.use("/api/toolset", toolsetRouter);
|
||||
app.use("/api/github", githubRouter);
|
||||
app.use("/api/repos", reposRouter);
|
||||
|
||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||
app.use(express.static(frontendDist));
|
||||
app.get("*path", (_req, res) => {
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
});
|
||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||
app.use(express.static(frontendDist));
|
||||
app.get("*path", (_req, res) => {
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
|
||||
if (url.pathname === "/ws/lsp") {
|
||||
const lspWss = new WebSocketServer({ noServer: true });
|
||||
lspWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
attachLspWebSocket(ws);
|
||||
if (url.pathname === "/ws/lsp") {
|
||||
const lspWss = new WebSocketServer({ noServer: true });
|
||||
lspWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
attachLspWebSocket(ws);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
||||
if (termMatch) {
|
||||
const sessionId = termMatch[1];
|
||||
const termWss = new WebSocketServer({ noServer: true });
|
||||
termWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
if (!attachWebSocket(sessionId, ws)) {
|
||||
createTerminalSession(sessionId);
|
||||
attachWebSocket(sessionId, ws);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
handleEventUpgrade(request, socket, head);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`Layonara Forge listening on http://0.0.0.0:${port}`);
|
||||
startUpstreamPolling();
|
||||
const tlkPath = getRepoPath("nwn-haks", "layonara.tlk.json");
|
||||
loadTlkIndex(tlkPath).then(() => console.log(`TLK index loaded`)).catch(() => {});
|
||||
resolve(server);
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
||||
if (termMatch) {
|
||||
const sessionId = termMatch[1];
|
||||
const termWss = new WebSocketServer({ noServer: true });
|
||||
termWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
if (!attachWebSocket(sessionId, ws)) {
|
||||
createTerminalSession(sessionId);
|
||||
attachWebSocket(sessionId, ws);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
handleEventUpgrade(request, socket, head);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
|
||||
startUpstreamPolling();
|
||||
const tlkPath = getRepoPath("nwn-haks", "layonara.tlk.json");
|
||||
loadTlkIndex(tlkPath).then(() => console.log(`TLK index loaded`)).catch(() => {});
|
||||
});
|
||||
if (!process.env.ELECTRON) {
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
startServer(PORT);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { runEphemeralContainer } from "./docker.service.js";
|
||||
import { runEphemeralContainer, getDockerClient } from "./docker.service.js";
|
||||
import {
|
||||
getWorkspacePath,
|
||||
getServerPath,
|
||||
} from "./workspace.service.js";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
const BUILDER_IMAGE = "layonara-builder";
|
||||
|
||||
async function ensureBuilderImage(): Promise<void> {
|
||||
const docker = getDockerClient();
|
||||
try {
|
||||
await docker.getImage(BUILDER_IMAGE).inspect();
|
||||
return;
|
||||
} catch {
|
||||
// image doesn't exist — build it
|
||||
}
|
||||
|
||||
broadcast("build", "info", { message: "Building the builder image (first-time setup, ~45s)..." });
|
||||
|
||||
const builderDir = process.env.ELECTRON
|
||||
? path.join((process as any).resourcesPath ?? __dirname, "builder")
|
||||
: path.resolve(__dirname, "../../../builder");
|
||||
|
||||
const stream = await docker.buildImage(
|
||||
{ context: builderDir, src: ["."] },
|
||||
{ t: BUILDER_IMAGE },
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.modem.followProgress(
|
||||
stream,
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
broadcast("build", "error", { message: `Builder image build failed: ${err.message}` });
|
||||
reject(err);
|
||||
} else {
|
||||
broadcast("build", "info", { message: "Builder image ready." });
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
(event: { stream?: string }) => {
|
||||
if (event.stream) {
|
||||
broadcast("build", "output", { text: event.stream });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildModule(
|
||||
target: string = "bare",
|
||||
mode: "compile" | "pack" = "compile",
|
||||
@@ -18,9 +61,10 @@ export async function buildModule(
|
||||
: ["nasher", "pack", target, "--yes"];
|
||||
|
||||
broadcast("build", "start", { type: "module", target, mode });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd,
|
||||
binds: [
|
||||
`${workspacePath}/repos/nwn-module:/build/nwn-module`,
|
||||
@@ -101,9 +145,10 @@ export async function buildHaks(): Promise<{
|
||||
const workspacePath = getWorkspacePath();
|
||||
|
||||
broadcast("build", "start", { type: "haks" });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd: ["layonara_nwn", "hak", "--yes"],
|
||||
binds: [
|
||||
`${workspacePath}/repos/nwn-haks:/build/nwn-haks`,
|
||||
@@ -127,6 +172,7 @@ export async function buildNWNX(
|
||||
const workspacePath = getWorkspacePath();
|
||||
|
||||
broadcast("build", "start", { type: "nwnx", target });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const cmd = target
|
||||
? [
|
||||
@@ -141,7 +187,7 @@ export async function buildNWNX(
|
||||
];
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd,
|
||||
binds: [`${workspacePath}/repos/unified:/build/unified`],
|
||||
workingDir: "/build/unified",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Docker from "dockerode";
|
||||
import { platform } from "os";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const socketPath = platform() === "win32"
|
||||
? "//./pipe/docker_engine"
|
||||
: "/var/run/docker.sock";
|
||||
const docker = new Docker({ socketPath });
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string;
|
||||
|
||||
@@ -189,22 +189,33 @@ export async function seedDatabase(cdKey: string, playerName: string): Promise<v
|
||||
const docker = getDockerClient();
|
||||
const container = docker.getContainer(MARIADB_NAME);
|
||||
|
||||
const exec = await container.exec({
|
||||
Cmd: [
|
||||
"bash",
|
||||
"-c",
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`,
|
||||
],
|
||||
const schemaPath = path.resolve(__dirname, "../../../db/schema.sql");
|
||||
let schemaSql: string;
|
||||
try {
|
||||
schemaSql = await fs.readFile(schemaPath, "utf-8");
|
||||
} catch {
|
||||
const altPath = path.resolve(__dirname, "../../db/schema.sql");
|
||||
schemaSql = await fs.readFile(altPath, "utf-8");
|
||||
}
|
||||
|
||||
const schemaExec = await container.exec({
|
||||
Cmd: ["bash", "-c", "mysql -u root -p$MYSQL_ROOT_PASSWORD nwn 2>&1"],
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
await exec.start({});
|
||||
const schemaStream = await schemaExec.start({ hijack: true, stdin: true });
|
||||
schemaStream.write(schemaSql);
|
||||
schemaStream.end();
|
||||
await new Promise<void>((resolve) => schemaStream.on("end", resolve));
|
||||
|
||||
const safeKey = cdKey.replace(/'/g, "''");
|
||||
const safeName = playerName.replace(/'/g, "''");
|
||||
const dmExec = await container.exec({
|
||||
Cmd: [
|
||||
"bash",
|
||||
"-c",
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${cdKey}', '${playerName}', 1);" 2>&1`,
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${safeKey}', '${safeName}', 1);" 2>&1`,
|
||||
],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || path.join(homedir(), "Layonara Forge");
|
||||
|
||||
interface ForgeConfig {
|
||||
githubPat?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "../services/api";
|
||||
|
||||
const COMMIT_TYPES = [
|
||||
@@ -19,6 +19,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
const [issueRef, setIssueRef] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
let msg = `${type}${scope ? `(${scope})` : ""}: ${description}`;
|
||||
@@ -29,6 +30,16 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
|
||||
const isValid = description.trim().length > 0 && description.length <= 100;
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
async function handleSubmit(andPush: boolean) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
@@ -46,22 +57,59 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "oklch(0% 0 0 / 0.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="commit-dialog-title"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "32rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "1.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
|
||||
<h3
|
||||
id="commit-dialog-title"
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 600,
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
Commit Changes
|
||||
</h3>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="rounded border px-2 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
{COMMIT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
@@ -72,8 +120,15 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value)}
|
||||
placeholder="scope (optional)"
|
||||
className="w-28 rounded border px-2 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "7rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -82,8 +137,17 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, 100))}
|
||||
placeholder="Description (required, max 100 chars)"
|
||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
@@ -91,8 +155,18 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Body (optional)"
|
||||
rows={3}
|
||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -100,40 +174,91 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
value={issueRef}
|
||||
onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="Issue # (auto-formats as Fixes #NNN)"
|
||||
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "var(--font-mono)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.75rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
|
||||
<pre style={{ marginTop: "0.25rem", whiteSpace: "pre-wrap", color: "var(--forge-text)" }}>{preview}</pre>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded px-3 py-2 text-sm" style={{ backgroundColor: "var(--forge-danger-bg)", color: "var(--forge-danger)" }}>{error}</div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
color: "var(--forge-danger)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded border px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text)",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={!isValid || loading}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-accent)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
opacity: !isValid || loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={!isValid || loading}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-warning-bg)", borderColor: "var(--forge-warning-border)", color: "var(--forge-warning)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-warning-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-warning-bg)",
|
||||
color: "var(--forge-warning)",
|
||||
opacity: !isValid || loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Commit & Push
|
||||
</button>
|
||||
|
||||
@@ -28,8 +28,17 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="w-full max-w-lg">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", maxWidth: "32rem" }}>
|
||||
<ErrorDisplay
|
||||
title="Render Error"
|
||||
message={this.state.error.message}
|
||||
|
||||
@@ -22,32 +22,45 @@ export function ErrorDisplay({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
borderRadius: "0.5rem",
|
||||
padding: "1.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-danger-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-danger)" }}>
|
||||
<h3 style={{ fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-danger)", margin: 0 }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{fullLog && (
|
||||
<div className="mt-4">
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
textDecoration: "underline",
|
||||
color: "var(--forge-text-secondary)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{expanded ? "Hide Full Log" : "Show Full Log"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre
|
||||
className="mt-2 max-h-60 overflow-auto rounded p-3 text-xs"
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
maxHeight: "15rem",
|
||||
overflow: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
@@ -59,22 +72,32 @@ export function ErrorDisplay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="rounded px-4 py-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={copyError}
|
||||
className="rounded px-4 py-2 text-sm"
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
Copy Error
|
||||
|
||||
@@ -60,14 +60,37 @@ function ToastItem({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm shadow-lg"
|
||||
style={{ backgroundColor: bg, border: `1px solid ${border}`, color: text }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: bg,
|
||||
border: `1px solid ${border}`,
|
||||
color: text,
|
||||
boxShadow: "0 10px 15px -3px oklch(0% 0 0 / 0.2), 0 4px 6px -4px oklch(0% 0 0 / 0.1)",
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<span style={{ flex: 1 }}>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
className="ml-2 shrink-0 opacity-60 hover:opacity-100"
|
||||
style={{ color: text }}
|
||||
aria-label="Dismiss notification"
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
flexShrink: 0,
|
||||
opacity: 0.6,
|
||||
color: text,
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = "1"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = "0.6"; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -92,7 +115,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div aria-live="polite" role="status" className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
|
||||
<div
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
maxWidth: "360px",
|
||||
}}
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
||||
))}
|
||||
|
||||
@@ -33,9 +33,13 @@ function TabButton({
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
title={tab.path}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(); } }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
@@ -48,7 +52,6 @@ function TabButton({
|
||||
color: isActive ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
backgroundColor: isActive ? "var(--forge-bg)" : "transparent",
|
||||
borderBottom: isActive ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||
border: "none",
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
@@ -99,7 +102,7 @@ function TabButton({
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,6 +116,7 @@ export function EditorTabs({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
style={{
|
||||
display: "flex",
|
||||
overflowX: "auto",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api, type FileNode } from "../../services/api";
|
||||
import {
|
||||
FileCode2,
|
||||
FileJson,
|
||||
FileText,
|
||||
FileType2,
|
||||
FileImage,
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface FileExplorerProps {
|
||||
repo: string;
|
||||
@@ -7,32 +21,29 @@ interface FileExplorerProps {
|
||||
onFileSelect: (repo: string, filePath: string) => void;
|
||||
}
|
||||
|
||||
function getFileIcon(name: string): string {
|
||||
function getFileIcon(name: string): LucideIcon {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "nss":
|
||||
return "S";
|
||||
case "ncs":
|
||||
return FileCode2;
|
||||
case "json":
|
||||
return "J";
|
||||
return FileJson;
|
||||
case "xml":
|
||||
case "html":
|
||||
return "<>";
|
||||
case "md":
|
||||
return "M";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "Y";
|
||||
return FileType2;
|
||||
case "md":
|
||||
return FileText;
|
||||
case "png":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "bmp":
|
||||
case "tga":
|
||||
return "I";
|
||||
return FileImage;
|
||||
case "2da":
|
||||
return "2";
|
||||
case "ncs":
|
||||
return "C";
|
||||
case "git":
|
||||
case "are":
|
||||
case "ifo":
|
||||
@@ -48,9 +59,9 @@ function getFileIcon(name: string): string {
|
||||
case "dlg":
|
||||
case "jrl":
|
||||
case "fac":
|
||||
return "N";
|
||||
return FileText;
|
||||
default:
|
||||
return "F";
|
||||
return File;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +79,7 @@ function FileTreeNode({
|
||||
repo: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isSelected = selectedPath === node.path;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -78,35 +90,56 @@ function FileTreeNode({
|
||||
}
|
||||
}, [node, repo, onFileSelect]);
|
||||
|
||||
const FileIcon = node.type === "directory"
|
||||
? (expanded ? FolderOpen : Folder)
|
||||
: getFileIcon(node.name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex w-full items-center gap-1 px-1 py-0.5 text-left text-sm transition-colors hover:bg-white/5"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
paddingLeft: `${depth * 16 + 8}px`,
|
||||
backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
|
||||
paddingRight: "0.25rem",
|
||||
paddingTop: "0.125rem",
|
||||
paddingBottom: "0.125rem",
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
backgroundColor: isSelected
|
||||
? "var(--forge-surface)"
|
||||
: hovered
|
||||
? "var(--forge-surface-raised)"
|
||||
: "transparent",
|
||||
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
{node.type === "directory" ? (
|
||||
<span
|
||||
className="inline-block w-4 text-center text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
{expanded ? "\u25BC" : "\u25B6"}
|
||||
<span style={{ display: "flex", alignItems: "center", width: "16px", justifyContent: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block w-4 text-center text-xs font-bold"
|
||||
style={{ color: "var(--forge-accent)", fontSize: "10px" }}
|
||||
>
|
||||
{getFileIcon(node.name)}
|
||||
</span>
|
||||
<span style={{ width: "16px", flexShrink: 0 }} />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
<FileIcon
|
||||
size={14}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: node.type === "directory" ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
{node.type === "directory" && expanded && node.children && (
|
||||
<div>
|
||||
@@ -155,32 +188,56 @@ export function FileExplorer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Explorer
|
||||
</span>
|
||||
<button
|
||||
onClick={loadTree}
|
||||
className="rounded p-1 text-xs transition-colors hover:bg-white/10"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
title="Refresh"
|
||||
aria-label="Refresh file tree"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "none",
|
||||
background: "none",
|
||||
color: "var(--forge-text-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
↻
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0.25rem 0" }}>
|
||||
{loading && (
|
||||
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
@@ -201,7 +258,7 @@ export function FileExplorer({
|
||||
)}
|
||||
|
||||
{!loading && !error && tree.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,69 @@ function getVscodeApiConfig(): MonacoVscodeApiConfig {
|
||||
userConfiguration: {
|
||||
json: JSON.stringify({
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"workbench.colorCustomizations": {
|
||||
"editor.background": "#231e17",
|
||||
"editor.foreground": "#ece8e3",
|
||||
"editor.lineHighlightBackground": "#302a2040",
|
||||
"editor.selectionBackground": "#3d3018",
|
||||
"editor.selectionHighlightBackground": "#3d301860",
|
||||
"editor.inactiveSelectionBackground": "#3d301850",
|
||||
"editor.findMatchBackground": "#b07a0040",
|
||||
"editor.findMatchHighlightBackground": "#b07a0025",
|
||||
"editor.hoverHighlightBackground": "#3d301830",
|
||||
"editorCursor.foreground": "#b07a00",
|
||||
"editorWhitespace.foreground": "#4a403550",
|
||||
"editorIndentGuide.background": "#4a403530",
|
||||
"editorIndentGuide.activeBackground": "#4a403580",
|
||||
"editorLineNumber.foreground": "#a69f9650",
|
||||
"editorLineNumber.activeForeground": "#ece8e3",
|
||||
"editorBracketMatch.background": "#3d301840",
|
||||
"editorBracketMatch.border": "#b07a0080",
|
||||
"editorOverviewRuler.border": "#4a4035",
|
||||
"editorGutter.background": "#231e17",
|
||||
"editorWidget.background": "#3b3328",
|
||||
"editorWidget.foreground": "#ece8e3",
|
||||
"editorWidget.border": "#4a4035",
|
||||
"editorSuggestWidget.background": "#3b3328",
|
||||
"editorSuggestWidget.border": "#4a4035",
|
||||
"editorSuggestWidget.foreground": "#ece8e3",
|
||||
"editorSuggestWidget.highlightForeground": "#b07a00",
|
||||
"editorSuggestWidget.selectedBackground": "#3d3018",
|
||||
"editorHoverWidget.background": "#3b3328",
|
||||
"editorHoverWidget.border": "#4a4035",
|
||||
"editorGroupHeader.tabsBackground": "#302a20",
|
||||
"editorGroupHeader.tabsBorder": "#4a4035",
|
||||
"editorGroup.border": "#4a4035",
|
||||
"tab.activeBackground": "#231e17",
|
||||
"tab.activeForeground": "#ece8e3",
|
||||
"tab.activeBorderTop": "#b07a00",
|
||||
"tab.inactiveBackground": "#302a20",
|
||||
"tab.inactiveForeground": "#a69f96",
|
||||
"tab.border": "#4a4035",
|
||||
"input.background": "#231e17",
|
||||
"input.foreground": "#ece8e3",
|
||||
"input.border": "#4a4035",
|
||||
"input.placeholderForeground": "#a69f9680",
|
||||
"inputOption.activeBorder": "#b07a00",
|
||||
"dropdown.background": "#3b3328",
|
||||
"dropdown.foreground": "#ece8e3",
|
||||
"dropdown.border": "#4a4035",
|
||||
"list.activeSelectionBackground": "#3d3018",
|
||||
"list.activeSelectionForeground": "#ece8e3",
|
||||
"list.inactiveSelectionBackground": "#3d301880",
|
||||
"list.hoverBackground": "#302a2080",
|
||||
"list.highlightForeground": "#b07a00",
|
||||
"scrollbarSlider.background": "#4a403540",
|
||||
"scrollbarSlider.hoverBackground": "#4a403580",
|
||||
"scrollbarSlider.activeBackground": "#4a4035a0",
|
||||
"focusBorder": "#b07a0080",
|
||||
"peekView.border": "#b07a00",
|
||||
"peekViewEditor.background": "#231e17",
|
||||
"peekViewResult.background": "#302a20",
|
||||
"peekViewTitle.background": "#302a20",
|
||||
"minimap.selectionHighlight": "#3d3018",
|
||||
"minimap.findMatchHighlight": "#b07a0060",
|
||||
},
|
||||
"editor.fontSize": 14,
|
||||
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
|
||||
"editor.tabSize": 4,
|
||||
@@ -142,16 +205,7 @@ export function MonacoEditor({
|
||||
languageClientConfig={languageClientConfig}
|
||||
onTextChanged={handleTextChanged}
|
||||
onError={handleError}
|
||||
logLevel={LogLevel.Debug}
|
||||
onLanguageClientsStartDone={(lcsManager) => {
|
||||
console.log("[MonacoEditor] Language clients started:", lcsManager);
|
||||
}}
|
||||
onEditorStartDone={(editorApp) => {
|
||||
console.log("[MonacoEditor] Editor started:", editorApp ? "ok" : "no app");
|
||||
}}
|
||||
onVscodeApiInitDone={() => {
|
||||
console.log("[MonacoEditor] VSCode API initialized");
|
||||
}}
|
||||
logLevel={LogLevel.Warning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { api } from "../../services/api";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
interface SearchMatch {
|
||||
file: string;
|
||||
@@ -32,6 +33,8 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
|
||||
const [hoveredMatch, setHoveredMatch] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const doSearch = useCallback(async () => {
|
||||
@@ -87,45 +90,66 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.375rem",
|
||||
cursor: "pointer",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-3 py-2" style={{ borderBottom: "1px solid var(--forge-border)" }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--forge-border)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 rounded px-2 py-1 text-sm outline-none"
|
||||
aria-label="Search query"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setRegex((v) => !v)}
|
||||
className="rounded px-1.5 py-1"
|
||||
style={toggleBtnStyle(regex)}
|
||||
title="Use Regular Expression"
|
||||
>
|
||||
@@ -133,7 +157,6 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
className="rounded px-1.5 py-1"
|
||||
style={toggleBtnStyle(caseSensitive)}
|
||||
title="Match Case"
|
||||
>
|
||||
@@ -141,27 +164,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||
<input
|
||||
value={includePattern}
|
||||
onChange={(e) => setIncludePattern(e.target.value)}
|
||||
placeholder="Include (e.g. *.nss)"
|
||||
className="flex-1 rounded px-2 py-1 text-xs outline-none"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={excludePattern}
|
||||
onChange={(e) => setExcludePattern(e.target.value)}
|
||||
placeholder="Exclude (e.g. *.json)"
|
||||
className="flex-1 rounded px-2 py-1 text-xs outline-none"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,25 +200,34 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
<button
|
||||
onClick={doSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
cursor: loading || !query.trim() ? "not-allowed" : "pointer",
|
||||
opacity: loading || !query.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-sm" style={{ color: "var(--forge-danger)" }}>{error}</div>
|
||||
<div style={{ padding: "0.5rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{searched && !loading && !error && (
|
||||
<div
|
||||
className="px-3 py-1.5 text-xs"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
@@ -202,24 +242,44 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
<div key={group.file}>
|
||||
<button
|
||||
onClick={() => toggleCollapsed(group.file)}
|
||||
className="flex w-full items-center gap-1 px-3 py-1 text-left text-xs transition-colors hover:bg-white/5"
|
||||
style={{ color: "var(--forge-text)" }}
|
||||
onMouseEnter={() => setHoveredFile(group.file)}
|
||||
onMouseLeave={() => setHoveredFile(null)}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
textAlign: "left",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text)",
|
||||
border: "none",
|
||||
background: hoveredFile === group.file ? "var(--forge-surface-raised)" : "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-3 text-center"
|
||||
style={{ color: "var(--forge-text-secondary)", fontSize: "10px" }}
|
||||
>
|
||||
{collapsed.has(group.file) ? "\u25B6" : "\u25BC"}
|
||||
<span style={{ display: "flex", alignItems: "center", width: "12px", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||
{collapsed.has(group.file) ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1 truncate font-medium"
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: "12px" }}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 500,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{group.file}
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full px-1.5 text-xs"
|
||||
style={{
|
||||
borderRadius: "9999px",
|
||||
padding: "0 0.375rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
@@ -229,37 +289,57 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
</button>
|
||||
|
||||
{!collapsed.has(group.file) &&
|
||||
group.matches.map((match, i) => (
|
||||
<button
|
||||
key={`${match.line}-${match.column}-${i}`}
|
||||
onClick={() => onResultClick(match.file, match.line)}
|
||||
className="flex w-full items-start gap-2 px-3 py-0.5 text-left transition-colors hover:bg-white/5"
|
||||
style={{ paddingLeft: "28px" }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-xs"
|
||||
group.matches.map((match, i) => {
|
||||
const matchKey = `${match.file}-${match.line}-${match.column}-${i}`;
|
||||
return (
|
||||
<button
|
||||
key={matchKey}
|
||||
onClick={() => onResultClick(match.file, match.line)}
|
||||
onMouseEnter={() => setHoveredMatch(matchKey)}
|
||||
onMouseLeave={() => setHoveredMatch(null)}
|
||||
style={{
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "11px",
|
||||
minWidth: "32px",
|
||||
textAlign: "right",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
paddingLeft: "28px",
|
||||
paddingRight: "0.75rem",
|
||||
paddingTop: "0.125rem",
|
||||
paddingBottom: "0.125rem",
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
background: hoveredMatch === matchKey ? "var(--forge-surface-raised)" : "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
{match.line}
|
||||
</span>
|
||||
<span
|
||||
className="truncate text-xs"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "11px",
|
||||
minWidth: "32px",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{match.line}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -283,7 +363,7 @@ function HighlightedLine({
|
||||
return (
|
||||
<>
|
||||
<span>{before}</span>
|
||||
<span className="font-bold" style={{ color: "var(--forge-accent)" }}>
|
||||
<span style={{ fontWeight: 700, color: "var(--forge-accent)" }}>
|
||||
{matched}
|
||||
</span>
|
||||
<span>{after}</span>
|
||||
|
||||
@@ -25,21 +25,28 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Ability Scores
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem" }}>
|
||||
{abilities.flat().map((ab) => {
|
||||
const val = getFieldValue(data, ab);
|
||||
const num = typeof val === "number" ? val : 0;
|
||||
return (
|
||||
<div
|
||||
key={ab}
|
||||
className="flex flex-col items-center rounded border px-3 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.5rem 0.75rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-bold" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", fontWeight: 700, color: "var(--forge-text-secondary)" }}>
|
||||
{displayNames[ab]}
|
||||
</span>
|
||||
<input
|
||||
@@ -48,14 +55,20 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
|
||||
min={1}
|
||||
max={99}
|
||||
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))}
|
||||
className="mt-1 w-16 rounded border px-1 py-1 text-center text-lg font-semibold"
|
||||
style={{
|
||||
marginTop: "0.25rem",
|
||||
width: "4rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.25rem",
|
||||
textAlign: "center",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 600,
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="mt-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ marginTop: "0.125rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -73,34 +86,39 @@ function RaceGenderOverride({ data, onChange }: FieldOverrideProps) {
|
||||
const genderNum = typeof gender === "number" ? gender : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Race</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Race</label>
|
||||
<input
|
||||
type="number"
|
||||
value={raceNum}
|
||||
min={0}
|
||||
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
|
||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
(racialtypes.2da)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Gender</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Gender</label>
|
||||
<select
|
||||
value={genderNum}
|
||||
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
|
||||
className="rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
@@ -121,13 +139,13 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{scripts.map((s) => {
|
||||
const val = getFieldValue(data, s.label);
|
||||
const str = typeof val === "string" ? val : "";
|
||||
return (
|
||||
<div key={s.label} className="flex items-center gap-3">
|
||||
<label className="w-28 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div key={s.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<label style={{ width: "7rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{s.display}
|
||||
</label>
|
||||
<input
|
||||
@@ -135,10 +153,14 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
value={str}
|
||||
maxLength={16}
|
||||
onChange={(e) => onChange(s.label, e.target.value)}
|
||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
placeholder="(none)"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
gffTypeFromPath,
|
||||
getLocStringText,
|
||||
} from "./GffEditor";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
interface DialogEditorProps {
|
||||
repo: string;
|
||||
@@ -48,38 +49,38 @@ function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply"
|
||||
const sound = getStringVal(node, "Sound");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
Text
|
||||
</label>
|
||||
<p className="mt-0.5 text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
</span>
|
||||
<p style={{ marginTop: "0.125rem", fontSize: "var(--text-sm)", color: "var(--forge-text)", margin: "0.125rem 0 0" }}>
|
||||
{text || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
||||
{type === "entry" && speaker && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Speaker</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{speaker}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Speaker</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{speaker}</p>
|
||||
</div>
|
||||
)}
|
||||
{script && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Action Script</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{script}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Action Script</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{script}</p>
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Condition</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{active}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Condition</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{active}</p>
|
||||
</div>
|
||||
)}
|
||||
{sound && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Sound</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{sound}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Sound</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{sound}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,6 +104,7 @@ function DialogNodeItem({
|
||||
depth: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const text = getTextVal(node);
|
||||
const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||
|
||||
@@ -117,40 +119,63 @@ function DialogNodeItem({
|
||||
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:opacity-80"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
backgroundColor: expanded ? "var(--forge-surface)" : "transparent",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.375rem 0.5rem",
|
||||
textAlign: "left",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: expanded ? "var(--forge-surface)" : hovered ? "var(--forge-surface-raised)" : "transparent",
|
||||
color: "var(--forge-text)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span className="mt-0.5 font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{expanded ? "▼" : "▶"}
|
||||
<span style={{ marginTop: "0.125rem", display: "flex", alignItems: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.125rem 0.25rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
backgroundColor: type === "entry" ? "var(--forge-info-bg)" : "var(--forge-success-bg)",
|
||||
color: type === "entry" ? "var(--forge-info)" : "var(--forge-success)",
|
||||
}}
|
||||
>
|
||||
{type === "entry" ? "E" : "R"}{index}
|
||||
</span>
|
||||
<span className="flex-1 truncate">
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{truncated || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="ml-6 mt-1 space-y-2 border-l-2 pl-3"
|
||||
style={{ borderColor: "var(--forge-border)" }}
|
||||
style={{
|
||||
marginLeft: "1.5rem",
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
borderLeft: "2px solid var(--forge-border)",
|
||||
paddingLeft: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div className="rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ borderRadius: "0.25rem", padding: "0.75rem", backgroundColor: "var(--forge-bg)" }}>
|
||||
<NodeDetail node={node} type={type} />
|
||||
</div>
|
||||
|
||||
{childLinks && childLinks.length > 0 && depth < 4 && (
|
||||
<div className="space-y-1">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{childLinks.map((link, li) => {
|
||||
const idx = typeof link === "object" && link !== null
|
||||
? (typeof link.Index === "number" ? link.Index :
|
||||
@@ -254,31 +279,44 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
}, [schema]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ display: "flex", height: "100%", flexDirection: "column", backgroundColor: "var(--forge-bg)" }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||
Dialog Editor
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{entries.length} entries, {replies.length} replies
|
||||
</span>
|
||||
{dirty && (
|
||||
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)" }}>
|
||||
(unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{onSwitchToRaw && (
|
||||
<button
|
||||
onClick={onSwitchToRaw}
|
||||
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
Switch to Raw JSON
|
||||
</button>
|
||||
@@ -286,8 +324,16 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
opacity: !dirty || saving ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
@@ -296,17 +342,30 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="flex shrink-0 gap-0 border-b"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
role="tablist"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
gap: 0,
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
{(["tree", "properties"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="px-4 py-2 text-sm capitalize transition-colors"
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
textTransform: "capitalize",
|
||||
color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
border: "none",
|
||||
borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||
backgroundColor: "transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{tab === "tree" ? "Conversation Tree" : "Properties"}
|
||||
@@ -315,13 +374,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
|
||||
{error && (
|
||||
<p className="mb-4 text-sm" style={{ color: "var(--forge-danger)" }}>{error}</p>
|
||||
<p style={{ marginBottom: "1rem", fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
|
||||
)}
|
||||
|
||||
{activeTab === "tree" && (
|
||||
<div className="mx-auto max-w-3xl space-y-1">
|
||||
<div style={{ maxWidth: "48rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{startingEntries.length > 0 ? (
|
||||
startingEntries.map((link, i) => {
|
||||
const idx = typeof link === "object" && link !== null
|
||||
@@ -353,7 +412,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
depth={0}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
No dialog entries found
|
||||
</p>
|
||||
)}
|
||||
@@ -361,7 +420,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
)}
|
||||
|
||||
{activeTab === "properties" && (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<div style={{ maxWidth: "40rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{propertyFields.map((field) => {
|
||||
const raw = data[field.label];
|
||||
const value = raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)
|
||||
@@ -369,8 +428,8 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
: raw;
|
||||
|
||||
return (
|
||||
<div key={field.label} className="flex items-center gap-3" title={field.description}>
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div key={field.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }} title={field.description}>
|
||||
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
{field.type === GffFieldType.ResRef ? (
|
||||
@@ -392,10 +451,14 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
@@ -418,10 +481,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="w-32 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "8rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -18,22 +18,25 @@ interface ItemEditorProps {
|
||||
function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
|
||||
const num = typeof value === "number" ? value : 0;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={num}
|
||||
onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))}
|
||||
className="w-24 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "6rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
(baseitems.2da row)
|
||||
</span>
|
||||
</div>
|
||||
@@ -51,14 +54,14 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
|
||||
if (field.label !== "StackSize") return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
{[
|
||||
{ label: "StackSize", display: "Stack", value: stackSize, max: 99 },
|
||||
{ label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
|
||||
{ label: "Charges", display: "Charges", value: charges, max: 255 },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div key={item.label} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{item.display}
|
||||
</label>
|
||||
<input
|
||||
@@ -67,10 +70,13 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
|
||||
min={0}
|
||||
max={item.max}
|
||||
onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))}
|
||||
className="w-24 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "6rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
@@ -89,18 +95,17 @@ function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
|
||||
{flags.map((flag) => {
|
||||
const val = getFieldValue(data, flag.label);
|
||||
const checked = typeof val === "number" ? val !== 0 : Boolean(val);
|
||||
return (
|
||||
<label key={flag.label} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<label key={flag.label} style={{ display: "flex", cursor: "pointer", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)}
|
||||
className="h-4 w-4 rounded"
|
||||
style={{ accentColor: "var(--forge-accent)" }}
|
||||
style={{ width: "1rem", height: "1rem", borderRadius: "0.25rem", accentColor: "var(--forge-accent)" }}
|
||||
/>
|
||||
<span style={{ color: "var(--forge-text)" }}>{flag.display}</span>
|
||||
</label>
|
||||
@@ -114,41 +119,34 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
|
||||
const list = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||
Item Properties
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
+ Add Property
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{list.map((prop, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded border px-3 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.5rem 0.75rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 font-mono text-xs" style={{ color: "var(--forge-text)" }}>
|
||||
<span style={{ flex: 1, fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)" }}>
|
||||
{typeof prop === "object" && prop !== null
|
||||
? JSON.stringify(prop).slice(0, 80)
|
||||
: String(prop)}
|
||||
</span>
|
||||
<button
|
||||
className="text-xs"
|
||||
style={{ color: "var(--forge-danger)" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<p className="py-2 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<p style={{ padding: "0.5rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)", margin: 0 }}>
|
||||
No item properties
|
||||
</p>
|
||||
)}
|
||||
@@ -185,10 +183,13 @@ export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: I
|
||||
|
||||
const headerSlot = (
|
||||
<div
|
||||
className="border-b px-4 py-3"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||
<h2 style={{ fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)", margin: 0 }}>
|
||||
{itemName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,25 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
<item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
|
||||
{badge > 0 && (
|
||||
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-0.5 text-[8px] font-bold" style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "0.25rem",
|
||||
top: "0.25rem",
|
||||
display: "flex",
|
||||
height: "0.875rem",
|
||||
minWidth: "0.875rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "9999px",
|
||||
padding: "0 0.125rem",
|
||||
fontSize: "8px",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,38 @@ export default defineConfig({
|
||||
plugins: [importMetaUrlPlugin],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("@codingame/monaco-vscode-editor-api") ||
|
||||
id.includes("@codingame/monaco-vscode-api")) {
|
||||
return "monaco-editor";
|
||||
}
|
||||
if (id.includes("@codingame/")) {
|
||||
return "vscode-services";
|
||||
}
|
||||
if (id.includes("vscode/")) {
|
||||
return "vscode-core";
|
||||
}
|
||||
if (id.includes("lucide-react")) {
|
||||
return "icons";
|
||||
}
|
||||
if (id.includes("node_modules/react/") ||
|
||||
id.includes("node_modules/react-dom/") ||
|
||||
id.includes("node_modules/react-router")) {
|
||||
return "react";
|
||||
}
|
||||
if (id.includes("monaco-languageclient") ||
|
||||
id.includes("vscode-languageclient") ||
|
||||
id.includes("vscode-jsonrpc") ||
|
||||
id.includes("vscode-languageserver")) {
|
||||
return "lsp";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user