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:
plenarius
2026-04-21 12:14:38 -04:00
parent f39f1d818b
commit f851d8b8f2
62 changed files with 5519 additions and 5687 deletions
+151 -26
View File
@@ -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
+42 -6
View File
@@ -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"; }}
>
&times;
</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",
}}
>
&#x21bb;
<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>
+19 -1
View File
@@ -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>
)}
+32
View File
@@ -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: {