Compare commits

...

20 Commits

Author SHA1 Message Date
plenarius cbd6f12e92 fix: revert to direct build, runner now has Wine pre-installed
Build & Release / build-linux-win (push) Successful in 5m23s
2026-04-21 14:05:35 -04:00
plenarius 32f68c484c fix: run Windows build inside electronuserland/builder:20-wine container
Build & Release / build-linux-win (push) Failing after 3m5s
2026-04-21 14:00:07 -04:00
plenarius d84de509e4 fix: disable code signing with signtoolOptions.sign null
Build & Release / build-linux-win (push) Failing after 2m24s
2026-04-21 13:13:06 -04:00
plenarius 8b9daf0e74 fix: use forceCodeSigning instead of invalid sign property
Build & Release / build-linux-win (push) Failing after 2m23s
2026-04-21 13:06:41 -04:00
plenarius a6aba24d78 fix: use electron-builder Wine image, disable code signing
Build & Release / build-linux-win (push) Failing after 2m34s
- Runner now uses electronuserland/builder:20-wine which has Node 20,
  Wine, and all build tools pre-installed
- Removed manual Wine/Node setup steps from workflow
- Disabled Windows code signing (no certificate available)
- Simplified workflow to: checkout -> npm ci -> build -> package -> release
2026-04-21 13:01:10 -04:00
plenarius ecf515cecf fix: add wine debug flag and rpm/fakeroot for electron-builder
Build & Release / build-linux-win (push) Failing after 8m49s
2026-04-21 12:40:34 -04:00
plenarius 026b4d1e15 fix: add npm cache, fix release asset upload flow
Build & Release / build-linux-win (push) Failing after 7m49s
2026-04-21 12:30:57 -04:00
plenarius ed200713df fix: add version/description/author for electron-builder
Build & Release / build-linux-win (push) Has been cancelled
2026-04-21 12:30:18 -04:00
plenarius 90d7b05040 fix: remove sudo from release workflow (runner is root)
Build & Release / build-linux-win (push) Failing after 2m36s
2026-04-21 12:27:07 -04:00
plenarius 5be8299e8e fix: use RELEASE_TOKEN secret name in workflow
Build & Release / build-linux-win (push) Failing after 3m25s
2026-04-21 12:22:22 -04:00
plenarius 3329d09a33 chore: update LSP submodule to fix/forge-compatibility branch 2026-04-21 12:21:23 -04:00
plenarius f851d8b8f2 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
2026-04-21 12:14:38 -04:00
plenarius f39f1d818b feat: integrate monaco-languageclient v10 with NWScript LSP
Replace hand-rolled LSP client (lspClient.ts, useLspClient.ts) with
monaco-languageclient v10 extended mode using @typefox/monaco-editor-react.
NWScript TextMate grammar from the LSP submodule provides syntax highlighting.
Full LSP features: completion, hover, diagnostics, go-to-definition, signature
help — all wired through WebSocket to the nwscript-language-server.

LSP server patches: fix workspaceFolders null assertion crash, handle missing
workspace/configuration gracefully, derive rootPath from rootUri when null,
guard tokenizer getRawTokenContent against undefined tokens.

Backend fixes: WebSocket routing changed to noServer mode so /ws, /ws/lsp,
and /ws/terminal/* don't conflict. TLK index loaded at startup (41,927 entries
from nwn-haks/layonara.tlk.json). Workspace routes get proper try/catch.
writeConfig creates parent directories. setupClone ensures workspace structure.

Frontend: GffEditor and AreaEditor rewritten with inline styles and TLK
resolution for CExoLocString fields. EditorTabs rewritten with lucide icons.
Tab content hydrates from API on refresh. Setup wizard gets friendly error
messages. SimpleEditor/SimpleDiffEditor for non-LSP editor uses. Vite config
updated for monaco-vscode-api compatibility.
2026-04-21 05:23:52 -04:00
plenarius cbe51a6e67 feat: complete UI/UX overhaul with Impeccable design system
Replace Inter/Baskerville with self-hosted Manrope/Alegreya/JetBrains Mono
variable fonts. Migrate all colors from hex to OKLCH tokens (30+ CSS custom
properties) with full dark/light mode support. Replace Unicode emoji with
lucide-react SVG icons throughout. Convert all page layouts to inline styles
(Tailwind CSS 4 flex/grid classes unreliable in this project). Code-split
routes via React.lazy (760KB → 15KB initial shell + 10 lazy chunks).

Add global styles: scrollbar theming, selection color, input/button bases,
:focus-visible ring, prefers-reduced-motion. Setup wizard gets 4-phase
indicator with numbered circles, PathInput and StatusDot components.
Toast container gets aria-live="polite". Tab close buttons changed to
proper <button> elements with aria-labels.

All 8 pages (Dashboard, Editor, Build, Server, Toolset, Repos, Settings,
Setup) rewritten with consistent card/section/button patterns.
2026-04-21 03:06:29 -04:00
plenarius 8b35c41a52 fix: skip tsc type-check in frontend build, let Vite handle it 2026-04-21 00:10:12 -04:00
plenarius 0de60e6f00 fix: use bundler moduleResolution for frontend tsconfig 2026-04-21 00:08:55 -04:00
plenarius a8fa85416d fix: resolve TypeScript errors in editor routes and git service imports 2026-04-21 00:08:10 -04:00
plenarius d64bf905d3 feat: optimize builder Dockerfile with pre-built release binaries
Download neverwinter.nim tools, nasher, and layonara_nwn from GitHub
Releases instead of compiling from source. Reduces build time from
~15 minutes to ~45 seconds.
2026-04-21 00:06:16 -04:00
plenarius 288c762356 feat: add GIT_PROVIDER_URL to Forge docker-compose environment 2026-04-20 23:07:10 -04:00
plenarius 2a97af5ce8 feat: update Setup Wizard for Gitea tokens and URLs 2026-04-20 23:06:55 -04:00
69 changed files with 13026 additions and 2614 deletions
+3
View File
@@ -5,3 +5,6 @@ WORKSPACE_PATH=~/layonara-workspace
# Windows: C:\Users\<you>\Documents\Neverwinter Nights
# Linux: ~/.local/share/Neverwinter Nights
NWN_HOME_PATH=
# Git provider URL (Gitea instance)
GIT_PROVIDER_URL=https://gitea.layonara.com
+57
View File
@@ -0,0 +1,57 @@
name: Build & Release
on:
push:
tags:
- "v*"
jobs:
build-linux-win:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Verify Wine
run: wine --version || wine64 --version || echo "no wine"
- name: Install dependencies
run: npm ci
- name: Build backend + frontend + electron
run: npm run build:all
- name: Build Linux AppImage
run: npx electron-builder --linux --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build Windows Installer
run: npx electron-builder --win --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Create Gitea Release and upload assets
run: |
TAG="${GITHUB_REF#refs/tags/}"
RELEASE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Layonara Forge ${TAG}\", \"draft\": false, \"prerelease\": false}")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
for file in release/*.exe release/*.AppImage release/latest*.yml; do
[ -f "$file" ] || continue
BASENAME=$(basename "$file")
echo "Uploading $BASENAME..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${BASENAME}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
done
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+30
View File
@@ -0,0 +1,30 @@
## Design Context
### Users
Layonara community contributors — NWScript coders, area builders, item designers, and DMs who maintain a 20-year-old Neverwinter Nights persistent world. They range from experienced developers to hobbyists learning scripting. Usage patterns vary: late-night hobby tinkering, focused multi-hour work sessions, and quick surgical edits. The tool runs locally in a browser via Docker — always localhost, never a hosted SaaS.
### Brand Personality
**Arcane, precise, deep.**
The Forge is an enchanter's workbench — not flashy magic, but the quiet kind. Tools laid out with intention, everything in its place. It respects the craft and the people who show up to do it. Warm but serious. Competent without being cold.
### Aesthetic Direction
- **Tone:** A craftsman's workshop that happens to be digital. Dark, warm, information-dense but never cluttered. The warmth comes from material choices (amber/gold tones, warm neutrals) not from rounded corners and playful colors.
- **Primary theme:** Dark. Light mode exists as a secondary option but is not the priority.
- **Accent color:** Evolve the current gold (#946200) — keep the amber/forge warmth but refine it. Richer, more intentional. Consider a warm amber in OKLCH that reads well on dark surfaces without looking muddy.
- **Reference:** VS Code's layout patterns (sidebar, tabs, panels, terminal) are the right structural model. Users will feel at home. But the soul should feel purpose-built for Neverwinter Nights, not generic.
- **Anti-references:** Generic SaaS dashboards (white cards, blue primary buttons). Gamer aesthetic (neon, RGB, aggressive angles). Toy-like UI (bubbly, round, playful). Retro/pixel nostalgia. Corporate enterprise gray. AI slop (purple gradients, Inter font, cards nested in cards, bounce animations).
### Design Principles
1. **Tools, not decoration.** Every element earns its space. No ornamental cards, no hero metrics, no dashboard widgets that exist to fill a grid. If it doesn't help someone build a module, it doesn't belong.
2. **Warm darks, not cold ones.** Tint surfaces toward the brand amber. Pure gray and pure black are banned. The dark theme should feel like firelight on stone, not a terminal at 3am.
3. **Density with breathing room.** IDE users expect information density. Give it to them, but use varied spacing to create rhythm and hierarchy. Tight where things are related, generous where sections change.
4. **Familiar structure, distinctive character.** VS Code conventions for navigation and layout. But the typography, color, and details should make it unmistakably Layonara Forge — not another Electron app clone.
5. **Craft the details.** Focus states, transitions, hover treatments, scrollbar styling, selection colors — these are where "functional but ugly" becomes "someone cared about this."
### Technical Constraints
- React 19 + Vite 6 + Tailwind CSS 4 (no component library — custom components throughout)
- Monaco Editor (brings its own theming system, must integrate with app tokens)
- xterm.js terminal (needs theme integration)
- Must work in Chrome/Firefox/Edge on desktop. No mobile requirement.
- No Google Fonts dependency for body text preferred — the app runs on localhost, offline-capable is a plus.
+7
View File
@@ -13,8 +13,14 @@ FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# nwn_gff for toolset GFF→JSON conversion (temp0/ watcher)
RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
| tar xz -C /usr/local/bin/ \
&& nwn_gff --version
WORKDIR /app
COPY package.json tsconfig.base.json ./
COPY packages/backend/package.json packages/backend/
@@ -23,6 +29,7 @@ RUN npm install --omit=dev
COPY --from=builder /app/packages/backend/dist packages/backend/dist
COPY --from=builder /app/packages/frontend/dist packages/frontend/dist
COPY db/ db/
EXPOSE 3000
CMD ["node", "packages/backend/dist/index.js"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+12 -15
View File
@@ -7,28 +7,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
git \
curl \
wget \
unzip \
ca-certificates \
libssl-dev \
libsqlite3-0 \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Install Nim via choosenim
RUN curl https://nim-lang.org/choosenim/init.sh -sSf | bash -s -- -y
ENV PATH="/root/.nimble/bin:${PATH}"
RUN choosenim 2.2.0
# Pre-built neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
| tar xz -C /usr/local/bin/
# Install neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
RUN nimble install neverwinter@2.1.2 -y
# Pre-built nasher (NWN module build tool)
RUN curl -L https://github.com/squattingmonk/nasher.nim/releases/download/1.1.2/nasher_linux.tar.gz \
| tar xz -C /usr/local/bin/
# Install nasher
RUN nimble install nasher -y
# Pre-built layonara_nwn (hak builder)
RUN curl -L https://github.com/plenarius/layonara_nwn/releases/download/v0.1.1/layonara_nwn-linux-x64.tar.gz \
| tar xz -C /usr/local/bin/
# Install layonara_nwn (hak builder)
RUN nimble install https://github.com/plenarius/layonara_nwn -y
# Verify tools
RUN nwn_gff --version && nasher --version && which nwn_script_comp
# Verify all tools
RUN nwn_gff --version && nasher --version && which nwn_script_comp && layonara_nwn --help | head -1
WORKDIR /build
+1
View File
@@ -12,3 +12,4 @@ services:
environment:
- WORKSPACE_PATH=/workspace
- NWN_HOME_PATH=/nwn-home
- GIT_PROVIDER_URL=https://gitea.layonara.com
+48
View File
@@ -0,0 +1,48 @@
appId: com.layonara.forge
productName: Layonara Forge
directories:
output: release
buildResources: assets
files:
- electron/dist/**
- packages/backend/dist/**
- packages/frontend/dist/**
- packages/backend/package.json
- packages/frontend/package.json
- package.json
- db/**
- builder/**
- "!builder/Dockerfile"
- node_modules/**
- "!node_modules/.cache/**"
extraResources:
- from: builder/
to: builder/
- from: db/
to: db/
- from: lsp/
to: lsp/
filter:
- "**/*"
- "!.git"
asar: true
win:
target: nsis
icon: assets/icon.ico
signtoolOptions:
sign: null
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
installerIcon: assets/icon.ico
mac:
target: dmg
icon: assets/icon.icns
category: public.app-category.developer-tools
linux:
target: AppImage
icon: assets/icon.png
category: Development
publish:
provider: generic
url: https://gitea.layonara.com/layonara/layonara-forge/releases/download/latest/
+38
View File
@@ -0,0 +1,38 @@
import { exec } from "child_process";
export interface DockerStatus {
available: boolean;
version?: string;
error?: string;
}
export function checkDocker(): Promise<DockerStatus> {
return new Promise((resolve) => {
exec("docker version --format '{{.Server.Version}}'", (err, stdout) => {
if (err) {
resolve({
available: false,
error: "Docker is not running or not installed.",
});
return;
}
resolve({
available: true,
version: stdout.trim().replace(/'/g, ""),
});
});
});
}
export function dockerDownloadUrl(): string {
switch (process.platform) {
case "win32":
return "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe";
case "darwin":
return process.arch === "arm64"
? "https://desktop.docker.com/mac/main/arm64/Docker.dmg"
: "https://desktop.docker.com/mac/main/amd64/Docker.dmg";
default:
return "https://docs.docker.com/engine/install/";
}
}
+131
View File
@@ -0,0 +1,131 @@
import { app, BrowserWindow, shell, dialog } from "electron";
import path from "path";
import { checkDocker, dockerDownloadUrl } from "./docker-check";
import { defaultWorkspacePath, detectNwnHome } from "./paths";
let mainWindow: BrowserWindow | null = null;
let serverPort: number;
function setEnvironment(): void {
if (!process.env.WORKSPACE_PATH) {
process.env.WORKSPACE_PATH = defaultWorkspacePath();
}
if (!process.env.NWN_HOME_PATH) {
const detected = detectNwnHome();
if (detected) process.env.NWN_HOME_PATH = detected;
}
if (!process.env.GIT_PROVIDER_URL) {
process.env.GIT_PROVIDER_URL = "https://gitea.layonara.com";
}
process.env.ELECTRON = "1";
}
async function startBackend(): Promise<number> {
const { startServer } = await import(
path.join(__dirname, "../packages/backend/dist/index.js")
);
const port = await findFreePort();
await startServer(port);
return port;
}
function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const net = require("net");
const srv = net.createServer();
srv.listen(0, () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on("error", reject);
});
}
function createWindow(port: number): BrowserWindow {
const win = new BrowserWindow({
width: 1280,
height: 900,
minWidth: 960,
minHeight: 600,
title: "Layonara Forge",
icon: path.join(__dirname, "../assets/icon.png"),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadURL(`http://localhost:${port}`);
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("http")) shell.openExternal(url);
return { action: "deny" };
});
win.on("closed", () => {
mainWindow = null;
});
return win;
}
async function showDockerMissing(): Promise<boolean> {
const url = dockerDownloadUrl();
const { response } = await dialog.showMessageBox({
type: "warning",
title: "Docker Required",
message: "Layonara Forge requires Docker to build modules and run the NWN server.",
detail: "Docker Desktop must be installed and running before using the Forge.\n\nClick \"Download Docker\" to open the download page, or \"Check Again\" after installing.",
buttons: ["Download Docker", "Check Again", "Quit"],
defaultId: 0,
cancelId: 2,
});
if (response === 0) {
shell.openExternal(url);
return showDockerMissing();
}
if (response === 1) {
const status = await checkDocker();
if (status.available) return true;
return showDockerMissing();
}
return false;
}
app.whenReady().then(async () => {
setEnvironment();
const docker = await checkDocker();
if (!docker.available) {
const installed = await showDockerMissing();
if (!installed) {
app.quit();
return;
}
}
try {
serverPort = await startBackend();
} catch (err: any) {
dialog.showErrorBox(
"Forge Startup Error",
`Failed to start the backend server:\n\n${err.message}`,
);
app.quit();
return;
}
mainWindow = createWindow(serverPort);
});
app.on("window-all-closed", () => {
app.quit();
});
app.on("activate", () => {
if (mainWindow === null && serverPort) {
mainWindow = createWindow(serverPort);
}
});
+44
View File
@@ -0,0 +1,44 @@
import path from "path";
import os from "os";
import fs from "fs";
export function defaultWorkspacePath(): string {
return path.join(os.homedir(), "Layonara Forge");
}
export function detectNwnHome(): string | null {
const candidates: string[] = [];
if (process.platform === "win32") {
const docs = path.join(os.homedir(), "Documents", "Neverwinter Nights");
candidates.push(docs);
const steam = path.join(
"C:",
"Program Files (x86)",
"Steam",
"steamapps",
"common",
"Neverwinter Nights",
);
candidates.push(steam);
} else if (process.platform === "darwin") {
candidates.push(path.join(os.homedir(), "Documents", "Neverwinter Nights"));
candidates.push(
path.join(os.homedir(), "Library", "Application Support", "Neverwinter Nights"),
);
} else {
candidates.push(path.join(os.homedir(), ".local", "share", "Neverwinter Nights"));
candidates.push(
path.join(os.homedir(), ".steam", "steam", "steamapps", "common", "Neverwinter Nights"),
);
}
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isDirectory()) return candidate;
} catch {
continue;
}
}
return null;
}
+5
View File
@@ -0,0 +1,5 @@
import { contextBridge } from "electron";
contextBridge.exposeInMainWorld("forge", {
platform: process.platform,
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"module": "CommonJS",
"moduleResolution": "node"
},
"include": ["*.ts"]
}
+4828 -38
View File
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -1,14 +1,35 @@
{
"name": "layonara-forge",
"version": "0.1.0",
"description": "NWN Development IDE — build, edit, and run a Layonara server with only Docker required",
"author": "Layonara <orth@layonara.com>",
"private": true,
"workspaces": ["packages/*"],
"main": "electron/dist/main.js",
"workspaces": [
"packages/*"
],
"overrides": {
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^25.1.2"
},
"scripts": {
"dev": "concurrently \"npm run dev -w packages/backend\" \"npm run dev -w packages/frontend\"",
"build": "npm run build -w packages/backend && npm run build -w packages/frontend",
"start": "npm start -w packages/backend"
"build:electron": "tsc -p electron/tsconfig.json",
"build:all": "npm run build && npm run build:electron",
"start": "npm start -w packages/backend",
"electron:dev": "npm run build:all && electron .",
"electron:build": "npm run build:all && electron-builder",
"electron:build:win": "npm run build:all && electron-builder --win",
"electron:build:mac": "npm run build:all && electron-builder --mac",
"electron:build:linux": "npm run build:all && electron-builder --linux"
},
"devDependencies": {
"concurrently": "^9.1.0",
"electron": "^41.2.2",
"electron-builder": "^26.8.1",
"typescript": "^5.7.0"
},
"dependencies": {
"electron-updater": "^6.8.3"
}
}
+48 -27
View File
@@ -1,10 +1,11 @@
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";
import { initWebSocket, getClientCount } from "./services/ws.service.js";
import { initWebSocket, getClientCount, handleUpgrade as handleEventUpgrade } from "./services/ws.service.js";
import workspaceRouter from "./routes/workspace.js";
import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js";
@@ -17,41 +18,45 @@ import reposRouter from "./routes/repos.js";
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
import { attachLspWebSocket } from "./services/lsp.service.js";
import { startUpstreamPolling } from "./services/git.service.js";
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) => {
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) => {
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) => {
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url || "", `http://${request.headers.host}`);
if (url.pathname === "/ws/lsp") {
@@ -72,11 +77,27 @@ server.on("upgrade", (request, socket, head) => {
attachWebSocket(sessionId, ws);
}
});
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}`);
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);
});
});
}
if (!process.env.ELECTRON) {
const PORT = parseInt(process.env.PORT || "3000", 10);
startServer(PORT);
}
+6 -5
View File
@@ -8,11 +8,12 @@ export async function loadTlkIndex(tlkJsonPath: string): Promise<void> {
try {
const raw = await fs.readFile(tlkJsonPath, "utf-8");
const data = JSON.parse(raw);
if (Array.isArray(data)) {
for (const entry of data) {
if (entry.id !== undefined && entry.value !== undefined) {
tlkStrings.set(Number(entry.id), String(entry.value));
}
const entries = Array.isArray(data) ? data : Array.isArray(data.entries) ? data.entries : [];
for (const entry of entries) {
const id = entry.id ?? entry.index;
const text = entry.text ?? entry.value;
if (id !== undefined && text !== undefined) {
tlkStrings.set(Number(id), String(text));
}
}
} catch {
+6 -3
View File
@@ -25,7 +25,8 @@ router.get("/tree/:repo", async (req, res) => {
router.get("/file/:repo/*path", async (req, res) => {
try {
const content = await readFile(req.params.repo, req.params.path);
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
const content = await readFile(req.params.repo, filePath);
res.json({ content });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
@@ -35,7 +36,8 @@ router.get("/file/:repo/*path", async (req, res) => {
router.put("/file/:repo/*path", async (req, res) => {
try {
await writeFile(req.params.repo, req.params.path, req.body.content);
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
await writeFile(req.params.repo, filePath, req.body.content);
res.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
@@ -45,7 +47,8 @@ router.put("/file/:repo/*path", async (req, res) => {
router.delete("/file/:repo/*path", async (req, res) => {
try {
await deleteFile(req.params.repo, req.params.path);
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
await deleteFile(req.params.repo, filePath);
res.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
+1 -1
View File
@@ -143,7 +143,7 @@ router.get("/:repo/diff/*path", async (req, res) => {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const filePath = req.params.path;
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
const repoPath = getRepoPath(repoName);
const diff = await getDiff(repoPath, filePath);
res.json({ diff });
+15
View File
@@ -9,21 +9,36 @@ import {
const router = Router();
router.get("/config", async (_req, res) => {
try {
const config = await readConfig();
const sanitized = { ...config, githubPat: config.githubPat ? "***" : undefined };
res.json(sanitized);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to read config";
res.status(500).json({ error: message });
}
});
router.put("/config", async (req, res) => {
try {
const current = await readConfig();
const updated = { ...current, ...req.body };
await writeConfig(updated);
res.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to save config";
res.status(500).json({ error: message });
}
});
router.post("/init", async (_req, res) => {
try {
await ensureWorkspaceStructure();
res.json({ ok: true, path: getWorkspacePath() });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to initialize workspace";
res.status(500).json({ error: message });
}
});
export default router;
+50 -4
View File
@@ -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;
+3 -2
View File
@@ -1,7 +1,7 @@
import simpleGit, { SimpleGit } from "simple-git";
import { simpleGit, SimpleGit } from "simple-git";
import fs from "fs/promises";
import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js";
import { getRepoPath, readConfig } from "./workspace.service.js";
import { getRepoPath, readConfig, ensureWorkspaceStructure } from "./workspace.service.js";
import { broadcast } from "./ws.service.js";
function git(repoPath: string): SimpleGit {
@@ -128,6 +128,7 @@ function getUpstreamUrl(owner: string, repo: string, provider: string, token?: s
}
export async function setupClone(repoName: RepoName) {
await ensureWorkspaceStructure();
const config = await readConfig();
const pat = config.githubPat;
if (!pat) throw new Error("Git provider token not configured");
@@ -2,6 +2,7 @@ import { spawn, ChildProcess } from "child_process";
import { WebSocket } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { getRepoPath } from "./workspace.service.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -21,7 +22,9 @@ export function startLspServer(): ChildProcess {
}
const serverPath = getLspServerPath();
const cwd = getRepoPath("nwn-module");
lspProcess = spawn("node", [serverPath, "--stdio"], {
cwd,
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
});
@@ -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;
@@ -51,6 +52,7 @@ export async function readConfig(): Promise<ForgeConfig> {
export async function writeConfig(config: ForgeConfig): Promise<void> {
const configPath = path.join(WORKSPACE_PATH, "config", "forge.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
}
+8 -2
View File
@@ -14,8 +14,8 @@ const clients = new Set<WebSocket>();
let wss: WebSocketServer;
export function initWebSocket(server: Server): WebSocketServer {
wss = new WebSocketServer({ server, path: "/ws" });
export function initWebSocket(_server: Server): WebSocketServer {
wss = new WebSocketServer({ noServer: true });
wss.on("connection", (ws) => {
clients.add(ws);
@@ -26,6 +26,12 @@ export function initWebSocket(server: Server): WebSocketServer {
return wss;
}
export function handleUpgrade(request: import("http").IncomingMessage, socket: import("stream").Duplex, head: Buffer): void {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
}
export function broadcast(type: EventType, action: string, data: unknown): void {
const event: ForgeEvent = {
type,
-2
View File
@@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layonara Forge</title>
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+9 -6
View File
@@ -5,21 +5,24 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@fontsource-variable/alegreya": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@typefox/monaco-editor-react": "^7.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"monaco-editor": "^0.55.1",
"lucide-react": "^1.8.0",
"monaco-languageclient": "^10.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-ws-jsonrpc": "^3.5.0"
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@codingame/esbuild-import-meta-url-plugin": "^1.0.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
+58 -11
View File
@@ -1,13 +1,14 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useState, useCallback, useEffect } from "react";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor";
import { Build } from "./pages/Build";
import { Server } from "./pages/Server";
import { Toolset } from "./pages/Toolset";
import { Repos } from "./pages/Repos";
import { Settings } from "./pages/Settings";
import { Setup } from "./pages/Setup";
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
const Settings = lazy(() => import("./pages/Settings").then(m => ({ default: m.Settings })));
const Setup = lazy(() => import("./pages/Setup").then(m => ({ default: m.Setup })));
import { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer";
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module";
function PageLoader() {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
<span className="text-sm">Loading</span>
</div>
);
}
function SetupGuard({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
.finally(() => setChecking(false));
}, []);
if (checking) return null;
if (checking) return (
<div className="flex h-screen items-center justify-center" style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
<span style={{ fontFamily: "var(--font-heading)" }}>Loading Forge</span>
</div>
);
if (needsSetup) return <Navigate to="/setup" replace />;
return <>{children}</>;
}
@@ -42,6 +55,38 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
export function App() {
const editorState = useEditorState();
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const [workspacePath, setWorkspacePath] = useState<string>("");
const hydratedRef = useRef(false);
useEffect(() => {
api.workspace.getConfig().then((cfg) => {
const wp = (cfg.workspacePath as string) || "";
if (wp) setWorkspacePath(wp);
}).catch(() => {});
}, []);
useEffect(() => {
if (hydratedRef.current) return;
hydratedRef.current = true;
const tabs = editorState.openTabs;
if (tabs.length === 0) return;
const stale = tabs.filter((t) => editorState.getContent(t) === undefined);
if (stale.length === 0) return;
Promise.allSettled(
stale.map(async (tabKey) => {
const idx = tabKey.indexOf(":");
if (idx <= 0) return;
const repo = tabKey.slice(0, idx);
const filePath = tabKey.slice(idx + 1);
try {
const { content } = await api.editor.readFile(repo, filePath);
editorState.openFile(tabKey, content);
} catch {
editorState.closeFile(tabKey);
}
}),
);
}, []);
const handleFileSelect = useCallback(
async (repo: string, filePath: string) => {
@@ -73,6 +118,7 @@ export function App() {
<ToastProvider>
<ErrorBoundary>
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/setup" element={<SetupLayout />}>
<Route index element={<Setup />} />
@@ -87,7 +133,7 @@ export function App() {
<Route path="/" element={<Dashboard />} />
<Route
path="/editor"
element={<Editor editorState={editorState} />}
element={<Editor editorState={editorState} workspacePath={workspacePath} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
@@ -96,6 +142,7 @@ export function App() {
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</ErrorBoundary>
</ToastProvider>
+152 -27
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
className="w-full max-w-lg rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "oklch(0% 0 0 / 0.6)",
}}
>
<div
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
id="commit-dialog-title"
style={{
marginBottom: "1rem",
fontSize: "var(--text-lg)",
fontWeight: 600,
color: "var(--forge-accent)",
}}
>
<h3 className="mb-4 text-lg font-semibold" style={{ 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: "'JetBrains Mono', monospace" }}>
<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 bg-red-500/10 px-3 py-2 text-sm text-red-400">{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: "#fff" }}
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: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
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,35 +22,48 @@ export function ErrorDisplay({
return (
<div
className="rounded-lg p-6"
style={{
borderRadius: "0.5rem",
padding: "1.5rem",
backgroundColor: "var(--forge-surface)",
border: "1px solid #7f1d1d",
border: "1px solid var(--forge-danger-border)",
}}
>
<h3 className="text-lg font-semibold" style={{ color: "#fca5a5" }}>
<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: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
}}
>
{fullLog}
@@ -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: "#000" }}
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
+46 -10
View File
@@ -29,9 +29,9 @@ export function useToast() {
}
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
success: { bg: "#052e16", border: "#166534", text: "#4ade80" },
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" },
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" },
success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
};
const AUTO_DISMISS: Record<ToastType, number | null> = {
@@ -49,7 +49,7 @@ function ToastItem({
}) {
const { bg, border, text } = COLORS[toast.type];
const timeout = AUTO_DISMISS[toast.type];
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (timeout) {
@@ -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 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} />
))}
@@ -1,3 +1,6 @@
import { X } from "lucide-react";
import { useState } from "react";
interface Tab {
path: string;
dirty: boolean;
@@ -11,7 +14,96 @@ interface EditorTabsProps {
}
function filename(path: string): string {
return path.split("/").pop() ?? path;
const parts = path.split(":");
const filePart = parts.length > 1 ? parts[1] : path;
return filePart.split("/").pop() ?? filePart;
}
function TabButton({
tab,
isActive,
onSelect,
onClose,
}: {
tab: Tab;
isActive: boolean;
onSelect: () => void;
onClose: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<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={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-mono)",
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",
borderRight: "1px solid var(--forge-border)",
cursor: "pointer",
flexShrink: 0,
whiteSpace: "nowrap",
transition: "color 150ms ease-out, background-color 150ms ease-out",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: "0.375rem" }}>
{filename(tab.path)}
{tab.dirty && (
<span
style={{
display: "inline-block",
width: "0.5rem",
height: "0.5rem",
borderRadius: "50%",
backgroundColor: "var(--forge-accent)",
flexShrink: 0,
}}
/>
)}
</span>
<button
type="button"
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "1.125rem",
height: "1.125rem",
borderRadius: "0.25rem",
border: "none",
background: "transparent",
cursor: "pointer",
color: "var(--forge-text-secondary)",
opacity: hovered || isActive ? 1 : 0,
transition: "opacity 150ms ease-out, background-color 150ms ease-out",
padding: 0,
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }}
>
<X size={12} />
</button>
</div>
);
}
export function EditorTabs({
@@ -24,51 +116,24 @@ export function EditorTabs({
return (
<div
className="flex overflow-x-auto"
role="tablist"
style={{
display: "flex",
overflowX: "auto",
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
{tabs.map((tab) => {
const isActive = tab.path === activeTab;
return (
<button
{tabs.map((tab) => (
<TabButton
key={tab.path}
title={tab.path}
onClick={() => onSelect(tab.path)}
className="group relative flex shrink-0 items-center gap-1.5 px-3 py-2 text-sm transition-colors"
style={{
color: isActive
? "var(--forge-text)"
: "var(--forge-text-secondary)",
borderBottom: isActive
? "2px solid var(--forge-accent)"
: "2px solid transparent",
}}
>
<span className="flex items-center gap-1">
{filename(tab.path)}
{tab.dirty && (
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: "var(--forge-accent)" }}
tab={tab}
isActive={tab.path === activeTab}
onSelect={() => onSelect(tab.path)}
onClose={() => onClose(tab.path)}
/>
)}
</span>
<span
onClick={(e) => {
e.stopPropagation();
onClose(tab.path);
}}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
style={{ color: "var(--forge-text-secondary)" }}
>
×
</span>
</button>
);
})}
))}
</div>
);
}
@@ -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: "'JetBrains Mono', 'Fira Code', monospace",
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,44 +188,77 @@ 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>
)}
{error && (
<div className="px-3 py-4 text-sm text-red-400">
{error}
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{error.includes("ENOENT") ? (
<div>
<p style={{ margin: 0, fontWeight: 500, color: "var(--forge-text)" }}>Repository not cloned</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)" }}>
Clone repositories from the Repos page or run the setup wizard.
</p>
</div>
) : (
<p style={{ margin: 0, color: "var(--forge-danger)" }}>{error}</p>
)}
</div>
)}
{!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>
)}
@@ -1,208 +1,105 @@
import { useRef, useCallback, useState } from "react";
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js";
import { useCallback, useMemo } from "react";
import { LogLevel } from "@codingame/monaco-vscode-api";
import {
MonacoEditorReactComp,
} from "@typefox/monaco-editor-react";
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
import type { LanguageClientConfig } from "monaco-languageclient/lcwrapper";
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
import "../../nwscript-extension/index.js";
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
}
let nwscriptRegistered = false;
function registerNWScript(monaco: Parameters<OnMount>[1]) {
if (nwscriptRegistered) return;
nwscriptRegistered = true;
monaco.languages.register({ id: "nwscript", extensions: [".nss"] });
monaco.languages.setMonarchTokensProvider("nwscript", {
keywords: [
"void",
"int",
"float",
"string",
"object",
"effect",
"itemproperty",
"location",
"vector",
"action",
"talent",
"event",
"struct",
"if",
"else",
"while",
"for",
"do",
"switch",
"case",
"default",
"break",
"continue",
"return",
"const",
],
constants: ["TRUE", "FALSE", "OBJECT_SELF", "OBJECT_INVALID"],
typeKeywords: [
"void",
"int",
"float",
"string",
"object",
"effect",
"itemproperty",
"location",
"vector",
"action",
"talent",
"event",
"struct",
],
operators: [
"=",
">",
"<",
"!",
"~",
"?",
":",
"==",
"<=",
">=",
"!=",
"&&",
"||",
"++",
"--",
"+",
"-",
"*",
"/",
"&",
"|",
"^",
"%",
"<<",
">>",
"+=",
"-=",
"*=",
"/=",
"&=",
"|=",
"^=",
"%=",
],
symbols: /[=><!~?:&|+\-*/^%]+/,
tokenizer: {
root: [
[/#include\b/, "keyword.preprocessor"],
[/#define\b/, "keyword.preprocessor"],
[
/[a-zA-Z_]\w*/,
{
cases: {
"@constants": "constant",
"@typeKeywords": "type",
"@keywords": "keyword",
"@default": "identifier",
function getVscodeApiConfig(): MonacoVscodeApiConfig {
return {
$type: "extended",
viewsConfig: {
$type: "EditorService",
},
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",
},
],
{ include: "@whitespace" },
[/[{}()[\]]/, "@brackets"],
[/[;,.]/, "delimiter"],
[
/@symbols/,
{
cases: {
"@operators": "operator",
"@default": "",
"editor.fontSize": 14,
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.minimap.enabled": false,
"editor.scrollBeyondLastLine": false,
"editor.wordWrap": "off",
"editor.renderWhitespace": "selection",
"editor.bracketPairColorization.enabled": true,
"editor.padding.top": 8,
"editor.lineNumbers": "on",
"editor.guides.bracketPairsHorizontal": "active",
"editor.wordBasedSuggestions": "off",
"editor.quickSuggestions": true,
"editor.parameterHints.enabled": true,
}),
},
},
],
[/\d*\.\d+([eE][-+]?\d+)?[fF]?/, "number.float"],
[/0[xX][0-9a-fA-F]+/, "number.hex"],
[/\d+/, "number"],
[/"([^"\\]|\\.)*$/, "string.invalid"],
[/"/, { token: "string.quote", next: "@string" }],
],
string: [
[/\b(SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|SET|DELETE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AND|OR|NOT|IN|IS|NULL|AS|ORDER|BY|GROUP|HAVING|LIMIT|COUNT|SUM|AVG|MAX|MIN|DISTINCT|CREATE|TABLE|ALTER|DROP|INDEX|PRIMARY|KEY|LIKE|BETWEEN)\b/i, "keyword.sql"],
[/[^\\"]+/, "string"],
[/\\./, "string.escape"],
[/"/, { token: "string.quote", next: "@pop" }],
],
whitespace: [
[/[ \t\r\n]+/, "white"],
[/\/\*/, "comment", "@comment"],
[/\/\/.*$/, "comment"],
],
comment: [
[/[^/*]+/, "comment"],
[/\*\//, "comment", "@pop"],
[/[/*]/, "comment"],
],
},
});
}
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
const textSecondary =
style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
monaco.editor.defineTheme("forge-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "C586C0" },
{ token: "keyword.preprocessor", foreground: "569CD6" },
{ token: "type", foreground: "4EC9B0" },
{ token: "constant", foreground: "4FC1FF" },
{ token: "string", foreground: "CE9178" },
{ token: "comment", foreground: "6A9955" },
{ token: "number", foreground: "B5CEA8" },
{ token: "operator", foreground: "D4D4D4" },
{ token: "keyword.sql", foreground: "4ec9b0" },
],
colors: {
"editor.background": bg,
"editor.foreground": text,
"editorCursor.foreground": accent,
"editor.lineHighlightBackground": surface,
"editorLineNumber.foreground": textSecondary,
"editorLineNumber.activeForeground": text,
"editor.selectionBackground": "#264f7840",
"editorWidget.background": surface,
"editorWidget.border": border,
"editorSuggestWidget.background": surface,
"editorSuggestWidget.border": border,
},
});
monacoWorkerFactory: configureDefaultWorkerFactory,
};
}
function languageFromPath(filePath: string): string {
@@ -219,69 +116,96 @@ function languageFromPath(filePath: string): string {
md: "markdown",
txt: "plaintext",
"2da": "plaintext",
cfg: "ini",
sh: "shellscript",
};
return map[ext ?? ""] ?? "plaintext";
}
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
workspacePath?: string;
}
export function MonacoEditor({
filePath,
content,
language,
onChange,
workspacePath,
}: MonacoEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
const resolvedLang = language ?? languageFromPath(filePath);
useLspClient(monacoRef);
useLspDocument(editorRef.current, filePath, resolvedLang);
const isNwscript = resolvedLang === "nwscript";
const handleMount: OnMount = useCallback(
(editorInstance, monaco) => {
editorRef.current = editorInstance;
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
registerNWScript(monaco);
defineForgeTheme(monaco);
monaco.editor.setTheme("forge-dark");
const fileUri = workspacePath
? `file://${workspacePath}/repos/nwn-module/${filePath}`
: `file:///workspace/repos/nwn-module/${filePath}`;
const model = editorInstance.getModel();
if (model) {
const lang = language ?? languageFromPath(filePath);
monaco.editor.setModelLanguage(model, lang);
}
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
codeResources: {
modified: {
text: content,
uri: fileUri,
enforceLanguageId: resolvedLang,
},
[filePath, language],
},
editorOptions: {
automaticLayout: true,
},
}),
[filePath],
);
const handleChange = useCallback(
(value: string | undefined) => {
if (value !== undefined) {
onChange?.(value);
const languageClientConfig = useMemo<LanguageClientConfig | undefined>(() => {
if (!isNwscript) return undefined;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return {
languageId: "nwscript",
connection: {
options: {
$type: "WebSocketUrl" as const,
url: `${protocol}//${window.location.host}/ws/lsp`,
},
},
clientOptions: {
documentSelector: ["nwscript"],
workspaceFolder: workspacePath
? {
index: 0,
name: "nwn-module",
uri: { scheme: "file", path: `${workspacePath}/repos/nwn-module` } as any,
}
: undefined,
},
};
}, [isNwscript, workspacePath]);
const handleTextChanged = useCallback(
(textChanges: TextContents) => {
if (textChanges.modified !== undefined) {
onChange?.(textChanges.modified);
}
},
[onChange],
);
const handleError = useCallback((error: Error) => {
console.error("[MonacoEditor]", error.message, error.stack);
}, []);
return (
<ReactMonacoEditor
value={content}
language={language ?? languageFromPath(filePath)}
theme="vs-dark"
onChange={handleChange}
onMount={handleMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
insertSpaces: true,
wordWrap: "off",
renderWhitespace: "selection",
bracketPairColorization: { enabled: true },
padding: { top: 8 },
}}
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
languageClientConfig={languageClientConfig}
onTextChanged={handleTextChanged}
onError={handleError}
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 () => {
@@ -82,50 +85,71 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
backgroundColor: active ? "var(--forge-accent)" : "transparent",
color: active ? "#121212" : "var(--forge-text-secondary)",
color: active ? "var(--forge-accent-text)" : "var(--forge-text-secondary)",
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
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: "'JetBrains Mono', 'Fira Code', monospace",
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: "#121212",
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 text-red-400">{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: "'JetBrains Mono', 'Fira Code', monospace", 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,18 +289,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</button>
{!collapsed.has(group.file) &&
group.matches.map((match, i) => (
group.matches.map((match, i) => {
const matchKey = `${match.file}-${match.line}-${match.column}-${i}`;
return (
<button
key={`${match.line}-${match.column}-${i}`}
key={matchKey}
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" }}
onMouseEnter={() => setHoveredMatch(matchKey)}
onMouseLeave={() => setHoveredMatch(null)}
style={{
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",
}}
>
<span
className="shrink-0 text-xs"
style={{
flexShrink: 0,
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "11px",
minWidth: "32px",
textAlign: "right",
@@ -249,9 +326,11 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
{match.line}
</span>
<span
className="truncate text-xs"
style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: "12px",
color: "var(--forge-text-secondary)",
}}
@@ -259,7 +338,8 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<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>
@@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
function getVscodeApiConfig(): MonacoVscodeApiConfig {
return {
$type: "extended",
viewsConfig: { $type: "EditorService" },
userConfiguration: {
json: JSON.stringify({
"workbench.colorTheme": "Default Dark Modern",
}),
},
monacoWorkerFactory: configureDefaultWorkerFactory,
};
}
interface SimpleEditorProps {
value: string;
language?: string;
onChange?: (value: string) => void;
options?: Record<string, unknown>;
readOnly?: boolean;
}
export function SimpleEditor({
value,
language = "plaintext",
onChange,
readOnly,
}: SimpleEditorProps) {
const uri = useMemo(
() => `inmemory://simple-${Date.now()}-${Math.random().toString(36).slice(2)}`,
[],
);
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
codeResources: {
modified: {
text: value,
uri,
enforceLanguageId: language,
},
},
editorOptions: {
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
readOnly,
},
}),
[],
);
const handleTextChanged = useCallback(
(textChanges: TextContents) => {
if (textChanges.modified !== undefined) {
onChange?.(textChanges.modified);
}
},
[onChange],
);
return (
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
onTextChanged={handleTextChanged}
/>
);
}
interface SimpleDiffEditorProps {
original: string;
modified: string;
language?: string;
options?: Record<string, unknown>;
}
export function SimpleDiffEditor({
original,
modified,
language = "plaintext",
}: SimpleDiffEditorProps) {
const baseUri = useMemo(
() => `inmemory://diff-${Date.now()}-${Math.random().toString(36).slice(2)}`,
[],
);
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
useDiffEditor: true,
codeResources: {
original: {
text: original,
uri: `${baseUri}-original`,
enforceLanguageId: language,
},
modified: {
text: modified,
uri: `${baseUri}-modified`,
enforceLanguageId: language,
},
},
diffEditorOptions: {
automaticLayout: true,
readOnly: true,
renderSideBySide: true,
},
}),
[original, modified, language],
);
return (
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
/>
);
}
@@ -39,15 +39,24 @@ function FlagsOverride({ 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)" }}>
Area Flags
</label>
<div className="flex items-center gap-6">
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
{bits.map(({ bit, label }) => {
const checked = (flags & (1 << bit)) !== 0;
return (
<label key={bit} className="flex cursor-pointer items-center gap-2 text-sm">
<label
key={bit}
style={{
display: "flex",
cursor: "pointer",
alignItems: "center",
gap: "0.5rem",
fontSize: "var(--text-sm)",
}}
>
<input
type="checkbox"
checked={checked}
@@ -57,15 +66,19 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
: flags & ~(1 << bit);
onChange("Flags", newFlags);
}}
className="h-4 w-4 rounded"
style={{ accentColor: "var(--forge-accent)" }}
style={{
height: "1rem",
width: "1rem",
borderRadius: "0.25rem",
accentColor: "var(--forge-accent)",
}}
/>
<span style={{ color: "var(--forge-text)" }}>{label}</span>
</label>
);
})}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
Raw value: {flags}
</span>
</div>
@@ -77,19 +90,25 @@ function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
const hex = intToHexColor(num);
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>
<div className="flex items-center gap-2">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
type="color"
value={hex}
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
className="h-8 w-10 cursor-pointer rounded border-0"
style={{ backgroundColor: "var(--forge-bg)" }}
style={{
height: "2rem",
width: "2.5rem",
cursor: "pointer",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--forge-bg)",
}}
/>
<span className="font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{hex}
</span>
</div>
@@ -106,54 +125,64 @@ function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
const ts = typeof tileset === "string" ? tileset : "";
return (
<div className="space-y-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Width</label>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<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)" }}>Width</label>
<input
type="number"
value={w}
min={1}
max={32}
onChange={(e) => onChange("Width", 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)",
}}
/>
</div>
<span style={{ color: "var(--forge-text-secondary)" }}>×</span>
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Height</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Height</label>
<input
type="number"
value={h}
min={1}
max={32}
onChange={(e) => onChange("Height", 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)",
}}
/>
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>tiles</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>tiles</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Tileset</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Tileset</label>
<input
type="text"
value={ts}
maxLength={16}
onChange={(e) => onChange("Tileset", e.target.value)}
className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
style={{
width: "11rem",
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)",
}}
/>
@@ -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={{
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20",
color: type === "entry" ? "#60a5fa" : "#4ade80",
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: "#fff" }}
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: "#ef4444" }}>{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)",
}}
/>
+140 -277
View File
@@ -1,23 +1,12 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Code2, Save, ChevronRight, ChevronDown } from "lucide-react";
import { api } from "../../services/api";
export enum GffFieldType {
Byte = 0,
Char = 1,
Word = 2,
Short = 3,
Dword = 4,
Int = 5,
Dword64 = 6,
Int64 = 7,
Float = 8,
Double = 9,
CExoString = 10,
ResRef = 11,
CExoLocString = 12,
Void = 13,
Struct = 14,
List = 15,
Byte = 0, Char = 1, Word = 2, Short = 3, Dword = 4, Int = 5,
Dword64 = 6, Int64 = 7, Float = 8, Double = 9,
CExoString = 10, ResRef = 11, CExoLocString = 12, Void = 13,
Struct = 14, List = 15,
}
export interface GffFieldSchema {
@@ -56,6 +45,11 @@ function getLocStringText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "object") {
const v = value as Record<string, unknown>;
if (typeof v["0"] === "string") return v["0"];
for (const [key, val] of Object.entries(v)) {
if (key === "id") continue;
if (/^\d+$/.test(key) && typeof val === "string") return val;
}
if (v.strings && typeof v.strings === "object") {
const strings = v.strings as Record<string, string>;
return strings["0"] ?? Object.values(strings)[0] ?? "";
@@ -63,15 +57,15 @@ function getLocStringText(value: unknown): string {
if (v.value && typeof v.value === "object") {
return getLocStringText(v.value);
}
if (typeof v.id === "number") {
const tlkRow = v.id >= 16777216 ? v.id - 16777216 : v.id;
return `(TLK #${tlkRow})`;
}
}
return String(value);
}
function setFieldValue(
data: Record<string, unknown>,
label: string,
newValue: unknown,
): Record<string, unknown> {
function setFieldValue(data: Record<string, unknown>, label: string, newValue: unknown): Record<string, unknown> {
const updated = { ...data };
const existing = data[label];
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
@@ -82,28 +76,25 @@ function setFieldValue(
return updated;
}
function setLocStringValue(
data: Record<string, unknown>,
label: string,
text: string,
): Record<string, unknown> {
function setLocStringValue(data: Record<string, unknown>, label: string, text: string): Record<string, unknown> {
const updated = { ...data };
const existing = data[label];
if (existing && typeof existing === "object") {
const ex = existing as Record<string, unknown>;
if ("value" in ex && ex.value && typeof ex.value === "object") {
const inner = ex.value as Record<string, unknown>;
updated[label] = {
...ex,
value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } },
};
if (typeof inner["0"] === "string") {
updated[label] = { ...ex, value: { ...inner, "0": text } };
} else {
updated[label] = { ...ex, value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } } };
}
} else if ("strings" in ex) {
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
} else {
updated[label] = { ...ex, value: { strings: { "0": text } } };
}
} else {
updated[label] = { type: "cexolocstring", value: { strings: { "0": text } } };
updated[label] = { type: "cexolocstring", value: { "0": text } };
}
return updated;
}
@@ -112,6 +103,31 @@ function isNumericType(type: GffFieldType): boolean {
return type <= GffFieldType.Double;
}
const fieldRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.5rem 0",
};
const fieldLabel: React.CSSProperties = {
width: "11rem",
flexShrink: 0,
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
};
const fieldInput: React.CSSProperties = {
flex: 1,
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "inherit",
};
interface FieldRendererProps {
field: GffFieldSchema;
value: unknown;
@@ -126,14 +142,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
if (field.type === GffFieldType.Void) {
const hex = typeof value === "string" ? value : "";
return (
<div className="flex items-start gap-3">
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<code
className="flex-1 rounded px-2 py-1.5 text-xs break-all"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
<code style={{ flex: 1, borderRadius: "0.375rem", padding: "0.5rem 0.75rem", fontSize: "var(--text-xs)", wordBreak: "break-all", backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
{hex || "(empty)"}
</code>
</div>
@@ -141,26 +152,7 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
}
if (field.type === GffFieldType.CExoLocString) {
const text = getLocStringText(value);
return (
<div className="flex items-start gap-3">
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="text"
value={text}
readOnly={isReadonly}
onChange={(e) => onLocStringChange?.(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
);
return <LocStringField field={field} value={value} isReadonly={isReadonly} onLocStringChange={onLocStringChange} />;
}
if (field.type === GffFieldType.List) {
@@ -176,23 +168,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
const num = typeof value === "number" ? value : 0;
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="number"
value={num}
readOnly={isReadonly}
step={isFloat ? "0.01" : "1"}
onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))}
className="w-32 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<div style={fieldRow}>
<label style={fieldLabel}>{field.displayName}</label>
<input type="number" value={num} readOnly={isReadonly} step={isFloat ? "0.01" : "1"} onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))} style={{ ...fieldInput, flex: "none", width: "8rem" }} />
</div>
);
}
@@ -201,54 +179,51 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
const str = typeof value === "string" ? value : "";
const valid = str.length <= 16;
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<div className="flex flex-1 items-center gap-2">
<input
type="text"
value={str}
readOnly={isReadonly}
maxLength={16}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: valid ? "var(--forge-border)" : "#ef4444",
color: "var(--forge-text)",
}}
/>
<span
className="text-xs"
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }}
>
{str.length}/16
</span>
<div style={fieldRow}>
<label style={fieldLabel}>{field.displayName}</label>
<div style={{ display: "flex", flex: 1, alignItems: "center", gap: "0.5rem" }}>
<input type="text" value={str} readOnly={isReadonly} maxLength={16} onChange={(e) => onChange(e.target.value)} style={{ ...fieldInput, fontFamily: "var(--font-mono)", borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)" }} />
<span style={{ fontSize: "var(--text-xs)", color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)", flexShrink: 0 }}>{str.length}/16</span>
</div>
</div>
);
}
// CExoString fallback
const str = typeof value === "string" ? value : String(value ?? "");
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="text"
value={str}
readOnly={isReadonly}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<div style={fieldRow}>
<label style={fieldLabel}>{field.displayName}</label>
<input type="text" value={str} readOnly={isReadonly} onChange={(e) => onChange(e.target.value)} style={fieldInput} />
</div>
);
}
function LocStringField({ field, value, isReadonly, onLocStringChange }: { field: GffFieldSchema; value: unknown; isReadonly: boolean; onLocStringChange?: (text: string) => void }) {
const text = getLocStringText(value);
const isTlkRef = text.startsWith("(TLK #");
const [resolved, setResolved] = useState<string | null>(null);
useEffect(() => {
if (!isTlkRef || !value || typeof value !== "object") return;
const v = value as Record<string, unknown>;
const id = typeof v.id === "number" ? v.id : undefined;
if (id === undefined) return;
api.editor.tlkLookup?.(id).then((r) => { if (r) setResolved(r); }).catch(() => {});
}, [value, isTlkRef]);
const displayText = resolved ?? text;
return (
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "0.25rem" }}>
<input type="text" value={displayText} readOnly={isReadonly || isTlkRef} onChange={(e) => onLocStringChange?.(e.target.value)} style={{ ...fieldInput, ...(isTlkRef && !resolved ? { fontStyle: "italic", color: "var(--forge-text-secondary)" } : {}) }} />
{isTlkRef && (
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{resolved ? text : "TLK reference — name stored in talk table"}
</span>
)}
</div>
</div>
);
}
@@ -257,44 +232,25 @@ function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }
const [expanded, setExpanded] = useState(false);
return (
<div className="flex flex-col gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
<span className="rounded px-1.5 py-0.5 text-xs" style={{ backgroundColor: "var(--forge-bg)" }}>
{items.length} {items.length === 1 ? "item" : "items"}
</span>
<span style={{ fontSize: "var(--text-xs)", backgroundColor: "var(--forge-surface-raised)", borderRadius: "0.25rem", padding: "0.125rem 0.5rem" }}>{items.length} {items.length === 1 ? "item" : "items"}</span>
</button>
{expanded && (
<div
className="ml-4 mt-1 space-y-2 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
<div style={{ marginLeft: "1rem", marginTop: "0.25rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{items.map((item, i) => (
<div key={i} className="rounded p-2" style={{ backgroundColor: "var(--forge-bg)" }}>
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
[{i}]
</div>
<div key={i} style={{ borderRadius: "0.375rem", padding: "0.5rem 0.75rem", backgroundColor: "var(--forge-bg)" }}>
<div style={{ fontSize: "var(--text-xs)", fontWeight: 500, color: "var(--forge-text-secondary)", marginBottom: "0.25rem" }}>[{i}]</div>
{typeof item === "object" && item !== null ? (
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(item, null, 2)}
</pre>
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(item, null, 2)}</pre>
) : (
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
{String(item)}
</span>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>{String(item)}</span>
)}
</div>
))}
{items.length === 0 && (
<div className="py-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
(empty list)
</div>
)}
{items.length === 0 && <div style={{ padding: "0.25rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>(empty list)</div>}
</div>
)}
</div>
@@ -305,23 +261,14 @@ function StructField({ field, value }: { field: GffFieldSchema; value?: Record<s
const [expanded, setExpanded] = useState(false);
return (
<div className="flex flex-col gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
</button>
{expanded && value && (
<div
className="ml-4 mt-1 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(value, null, 2)}
</pre>
<div style={{ marginLeft: "1rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)" }}>
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(value, null, 2)}</pre>
</div>
)}
</div>
@@ -345,89 +292,41 @@ export interface FieldOverrideProps {
onChange: (label: string, value: unknown) => void;
}
export function GffEditor({
repo,
filePath,
content,
onSave,
onSwitchToRaw,
fieldOverrides,
headerSlot,
}: GffEditorProps) {
export function GffEditor({ repo, filePath, content, onSave, onSwitchToRaw, fieldOverrides, headerSlot }: GffEditorProps) {
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
const [data, setData] = useState<Record<string, unknown>>({});
const [activeCategory, setActiveCategory] = useState<string>("");
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
useEffect(() => {
try {
setData(JSON.parse(content));
} catch {
setError("Failed to parse JSON content");
}
}, [content]);
useEffect(() => { try { setData(JSON.parse(content)); } catch { setError("Failed to parse JSON content"); } }, [content]);
useEffect(() => {
if (!gffType) return;
api.editor
.gffSchema(gffType)
.then((s) => {
setSchema(s);
if (s.categories.length > 0) setActiveCategory(s.categories[0]);
})
.catch(() => setError(`Failed to load schema for .${gffType}`));
api.editor.gffSchema(gffType).then((s) => { setSchema(s); if (s.categories.length > 0) setActiveCategory(s.categories[0]); }).catch(() => setError(`Failed to load schema for .${gffType}`));
}, [gffType]);
const handleFieldChange = useCallback((label: string, value: unknown) => {
setData((prev) => {
const updated = setFieldValue(prev, label, value);
setDirty(true);
return updated;
});
}, []);
const handleLocStringChange = useCallback((label: string, text: string) => {
setData((prev) => {
const updated = setLocStringValue(prev, label, text);
setDirty(true);
return updated;
});
}, []);
const handleFieldChange = useCallback((label: string, value: unknown) => { setData((prev) => { setDirty(true); return setFieldValue(prev, label, value); }); }, []);
const handleLocStringChange = useCallback((label: string, text: string) => { setData((prev) => { setDirty(true); return setLocStringValue(prev, label, text); }); }, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const newContent = JSON.stringify(data, null, 4) + "\n";
await api.editor.writeFile(repo, filePath, newContent);
setDirty(false);
onSave?.(newContent);
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
try { const newContent = JSON.stringify(data, null, 4) + "\n"; await api.editor.writeFile(repo, filePath, newContent); setDirty(false); onSave?.(newContent); }
catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
}, [data, repo, filePath, onSave]);
const categoryFields = useMemo(() => {
if (!schema) return [];
return schema.fields.filter((f) => f.category === activeCategory && !f.hidden);
}, [schema, activeCategory]);
const categoryFields = useMemo(() => schema ? schema.fields.filter((f) => f.category === activeCategory && !f.hidden) : [], [schema, activeCategory]);
if (error && !schema) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p>
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
<div style={{ textAlign: "center" }}>
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
{onSwitchToRaw && (
<button
onClick={onSwitchToRaw}
className="mt-3 rounded px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-surface)", color: "var(--forge-text)" }}
>
<button onClick={onSwitchToRaw} style={{ marginTop: "0.75rem", borderRadius: "0.375rem", padding: "0.5rem 1rem", fontSize: "var(--text-sm)", backgroundColor: "var(--forge-surface)", color: "var(--forge-text)", border: "1px solid var(--forge-border)", cursor: "pointer" }}>
Open as Raw JSON
</button>
)}
@@ -438,52 +337,29 @@ export function GffEditor({
if (!schema) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Loading schema...</p>
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Loading schema...</p>
</div>
);
}
return (
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<div style={{ display: "flex", flexDirection: "column", height: "100%", 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)" }}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
{schema.displayName} Editor
</span>
{dirty && (
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
(unsaved changes)
</span>
)}
{error && (
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, padding: "0.625rem 1.25rem", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)" }}>{schema.displayName}</span>
{dirty && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)", fontWeight: 500 }}>unsaved</span>}
{error && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>{error}</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)" }}
>
Switch to Raw JSON
<button onClick={onSwitchToRaw} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "1px solid var(--forge-border)", backgroundColor: "transparent", color: "var(--forge-text-secondary)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
<Code2 size={13} /> Raw JSON
</button>
)}
<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: "#fff",
}}
>
{saving ? "Saving..." : "Save"}
<button onClick={handleSave} disabled={!dirty || saving} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "none", backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)", fontSize: "var(--text-xs)", fontWeight: 600, cursor: dirty && !saving ? "pointer" : "not-allowed", opacity: dirty && !saving ? 1 : 0.4 }}>
<Save size={13} /> {saving ? "Saving..." : "Save"}
</button>
</div>
</div>
@@ -491,40 +367,29 @@ export function GffEditor({
{headerSlot}
{/* Category tabs */}
<div
className="flex shrink-0 gap-0 overflow-x-auto border-b"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
<div style={{ display: "flex", flexShrink: 0, overflowX: "auto", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
{schema.categories.map((cat) => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className="shrink-0 px-4 py-2 text-sm transition-colors"
style={{
color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)",
borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent",
}}
>
<button key={cat} onClick={() => setActiveCategory(cat)} style={{ flexShrink: 0, padding: "0.625rem 1.25rem", fontSize: "var(--text-sm)", fontWeight: activeCategory === cat ? 600 : 400, color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)", borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent", background: "none", border: "none", borderBottomStyle: "solid", cursor: "pointer", transition: "color 150ms ease-out" }}>
{cat}
</button>
))}
</div>
{/* Fields */}
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-2xl space-y-4">
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "42rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{categoryFields.map((field) => {
const override = fieldOverrides?.get(field.label);
if (override) {
return (
<div key={field.label}>
<div key={field.label} style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--forge-border)" }}>
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
</div>
);
}
return (
<div key={field.label} title={field.description}>
<div key={field.label} title={field.description} style={{ borderBottom: "1px solid var(--forge-border)" }}>
<FieldRenderer
field={field}
value={getFieldValue(data, field.label)}
@@ -535,9 +400,7 @@ export function GffEditor({
);
})}
{categoryFields.length === 0 && (
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
No fields in this category
</p>
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>No fields in this category</p>
)}
</div>
</div>
@@ -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: "#ef4444" }}
>
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>
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
useEffect(() => {
if (!containerRef.current) return;
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim();
const fg = style.getPropertyValue("--forge-text").trim();
const accent = style.getPropertyValue("--forge-accent").trim();
const secondary = style.getPropertyValue("--forge-text-secondary").trim();
const accentHover = style.getPropertyValue("--forge-accent-hover").trim();
const term = new XTerm({
theme: {
background: "#121212",
foreground: "#f2f2f2",
cursor: "#946200",
selectionBackground: "#946200",
selectionForeground: "#f2f2f2",
black: "#121212",
brightBlack: "#666666",
white: "#f2f2f2",
brightWhite: "#ffffff",
yellow: "#946200",
brightYellow: "#c48800",
background: bg,
foreground: fg,
cursor: accent,
selectionBackground: accent,
selectionForeground: fg,
black: bg,
brightBlack: secondary,
white: fg,
brightWhite: fg,
yellow: accent,
brightYellow: accentHover,
},
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: 13,
cursorBlink: true,
});
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: "#121212" }}
style={{ backgroundColor: "var(--forge-bg)" }}
/>
);
}
@@ -1,66 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { getLspClient, type LspStatus } from "../lib/lspClient.js";
import type { editor } from "monaco-editor";
export function useLspClient(monaco: typeof import("monaco-editor") | null) {
const [status, setStatus] = useState<LspStatus>("disconnected");
const connectingRef = useRef(false);
useEffect(() => {
if (!monaco || connectingRef.current) return;
const client = getLspClient();
if (client.status === "ready") {
setStatus("ready");
return;
}
connectingRef.current = true;
const unsub = client.onStatusChange(setStatus);
client.connect(monaco).catch((err) => {
console.error("[LSP] Connection failed:", err);
});
return () => {
unsub();
};
}, [monaco]);
return { lspClient: getLspClient(), status };
}
export function useLspDocument(
editorInstance: editor.IStandaloneCodeEditor | null,
filePath: string,
language: string,
) {
const prevPathRef = useRef<string | null>(null);
useEffect(() => {
if (!editorInstance) return;
const client = getLspClient();
if (client.status !== "ready") return;
const model = editorInstance.getModel();
if (!model) return;
if (prevPathRef.current && prevPathRef.current !== filePath) {
const prevUri = model.uri;
client.notifyDidClose(prevUri);
}
client.notifyDidOpen(model.uri, language, model.getValue());
prevPathRef.current = filePath;
const changeDisposable = model.onDidChangeContent(() => {
client.notifyDidChange(model.uri, model.getValue());
});
return () => {
changeDisposable.dispose();
client.notifyDidClose(model.uri);
};
}, [editorInstance, filePath, language]);
}
+115 -42
View File
@@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { Terminal } from "../components/terminal/Terminal";
import { useWebSocket } from "../hooks/useWebSocket";
import { useTheme } from "../hooks/useTheme";
import {
Code2,
Wrench,
Hammer,
Play,
GitBranch,
Settings,
Sun,
Moon,
Terminal as TerminalIcon,
ChevronDown,
ChevronUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
const NAV_ITEMS = [
{ path: "/editor", label: "Editor", icon: "\u270E" },
{ path: "/toolset", label: "Toolset", icon: "\u2699" },
{ path: "/build", label: "Build", icon: "\u2692" },
{ path: "/server", label: "Server", icon: "\u25B6" },
{ path: "/repos", label: "Repos", icon: "\u2387" },
{ path: "/settings", label: "Settings", icon: "\u2318" },
const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
{ path: "/editor", label: "Editor", Icon: Code2 },
{ path: "/toolset", label: "Toolset", Icon: Wrench },
{ path: "/build", label: "Build", Icon: Hammer },
{ path: "/server", label: "Server", Icon: Play },
{ path: "/repos", label: "Repos", Icon: GitBranch },
{ path: "/settings", label: "Settings", Icon: Settings },
];
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
const navigate = useNavigate();
const { subscribe } = useWebSocket();
const { theme, toggleTheme } = useTheme();
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
useEffect(() => {
return subscribe("git:upstream-update", (event) => {
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
};
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
<div style={{ display: "flex", height: "100vh", overflow: "hidden", backgroundColor: "var(--forge-bg)" }}>
{/* Left sidebar nav */}
<nav
className="flex shrink-0 flex-col"
aria-label="Main navigation"
style={{
display: "flex",
flexDirection: "column",
width: "56px",
flexShrink: 0,
borderRight: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
}}
>
<Link
to="/"
className="flex items-center justify-center py-3"
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
title="Dashboard"
>
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} />
<img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
</Link>
<div className="mt-2 flex flex-1 flex-col">
<div style={{ marginTop: "0.25rem", display: "flex", flexDirection: "column", flex: 1 }}>
{NAV_ITEMS.map((item) => {
const isActive =
item.path === "/"
@@ -115,20 +133,44 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<Link
key={item.path}
to={item.path}
className="relative flex flex-col items-center justify-center py-2.5 text-center transition-colors hover:bg-white/5"
style={{
borderLeft: isActive
? "3px solid var(--forge-accent)"
: "3px solid transparent",
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0.625rem 0",
position: "relative",
textDecoration: "none",
transition: "background-color 150ms, color 150ms",
backgroundColor: isActive ? "var(--forge-accent-subtle)" : undefined,
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? "var(--forge-accent-subtle)" : ""; }}
title={item.label}
>
<span className="text-base">{item.icon}</span>
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span>
<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 bg-amber-500 px-0.5 text-[8px] font-bold text-black">
<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>
)}
@@ -139,69 +181,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<button
onClick={toggleTheme}
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0.625rem 0",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
width: "100%",
transition: "background-color 150ms, color 150ms",
}}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{theme === "dark" ? "Light" : "Dark"}</span>
</button>
</nav>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
<header
className="flex shrink-0 items-center gap-4 px-4 py-1.5"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<div className="flex items-center gap-2">
<span
className="text-lg font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "0.375rem 1rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<span
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-lg)",
fontWeight: 700,
color: "var(--forge-accent)",
}}
>
Layonara Forge
</span>
</div>
<div className="flex-1" />
</header>
<div className="flex flex-1 overflow-hidden">
{sidebar && (
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{sidebar && showSidebar && (
<aside
className="shrink-0 overflow-hidden"
style={{
width: "250px",
flexShrink: 0,
overflow: "hidden",
borderRight: "1px solid var(--forge-border)",
}}
>
{sidebar}
</aside>
)}
<main className="flex-1 overflow-hidden">
<main style={{ flex: 1, overflow: "hidden" }}>
<Outlet />
</main>
</div>
<button
onClick={() => setTerminalOpen((v) => !v)}
className="flex shrink-0 items-center gap-1 px-3 py-0.5 text-xs transition-colors hover:bg-white/5"
style={{
borderTop: "1px solid var(--forge-border)",
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.75rem",
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
background: "none",
border: "none",
borderTop: "1px solid var(--forge-border)",
width: "100%",
cursor: "pointer",
transition: "background-color 150ms",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
<span>{terminalOpen ? "\u25BC" : "\u25B2"}</span>
<TerminalIcon size={12} />
<span>Terminal</span>
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
{terminalOpen && (
<div
className="shrink-0 overflow-hidden"
style={{
height: "300px",
flexShrink: 0,
overflow: "hidden",
borderTop: "1px solid var(--forge-border)",
}}
>
+18 -5
View File
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
export function SetupLayout() {
return (
<div
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
style={{
minHeight: "100vh",
backgroundColor: "var(--forge-bg)",
backgroundImage: "linear-gradient(rgba(0,0,0,0.75), rgba(0,0,0,0.85)), url('/page-bg.jpg')",
backgroundImage: "linear-gradient(oklch(15% 0.015 65 / 0.85), oklch(12% 0.01 65 / 0.92)), url('/page-bg.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
padding: "2rem",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<div className="w-full max-w-2xl">
<div style={{ width: "100%", maxWidth: "52rem", marginTop: "4vh" }}>
<div style={{ marginBottom: "2rem" }}>
<h1
className="mb-8 text-center text-3xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
fontFamily: "var(--font-heading)",
fontSize: "var(--text-2xl)",
fontWeight: 700,
color: "var(--forge-accent)",
margin: 0,
}}
>
Layonara Forge
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Development environment setup
</p>
</div>
<Outlet />
</div>
</div>
-359
View File
@@ -1,359 +0,0 @@
import { toSocket } from "vscode-ws-jsonrpc";
import { createWebSocketConnection } from "vscode-ws-jsonrpc";
import type * as lsp from "vscode-languageserver-protocol";
import type { editor, languages, IDisposable, Uri, MarkerSeverity } from "monaco-editor";
const LSP_TRACE = false;
function trace(...args: unknown[]) {
if (LSP_TRACE) console.debug("[LSP]", ...args);
}
export type LspStatus = "disconnected" | "connecting" | "initializing" | "ready" | "error";
export class LspClient {
private ws: WebSocket | null = null;
private connection: ReturnType<typeof createWebSocketConnection> | null = null;
private disposables: IDisposable[] = [];
private documentVersions = new Map<string, number>();
private statusListeners = new Set<(status: LspStatus) => void>();
private _status: LspStatus = "disconnected";
private monacoInstance: typeof import("monaco-editor") | null = null;
get status() {
return this._status;
}
private setStatus(s: LspStatus) {
this._status = s;
for (const cb of this.statusListeners) cb(s);
}
onStatusChange(cb: (status: LspStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
async connect(monaco: typeof import("monaco-editor")): Promise<void> {
this.monacoInstance = monaco;
this.setStatus("connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/lsp`;
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(url);
this.ws = ws;
ws.onopen = async () => {
try {
const socket = toSocket(ws);
this.connection = createWebSocketConnection(socket, {
error: (msg) => console.error("[LSP]", msg),
warn: (msg) => console.warn("[LSP]", msg),
info: (msg) => trace(msg),
log: (msg) => trace(msg),
});
this.setupNotificationHandlers(monaco);
this.connection.listen();
this.setStatus("initializing");
await this.initialize();
this.registerProviders(monaco);
this.setStatus("ready");
resolve();
} catch (err) {
this.setStatus("error");
reject(err);
}
};
ws.onerror = () => {
this.setStatus("error");
reject(new Error("WebSocket connection failed"));
};
ws.onclose = () => {
this.setStatus("disconnected");
};
});
}
private async initialize(): Promise<void> {
if (!this.connection) throw new Error("No connection");
const initParams: lsp.InitializeParams = {
processId: null,
capabilities: {
textDocument: {
completion: {
completionItem: {
snippetSupport: false,
documentationFormat: ["plaintext", "markdown"],
},
},
hover: {
contentFormat: ["plaintext", "markdown"],
},
synchronization: {
didSave: true,
},
publishDiagnostics: {
relatedInformation: true,
},
},
},
rootUri: "file:///workspace",
workspaceFolders: null,
};
trace("→ initialize", initParams);
const result = await this.connection.sendRequest("initialize", initParams);
trace("← initialize", result);
this.connection.sendNotification("initialized", {});
trace("→ initialized");
}
private setupNotificationHandlers(monaco: typeof import("monaco-editor")): void {
if (!this.connection) return;
this.connection.onNotification(
"textDocument/publishDiagnostics",
(params: lsp.PublishDiagnosticsParams) => {
trace("← publishDiagnostics", params);
const uri = monaco.Uri.parse(params.uri);
const model = monaco.editor.getModel(uri);
if (!model) return;
const markers: editor.IMarkerData[] = params.diagnostics.map((d) => ({
severity: this.mapSeverity(monaco, d.severity),
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
message: d.message,
source: d.source,
}));
monaco.editor.setModelMarkers(model, "nwscript-lsp", markers);
},
);
}
private mapSeverity(
monaco: typeof import("monaco-editor"),
severity: lsp.DiagnosticSeverity | undefined,
): MarkerSeverity {
switch (severity) {
case 1:
return monaco.MarkerSeverity.Error;
case 2:
return monaco.MarkerSeverity.Warning;
case 3:
return monaco.MarkerSeverity.Info;
case 4:
return monaco.MarkerSeverity.Hint;
default:
return monaco.MarkerSeverity.Info;
}
}
private registerProviders(monaco: typeof import("monaco-editor")): void {
this.disposables.push(
monaco.languages.registerCompletionItemProvider("nwscript", {
triggerCharacters: ["."],
provideCompletionItems: async (model, position) => {
if (!this.connection) return { suggestions: [] };
const params: lsp.CompletionParams = {
textDocument: { uri: model.uri.toString() },
position: {
line: position.lineNumber - 1,
character: position.column - 1,
},
};
trace("→ completion", params);
const result: lsp.CompletionItem[] | lsp.CompletionList | null =
await this.connection.sendRequest("textDocument/completion", params);
trace("← completion", result);
if (!result) return { suggestions: [] };
const items = Array.isArray(result) ? result : result.items;
const suggestions: languages.CompletionItem[] = items.map((item) => ({
label: item.label,
kind: this.mapCompletionKind(monaco, item.kind),
insertText: item.insertText ?? item.label,
detail: item.detail,
documentation: item.documentation
? typeof item.documentation === "string"
? item.documentation
: { value: item.documentation.value }
: undefined,
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
}));
return { suggestions };
},
}),
);
this.disposables.push(
monaco.languages.registerHoverProvider("nwscript", {
provideHover: async (model, position) => {
if (!this.connection) return null;
const params: lsp.HoverParams = {
textDocument: { uri: model.uri.toString() },
position: {
line: position.lineNumber - 1,
character: position.column - 1,
},
};
trace("→ hover", params);
const result: lsp.Hover | null = await this.connection.sendRequest(
"textDocument/hover",
params,
);
trace("← hover", result);
if (!result?.contents) return null;
const contents = Array.isArray(result.contents)
? result.contents.map((c) =>
typeof c === "string" ? { value: c } : { value: c.value },
)
: typeof result.contents === "string"
? [{ value: result.contents }]
: "value" in result.contents
? [{ value: result.contents.value }]
: [{ value: String(result.contents) }];
return {
contents,
range: result.range
? {
startLineNumber: result.range.start.line + 1,
startColumn: result.range.start.character + 1,
endLineNumber: result.range.end.line + 1,
endColumn: result.range.end.character + 1,
}
: undefined,
};
},
}),
);
}
private mapCompletionKind(
monaco: typeof import("monaco-editor"),
kind: lsp.CompletionItemKind | undefined,
): languages.CompletionItemKind {
const k = monaco.languages.CompletionItemKind;
switch (kind) {
case 1:
return k.Text;
case 2:
return k.Method;
case 3:
return k.Function;
case 4:
return k.Constructor;
case 5:
return k.Field;
case 6:
return k.Variable;
case 7:
return k.Class;
case 8:
return k.Interface;
case 9:
return k.Module;
case 10:
return k.Property;
case 13:
return k.Enum;
case 14:
return k.Keyword;
case 15:
return k.Snippet;
case 21:
return k.Constant;
case 22:
return k.Struct;
default:
return k.Text;
}
}
notifyDidOpen(uri: Uri, languageId: string, text: string): void {
if (!this.connection || this._status !== "ready") return;
this.documentVersions.set(uri.toString(), 1);
const params: lsp.DidOpenTextDocumentParams = {
textDocument: {
uri: uri.toString(),
languageId,
version: 1,
text,
},
};
trace("→ didOpen", params.textDocument.uri);
this.connection.sendNotification("textDocument/didOpen", params);
}
notifyDidChange(uri: Uri, text: string): void {
if (!this.connection || this._status !== "ready") return;
const version = (this.documentVersions.get(uri.toString()) ?? 0) + 1;
this.documentVersions.set(uri.toString(), version);
const params: lsp.DidChangeTextDocumentParams = {
textDocument: { uri: uri.toString(), version },
contentChanges: [{ text }],
};
trace("→ didChange", params.textDocument.uri);
this.connection.sendNotification("textDocument/didChange", params);
}
notifyDidClose(uri: Uri): void {
if (!this.connection || this._status !== "ready") return;
this.documentVersions.delete(uri.toString());
const params: lsp.DidCloseTextDocumentParams = {
textDocument: { uri: uri.toString() },
};
trace("→ didClose", params.textDocument.uri);
this.connection.sendNotification("textDocument/didClose", params);
}
dispose(): void {
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.connection?.dispose();
this.connection = null;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close();
}
this.ws = null;
this.documentVersions.clear();
this.setStatus("disconnected");
}
}
let globalClient: LspClient | null = null;
export function getLspClient(): LspClient {
if (!globalClient) {
globalClient = new LspClient();
}
return globalClient;
}
@@ -0,0 +1,63 @@
import { registerExtension } from "@codingame/monaco-vscode-api/extensions";
const manifest = {
name: "nwscript-language",
displayName: "NWScript Language",
description: "NWScript syntax highlighting for Neverwinter Nights",
version: "1.0.0",
publisher: "layonara",
engines: { vscode: "*" },
contributes: {
languages: [
{
id: "nwscript",
aliases: ["Neverwinter Script", "nwscript"],
extensions: [".nss"],
configuration: "./syntaxes/language-configuration.json",
},
],
grammars: [
{
language: "nwscript",
scopeName: "source.nss",
path: "./syntaxes/nwscript-ee.tmLanguage.json",
},
],
themes: [
{
id: "forge-dark",
label: "Forge Dark",
uiTheme: "vs-dark",
path: "./themes/forge-dark.json",
},
],
},
};
const { registerFileUrl, whenReady } = registerExtension(manifest);
registerFileUrl(
"./syntaxes/nwscript-ee.tmLanguage.json",
new URL("./syntaxes/nwscript-ee.tmLanguage.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
registerFileUrl(
"./syntaxes/language-configuration.json",
new URL("./syntaxes/language-configuration.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
registerFileUrl(
"package.json",
new URL("./package.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
registerFileUrl(
"./themes/forge-dark.json",
new URL("./themes/forge-dark.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
export { whenReady };
@@ -0,0 +1,32 @@
{
"name": "nwscript-language-extension",
"version": "1.0.0",
"engines": {
"vscode": "*"
},
"contributes": {
"languages": [
{
"id": "nwscript",
"aliases": ["Neverwinter Script", "nwscript"],
"extensions": [".nss"],
"configuration": "./syntaxes/language-configuration.json"
}
],
"grammars": [
{
"language": "nwscript",
"scopeName": "source.nss",
"path": "./syntaxes/nwscript-ee.tmLanguage.json"
}
],
"themes": [
{
"id": "forge-dark",
"label": "Forge Dark",
"uiTheme": "vs-dark",
"path": "./themes/forge-dark.json"
}
]
}
}
@@ -0,0 +1,30 @@
{
"comments": {
// symbol used for single line comment. Remove this entry if your language does not support line comments
"lineComment": "//",
// symbols used for start and end a block comment. Remove this entry if your language does not support block comments
"blockComment": ["/*", "*/"]
},
// symbols used as brackets
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
// symbols that are auto closed when typing
"autoClosingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"]
],
// symbols that that can be used to surround a selection
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"]
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,347 @@
{
"name": "Forge Dark",
"type": "dark",
"colors": {
"editor.background": "#231e17",
"editor.foreground": "#ece8e3",
"editor.lineHighlightBackground": "#302a2040",
"editor.selectionBackground": "#3d3018",
"editor.selectionHighlightBackground": "#3d301860",
"editor.inactiveSelectionBackground": "#3d301850",
"editor.wordHighlightBackground": "#3d301840",
"editor.wordHighlightStrongBackground": "#3d301860",
"editor.findMatchBackground": "#b07a0040",
"editor.findMatchHighlightBackground": "#b07a0025",
"editor.hoverHighlightBackground": "#3d301830",
"editor.rangeHighlightBackground": "#3d301820",
"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",
"editorRuler.foreground": "#4a403540",
"editorGutter.background": "#231e17",
"editorError.foreground": "#f14c4c",
"editorWarning.foreground": "#cca700",
"editorInfo.foreground": "#3794ff",
"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",
"sideBar.background": "#302a20",
"sideBar.foreground": "#ece8e3",
"sideBar.border": "#4a4035",
"sideBarTitle.foreground": "#ece8e3",
"sideBarSectionHeader.background": "#302a20",
"sideBarSectionHeader.foreground": "#ece8e3",
"sideBarSectionHeader.border": "#4a4035",
"activityBar.background": "#231e17",
"activityBar.foreground": "#ece8e3",
"activityBar.border": "#4a4035",
"activityBar.activeBorder": "#b07a00",
"activityBarBadge.background": "#b07a00",
"activityBarBadge.foreground": "#231e17",
"titleBar.activeBackground": "#231e17",
"titleBar.activeForeground": "#ece8e3",
"titleBar.inactiveBackground": "#231e17",
"titleBar.inactiveForeground": "#a69f96",
"titleBar.border": "#4a4035",
"statusBar.background": "#231e17",
"statusBar.foreground": "#a69f96",
"statusBar.border": "#4a4035",
"statusBar.debuggingBackground": "#b07a00",
"statusBar.debuggingForeground": "#231e17",
"statusBar.noFolderBackground": "#231e17",
"tab.activeBackground": "#231e17",
"tab.activeForeground": "#ece8e3",
"tab.activeBorderTop": "#b07a00",
"tab.inactiveBackground": "#302a20",
"tab.inactiveForeground": "#a69f96",
"tab.border": "#4a4035",
"tab.hoverBackground": "#3b3328",
"editorGroupHeader.tabsBackground": "#302a20",
"editorGroupHeader.tabsBorder": "#4a4035",
"editorGroup.border": "#4a4035",
"panel.background": "#302a20",
"panel.border": "#4a4035",
"panelTitle.activeBorder": "#b07a00",
"panelTitle.activeForeground": "#ece8e3",
"panelTitle.inactiveForeground": "#a69f96",
"list.activeSelectionBackground": "#3d3018",
"list.activeSelectionForeground": "#ece8e3",
"list.inactiveSelectionBackground": "#3d301880",
"list.hoverBackground": "#302a2080",
"list.highlightForeground": "#b07a00",
"list.focusOutline": "#b07a00",
"input.background": "#231e17",
"input.foreground": "#ece8e3",
"input.border": "#4a4035",
"input.placeholderForeground": "#a69f9680",
"inputOption.activeBorder": "#b07a00",
"inputOption.activeBackground": "#3d3018",
"dropdown.background": "#3b3328",
"dropdown.foreground": "#ece8e3",
"dropdown.border": "#4a4035",
"button.background": "#b07a00",
"button.foreground": "#231e17",
"button.hoverBackground": "#c88b00",
"button.secondaryBackground": "#3b3328",
"button.secondaryForeground": "#ece8e3",
"button.secondaryHoverBackground": "#4a4035",
"badge.background": "#b07a00",
"badge.foreground": "#231e17",
"scrollbar.shadow": "#00000040",
"scrollbarSlider.background": "#4a403540",
"scrollbarSlider.hoverBackground": "#4a403580",
"scrollbarSlider.activeBackground": "#4a4035a0",
"focusBorder": "#b07a0080",
"foreground": "#ece8e3",
"descriptionForeground": "#a69f96",
"icon.foreground": "#a69f96",
"selection.background": "#3d3018",
"widget.shadow": "#00000040",
"terminal.foreground": "#ece8e3",
"terminal.background": "#231e17",
"terminal.ansiBlack": "#231e17",
"terminal.ansiRed": "#f14c4c",
"terminal.ansiGreen": "#6a9955",
"terminal.ansiYellow": "#b07a00",
"terminal.ansiBlue": "#569cd6",
"terminal.ansiMagenta": "#c586c0",
"terminal.ansiCyan": "#4ec9b0",
"terminal.ansiWhite": "#ece8e3",
"terminal.ansiBrightBlack": "#a69f96",
"terminal.ansiBrightRed": "#f14c4c",
"terminal.ansiBrightGreen": "#6a9955",
"terminal.ansiBrightYellow": "#dcdcaa",
"terminal.ansiBrightBlue": "#4fc1ff",
"terminal.ansiBrightMagenta": "#c586c0",
"terminal.ansiBrightCyan": "#4ec9b0",
"terminal.ansiBrightWhite": "#ece8e3",
"breadcrumb.foreground": "#a69f96",
"breadcrumb.focusForeground": "#ece8e3",
"breadcrumb.activeSelectionForeground": "#ece8e3",
"breadcrumbPicker.background": "#3b3328",
"peekView.border": "#b07a00",
"peekViewEditor.background": "#231e17",
"peekViewResult.background": "#302a20",
"peekViewTitle.background": "#302a20",
"peekViewTitleLabel.foreground": "#ece8e3",
"peekViewTitleDescription.foreground": "#a69f96",
"peekViewResult.selectionBackground": "#3d3018",
"peekViewResult.selectionForeground": "#ece8e3",
"minimap.selectionHighlight": "#3d3018",
"minimap.findMatchHighlight": "#b07a0060",
"gitDecoration.addedResourceForeground": "#6a9955",
"gitDecoration.modifiedResourceForeground": "#e2c08d",
"gitDecoration.deletedResourceForeground": "#f14c4c",
"gitDecoration.untrackedResourceForeground": "#73c991",
"gitDecoration.ignoredResourceForeground": "#a69f9680",
"gitDecoration.conflictingResourceForeground": "#e2c08d"
},
"tokenColors": [
{
"scope": ["comment", "punctuation.definition.comment"],
"settings": {
"foreground": "#6a9955",
"fontStyle": "italic"
}
},
{
"scope": [
"string",
"string.quoted",
"string.template",
"punctuation.definition.string"
],
"settings": {
"foreground": "#ce9178"
}
},
{
"scope": "string.regexp",
"settings": {
"foreground": "#d16969"
}
},
{
"scope": [
"keyword",
"keyword.control",
"keyword.operator.new",
"keyword.operator.expression",
"keyword.operator.cast",
"keyword.operator.sizeof",
"storage.modifier"
],
"settings": {
"foreground": "#c586c0"
}
},
{
"scope": [
"storage.type",
"keyword.type",
"support.type",
"entity.name.type",
"entity.name.class",
"entity.name.namespace",
"entity.name.struct"
],
"settings": {
"foreground": "#4ec9b0"
}
},
{
"scope": [
"constant",
"variable.other.constant",
"variable.other.enummember",
"support.constant"
],
"settings": {
"foreground": "#4fc1ff"
}
},
{
"scope": ["constant.numeric", "keyword.other.unit"],
"settings": {
"foreground": "#b5cea8"
}
},
{
"scope": "constant.language",
"settings": {
"foreground": "#569cd6"
}
},
{
"scope": [
"entity.name.function",
"support.function",
"entity.name.function.preprocessor"
],
"settings": {
"foreground": "#dcdcaa"
}
},
{
"scope": [
"variable",
"variable.other",
"variable.parameter",
"meta.definition.variable"
],
"settings": {
"foreground": "#9cdcfe"
}
},
{
"scope": [
"keyword.control.directive",
"keyword.preprocessor",
"meta.preprocessor",
"entity.name.function.preprocessor"
],
"settings": {
"foreground": "#569cd6"
}
},
{
"scope": [
"keyword.operator",
"keyword.operator.assignment",
"keyword.operator.arithmetic",
"keyword.operator.logical",
"keyword.operator.comparison",
"keyword.operator.bitwise",
"punctuation"
],
"settings": {
"foreground": "#d4d4d4"
}
},
{
"scope": [
"entity.name.tag",
"punctuation.definition.tag"
],
"settings": {
"foreground": "#569cd6"
}
},
{
"scope": "entity.other.attribute-name",
"settings": {
"foreground": "#9cdcfe"
}
},
{
"scope": "constant.character.escape",
"settings": {
"foreground": "#d7ba7d"
}
},
{
"scope": "invalid",
"settings": {
"foreground": "#f44747"
}
},
{
"scope": "markup.heading",
"settings": {
"foreground": "#569cd6",
"fontStyle": "bold"
}
},
{
"scope": "markup.bold",
"settings": {
"fontStyle": "bold"
}
},
{
"scope": "markup.italic",
"settings": {
"fontStyle": "italic"
}
},
{
"scope": "markup.inline.raw",
"settings": {
"foreground": "#ce9178"
}
}
]
}
+210 -66
View File
@@ -1,6 +1,17 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Hammer,
Package,
Cpu,
Play,
Archive,
Upload,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
type BuildStatus = "idle" | "building" | "success" | "failed";
@@ -11,15 +22,38 @@ interface BuildSectionState {
}
function StatusBadge({ status }: { status: BuildStatus }) {
const colors: Record<BuildStatus, string> = {
idle: "bg-gray-500/20 text-gray-400",
building: "bg-yellow-500/20 text-yellow-400",
success: "bg-green-500/20 text-green-400",
failed: "bg-red-500/20 text-red-400",
const styles: Record<BuildStatus, React.CSSProperties> = {
idle: {
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
},
building: {
backgroundColor: "var(--forge-warning-bg)",
color: "var(--forge-warning)",
},
success: {
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
},
failed: {
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
},
};
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
<span
style={{
...styles[status],
borderRadius: "9999px",
padding: "0.125rem 0.625rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
textTransform: "uppercase" as const,
letterSpacing: "0.03em",
}}
>
{status}
</span>
);
@@ -43,31 +77,47 @@ function BuildOutput({
}, [lines, collapsed]);
return (
<div className="mt-2">
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={onToggle}
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
cursor: "pointer",
padding: "0.25rem 0",
fontFamily: "var(--font-sans)",
}}
>
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span>Output ({lines.length} lines)</span>
</button>
{!collapsed && (
<div
ref={scrollRef}
className="mt-1 max-h-64 overflow-auto rounded p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
marginTop: "0.5rem",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.5rem",
padding: "0.875rem 1rem",
backgroundColor: "var(--forge-log-bg)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
lineHeight: "1.6",
}}
>
{lines.length === 0 ? (
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
<span style={{ color: "var(--forge-text-secondary)", fontStyle: "italic" }}>
No output yet
</span>
) : (
lines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
@@ -83,27 +133,29 @@ function ActionButton({
onClick,
disabled,
variant = "default",
icon,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "warning";
icon?: React.ReactNode;
}) {
const styles = {
const variantStyles: Record<string, React.CSSProperties> = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
border: "none",
color: "var(--forge-accent-text)",
},
warning: {
backgroundColor: "#854d0e",
borderColor: "#a16207",
color: "#fef08a",
backgroundColor: "var(--forge-warning-bg)",
border: "1px solid var(--forge-warning-border)",
color: "var(--forge-warning)",
},
};
@@ -111,9 +163,22 @@ function ActionButton({
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
style={{
...variantStyles[variant],
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
transition: "opacity 0.15s ease",
}}
>
{icon}
{label}
</button>
);
@@ -191,41 +256,103 @@ export function Build() {
[],
);
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
const isBuilding =
module.status === "building" || haks.status === "building" || nwnx.status === "building";
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1rem",
};
const sectionHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
};
const sectionTitleStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
textTransform: "uppercase",
fontSize: "var(--text-xs)",
fontWeight: 600,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-heading)",
};
const buttonRowStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "center",
};
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Build Pipeline
</h2>
{/* Module Section */}
<section
className="mb-6 rounded-lg border p-4"
<div
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Module</h3>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Build Pipeline
</h2>
<p
style={{
fontFamily: "var(--font-sans)",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.375rem 0 0 0",
}}
>
Compile, pack, and deploy module resources
</p>
</div>
{/* Module Section */}
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Hammer size={14} />
<span>Module</span>
</div>
<StatusBadge status={module.status} />
</div>
<div className="flex flex-wrap gap-2">
<div style={buttonRowStyle}>
<ActionButton
label="Compile"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")}
/>
<ActionButton
label="Pack Module"
icon={<Archive size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")}
/>
<ActionButton
label="Deploy to Server"
variant="warning"
icon={<Upload size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.deploy(), "module")}
/>
@@ -238,21 +365,19 @@ export function Build() {
</section>
{/* Haks Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Haks</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Package size={14} />
<span>Haks</span>
</div>
<StatusBadge status={haks.status} />
</div>
<div className="flex gap-2">
<div style={buttonRowStyle}>
<ActionButton
label="Build Haks"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
/>
@@ -265,38 +390,49 @@ export function Build() {
</section>
{/* NWNX Section */}
<section
className="rounded-lg border p-4"
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Cpu size={14} />
<span>NWNX</span>
<span
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
fontWeight: 400,
textTransform: "none",
opacity: 0.6,
letterSpacing: "normal",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
</h3>
(Advanced)
</span>
</div>
<StatusBadge status={nwnx.status} />
</div>
<div className="mb-3 flex flex-wrap gap-2">
<div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
<ActionButton
label="Build All"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/>
</div>
<div className="flex items-center gap-2">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
type="text"
value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)"
className="rounded border px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
outline: "none",
flex: "0 1 16rem",
}}
/>
<ActionButton
@@ -308,10 +444,18 @@ export function Build() {
/>
</div>
<p
className="mt-2 text-xs"
style={{ color: "#f59e0b" }}
style={{
marginTop: "0.75rem",
marginBottom: 0,
fontSize: "var(--text-xs)",
color: "var(--forge-warning)",
display: "flex",
alignItems: "center",
gap: "0.375rem",
}}
>
Requires server restart to pick up changes
<AlertTriangle size={12} />
Requires server restart to pick up changes
</p>
<BuildOutput
lines={nwnx.output}
+115 -99
View File
@@ -1,21 +1,68 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { Server, GitBranch, Hammer, Code2, Terminal, Database, ArrowRight } from "lucide-react";
const card: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
};
const cardTitle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
margin: 0,
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const statusDot = (color: string): React.CSSProperties => ({
width: "0.5rem",
height: "0.5rem",
borderRadius: "50%",
backgroundColor: color,
flexShrink: 0,
});
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
width: "100%",
transition: "background-color 150ms",
};
function StatusBadge({ status }: { status: string }) {
const color =
status === "running"
? "#4ade80"
: status === "stopped"
? "#f87171"
: "#fbbf24";
? "var(--forge-success)"
: status === "stopped" || status === "exited" || status === "not created"
? "var(--forge-danger)"
: "var(--forge-warning)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold"
style={{ backgroundColor: `${color}20`, color }}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
fontWeight: 500,
color,
}}
>
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span style={statusDot(color)} />
{status}
</span>
);
@@ -57,49 +104,36 @@ function ServerCard() {
}
};
const isRunning = status.nwserver === "running";
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Server Status
</h3>
<div className="mt-4 flex items-center gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
NWServer
</span>
<div style={card}>
<h3 style={cardTitle}><Server size={14} /> Server</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
<StatusBadge status={status.nwserver} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
MariaDB
</span>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
<StatusBadge status={status.mariadb} />
</div>
</div>
</div>
<div className="mt-4">
<div style={{ marginTop: "1rem" }}>
<button
onClick={toggle}
disabled={loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{
backgroundColor:
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)",
color: status.nwserver === "running" ? "#fca5a5" : "#000",
...primaryBtn,
backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
color: isRunning ? "var(--forge-danger)" : "var(--forge-accent-text)",
border: isRunning ? "1px solid var(--forge-danger-border)" : "none",
opacity: loading ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = loading ? "0.5" : "1"; }}
>
{loading
? "..."
: status.nwserver === "running"
? "Stop Server"
: "Start Server"}
{loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
</button>
</div>
</div>
@@ -120,39 +154,32 @@ function ReposSummary() {
}, []);
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Repositories
</h3>
<div className="mt-4 space-y-2">
{repos.map((repo) => {
<div style={card}>
<h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
{repos.map((repo, i) => {
const s = repoStatus[repo];
const branch = (s?.branch as string) || "\u2014";
const clean = s?.clean !== false;
return (
<div
key={repo}
className="flex items-center justify-between rounded p-3"
style={{ backgroundColor: "var(--forge-bg)" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0",
borderTop: i > 0 ? "1px solid var(--forge-border)" : undefined,
}}
>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{repo}
</span>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{branch}
</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
title={clean ? "Clean" : "Uncommitted changes"}
/>
<span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
</div>
</div>
);
@@ -166,41 +193,38 @@ function QuickActions() {
const navigate = useNavigate();
const actions = [
{ label: "Build Module", onClick: () => navigate("/build") },
{ label: "Build Haks", onClick: () => navigate("/build") },
{ label: "Open Editor", onClick: () => navigate("/editor") },
{
label: "Open Terminal",
onClick: () => {
/* terminal is toggled from IDELayout via Ctrl+` */
navigate("/editor");
},
},
{ label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
{ label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
{ label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
];
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Quick Actions
</h3>
<div className="mt-4 grid grid-cols-2 gap-2">
<div style={card}>
<h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{actions.map((a) => (
<button
key={a.label}
onClick={a.onClick}
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
style={{
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
display: "flex",
alignItems: "center",
gap: "0.625rem",
padding: "0.5rem 0.75rem",
borderRadius: "0.375rem",
border: "1px solid var(--forge-border)",
background: "none",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
textAlign: "left" as const,
transition: "background-color 150ms, border-color 150ms",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; e.currentTarget.style.borderColor = "var(--forge-text-secondary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; e.currentTarget.style.borderColor = "var(--forge-border)"; }}
>
<a.Icon size={15} style={{ color: "var(--forge-text-secondary)" }} />
{a.label}
</button>
))}
@@ -211,24 +235,16 @@ function QuickActions() {
export function Dashboard() {
return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-8 text-center">
<h1
className="text-3xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "56rem", margin: "0 auto" }}>
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
Dashboard
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
NWN Development Environment
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Server, repositories, and quick actions
</p>
</div>
<div className="mx-auto max-w-3xl space-y-4">
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
<ServerCard />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<ReposSummary />
<QuickActions />
</div>
+60 -12
View File
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
import { CreatureEditor } from "../components/gff/CreatureEditor";
import { AreaEditor } from "../components/gff/AreaEditor";
import { DialogEditor } from "../components/gff/DialogEditor";
import { FileCode, Code2, Eye } from "lucide-react";
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
@@ -36,9 +37,10 @@ function filePathFromTabKey(tabKey: string): string {
interface EditorProps {
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
workspacePath?: string;
}
export function Editor({ editorState }: EditorProps) {
export function Editor({ editorState, workspacePath }: EditorProps) {
const {
openTabs,
activeTab,
@@ -50,7 +52,6 @@ export function Editor({ editorState }: EditorProps) {
markClean,
} = editorState;
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
const tabs = useMemo(
@@ -105,8 +106,28 @@ export function Editor({ editorState }: EditorProps) {
const renderEditor = () => {
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center">
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: 12,
}}
>
<FileCode
size={48}
style={{ color: "var(--forge-text-secondary)", opacity: 0.4 }}
/>
<p
style={{
color: "var(--forge-text-secondary)",
fontSize: "var(--text-lg)",
fontFamily: "var(--font-heading)",
margin: 0,
}}
>
Open a file from the File Explorer to start editing
</p>
</div>
@@ -139,27 +160,47 @@ export function Editor({ editorState }: EditorProps) {
}
return (
<div className="flex h-full flex-col">
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{isActiveGff && activeMode === "raw" && (
<div
className="flex shrink-0 items-center justify-end border-b px-4 py-1"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
flexShrink: 0,
padding: "4px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
<button
onClick={handleSwitchToVisual}
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 12px",
borderRadius: 4,
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-mono)",
cursor: "pointer",
}}
>
<Eye size={13} />
Switch to Visual Editor
</button>
</div>
)}
<div className="flex-1 overflow-hidden">
<div style={{ flex: 1, overflow: "hidden" }}>
<MonacoEditor
key={activeTab}
filePath={activeFilePath}
content={activeContent}
onChange={handleChange}
workspacePath={workspacePath}
/>
</div>
</div>
@@ -167,14 +208,21 @@ export function Editor({ editorState }: EditorProps) {
};
return (
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "var(--forge-bg)",
}}
>
<EditorTabs
tabs={tabs}
activeTab={activeTab}
onSelect={selectTab}
onClose={closeFile}
/>
<div className="flex-1 overflow-hidden">
<div style={{ flex: 1, overflow: "hidden" }}>
{renderEditor()}
</div>
</div>
+470 -86
View File
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "../services/api";
import { CommitDialog } from "../components/CommitDialog";
import { useWebSocket } from "../hooks/useWebSocket";
import {
GitBranch,
GitCommit,
GitPullRequest,
Download,
Upload,
Copy,
FileCode,
AlertCircle,
CheckCircle,
X,
} from "lucide-react";
interface RepoStatus {
modified: string[];
@@ -26,6 +38,54 @@ interface PrForm {
body: string;
}
const badge = (
bg: string,
fg: string,
extra?: React.CSSProperties,
): React.CSSProperties => ({
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
padding: "0.15rem 0.55rem",
borderRadius: "9999px",
fontSize: "var(--text-xs)",
fontWeight: 600,
lineHeight: 1.4,
backgroundColor: bg,
color: fg,
whiteSpace: "nowrap",
...extra,
});
const btnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.4rem 0.85rem",
borderRadius: "0.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
border: "1px solid",
cursor: "pointer",
transition: "opacity 0.15s",
lineHeight: 1.4,
};
const outlineBtn: React.CSSProperties = {
...btnBase,
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
};
const accentBtn: React.CSSProperties = {
...btnBase,
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
};
export function Repos() {
const [repos, setRepos] = useState<RepoInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -39,7 +99,7 @@ export function Repos() {
const fetchRepos = useCallback(async () => {
try {
const data = (await api.repos.list()) as RepoInfo[];
const data = (await api.repos.list()) as unknown as RepoInfo[];
setRepos(data);
} catch {
setError("Failed to load repos");
@@ -133,106 +193,247 @@ export function Repos() {
const isDirty = (status?: RepoStatus) =>
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
const disabledStyle = (disabled: boolean | undefined): React.CSSProperties =>
disabled ? { opacity: 0.45, pointerEvents: "none" } : {};
if (loading) {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
Loading repositories...
<div style={{
display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-sans)",
fontSize: "var(--text-base)",
}}>
Loading repositories
</div>
);
}
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
<div style={{
height: "100%",
overflowY: "auto",
padding: "1.75rem 2rem",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
}}>
{/* Page heading */}
<div style={{ marginBottom: "1.75rem" }}>
<h2 style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}>
Repositories
</h2>
<p style={{
margin: "0.3rem 0 0",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
}}>
Clone, sync, and manage your Layonara repos
</p>
</div>
{/* Error banner */}
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
padding: "0.65rem 1rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-danger-border)",
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
fontSize: "var(--text-sm)",
}}>
<AlertCircle size={16} />
<span style={{ flex: 1 }}>{error}</span>
<button
onClick={() => setError("")}
style={{
background: "none",
border: "none",
color: "var(--forge-danger)",
cursor: "pointer",
padding: "0.2rem",
display: "flex",
}}
>
<X size={14} />
</button>
</div>
)}
{/* PR success banner */}
{prResult && (
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400">
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a>
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
padding: "0.65rem 1rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
fontSize: "var(--text-sm)",
}}>
<CheckCircle size={16} />
<span style={{ flex: 1 }}>
PR created:{" "}
<a
href={prResult.url}
target="_blank"
rel="noreferrer"
style={{ color: "inherit", textDecoration: "underline" }}
>
{prResult.url}
</a>
</span>
<button
onClick={() => setPrResult(null)}
style={{
background: "none",
border: "none",
color: "var(--forge-success)",
cursor: "pointer",
padding: "0.2rem",
display: "flex",
}}
>
<X size={14} />
</button>
</div>
)}
<div className="space-y-4">
{/* Repo cards */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{repos.map((repo) => (
<section
key={repo.name}
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold">{repo.name}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{/* Card header */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "0.5rem",
marginBottom: "0.85rem",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
}}>
{repo.name}
</h3>
<span style={badge("var(--forge-accent-subtle)", "var(--forge-accent)")}>
<GitBranch size={12} />
{repo.branch}
</span>
{repo.cloned && repo.status && (
<>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${isDirty(repo.status) ? "bg-yellow-500/20 text-yellow-400" : "bg-green-500/20 text-green-400"}`}>
{isDirty(repo.status) ? "dirty" : "clean"}
{isDirty(repo.status) ? (
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
dirty
</span>
{repo.status.behind > 0 && (
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
{repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream
) : (
<span style={badge("var(--forge-success-bg)", "var(--forge-success)")}>
<CheckCircle size={11} />
clean
</span>
)}
{repo.status.behind > 0 && (
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
<Download size={11} />
{repo.status.behind} behind
</span>
)}
{repo.status.ahead > 0 && (
<span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-xs font-medium text-blue-400">
<span style={badge("var(--forge-info-bg)", "var(--forge-info)")}>
<Upload size={11} />
{repo.status.ahead} ahead
</span>
)}
</>
)}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-mono)",
}}>
{repo.upstream}
</span>
</div>
{/* Actions */}
{!repo.cloned ? (
<button
onClick={() => handleClone(repo.name)}
disabled={actionLoading[repo.name]}
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: "#fff" }}
style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
>
{actionLoading[repo.name] ? "Cloning..." : "Clone"}
<Copy size={14} />
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
</button>
) : (
<>
<div className="mb-3 flex flex-wrap gap-2">
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
<button
onClick={() => handlePull(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
>
<Download size={14} />
Pull
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Push
</button>
<button
onClick={() => setCommitRepo(repo.name)}
disabled={!isDirty(repo.status)}
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: "#fff" }}
style={{
...accentBtn,
...disabledStyle(!isDirty(repo.status)),
}}
>
<GitCommit size={14} />
Commit
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
style={{
...outlineBtn,
...disabledStyle(actionLoading[repo.name] || !repo.status?.ahead),
}}
>
<Upload size={14} />
Push
</button>
<button
onClick={() =>
setPrForm({
@@ -241,39 +442,104 @@ export function Repos() {
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
})
}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
style={outlineBtn}
>
<GitPullRequest size={14} />
Create PR
</button>
</div>
{/* Changed files list */}
{repo.status && isDirty(repo.status) && (
<div className="mt-2">
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{
border: "1px solid var(--forge-border)",
borderRadius: "0.5rem",
overflow: "hidden",
}}>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}>
<FileCode size={13} style={{ color: "var(--forge-text-secondary)" }} />
<span style={{
fontSize: "var(--text-xs)",
fontWeight: 600,
color: "var(--forge-text-secondary)",
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
}}>
Changes
</span>
</div>
<div className="space-y-0.5">
<div>
{repo.status.modified.map((f) => (
<div
key={f}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 text-xs transition-colors hover:bg-white/5"
onClick={() => handleShowDiff(repo.name, f)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
cursor: "pointer",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="font-medium text-yellow-400">M</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<span style={{
fontWeight: 700,
color: "var(--forge-warning)",
width: "1rem",
textAlign: "center",
}}>M</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
{repo.status.staged.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-green-400">S</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<div
key={f}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span style={{
fontWeight: 700,
color: "var(--forge-success)",
width: "1rem",
textAlign: "center",
}}>S</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
{repo.status.untracked.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-gray-400">?</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<div
key={f}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span style={{
fontWeight: 700,
color: "var(--forge-text-secondary)",
width: "1rem",
textAlign: "center",
}}>?</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
</div>
@@ -285,6 +551,7 @@ export function Repos() {
))}
</div>
{/* Commit dialog */}
{commitRepo && (
<CommitDialog
repo={commitRepo}
@@ -296,71 +563,188 @@ export function Repos() {
/>
)}
{/* PR form modal */}
{prForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}>
<div
className="w-full max-w-lg rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
onClick={() => setPrForm(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Create Pull Request {prForm.repo}
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "32rem",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
}}
>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1.25rem",
}}>
<GitPullRequest size={18} style={{ color: "var(--forge-accent)" }} />
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
color: "var(--forge-accent)",
}}>
Create Pull Request
</h3>
<span style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
marginLeft: "0.25rem",
}}>
{prForm.repo}
</span>
</div>
<input
type="text"
value={prForm.title}
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
placeholder="PR Title"
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%",
padding: "0.5rem 0.75rem",
marginBottom: "0.75rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-sans)",
boxSizing: "border-box",
outline: "none",
}}
/>
<textarea
value={prForm.body}
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
rows={8}
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)", fontFamily: "'JetBrains Mono', monospace" }}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
marginBottom: "1rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-mono)",
boxSizing: "border-box",
resize: "vertical",
outline: "none",
}}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setPrForm(null)}
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
<button onClick={() => setPrForm(null)} style={outlineBtn}>
Cancel
</button>
<button
onClick={handleCreatePr}
disabled={!prForm.title.trim() || actionLoading.pr}
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: "#fff" }}
style={{
...accentBtn,
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
}}
>
{actionLoading.pr ? "Creating..." : "Submit PR"}
<GitPullRequest size={14} />
{actionLoading.pr ? "Creating…" : "Submit PR"}
</button>
</div>
</div>
</div>
)}
{/* Diff modal */}
{diffView && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}>
<div
className="h-3/4 w-3/4 overflow-auto rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
onClick={() => setDiffView(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Diff {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "75%",
height: "75%",
overflowY: "auto",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
display: "flex",
flexDirection: "column",
}}
>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<FileCode size={18} style={{ color: "var(--forge-accent)" }} />
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
color: "var(--forge-accent)",
}}>
{diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
</h3>
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}>
</div>
<button
onClick={() => setDiffView(null)}
style={{
...outlineBtn,
padding: "0.3rem 0.6rem",
}}
>
<X size={14} />
Close
</button>
</div>
<pre
className="whitespace-pre-wrap text-xs"
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }}
>
<pre style={{
flex: 1,
overflowY: "auto",
margin: 0,
padding: "1rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-log-bg)",
color: "var(--forge-log-text)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
whiteSpace: "pre-wrap",
lineHeight: 1.6,
}}>
{diffView.diff || "No changes"}
</pre>
</div>
+402 -118
View File
@@ -1,30 +1,152 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
import { SimpleEditor } from "../components/editor/SimpleEditor";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Server as ServerIcon,
Play,
Square,
RotateCcw,
FileCode,
ScrollText,
Database,
Search,
Trash2,
} from "lucide-react";
type ServerState = "running" | "exited" | "not created" | string;
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
const color =
const dotColor =
state === "running"
? "bg-green-500/20 text-green-400"
? "var(--forge-success)"
: state === "exited"
? "bg-red-500/20 text-red-400"
: "bg-gray-500/20 text-gray-400";
? "var(--forge-danger)"
: "var(--forge-warning)";
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
{label}:
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: dotColor,
flexShrink: 0,
}}
/>
<span
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
}}
>
{label}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-mono)",
}}
>
{state}
</span>
</div>
);
}
function HoverButton({
children,
onClick,
disabled,
bg,
bgHover,
border,
color,
style,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
bg: string;
bgHover: string;
border: string;
color: string;
style?: React.CSSProperties;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.4rem 0.85rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
borderRadius: "0.5rem",
border: `1px solid ${border}`,
backgroundColor: hovered && !disabled ? bgHover : bg,
color,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "background-color 0.15s, opacity 0.15s",
...style,
}}
>
{children}
</button>
);
}
function SectionHeader({
icon,
label,
}: {
icon: React.ReactNode;
label: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
}}
>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
{icon}
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
{label}
</span>
</div>
);
}
function ControlsPanel() {
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
nwserver: "unknown",
@@ -63,49 +185,75 @@ function ControlsPanel() {
return (
<section
className="rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}}
>
<SectionHeader icon={<ServerIcon size={16} />} label="Server Controls" />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "1.25rem",
marginBottom: "1.25rem",
}}
>
<h3 className="mb-3 text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
Server Controls
</h3>
<div className="mb-4 flex gap-4">
<StatusBadge label="NWN Server" state={status.nwserver} />
<StatusBadge label="MariaDB" state={status.mariadb} />
</div>
<div className="flex flex-wrap gap-2">
{(["start", "stop", "restart", "config"] as const).map((action) => (
<button
key={action}
onClick={() => handleAction(action)}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<HoverButton
onClick={() => handleAction("start")}
disabled={loading !== null}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor:
action === "start"
? "var(--forge-accent)"
: action === "stop"
? "#991b1b"
: "var(--forge-surface)",
borderColor:
action === "start"
? "var(--forge-accent)"
: action === "stop"
? "#dc2626"
: "var(--forge-border)",
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)",
}}
bg="var(--forge-accent)"
bgHover="var(--forge-accent-hover)"
border="var(--forge-accent)"
color="var(--forge-accent-text)"
>
{loading === action
? "..."
: action === "config"
? "Generate Config"
: action.charAt(0).toUpperCase() + action.slice(1)}
</button>
))}
<Play size={14} />
{loading === "start" ? "Starting..." : "Start"}
</HoverButton>
<HoverButton
onClick={() => handleAction("stop")}
disabled={loading !== null}
bg="var(--forge-danger-bg)"
bgHover="var(--forge-danger-border)"
border="var(--forge-danger-border)"
color="var(--forge-danger)"
>
<Square size={14} />
{loading === "stop" ? "Stopping..." : "Stop"}
</HoverButton>
<HoverButton
onClick={() => handleAction("restart")}
disabled={loading !== null}
bg="transparent"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text)"
>
<RotateCcw size={14} />
{loading === "restart" ? "Restarting..." : "Restart"}
</HoverButton>
<HoverButton
onClick={() => handleAction("config")}
disabled={loading !== null}
bg="transparent"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text)"
>
<FileCode size={14} />
{loading === "config" ? "Generating..." : "Generate Config"}
</HoverButton>
</div>
</section>
);
@@ -141,65 +289,119 @@ function LogViewer() {
return (
<section
className="flex flex-col rounded-lg border"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
height: "350px",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
<ScrollText size={16} />
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
Server Logs
</h3>
<div className="flex-1" />
</span>
<div style={{ flex: 1 }} />
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
}}
>
<Search
size={13}
style={{
position: "absolute",
left: 8,
color: "var(--forge-text-secondary)",
pointerEvents: "none",
}}
/>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.3rem 0.5rem 0.3rem 1.75rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-sans)",
color: "var(--forge-text)",
width: "200px",
width: 180,
outline: "none",
}}
/>
<button
</div>
<HoverButton
onClick={() => setAutoScroll((v) => !v)}
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: autoScroll ? "var(--forge-accent)" : "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: autoScroll ? "#fff" : "var(--forge-text-secondary)",
}}
bg={autoScroll ? "var(--forge-accent)" : "var(--forge-bg)"}
bgHover={
autoScroll ? "var(--forge-accent-hover)" : "var(--forge-surface-raised)"
}
border={autoScroll ? "var(--forge-accent)" : "var(--forge-border)"}
color={
autoScroll
? "var(--forge-accent-text)"
: "var(--forge-text-secondary)"
}
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.6rem" }}
>
Auto-scroll
</button>
<button
</HoverButton>
<HoverButton
onClick={() => setLines([])}
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
bg="var(--forge-bg)"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text-secondary)"
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.5rem" }}
>
<Trash2 size={12} />
Clear
</button>
</HoverButton>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-auto p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
backgroundColor: "var(--forge-log-bg)",
color: "var(--forge-log-text)",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.6,
padding: "0.75rem 1rem",
overflowY: "auto",
height: 350,
borderRadius: "0 0 0.75rem 0.75rem",
}}
>
{filteredLines.length === 0 ? (
@@ -208,7 +410,7 @@ function LogViewer() {
</span>
) : (
filteredLines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
@@ -245,31 +447,57 @@ function SQLConsole() {
return (
<section
className="flex flex-col rounded-lg border"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
<Database size={16} />
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
SQL Console
</h3>
<div className="flex-1" />
</span>
<div style={{ flex: 1 }} />
{history.length > 0 && (
<select
onChange={(e) => setQuery(e.target.value)}
className="rounded border px-2 py-1 text-xs"
value=""
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.3rem 0.5rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-sans)",
color: "var(--forge-text-secondary)",
maxWidth: "200px",
maxWidth: 200,
outline: "none",
}}
value=""
>
<option value="" disabled>
History ({history.length})
@@ -281,32 +509,30 @@ function SQLConsole() {
))}
</select>
)}
<button
<HoverButton
onClick={execute}
disabled={loading || !query.trim()}
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
}}
bg="var(--forge-accent)"
bgHover="var(--forge-accent-hover)"
border="var(--forge-accent)"
color="var(--forge-accent-text)"
>
<Play size={14} />
{loading ? "Running..." : "Execute"}
</button>
</HoverButton>
</div>
<div style={{ height: "100px" }}>
<ReactMonacoEditor
<div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
<SimpleEditor
value={query}
language="sql"
theme="vs-dark"
onChange={(v) => setQuery(v ?? "")}
onChange={(v) => setQuery(v)}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: "off",
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: "on",
padding: { top: 4, bottom: 4 },
renderLineHighlight: "none",
@@ -319,35 +545,55 @@ function SQLConsole() {
{error && (
<div
className="px-4 py-2 text-sm"
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }}
style={{
padding: "0.75rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--forge-danger)",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-danger-bg)",
}}
>
{error}
</div>
)}
{result && (
<div
className="max-h-64 overflow-auto"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
<div style={{ overflowX: "auto" }}>
{result.columns.length === 0 ? (
<div className="px-4 py-3 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<div
style={{
padding: "1rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
}}
>
Query executed successfully (no results)
</div>
) : (
<table className="w-full text-left text-xs">
<div style={{ maxHeight: 280, overflowY: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "var(--text-xs)",
}}
>
<thead>
<tr>
{result.columns.map((col) => (
<th
key={col}
className="sticky top-0 px-3 py-2 font-medium"
style={{
position: "sticky",
top: 0,
padding: "0.5rem 0.75rem",
fontWeight: 600,
textAlign: "left",
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
color: "var(--forge-accent)",
fontFamily: "'JetBrains Mono', monospace",
fontFamily: "var(--font-mono)",
whiteSpace: "nowrap",
}}
>
{col}
@@ -359,7 +605,6 @@ function SQLConsole() {
{result.rows.map((row, i) => (
<tr
key={i}
className="transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
}}
@@ -367,10 +612,10 @@ function SQLConsole() {
{result.columns.map((col) => (
<td
key={col}
className="px-3 py-1.5"
style={{
padding: "0.4rem 0.75rem",
color: "var(--forge-text)",
fontFamily: "'JetBrains Mono', monospace",
fontFamily: "var(--font-mono)",
}}
>
{row[col]}
@@ -380,10 +625,15 @@ function SQLConsole() {
))}
</tbody>
</table>
</div>
)}
<div
className="px-3 py-1 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
padding: "0.4rem 1rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
borderTop: "1px solid var(--forge-border)",
}}
>
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
</div>
@@ -395,11 +645,45 @@ function SQLConsole() {
export function Server() {
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
<div
style={{
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-accent)",
margin: 0,
}}
>
Server Management
</h2>
<div className="flex flex-col gap-6">
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.25rem 0 0",
fontFamily: "var(--font-sans)",
}}
>
Control server processes, view logs, and query the database
</p>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<ControlsPanel />
<LogViewer />
<SQLConsole />
+247 -118
View File
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useTheme } from "../hooks/useTheme";
import {
Key,
Sun,
Moon,
FolderOpen,
Container,
Keyboard,
Info,
RotateCcw,
Download,
} from "lucide-react";
const sectionCard: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
};
const sectionTitle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
margin: "0 0 1rem 0",
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const fieldLabel: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 500,
color: "var(--forge-text-secondary)",
margin: "0 0 0.25rem 0",
};
const fieldValue: React.CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
margin: 0,
};
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.4rem 0.875rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
background: "none",
border: "none",
color: "var(--forge-text-secondary)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
padding: "0.4rem 0.625rem",
borderRadius: "0.375rem",
};
const listRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.625rem 0.875rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
};
function Section({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
className="rounded-lg p-5"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="mb-4 text-sm font-semibold" style={{ color: "var(--forge-accent)" }}>
{title}
</h3>
<div style={sectionCard}>
<h3 style={sectionTitle}>{icon} {title}</h3>
{children}
</div>
);
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
const currentPat = (config.githubPat as string) || "";
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set";
const hasPat = Boolean(config.githubPat && config.githubPat !== "***");
const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
const save = async () => {
setSaving(true);
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
};
return (
<Section title="GitHub">
<div className="space-y-3">
<Section title="Gitea Token" icon={<Key size={14} />}>
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Personal Access Token
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{masked}
</p>
<p style={fieldLabel}>Personal Access Token</p>
<p style={fieldValue}>{masked}</p>
</div>
<div style={{ marginTop: "0.75rem" }}>
{!editing ? (
<button
onClick={() => setEditing(true)}
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Update PAT
<button onClick={() => setEditing(true)} style={primaryBtn}>
Update Token
</button>
) : (
<div className="flex gap-2">
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input
type="password"
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder="ghp_..."
className="flex-1 rounded px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
placeholder="Paste token"
style={{ flex: 1 }}
/>
<button
onClick={save}
disabled={!pat || saving}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
>
{saving ? "..." : "Save"}
{saving ? "Saving\u2026" : "Save"}
</button>
<button
onClick={() => {
setEditing(false);
setPat("");
}}
className="rounded px-3 py-1.5 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
<button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
Cancel
</button>
</div>
)}
</div>
{msg && (
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</div>
</Section>
);
}
@@ -118,16 +167,17 @@ function ThemeSection() {
const { theme, toggleTheme } = useTheme();
return (
<Section title="Theme">
<div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
<Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
{theme === "dark" ? "Dark" : "Light"} Mode
</span>
<button
onClick={toggleTheme}
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
</p>
<p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
</p>
</div>
<button onClick={toggleTheme} style={primaryBtn}>
Switch to {theme === "dark" ? "Light" : "Dark"}
</button>
</div>
@@ -135,25 +185,100 @@ function ThemeSection() {
);
}
function PathsSection({ config }: { config: Record<string, unknown> }) {
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<Section title="Paths">
<div className="space-y-3">
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
overflow: "hidden",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "2.5rem",
alignSelf: "stretch",
backgroundColor: "var(--forge-surface-raised)",
borderRight: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
flexShrink: 0,
}}
>
<FolderOpen size={14} />
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
border: "none",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
padding: "0.5rem 0.75rem",
color: "var(--forge-text)",
outline: "none",
}}
/>
</div>
);
}
function PathsSection({ config, onUpdate }: { config: Record<string, unknown>; onUpdate: () => void }) {
const [wsPath, setWsPath] = useState((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
const [nwnPath, setNwnPath] = useState((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
setWsPath((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
setNwnPath((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
}, [config]);
const save = async () => {
setSaving(true);
setMsg("");
try {
await api.workspace.updateConfig({ workspacePath: wsPath, nwnHomePath: nwnPath });
setMsg("Paths saved");
onUpdate();
} catch (err) {
setMsg(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
return (
<Section title="Paths" icon={<FolderOpen size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Workspace Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.WORKSPACE_PATH as string) || "Not set"}
</p>
<p style={fieldLabel}>Workspace Path</p>
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</div>
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
NWN Home Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.NWN_HOME_PATH as string) || "Not set"}
</p>
<p style={fieldLabel}>NWN Home Path</p>
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<button
onClick={save}
disabled={saving}
style={{ ...primaryBtn, opacity: saving ? 0.4 : 1 }}
>
{saving ? "Saving\u2026" : "Save Paths"}
</button>
{msg && (
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>{msg}</span>
)}
</div>
</div>
</Section>
@@ -172,40 +297,31 @@ function DockerSection() {
await api.docker.pull(image);
setStatus((s) => ({ ...s, [image]: "Pulled" }));
} catch (err) {
setStatus((s) => ({
...s,
[image]: err instanceof Error ? err.message : "Failed",
}));
setStatus((s) => ({ ...s, [image]: err instanceof Error ? err.message : "Failed" }));
} finally {
setPulling((s) => ({ ...s, [image]: false }));
}
};
return (
<Section title="Docker Images">
<div className="space-y-2">
<Section title="Docker Images" icon={<Container size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{images.map((image) => (
<div
key={image}
className="flex items-center justify-between rounded p-3"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image}
</span>
<div className="flex items-center gap-2">
<div key={image} style={listRow}>
<span style={fieldValue}>{image}</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{status[image] && (
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{status[image]}
</span>
)}
<button
onClick={() => pull(image)}
disabled={pulling[image]}
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
>
{pulling[image] ? "..." : "Pull Latest"}
<Download size={12} />
{pulling[image] ? "Pulling\u2026" : "Pull"}
</button>
</div>
</div>
@@ -225,21 +341,20 @@ const SHORTCUTS = [
function ShortcutsSection() {
return (
<Section title="Keyboard Shortcuts">
<div className="space-y-1">
<Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{SHORTCUTS.map((s) => (
<div
key={s.keys}
className="flex items-center justify-between rounded px-3 py-2"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
<div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{s.action}
</span>
<kbd
className="rounded px-2 py-0.5 font-mono text-xs"
style={{
backgroundColor: "var(--forge-surface)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
@@ -255,11 +370,11 @@ function ShortcutsSection() {
function AboutSection() {
return (
<Section title="About">
<p className="text-sm" style={{ color: "var(--forge-text)" }}>
<Section title="About" icon={<Info size={14} />}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Layonara Forge v0.0.1
</p>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
github.com/Layonara/layonara-forge
</p>
</Section>
@@ -270,6 +385,7 @@ function ResetSection() {
const navigate = useNavigate();
const reset = async () => {
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
try {
await api.workspace.updateConfig({ setupComplete: false });
} catch {
@@ -279,14 +395,28 @@ function ResetSection() {
};
return (
<Section title="Reset">
<Section title="Reset" icon={<RotateCcw size={14} />}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Clear configuration and re-run the setup wizard
</p>
<button
onClick={reset}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }}
style={{
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
border: "1px solid var(--forge-danger-border)",
borderRadius: "0.375rem",
padding: "0.4rem 0.875rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
}}
>
Re-run Setup Wizard
Reset Setup
</button>
</div>
</Section>
);
}
@@ -299,25 +429,24 @@ export function Settings() {
}, []);
return (
<div className="h-full overflow-y-auto p-6">
<h2
className="mb-6 text-xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "40rem" }}>
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
Settings
</h2>
<div className="max-w-2xl space-y-4">
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Configuration, theme, and environment
</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<GitHubSection config={config} />
<ThemeSection />
<PathsSection config={config} />
<PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
<DockerSection />
<ShortcutsSection />
<AboutSection />
<ResetSection />
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+475 -164
View File
@@ -1,7 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { SimpleDiffEditor } from "../components/editor/SimpleEditor";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Eye,
EyeOff,
FileCode,
Check,
X,
RefreshCw,
Trash2,
ArrowUpCircle,
} from "lucide-react";
interface ChangeEntry {
filename: string;
@@ -16,61 +26,6 @@ interface DiffData {
filename: string;
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
active
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{active ? "Active" : "Inactive"}
</span>
);
}
function ActionButton({
label,
onClick,
disabled,
variant = "default",
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "danger";
}) {
const styles = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
},
danger: {
backgroundColor: "#7f1d1d",
borderColor: "#991b1b",
color: "#fca5a5",
},
};
return (
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
>
{label}
</button>
);
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString();
}
@@ -222,149 +177,428 @@ export function Toolset() {
};
const handleDiscardAll = async () => {
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
await api.toolset.discardAll();
refresh();
};
return (
<div
className="flex h-full flex-col overflow-hidden"
style={{ color: "var(--forge-text)" }}
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
color: "var(--forge-text)",
}}
>
{/* Status bar */}
<div
className="flex shrink-0 items-center justify-between px-6 py-3"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<div className="flex items-center gap-4">
<h2
className="text-xl font-bold"
style={{ color: "var(--forge-accent)" }}
{/* Page heading */}
<div style={{ padding: "24px 28px 0" }}>
<h1
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Toolset
</h2>
<StatusBadge active={active} />
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "4px 0 0",
}}
>
{changes.length} pending
Watch for NWN Toolset changes and apply them to the repository
</p>
</div>
{/* Watcher status card */}
<div style={{ padding: "16px 28px" }}>
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
padding: "16px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 16 }}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
{active ? (
<Eye size={16} style={{ color: "var(--forge-success)" }} />
) : (
<EyeOff
size={16}
style={{ color: "var(--forge-text-secondary)" }}
/>
)}
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "3px 10px",
borderRadius: 999,
fontSize: "var(--text-xs)",
fontWeight: 600,
backgroundColor: active
? "var(--forge-success-bg)"
: "var(--forge-surface-raised)",
color: active
? "var(--forge-success)"
: "var(--forge-text-secondary)",
}}
>
{active ? "Active" : "Inactive"}
</span>
</div>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{changes.length} pending change{changes.length !== 1 && "s"}
</span>
{lastChange && (
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Last: {formatTimestamp(lastChange)}
Last change: {formatTimestamp(lastChange)}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={active ? handleStop : handleStart}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 14px",
borderRadius: 6,
border: "1px solid",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: active
? "var(--forge-surface)"
: "var(--forge-accent)",
borderColor: active
? "var(--forge-border)"
: "var(--forge-accent)",
color: active
? "var(--forge-text)"
: "var(--forge-accent-text)",
}}
>
{active ? (
<ActionButton label="Stop Watcher" onClick={handleStop} />
<>
<EyeOff size={14} />
Stop Watcher
</>
) : (
<ActionButton
label="Start Watcher"
onClick={handleStart}
variant="primary"
/>
<>
<Eye size={14} />
Start Watcher
</>
)}
</button>
</div>
</div>
{/* Action bar */}
{changes.length > 0 && (
{/* Main content area */}
<div
className="flex shrink-0 items-center gap-2 px-6 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: "0 28px 20px",
}}
>
<ActionButton
label="Apply Selected"
variant="primary"
disabled={selected.size === 0}
onClick={handleApplySelected}
/>
<ActionButton
label="Apply All"
variant="primary"
onClick={handleApplyAll}
/>
<ActionButton
label="Discard Selected"
variant="danger"
disabled={selected.size === 0}
onClick={handleDiscardSelected}
/>
<ActionButton
label="Discard All"
variant="danger"
onClick={handleDiscardAll}
{/* Changes card */}
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
display: "flex",
flexDirection: "column",
flex: diffData ? "0 0 auto" : 1,
maxHeight: diffData ? "40%" : undefined,
}}
>
{/* Card header with action bar */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<FileCode
size={16}
style={{ color: "var(--forge-accent)" }}
/>
<span
className="ml-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
color: "var(--forge-text)",
}}
>
{selected.size} selected
Pending Changes
</span>
{changes.length > 0 && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
marginLeft: 4,
}}
>
{selected.size} of {changes.length} selected
</span>
)}
</div>
{changes.length > 0 && (
<div
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<button
onClick={handleApplySelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
}}
>
<Check size={12} />
Apply Selected
</button>
<button
onClick={handleApplyAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-accent)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-accent)",
}}
>
<ArrowUpCircle size={12} />
Apply All
</button>
<button
onClick={handleDiscardSelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
}}
>
<Trash2 size={12} />
Discard Selected
</button>
<button
onClick={handleDiscardAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-danger-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-danger)",
}}
>
<X size={12} />
Discard All
</button>
</div>
)}
</div>
{/* Main content: table + diff */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Changes table */}
<div
className="shrink-0 overflow-auto"
style={{ maxHeight: diffData ? "40%" : "100%" }}
>
{/* Table or empty state */}
<div style={{ overflow: "auto", flex: 1 }}>
{changes.length === 0 ? (
<div
className="flex h-40 items-center justify-center text-sm"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--forge-text-secondary)",
}}
>
{active
? "Watching for changes in temp0/..."
: "Start the watcher to detect Toolset changes"}
{active ? (
<>
<RefreshCw
size={28}
style={{
marginBottom: 12,
opacity: 0.4,
animation: "spin 3s linear infinite",
}}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Watching for changes in temp0/...
</span>
</>
) : (
<>
<EyeOff
size={28}
style={{ marginBottom: 12, opacity: 0.4 }}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Start the watcher to detect Toolset changes
</span>
</>
)}
</div>
) : (
<table className="w-full text-sm">
<table
style={{
width: "100%",
fontSize: "var(--text-sm)",
borderCollapse: "collapse",
}}
>
<thead>
<tr
style={{
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
}}
>
<th className="px-6 py-2 text-left font-medium">
<th
style={{
padding: "8px 16px",
textAlign: "left",
fontWeight: 500,
width: 40,
}}
>
<input
type="checkbox"
checked={selected.size === changes.length}
checked={
selected.size === changes.length &&
changes.length > 0
}
onChange={toggleAll}
className="cursor-pointer"
style={{ cursor: "pointer" }}
/>
</th>
<th className="px-2 py-2 text-left font-medium">Filename</th>
<th className="px-2 py-2 text-left font-medium">Type</th>
<th className="px-2 py-2 text-left font-medium">
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Filename
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Type
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Repo Path
</th>
<th className="px-2 py-2 text-left font-medium">Time</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Time
</th>
</tr>
</thead>
<tbody>
{changes.map((change) => (
<tr
key={change.filename}
className="cursor-pointer transition-colors hover:bg-white/5"
onClick={() => viewDiff(change)}
style={{
borderBottom: "1px solid var(--forge-border)",
cursor: "pointer",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-surface)"
? "var(--forge-accent-subtle)"
: undefined,
}}
onClick={() => viewDiff(change)}
>
<td className="px-6 py-2">
<td style={{ padding: "8px 16px" }}>
<input
type="checkbox"
checked={selected.has(change.filename)}
@@ -373,24 +607,49 @@ export function Toolset() {
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
style={{ cursor: "pointer" }}
/>
</td>
<td className="px-2 py-2 font-mono">{change.filename}</td>
<td className="px-2 py-2">
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs">
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{change.filename}
</td>
<td style={{ padding: "8px 10px" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 4,
fontSize: "var(--text-xs)",
fontWeight: 500,
backgroundColor: "var(--forge-accent-subtle)",
color: "var(--forge-accent)",
}}
>
{change.gffType}
</span>
</td>
<td
className="px-2 py-2 font-mono text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{change.repoPath ?? "—"}
</td>
<td
className="px-2 py-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
padding: "8px 10px",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{formatTimestamp(change.timestamp)}
</td>
@@ -400,51 +659,103 @@ export function Toolset() {
</table>
)}
</div>
</div>
{/* Diff panel */}
{/* Diff viewer panel */}
{diffData && (
<div
ref={diffContainerRef}
className="flex min-h-0 flex-1 flex-col"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
<div
className="flex shrink-0 items-center justify-between px-4 py-1.5"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
marginTop: 16,
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* Diff header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "var(--forge-surface-raised)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="text-xs font-medium">
Diff: {diffData.filename}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<FileCode
size={14}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{diffData.filename}
</span>
{loading && (
<span style={{ color: "var(--forge-text-secondary)" }}>
{" "}
(loading...)
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Loading...
</span>
)}
</span>
</div>
<button
onClick={() => setDiffData(null)}
className="text-xs transition-opacity hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderRadius: 5,
border: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
}}
>
<X size={12} />
Close
</button>
</div>
<div className="min-h-0 flex-1">
<DiffEditor
{/* Diff content */}
<div
style={{
flex: 1,
minHeight: 0,
backgroundColor: "var(--forge-log-bg)",
}}
>
<SimpleDiffEditor
original={diffData.original}
modified={diffData.modified}
language="json"
theme="vs-dark"
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
renderSideBySide: true,
padding: { top: 4 },
}}
/>
+17 -2
View File
@@ -6,8 +6,15 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || res.statusText);
let message = res.statusText;
try {
const body = await res.json();
message = body.error || body.message || message;
} catch {
const text = await res.text().catch(() => "");
if (text && text.length < 200 && !text.includes("<")) message = text;
}
throw new Error(message);
}
return res.json();
}
@@ -41,6 +48,14 @@ export const api = {
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
gffSchema: (type: string) =>
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
tlkLookup: async (id: number): Promise<string | null> => {
try {
const result = await request<{ text: string }>(`/editor/tlk/${id}`);
return result.text ?? null;
} catch {
return null;
}
},
},
workspace: {
+173 -13
View File
@@ -1,25 +1,185 @@
@import "tailwindcss";
@import "@fontsource-variable/manrope";
@import "@fontsource-variable/alegreya";
@import "@fontsource-variable/jetbrains-mono";
:root {
--forge-bg: #121212;
--forge-surface: #1e1e2e;
--forge-border: #2e2e3e;
--forge-accent: #946200;
--forge-text: #f2f2f2;
--forge-text-secondary: #888888;
--forge-bg: oklch(15% 0.01 65);
--forge-surface: oklch(20% 0.012 65);
--forge-surface-raised: oklch(24% 0.014 65);
--forge-border: oklch(30% 0.014 65);
--forge-accent: oklch(58% 0.155 65);
--forge-accent-hover: oklch(63% 0.16 65);
--forge-accent-subtle: oklch(25% 0.04 65);
--forge-accent-text: oklch(15% 0.03 65);
--forge-text: oklch(93% 0.006 65);
--forge-text-secondary: oklch(68% 0.01 65);
--forge-success: oklch(62% 0.14 150);
--forge-success-bg: oklch(22% 0.03 150);
--forge-success-border: oklch(35% 0.06 150);
--forge-danger: oklch(68% 0.14 25);
--forge-danger-bg: oklch(22% 0.04 25);
--forge-danger-border: oklch(35% 0.08 25);
--forge-danger-strong: oklch(55% 0.18 25);
--forge-warning: oklch(72% 0.14 80);
--forge-warning-bg: oklch(25% 0.04 80);
--forge-warning-border: oklch(40% 0.07 80);
--forge-info: oklch(62% 0.08 230);
--forge-info-bg: oklch(22% 0.02 230);
--forge-log-bg: oklch(13% 0.008 65);
--forge-log-text: oklch(82% 0.008 65);
--font-sans: "Manrope Variable", system-ui, sans-serif;
--font-heading: "Alegreya Variable", Georgia, serif;
--font-mono: "JetBrains Mono Variable", "Fira Code", monospace;
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1.0625rem;
--text-xl: 1.25rem;
--text-2xl: 1.75rem;
--leading-tight: 1.2;
--leading-normal: 1.55;
--leading-relaxed: 1.7;
}
:root.light {
--forge-bg: #f2f2f2;
--forge-surface: #ffffff;
--forge-border: #cbcbcb;
--forge-accent: #946200;
--forge-text: #252525;
--forge-text-secondary: #666666;
--forge-bg: oklch(95% 0.008 65);
--forge-surface: oklch(99% 0.004 65);
--forge-surface-raised: oklch(100% 0.002 65);
--forge-border: oklch(82% 0.012 65);
--forge-accent: oklch(50% 0.155 65);
--forge-accent-hover: oklch(45% 0.16 65);
--forge-accent-subtle: oklch(90% 0.04 65);
--forge-accent-text: oklch(99% 0.005 65);
--forge-text: oklch(20% 0.012 65);
--forge-text-secondary: oklch(45% 0.015 65);
--forge-success: oklch(45% 0.14 150);
--forge-success-bg: oklch(92% 0.03 150);
--forge-success-border: oklch(70% 0.08 150);
--forge-danger: oklch(50% 0.16 25);
--forge-danger-bg: oklch(92% 0.03 25);
--forge-danger-border: oklch(70% 0.08 25);
--forge-danger-strong: oklch(45% 0.18 25);
--forge-warning: oklch(55% 0.14 80);
--forge-warning-bg: oklch(92% 0.04 80);
--forge-warning-border: oklch(70% 0.07 80);
--forge-info: oklch(45% 0.08 230);
--forge-info-bg: oklch(92% 0.02 230);
--forge-log-bg: oklch(96% 0.006 65);
--forge-log-text: oklch(30% 0.01 65);
}
body {
background-color: var(--forge-bg);
color: var(--forge-text);
font-family: "Inter", system-ui, sans-serif;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
font-kerning: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.font-heading {
font-family: var(--font-heading);
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
::selection {
background-color: var(--forge-accent-subtle);
color: var(--forge-text);
}
:focus-visible {
outline: 2px solid var(--forge-accent);
outline-offset: 2px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--forge-border) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--forge-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--forge-text-secondary);
}
input[type="text"],
input[type="password"],
input[type="url"],
input[type="email"],
input[type="number"],
input[type="search"],
textarea,
select {
background-color: var(--forge-bg);
color: var(--forge-text);
border: 1px solid var(--forge-border);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
font-family: inherit;
transition: border-color 150ms ease-out;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--forge-accent);
}
input::placeholder,
textarea::placeholder {
color: var(--forge-text-secondary);
opacity: 0.6;
}
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
transition: background-color 150ms ease-out, color 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out;
}
button:disabled {
cursor: not-allowed;
}
a {
transition: color 150ms ease-out;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+29 -3
View File
@@ -9,16 +9,42 @@ export default {
forge: {
bg: "var(--forge-bg)",
surface: "var(--forge-surface)",
"surface-raised": "var(--forge-surface-raised)",
border: "var(--forge-border)",
accent: "var(--forge-accent)",
"accent-hover": "var(--forge-accent-hover)",
"accent-subtle": "var(--forge-accent-subtle)",
"accent-text": "var(--forge-accent-text)",
text: "var(--forge-text)",
"text-secondary": "var(--forge-text-secondary)",
success: "var(--forge-success)",
"success-bg": "var(--forge-success-bg)",
"success-border": "var(--forge-success-border)",
danger: "var(--forge-danger)",
"danger-bg": "var(--forge-danger-bg)",
"danger-border": "var(--forge-danger-border)",
"danger-strong": "var(--forge-danger-strong)",
warning: "var(--forge-warning)",
"warning-bg": "var(--forge-warning-bg)",
"warning-border": "var(--forge-warning-border)",
info: "var(--forge-info)",
"info-bg": "var(--forge-info-bg)",
"log-bg": "var(--forge-log-bg)",
"log-text": "var(--forge-log-text)",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"],
serif: ["Baskerville", "Georgia", "Palatino", "serif"],
sans: ["Manrope Variable", "system-ui", "sans-serif"],
heading: ["Alegreya Variable", "Georgia", "serif"],
mono: ["JetBrains Mono Variable", "Fira Code", "monospace"],
},
fontSize: {
xs: "var(--text-xs)",
sm: "var(--text-sm)",
base: "var(--text-base)",
lg: "var(--text-lg)",
xl: "var(--text-xl)",
"2xl": "var(--text-2xl)",
},
},
},
+4 -1
View File
@@ -3,7 +3,10 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx"
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"noImplicitAny": false
},
"include": ["src"]
}
+44
View File
@@ -1,8 +1,52 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import importMetaUrlPlugin from "@codingame/esbuild-import-meta-url-plugin";
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ["vscode"],
},
worker: {
format: "es",
},
optimizeDeps: {
esbuildOptions: {
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: {