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 # Windows: C:\Users\<you>\Documents\Neverwinter Nights
# Linux: ~/.local/share/Neverwinter Nights # Linux: ~/.local/share/Neverwinter Nights
NWN_HOME_PATH= 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
git \
&& rm -rf /var/lib/apt/lists/* && 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 WORKDIR /app
COPY package.json tsconfig.base.json ./ COPY package.json tsconfig.base.json ./
COPY packages/backend/package.json packages/backend/ 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/backend/dist packages/backend/dist
COPY --from=builder /app/packages/frontend/dist packages/frontend/dist COPY --from=builder /app/packages/frontend/dist packages/frontend/dist
COPY db/ db/
EXPOSE 3000 EXPOSE 3000
CMD ["node", "packages/backend/dist/index.js"] 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 \ cmake \
git \ git \
curl \ curl \
wget \
unzip \
ca-certificates \ ca-certificates \
libssl-dev \ libssl-dev \
libsqlite3-0 \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Nim via choosenim # Pre-built neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
RUN curl https://nim-lang.org/choosenim/init.sh -sSf | bash -s -- -y RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
ENV PATH="/root/.nimble/bin:${PATH}" | tar xz -C /usr/local/bin/
RUN choosenim 2.2.0
# Install neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.) # Pre-built nasher (NWN module build tool)
RUN nimble install neverwinter@2.1.2 -y 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 # Pre-built layonara_nwn (hak builder)
RUN nimble install nasher -y 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) # Verify all tools
RUN nimble install https://github.com/plenarius/layonara_nwn -y RUN nwn_gff --version && nasher --version && which nwn_script_comp && layonara_nwn --help | head -1
# Verify tools
RUN nwn_gff --version && nasher --version && which nwn_script_comp
WORKDIR /build WORKDIR /build
+1
View File
@@ -12,3 +12,4 @@ services:
environment: environment:
- WORKSPACE_PATH=/workspace - WORKSPACE_PATH=/workspace
- NWN_HOME_PATH=/nwn-home - 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", "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, "private": true,
"workspaces": ["packages/*"], "main": "electron/dist/main.js",
"workspaces": [
"packages/*"
],
"overrides": {
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^25.1.2"
},
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev -w packages/backend\" \"npm run dev -w packages/frontend\"", "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", "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": { "devDependencies": {
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"electron": "^41.2.2",
"electron-builder": "^26.8.1",
"typescript": "^5.7.0" "typescript": "^5.7.0"
},
"dependencies": {
"electron-updater": "^6.8.3"
} }
} }
+73 -52
View File
@@ -1,10 +1,11 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import { createServer } from "http"; import { createServer } from "http";
import type { Server } from "http";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { WebSocketServer } from "ws"; 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 workspaceRouter from "./routes/workspace.js";
import dockerRouter from "./routes/docker.js"; import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js"; import editorRouter from "./routes/editor.js";
@@ -17,66 +18,86 @@ import reposRouter from "./routes/repos.js";
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js"; import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
import { attachLspWebSocket } from "./services/lsp.service.js"; import { attachLspWebSocket } from "./services/lsp.service.js";
import { startUpstreamPolling } from "./services/git.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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const server = createServer(app);
app.use(cors()); export function startServer(port: number): Promise<Server> {
app.use(express.json()); const app = express();
const server = createServer(app);
initWebSocket(server); app.use(cors());
app.use(express.json());
app.get("/api/health", (_req, res) => { initWebSocket(server);
res.json({
status: "ok", app.get("/api/health", (_req, res) => {
wsClients: getClientCount(), res.json({
uptime: process.uptime(), status: "ok",
wsClients: getClientCount(),
uptime: process.uptime(),
});
}); });
});
app.use("/api/workspace", workspaceRouter); app.use("/api/workspace", workspaceRouter);
app.use("/api/docker", dockerRouter); app.use("/api/docker", dockerRouter);
app.use("/api/editor", editorRouter); app.use("/api/editor", editorRouter);
app.use("/api/terminal", terminalRouter); app.use("/api/terminal", terminalRouter);
app.use("/api/build", buildRouter); app.use("/api/build", buildRouter);
app.use("/api/server", serverRouter); app.use("/api/server", serverRouter);
app.use("/api/toolset", toolsetRouter); app.use("/api/toolset", toolsetRouter);
app.use("/api/github", githubRouter); app.use("/api/github", githubRouter);
app.use("/api/repos", reposRouter); app.use("/api/repos", reposRouter);
const frontendDist = path.resolve(__dirname, "../../frontend/dist"); const frontendDist = path.resolve(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist)); app.use(express.static(frontendDist));
app.get("*path", (_req, res) => { app.get("*path", (_req, res) => {
res.sendFile(path.join(frontendDist, "index.html")); 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}`); const url = new URL(request.url || "", `http://${request.headers.host}`);
if (url.pathname === "/ws/lsp") { if (url.pathname === "/ws/lsp") {
const lspWss = new WebSocketServer({ noServer: true }); const lspWss = new WebSocketServer({ noServer: true });
lspWss.handleUpgrade(request, socket, head, (ws) => { lspWss.handleUpgrade(request, socket, head, (ws) => {
attachLspWebSocket(ws); attachLspWebSocket(ws);
});
return;
}
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
if (termMatch) {
const sessionId = termMatch[1];
const termWss = new WebSocketServer({ noServer: true });
termWss.handleUpgrade(request, socket, head, (ws) => {
if (!attachWebSocket(sessionId, ws)) {
createTerminalSession(sessionId);
attachWebSocket(sessionId, ws);
}
});
return;
}
if (url.pathname === "/ws") {
handleEventUpgrade(request, socket, head);
return;
}
});
return new Promise((resolve) => {
server.listen(port, "0.0.0.0", () => {
console.log(`Layonara Forge listening on http://0.0.0.0:${port}`);
startUpstreamPolling();
const tlkPath = getRepoPath("nwn-haks", "layonara.tlk.json");
loadTlkIndex(tlkPath).then(() => console.log(`TLK index loaded`)).catch(() => {});
resolve(server);
}); });
return; });
} }
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/); if (!process.env.ELECTRON) {
if (termMatch) { const PORT = parseInt(process.env.PORT || "3000", 10);
const sessionId = termMatch[1]; startServer(PORT);
const termWss = new WebSocketServer({ noServer: true }); }
termWss.handleUpgrade(request, socket, head, (ws) => {
if (!attachWebSocket(sessionId, ws)) {
createTerminalSession(sessionId);
attachWebSocket(sessionId, ws);
}
});
}
});
const PORT = parseInt(process.env.PORT || "3000", 10);
server.listen(PORT, "0.0.0.0", () => {
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
startUpstreamPolling();
});
+6 -5
View File
@@ -8,11 +8,12 @@ export async function loadTlkIndex(tlkJsonPath: string): Promise<void> {
try { try {
const raw = await fs.readFile(tlkJsonPath, "utf-8"); const raw = await fs.readFile(tlkJsonPath, "utf-8");
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (Array.isArray(data)) { const entries = Array.isArray(data) ? data : Array.isArray(data.entries) ? data.entries : [];
for (const entry of data) { for (const entry of entries) {
if (entry.id !== undefined && entry.value !== undefined) { const id = entry.id ?? entry.index;
tlkStrings.set(Number(entry.id), String(entry.value)); const text = entry.text ?? entry.value;
} if (id !== undefined && text !== undefined) {
tlkStrings.set(Number(id), String(text));
} }
} }
} catch { } catch {
+6 -3
View File
@@ -25,7 +25,8 @@ router.get("/tree/:repo", async (req, res) => {
router.get("/file/:repo/*path", async (req, res) => { router.get("/file/:repo/*path", async (req, res) => {
try { 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 }); res.json({ content });
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error"; 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) => { router.put("/file/:repo/*path", async (req, res) => {
try { 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 }); res.json({ ok: true });
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error"; 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) => { router.delete("/file/:repo/*path", async (req, res) => {
try { 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 }); res.json({ ok: true });
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error"; 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}` }); res.status(400).json({ error: `Unknown repo: ${repoName}` });
return; 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 repoPath = getRepoPath(repoName);
const diff = await getDiff(repoPath, filePath); const diff = await getDiff(repoPath, filePath);
res.json({ diff }); res.json({ diff });
+24 -9
View File
@@ -9,21 +9,36 @@ import {
const router = Router(); const router = Router();
router.get("/config", async (_req, res) => { router.get("/config", async (_req, res) => {
const config = await readConfig(); try {
const sanitized = { ...config, githubPat: config.githubPat ? "***" : undefined }; const config = await readConfig();
res.json(sanitized); 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) => { router.put("/config", async (req, res) => {
const current = await readConfig(); try {
const updated = { ...current, ...req.body }; const current = await readConfig();
await writeConfig(updated); const updated = { ...current, ...req.body };
res.json({ ok: true }); 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) => { router.post("/init", async (_req, res) => {
await ensureWorkspaceStructure(); try {
res.json({ ok: true, path: getWorkspacePath() }); 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; export default router;
+50 -4
View File
@@ -1,12 +1,55 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { runEphemeralContainer } from "./docker.service.js"; import { runEphemeralContainer, getDockerClient } from "./docker.service.js";
import { import {
getWorkspacePath, getWorkspacePath,
getServerPath, getServerPath,
} from "./workspace.service.js"; } from "./workspace.service.js";
import { broadcast } from "./ws.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( export async function buildModule(
target: string = "bare", target: string = "bare",
mode: "compile" | "pack" = "compile", mode: "compile" | "pack" = "compile",
@@ -18,9 +61,10 @@ export async function buildModule(
: ["nasher", "pack", target, "--yes"]; : ["nasher", "pack", target, "--yes"];
broadcast("build", "start", { type: "module", target, mode }); broadcast("build", "start", { type: "module", target, mode });
await ensureBuilderImage();
const result = await runEphemeralContainer({ const result = await runEphemeralContainer({
image: "layonara-builder", image: BUILDER_IMAGE,
cmd, cmd,
binds: [ binds: [
`${workspacePath}/repos/nwn-module:/build/nwn-module`, `${workspacePath}/repos/nwn-module:/build/nwn-module`,
@@ -101,9 +145,10 @@ export async function buildHaks(): Promise<{
const workspacePath = getWorkspacePath(); const workspacePath = getWorkspacePath();
broadcast("build", "start", { type: "haks" }); broadcast("build", "start", { type: "haks" });
await ensureBuilderImage();
const result = await runEphemeralContainer({ const result = await runEphemeralContainer({
image: "layonara-builder", image: BUILDER_IMAGE,
cmd: ["layonara_nwn", "hak", "--yes"], cmd: ["layonara_nwn", "hak", "--yes"],
binds: [ binds: [
`${workspacePath}/repos/nwn-haks:/build/nwn-haks`, `${workspacePath}/repos/nwn-haks:/build/nwn-haks`,
@@ -127,6 +172,7 @@ export async function buildNWNX(
const workspacePath = getWorkspacePath(); const workspacePath = getWorkspacePath();
broadcast("build", "start", { type: "nwnx", target }); broadcast("build", "start", { type: "nwnx", target });
await ensureBuilderImage();
const cmd = target const cmd = target
? [ ? [
@@ -141,7 +187,7 @@ export async function buildNWNX(
]; ];
const result = await runEphemeralContainer({ const result = await runEphemeralContainer({
image: "layonara-builder", image: BUILDER_IMAGE,
cmd, cmd,
binds: [`${workspacePath}/repos/unified:/build/unified`], binds: [`${workspacePath}/repos/unified:/build/unified`],
workingDir: "/build/unified", workingDir: "/build/unified",
@@ -1,7 +1,11 @@
import Docker from "dockerode"; import Docker from "dockerode";
import { platform } from "os";
import { broadcast } from "./ws.service.js"; 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 { export interface ContainerInfo {
id: string; 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 fs from "fs/promises";
import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js"; 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"; import { broadcast } from "./ws.service.js";
function git(repoPath: string): SimpleGit { 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) { export async function setupClone(repoName: RepoName) {
await ensureWorkspaceStructure();
const config = await readConfig(); const config = await readConfig();
const pat = config.githubPat; const pat = config.githubPat;
if (!pat) throw new Error("Git provider token not configured"); 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 { WebSocket } from "ws";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { getRepoPath } from "./workspace.service.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -21,7 +22,9 @@ export function startLspServer(): ChildProcess {
} }
const serverPath = getLspServerPath(); const serverPath = getLspServerPath();
const cwd = getRepoPath("nwn-module");
lspProcess = spawn("node", [serverPath, "--stdio"], { lspProcess = spawn("node", [serverPath, "--stdio"], {
cwd,
env: { ...process.env }, env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); });
@@ -189,22 +189,33 @@ export async function seedDatabase(cdKey: string, playerName: string): Promise<v
const docker = getDockerClient(); const docker = getDockerClient();
const container = docker.getContainer(MARIADB_NAME); const container = docker.getContainer(MARIADB_NAME);
const exec = await container.exec({ const schemaPath = path.resolve(__dirname, "../../../db/schema.sql");
Cmd: [ let schemaSql: string;
"bash", try {
"-c", schemaSql = await fs.readFile(schemaPath, "utf-8");
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`, } 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, AttachStdout: true,
AttachStderr: 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({ const dmExec = await container.exec({
Cmd: [ Cmd: [
"bash", "bash",
"-c", "-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, AttachStdout: true,
AttachStderr: true, AttachStderr: true,
@@ -1,7 +1,8 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; 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 { interface ForgeConfig {
githubPat?: string; githubPat?: string;
@@ -51,6 +52,7 @@ export async function readConfig(): Promise<ForgeConfig> {
export async function writeConfig(config: ForgeConfig): Promise<void> { export async function writeConfig(config: ForgeConfig): Promise<void> {
const configPath = path.join(WORKSPACE_PATH, "config", "forge.json"); 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)); 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; let wss: WebSocketServer;
export function initWebSocket(server: Server): WebSocketServer { export function initWebSocket(_server: Server): WebSocketServer {
wss = new WebSocketServer({ server, path: "/ws" }); wss = new WebSocketServer({ noServer: true });
wss.on("connection", (ws) => { wss.on("connection", (ws) => {
clients.add(ws); clients.add(ws);
@@ -26,6 +26,12 @@ export function initWebSocket(server: Server): WebSocketServer {
return wss; 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 { export function broadcast(type: EventType, action: string, data: unknown): void {
const event: ForgeEvent = { const event: ForgeEvent = {
type, type,
-2
View File
@@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layonara Forge</title> <title>Layonara Forge</title>
<link rel="icon" href="/favicon.ico" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+9 -6
View File
@@ -5,21 +5,24 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0"
"vscode-languageserver-protocol": "^3.17.5",
"vscode-ws-jsonrpc": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@codingame/esbuild-import-meta-url-plugin": "^1.0.3",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
+79 -32
View File
@@ -1,13 +1,14 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useRef, lazy, Suspense } from "react";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor"; const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
import { Build } from "./pages/Build"; const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
import { Server } from "./pages/Server"; const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
import { Toolset } from "./pages/Toolset"; const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
import { Repos } from "./pages/Repos"; const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
import { Settings } from "./pages/Settings"; const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
import { Setup } from "./pages/Setup"; 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 { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout"; import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer"; import { FileExplorer } from "./components/editor/FileExplorer";
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module"; 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 }) { function SetupGuard({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false); const [needsSetup, setNeedsSetup] = useState(false);
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
.finally(() => setChecking(false)); .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 />; if (needsSetup) return <Navigate to="/setup" replace />;
return <>{children}</>; return <>{children}</>;
} }
@@ -42,6 +55,38 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
export function App() { export function App() {
const editorState = useEditorState(); const editorState = useEditorState();
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null); 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( const handleFileSelect = useCallback(
async (repo: string, filePath: string) => { async (repo: string, filePath: string) => {
@@ -73,29 +118,31 @@ export function App() {
<ToastProvider> <ToastProvider>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <BrowserRouter>
<Routes> <Suspense fallback={<PageLoader />}>
<Route path="/setup" element={<SetupLayout />}> <Routes>
<Route index element={<Setup />} /> <Route path="/setup" element={<SetupLayout />}>
</Route> <Route index element={<Setup />} />
<Route </Route>
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Route <Route
path="/editor" element={
element={<Editor editorState={editorState} />} <SetupGuard>
/> <IDELayout sidebar={sidebar} />
<Route path="build" element={<Build />} /> </SetupGuard>
<Route path="server" element={<Server />} /> }
<Route path="toolset" element={<Toolset />} /> >
<Route path="repos" element={<Repos />} /> <Route path="/" element={<Dashboard />} />
<Route path="settings" element={<Settings />} /> <Route
</Route> path="/editor"
</Routes> element={<Editor editorState={editorState} workspacePath={workspacePath} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</ErrorBoundary> </ErrorBoundary>
</ToastProvider> </ToastProvider>
+151 -26
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { api } from "../services/api"; import { api } from "../services/api";
const COMMIT_TYPES = [ const COMMIT_TYPES = [
@@ -19,6 +19,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
const [issueRef, setIssueRef] = useState(""); const [issueRef, setIssueRef] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
const preview = useMemo(() => { const preview = useMemo(() => {
let msg = `${type}${scope ? `(${scope})` : ""}: ${description}`; 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 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) { async function handleSubmit(andPush: boolean) {
setError(""); setError("");
setLoading(true); setLoading(true);
@@ -46,22 +57,59 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}> <div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "oklch(0% 0 0 / 0.6)",
}}
>
<div <div
className="w-full max-w-lg rounded-lg border p-6" ref={dialogRef}
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }} role="dialog"
aria-modal="true"
aria-labelledby="commit-dialog-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "32rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
padding: "1.5rem",
backgroundColor: "var(--forge-surface)",
outline: "none",
}}
> >
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}> <h3
id="commit-dialog-title"
style={{
marginBottom: "1rem",
fontSize: "var(--text-lg)",
fontWeight: 600,
color: "var(--forge-accent)",
}}
>
Commit Changes Commit Changes
</h3> </h3>
<div className="mb-3 flex gap-2"> <div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}>
<select <select
value={type} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
className="rounded border px-2 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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) => ( {COMMIT_TYPES.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>{t}</option>
@@ -72,8 +120,15 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
value={scope} value={scope}
onChange={(e) => setScope(e.target.value)} onChange={(e) => setScope(e.target.value)}
placeholder="scope (optional)" placeholder="scope (optional)"
className="w-28 rounded border px-2 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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> </div>
@@ -82,8 +137,17 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
value={description} value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 100))} onChange={(e) => setDescription(e.target.value.slice(0, 100))}
placeholder="Description (required, max 100 chars)" placeholder="Description (required, max 100 chars)"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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 <textarea
@@ -91,8 +155,18 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
placeholder="Body (optional)" placeholder="Body (optional)"
rows={3} rows={3}
className="mb-3 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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 <input
@@ -100,40 +174,91 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
value={issueRef} value={issueRef}
onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))} onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))}
placeholder="Issue # (auto-formats as Fixes #NNN)" placeholder="Issue # (auto-formats as Fixes #NNN)"
className="mb-4 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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> <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> </div>
{error && ( {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 <button
onClick={onClose} onClick={onClose}
className="rounded border px-3 py-1.5 text-sm" style={{
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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 Cancel
</button> </button>
<button <button
onClick={() => handleSubmit(false)} onClick={() => handleSubmit(false)}
disabled={!isValid || loading} disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }} 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 Commit
</button> </button>
<button <button
onClick={() => handleSubmit(true)} onClick={() => handleSubmit(true)}
disabled={!isValid || loading} disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }} 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 Commit & Push
</button> </button>
@@ -28,8 +28,17 @@ export class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError && this.state.error) { if (this.state.hasError && this.state.error) {
return ( return (
<div className="flex h-full items-center justify-center p-8"> <div
<div className="w-full max-w-lg"> style={{
display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
backgroundColor: "var(--forge-bg)",
}}
>
<div style={{ width: "100%", maxWidth: "32rem" }}>
<ErrorDisplay <ErrorDisplay
title="Render Error" title="Render Error"
message={this.state.error.message} message={this.state.error.message}
@@ -22,35 +22,48 @@ export function ErrorDisplay({
return ( return (
<div <div
className="rounded-lg p-6"
style={{ style={{
borderRadius: "0.5rem",
padding: "1.5rem",
backgroundColor: "var(--forge-surface)", 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} {title}
</h3> </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} {message}
</p> </p>
{fullLog && ( {fullLog && (
<div className="mt-4"> <div style={{ marginTop: "1rem" }}>
<button <button
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
className="text-xs underline" style={{
style={{ color: "var(--forge-text-secondary)" }} 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"} {expanded ? "Hide Full Log" : "Show Full Log"}
</button> </button>
{expanded && ( {expanded && (
<pre <pre
className="mt-2 max-h-60 overflow-auto rounded p-3 text-xs"
style={{ style={{
marginTop: "0.5rem",
maxHeight: "15rem",
overflow: "auto",
borderRadius: "0.25rem",
padding: "0.75rem",
fontSize: "var(--text-xs)",
backgroundColor: "var(--forge-bg)", backgroundColor: "var(--forge-bg)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
}} }}
> >
{fullLog} {fullLog}
@@ -59,22 +72,32 @@ export function ErrorDisplay({
</div> </div>
)} )}
<div className="mt-4 flex gap-2"> <div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
{onRetry && ( {onRetry && (
<button <button
onClick={onRetry} onClick={onRetry}
className="rounded px-4 py-2 text-sm font-semibold" style={{
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} 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 Retry
</button> </button>
)} )}
<button <button
onClick={copyError} onClick={copyError}
className="rounded px-4 py-2 text-sm"
style={{ style={{
borderRadius: "0.25rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
border: "1px solid var(--forge-border)", border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
backgroundColor: "transparent",
}} }}
> >
Copy Error Copy Error
+46 -10
View File
@@ -29,9 +29,9 @@ export function useToast() {
} }
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = { const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
success: { bg: "#052e16", border: "#166534", text: "#4ade80" }, success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" }, error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" }, info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
}; };
const AUTO_DISMISS: Record<ToastType, number | null> = { const AUTO_DISMISS: Record<ToastType, number | null> = {
@@ -49,7 +49,7 @@ function ToastItem({
}) { }) {
const { bg, border, text } = COLORS[toast.type]; const { bg, border, text } = COLORS[toast.type];
const timeout = AUTO_DISMISS[toast.type]; const timeout = AUTO_DISMISS[toast.type];
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
if (timeout) { if (timeout) {
@@ -60,14 +60,37 @@ function ToastItem({
return ( return (
<div <div
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm shadow-lg" style={{
style={{ backgroundColor: bg, border: `1px solid ${border}`, color: text }} 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 <button
onClick={() => onDismiss(toast.id)} onClick={() => onDismiss(toast.id)}
className="ml-2 shrink-0 opacity-60 hover:opacity-100" aria-label="Dismiss notification"
style={{ color: text }} 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; &times;
</button> </button>
@@ -92,7 +115,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return ( return (
<ToastContext.Provider value={{ showToast }}> <ToastContext.Provider value={{ showToast }}>
{children} {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) => ( {toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} /> <ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))} ))}
@@ -1,3 +1,6 @@
import { X } from "lucide-react";
import { useState } from "react";
interface Tab { interface Tab {
path: string; path: string;
dirty: boolean; dirty: boolean;
@@ -11,7 +14,96 @@ interface EditorTabsProps {
} }
function filename(path: string): string { 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({ export function EditorTabs({
@@ -24,51 +116,24 @@ export function EditorTabs({
return ( return (
<div <div
className="flex overflow-x-auto" role="tablist"
style={{ style={{
display: "flex",
overflowX: "auto",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)", borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}} }}
> >
{tabs.map((tab) => { {tabs.map((tab) => (
const isActive = tab.path === activeTab; <TabButton
return ( key={tab.path}
<button tab={tab}
key={tab.path} isActive={tab.path === activeTab}
title={tab.path} onSelect={() => onSelect(tab.path)}
onClick={() => onSelect(tab.path)} onClose={() => onClose(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)" }}
/>
)}
</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> </div>
); );
} }
@@ -1,5 +1,19 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { api, type FileNode } from "../../services/api"; 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 { interface FileExplorerProps {
repo: string; repo: string;
@@ -7,32 +21,29 @@ interface FileExplorerProps {
onFileSelect: (repo: string, filePath: string) => void; onFileSelect: (repo: string, filePath: string) => void;
} }
function getFileIcon(name: string): string { function getFileIcon(name: string): LucideIcon {
const ext = name.split(".").pop()?.toLowerCase(); const ext = name.split(".").pop()?.toLowerCase();
switch (ext) { switch (ext) {
case "nss": case "nss":
return "S"; case "ncs":
return FileCode2;
case "json": case "json":
return "J"; return FileJson;
case "xml": case "xml":
case "html": case "html":
return "<>";
case "md":
return "M";
case "yml": case "yml":
case "yaml": case "yaml":
return "Y"; return FileType2;
case "md":
return FileText;
case "png": case "png":
case "jpg": case "jpg":
case "jpeg": case "jpeg":
case "gif": case "gif":
case "bmp": case "bmp":
case "tga": case "tga":
return "I"; return FileImage;
case "2da": case "2da":
return "2";
case "ncs":
return "C";
case "git": case "git":
case "are": case "are":
case "ifo": case "ifo":
@@ -48,9 +59,9 @@ function getFileIcon(name: string): string {
case "dlg": case "dlg":
case "jrl": case "jrl":
case "fac": case "fac":
return "N"; return FileText;
default: default:
return "F"; return File;
} }
} }
@@ -68,6 +79,7 @@ function FileTreeNode({
repo: string; repo: string;
}) { }) {
const [expanded, setExpanded] = useState(depth === 0); const [expanded, setExpanded] = useState(depth === 0);
const [hovered, setHovered] = useState(false);
const isSelected = selectedPath === node.path; const isSelected = selectedPath === node.path;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@@ -78,35 +90,56 @@ function FileTreeNode({
} }
}, [node, repo, onFileSelect]); }, [node, repo, onFileSelect]);
const FileIcon = node.type === "directory"
? (expanded ? FolderOpen : Folder)
: getFileIcon(node.name);
return ( return (
<div> <div>
<button <button
onClick={handleClick} 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={{ style={{
display: "flex",
width: "100%",
alignItems: "center",
gap: "0.25rem",
paddingLeft: `${depth * 16 + 8}px`, 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)", color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "13px", fontSize: "13px",
cursor: "pointer",
transition: "background-color 100ms ease-out",
}} }}
> >
{node.type === "directory" ? ( {node.type === "directory" ? (
<span <span style={{ display: "flex", alignItems: "center", width: "16px", justifyContent: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
className="inline-block w-4 text-center text-xs" {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
style={{ color: "var(--forge-text-secondary)" }}
>
{expanded ? "\u25BC" : "\u25B6"}
</span> </span>
) : ( ) : (
<span <span style={{ width: "16px", flexShrink: 0 }} />
className="inline-block w-4 text-center text-xs font-bold"
style={{ color: "var(--forge-accent)", fontSize: "10px" }}
>
{getFileIcon(node.name)}
</span>
)} )}
<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> </button>
{node.type === "directory" && expanded && node.children && ( {node.type === "directory" && expanded && node.children && (
<div> <div>
@@ -155,44 +188,77 @@ export function FileExplorer({
return ( return (
<div <div
className="flex h-full flex-col overflow-hidden" style={{
style={{ backgroundColor: "var(--forge-bg)" }} display: "flex",
height: "100%",
flexDirection: "column",
overflow: "hidden",
backgroundColor: "var(--forge-bg)",
}}
> >
<div <div
className="flex items-center justify-between px-3 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--forge-border)",
}}
> >
<span <span
className="text-xs font-semibold uppercase tracking-wider" style={{
style={{ color: "var(--forge-text-secondary)" }} fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
}}
> >
Explorer Explorer
</span> </span>
<button <button
onClick={loadTree} onClick={loadTree}
className="rounded p-1 text-xs transition-colors hover:bg-white/10" aria-label="Refresh file tree"
style={{ color: "var(--forge-text-secondary)" }} style={{
title="Refresh" 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> </button>
</div> </div>
<div className="flex-1 overflow-y-auto py-1"> <div style={{ flex: 1, overflowY: "auto", padding: "0.25rem 0" }}>
{loading && ( {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... Loading...
</div> </div>
)} )}
{error && ( {error && (
<div className="px-3 py-4 text-sm text-red-400"> <div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{error} {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> </div>
)} )}
{!loading && !error && tree.length === 0 && ( {!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 No files found
</div> </div>
)} )}
@@ -1,208 +1,105 @@
import { useRef, useCallback, useState } from "react"; import { useCallback, useMemo } from "react";
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react"; import { LogLevel } from "@codingame/monaco-vscode-api";
import type { editor } from "monaco-editor"; import {
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js"; 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 { function getVscodeApiConfig(): MonacoVscodeApiConfig {
filePath: string; return {
content: string; $type: "extended",
language?: string; viewsConfig: {
onChange?: (value: string) => void; $type: "EditorService",
}
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",
},
},
],
{ include: "@whitespace" },
[/[{}()[\]]/, "@brackets"],
[/[;,.]/, "delimiter"],
[
/@symbols/,
{
cases: {
"@operators": "operator",
"@default": "",
},
},
],
[/\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"],
],
}, },
}); userConfiguration: {
} json: JSON.stringify({
"workbench.colorTheme": "Default Dark Modern",
function defineForgeTheme(monaco: Parameters<OnMount>[1]) { "workbench.colorCustomizations": {
const style = getComputedStyle(document.documentElement); "editor.background": "#231e17",
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212"; "editor.foreground": "#ece8e3",
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e"; "editor.lineHighlightBackground": "#302a2040",
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200"; "editor.selectionBackground": "#3d3018",
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2"; "editor.selectionHighlightBackground": "#3d301860",
const textSecondary = "editor.inactiveSelectionBackground": "#3d301850",
style.getPropertyValue("--forge-text-secondary").trim() || "#888888"; "editor.findMatchBackground": "#b07a0040",
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e"; "editor.findMatchHighlightBackground": "#b07a0025",
"editor.hoverHighlightBackground": "#3d301830",
monaco.editor.defineTheme("forge-dark", { "editorCursor.foreground": "#b07a00",
base: "vs-dark", "editorWhitespace.foreground": "#4a403550",
inherit: true, "editorIndentGuide.background": "#4a403530",
rules: [ "editorIndentGuide.activeBackground": "#4a403580",
{ token: "keyword", foreground: "C586C0" }, "editorLineNumber.foreground": "#a69f9650",
{ token: "keyword.preprocessor", foreground: "569CD6" }, "editorLineNumber.activeForeground": "#ece8e3",
{ token: "type", foreground: "4EC9B0" }, "editorBracketMatch.background": "#3d301840",
{ token: "constant", foreground: "4FC1FF" }, "editorBracketMatch.border": "#b07a0080",
{ token: "string", foreground: "CE9178" }, "editorOverviewRuler.border": "#4a4035",
{ token: "comment", foreground: "6A9955" }, "editorGutter.background": "#231e17",
{ token: "number", foreground: "B5CEA8" }, "editorWidget.background": "#3b3328",
{ token: "operator", foreground: "D4D4D4" }, "editorWidget.foreground": "#ece8e3",
{ token: "keyword.sql", foreground: "4ec9b0" }, "editorWidget.border": "#4a4035",
], "editorSuggestWidget.background": "#3b3328",
colors: { "editorSuggestWidget.border": "#4a4035",
"editor.background": bg, "editorSuggestWidget.foreground": "#ece8e3",
"editor.foreground": text, "editorSuggestWidget.highlightForeground": "#b07a00",
"editorCursor.foreground": accent, "editorSuggestWidget.selectedBackground": "#3d3018",
"editor.lineHighlightBackground": surface, "editorHoverWidget.background": "#3b3328",
"editorLineNumber.foreground": textSecondary, "editorHoverWidget.border": "#4a4035",
"editorLineNumber.activeForeground": text, "editorGroupHeader.tabsBackground": "#302a20",
"editor.selectionBackground": "#264f7840", "editorGroupHeader.tabsBorder": "#4a4035",
"editorWidget.background": surface, "editorGroup.border": "#4a4035",
"editorWidget.border": border, "tab.activeBackground": "#231e17",
"editorSuggestWidget.background": surface, "tab.activeForeground": "#ece8e3",
"editorSuggestWidget.border": border, "tab.activeBorderTop": "#b07a00",
"tab.inactiveBackground": "#302a20",
"tab.inactiveForeground": "#a69f96",
"tab.border": "#4a4035",
"input.background": "#231e17",
"input.foreground": "#ece8e3",
"input.border": "#4a4035",
"input.placeholderForeground": "#a69f9680",
"inputOption.activeBorder": "#b07a00",
"dropdown.background": "#3b3328",
"dropdown.foreground": "#ece8e3",
"dropdown.border": "#4a4035",
"list.activeSelectionBackground": "#3d3018",
"list.activeSelectionForeground": "#ece8e3",
"list.inactiveSelectionBackground": "#3d301880",
"list.hoverBackground": "#302a2080",
"list.highlightForeground": "#b07a00",
"scrollbarSlider.background": "#4a403540",
"scrollbarSlider.hoverBackground": "#4a403580",
"scrollbarSlider.activeBackground": "#4a4035a0",
"focusBorder": "#b07a0080",
"peekView.border": "#b07a00",
"peekViewEditor.background": "#231e17",
"peekViewResult.background": "#302a20",
"peekViewTitle.background": "#302a20",
"minimap.selectionHighlight": "#3d3018",
"minimap.findMatchHighlight": "#b07a0060",
},
"editor.fontSize": 14,
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
"editor.tabSize": 4,
"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,
}),
}, },
}); monacoWorkerFactory: configureDefaultWorkerFactory,
};
} }
function languageFromPath(filePath: string): string { function languageFromPath(filePath: string): string {
@@ -219,69 +116,96 @@ function languageFromPath(filePath: string): string {
md: "markdown", md: "markdown",
txt: "plaintext", txt: "plaintext",
"2da": "plaintext", "2da": "plaintext",
cfg: "ini",
sh: "shellscript",
}; };
return map[ext ?? ""] ?? "plaintext"; return map[ext ?? ""] ?? "plaintext";
} }
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
workspacePath?: string;
}
export function MonacoEditor({ export function MonacoEditor({
filePath, filePath,
content, content,
language, language,
onChange, onChange,
workspacePath,
}: MonacoEditorProps) { }: MonacoEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
const resolvedLang = language ?? languageFromPath(filePath); const resolvedLang = language ?? languageFromPath(filePath);
useLspClient(monacoRef); const isNwscript = resolvedLang === "nwscript";
useLspDocument(editorRef.current, filePath, resolvedLang);
const handleMount: OnMount = useCallback( const fileUri = workspacePath
(editorInstance, monaco) => { ? `file://${workspacePath}/repos/nwn-module/${filePath}`
editorRef.current = editorInstance; : `file:///workspace/repos/nwn-module/${filePath}`;
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
registerNWScript(monaco);
defineForgeTheme(monaco);
monaco.editor.setTheme("forge-dark");
const model = editorInstance.getModel(); const editorAppConfig = useMemo<EditorAppConfig>(
if (model) { () => ({
const lang = language ?? languageFromPath(filePath); codeResources: {
monaco.editor.setModelLanguage(model, lang); modified: {
} text: content,
}, uri: fileUri,
[filePath, language], enforceLanguageId: resolvedLang,
},
},
editorOptions: {
automaticLayout: true,
},
}),
[filePath],
); );
const handleChange = useCallback( const languageClientConfig = useMemo<LanguageClientConfig | undefined>(() => {
(value: string | undefined) => { if (!isNwscript) return undefined;
if (value !== undefined) { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
onChange?.(value); 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], [onChange],
); );
const handleError = useCallback((error: Error) => {
console.error("[MonacoEditor]", error.message, error.stack);
}, []);
return ( return (
<ReactMonacoEditor <MonacoEditorReactComp
value={content} style={{ width: "100%", height: "100%" }}
language={language ?? languageFromPath(filePath)} vscodeApiConfig={getVscodeApiConfig()}
theme="vs-dark" editorAppConfig={editorAppConfig}
onChange={handleChange} languageClientConfig={languageClientConfig}
onMount={handleMount} onTextChanged={handleTextChanged}
options={{ onError={handleError}
minimap: { enabled: false }, logLevel={LogLevel.Warning}
fontSize: 14,
lineNumbers: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
insertSpaces: true,
wordWrap: "off",
renderWhitespace: "selection",
bracketPairColorization: { enabled: true },
padding: { top: 8 },
}}
/> />
); );
} }
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { api } from "../../services/api"; import { api } from "../../services/api";
import { ChevronRight, ChevronDown } from "lucide-react";
interface SearchMatch { interface SearchMatch {
file: string; file: string;
@@ -32,6 +33,8 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); 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 inputRef = useRef<HTMLInputElement>(null);
const doSearch = useCallback(async () => { const doSearch = useCallback(async () => {
@@ -82,50 +85,71 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({ const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
backgroundColor: active ? "var(--forge-accent)" : "transparent", 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)"}`, border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "12px", fontSize: "12px",
lineHeight: "1", lineHeight: "1",
borderRadius: "0.25rem",
padding: "0.25rem 0.375rem",
cursor: "pointer",
}); });
return ( return (
<div <div
className="flex h-full flex-col overflow-hidden" style={{
style={{ backgroundColor: "var(--forge-bg)" }} display: "flex",
height: "100%",
flexDirection: "column",
overflow: "hidden",
backgroundColor: "var(--forge-bg)",
}}
> >
<div <div
className="flex items-center justify-between px-3 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--forge-border)",
}}
> >
<span <span
className="text-xs font-semibold uppercase tracking-wider" style={{
style={{ color: "var(--forge-text-secondary)" }} fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
}}
> >
Search Search
</span> </span>
</div> </div>
<div className="space-y-2 px-3 py-2" style={{ borderBottom: "1px solid var(--forge-border)" }}> <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--forge-border)" }}>
<div className="flex items-center gap-1"> <div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search..." placeholder="Search..."
className="flex-1 rounded px-2 py-1 text-sm outline-none" aria-label="Search query"
style={{ style={{
flex: 1,
borderRadius: "0.25rem",
padding: "0.25rem 0.5rem",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)", color: "var(--forge-text)",
border: "1px solid var(--forge-border)", border: "1px solid var(--forge-border)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "13px", fontSize: "13px",
outline: "none",
}} }}
/> />
<button <button
onClick={() => setRegex((v) => !v)} onClick={() => setRegex((v) => !v)}
className="rounded px-1.5 py-1"
style={toggleBtnStyle(regex)} style={toggleBtnStyle(regex)}
title="Use Regular Expression" title="Use Regular Expression"
> >
@@ -133,7 +157,6 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</button> </button>
<button <button
onClick={() => setCaseSensitive((v) => !v)} onClick={() => setCaseSensitive((v) => !v)}
className="rounded px-1.5 py-1"
style={toggleBtnStyle(caseSensitive)} style={toggleBtnStyle(caseSensitive)}
title="Match Case" title="Match Case"
> >
@@ -141,27 +164,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</button> </button>
</div> </div>
<div className="flex gap-1"> <div style={{ display: "flex", gap: "0.25rem" }}>
<input <input
value={includePattern} value={includePattern}
onChange={(e) => setIncludePattern(e.target.value)} onChange={(e) => setIncludePattern(e.target.value)}
placeholder="Include (e.g. *.nss)" placeholder="Include (e.g. *.nss)"
className="flex-1 rounded px-2 py-1 text-xs outline-none"
style={{ style={{
flex: 1,
borderRadius: "0.25rem",
padding: "0.25rem 0.5rem",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)", color: "var(--forge-text)",
border: "1px solid var(--forge-border)", border: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
outline: "none",
}} }}
/> />
<input <input
value={excludePattern} value={excludePattern}
onChange={(e) => setExcludePattern(e.target.value)} onChange={(e) => setExcludePattern(e.target.value)}
placeholder="Exclude (e.g. *.json)" placeholder="Exclude (e.g. *.json)"
className="flex-1 rounded px-2 py-1 text-xs outline-none"
style={{ style={{
flex: 1,
borderRadius: "0.25rem",
padding: "0.25rem 0.5rem",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)", color: "var(--forge-text)",
border: "1px solid var(--forge-border)", border: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
outline: "none",
}} }}
/> />
</div> </div>
@@ -169,25 +200,34 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<button <button
onClick={doSearch} onClick={doSearch}
disabled={loading || !query.trim()} disabled={loading || !query.trim()}
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
style={{ style={{
width: "100%",
borderRadius: "0.25rem",
padding: "0.25rem 0.75rem",
fontSize: "var(--text-sm)",
fontWeight: 500,
backgroundColor: "var(--forge-accent)", 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"} {loading ? "Searching..." : "Search"}
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div style={{ flex: 1, overflowY: "auto" }}>
{error && ( {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 && ( {searched && !loading && !error && (
<div <div
className="px-3 py-1.5 text-xs" aria-live="polite"
style={{ style={{
padding: "0.375rem 0.75rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
borderBottom: "1px solid var(--forge-border)", borderBottom: "1px solid var(--forge-border)",
}} }}
@@ -202,24 +242,44 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<div key={group.file}> <div key={group.file}>
<button <button
onClick={() => toggleCollapsed(group.file)} 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" onMouseEnter={() => setHoveredFile(group.file)}
style={{ color: "var(--forge-text)" }} 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 <span style={{ display: "flex", alignItems: "center", width: "12px", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
className="inline-block w-3 text-center" {collapsed.has(group.file) ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
style={{ color: "var(--forge-text-secondary)", fontSize: "10px" }}
>
{collapsed.has(group.file) ? "\u25B6" : "\u25BC"}
</span> </span>
<span <span
className="flex-1 truncate font-medium" style={{
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }} flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
fontFamily: "var(--font-mono)",
fontSize: "12px",
}}
> >
{group.file} {group.file}
</span> </span>
<span <span
className="rounded-full px-1.5 text-xs"
style={{ style={{
borderRadius: "9999px",
padding: "0 0.375rem",
fontSize: "var(--text-xs)",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
}} }}
@@ -229,37 +289,57 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</button> </button>
{!collapsed.has(group.file) && {!collapsed.has(group.file) &&
group.matches.map((match, i) => ( group.matches.map((match, i) => {
<button const matchKey = `${match.file}-${match.line}-${match.column}-${i}`;
key={`${match.line}-${match.column}-${i}`} return (
onClick={() => onResultClick(match.file, match.line)} <button
className="flex w-full items-start gap-2 px-3 py-0.5 text-left transition-colors hover:bg-white/5" key={matchKey}
style={{ paddingLeft: "28px" }} onClick={() => onResultClick(match.file, match.line)}
> onMouseEnter={() => setHoveredMatch(matchKey)}
<span onMouseLeave={() => setHoveredMatch(null)}
className="shrink-0 text-xs"
style={{ style={{
color: "var(--forge-text-secondary)", display: "flex",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", width: "100%",
fontSize: "11px", alignItems: "flex-start",
minWidth: "32px", gap: "0.5rem",
textAlign: "right", paddingLeft: "28px",
paddingRight: "0.75rem",
paddingTop: "0.125rem",
paddingBottom: "0.125rem",
textAlign: "left",
border: "none",
background: hoveredMatch === matchKey ? "var(--forge-surface-raised)" : "none",
cursor: "pointer",
transition: "background-color 100ms ease-out",
}} }}
> >
{match.line} <span
</span> style={{
<span flexShrink: 0,
className="truncate text-xs" color: "var(--forge-text-secondary)",
style={{ fontFamily: "var(--font-mono)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "11px",
fontSize: "12px", minWidth: "32px",
color: "var(--forge-text-secondary)", textAlign: "right",
}} }}
> >
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} /> {match.line}
</span> </span>
</button> <span
))} style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: "12px",
color: "var(--forge-text-secondary)",
}}
>
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} />
</span>
</button>
);
})}
</div> </div>
))} ))}
</div> </div>
@@ -283,7 +363,7 @@ function HighlightedLine({
return ( return (
<> <>
<span>{before}</span> <span>{before}</span>
<span className="font-bold" style={{ color: "var(--forge-accent)" }}> <span style={{ fontWeight: 700, color: "var(--forge-accent)" }}>
{matched} {matched}
</span> </span>
<span>{after}</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 ( return (
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Area Flags Area Flags
</label> </label>
<div className="flex items-center gap-6"> <div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
{bits.map(({ bit, label }) => { {bits.map(({ bit, label }) => {
const checked = (flags & (1 << bit)) !== 0; const checked = (flags & (1 << bit)) !== 0;
return ( 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 <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
@@ -57,15 +66,19 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
: flags & ~(1 << bit); : flags & ~(1 << bit);
onChange("Flags", newFlags); onChange("Flags", newFlags);
}} }}
className="h-4 w-4 rounded" style={{
style={{ accentColor: "var(--forge-accent)" }} height: "1rem",
width: "1rem",
borderRadius: "0.25rem",
accentColor: "var(--forge-accent)",
}}
/> />
<span style={{ color: "var(--forge-text)" }}>{label}</span> <span style={{ color: "var(--forge-text)" }}>{label}</span>
</label> </label>
); );
})} })}
</div> </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} Raw value: {flags}
</span> </span>
</div> </div>
@@ -77,19 +90,25 @@ function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
const hex = intToHexColor(num); const hex = intToHexColor(num);
return ( return (
<div className="flex items-center gap-3"> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{field.displayName} {field.displayName}
</label> </label>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input <input
type="color" type="color"
value={hex} value={hex}
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))} onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
className="h-8 w-10 cursor-pointer rounded border-0" style={{
style={{ backgroundColor: "var(--forge-bg)" }} 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} {hex}
</span> </span>
</div> </div>
@@ -106,54 +125,64 @@ function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
const ts = typeof tileset === "string" ? tileset : ""; const ts = typeof tileset === "string" ? tileset : "";
return ( return (
<div className="space-y-3"> <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="flex items-center gap-4"> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Width</label> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Width</label>
<input <input
type="number" type="number"
value={w} value={w}
min={1} min={1}
max={32} max={32}
onChange={(e) => onChange("Width", parseInt(e.target.value, 10))} onChange={(e) => onChange("Width", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
</div> </div>
<span style={{ color: "var(--forge-text-secondary)" }}>×</span> <span style={{ color: "var(--forge-text-secondary)" }}>×</span>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Height</label> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Height</label>
<input <input
type="number" type="number"
value={h} value={h}
min={1} min={1}
max={32} max={32}
onChange={(e) => onChange("Height", parseInt(e.target.value, 10))} onChange={(e) => onChange("Height", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
</div> </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>
<div className="flex items-center gap-3"> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Tileset</label> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Tileset</label>
<input <input
type="text" type="text"
value={ts} value={ts}
maxLength={16} maxLength={16}
onChange={(e) => onChange("Tileset", e.target.value)} onChange={(e) => onChange("Tileset", e.target.value)}
className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
@@ -25,21 +25,28 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
}; };
return ( return (
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Ability Scores Ability Scores
</label> </label>
<div className="grid grid-cols-3 gap-3"> <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem" }}>
{abilities.flat().map((ab) => { {abilities.flat().map((ab) => {
const val = getFieldValue(data, ab); const val = getFieldValue(data, ab);
const num = typeof val === "number" ? val : 0; const num = typeof val === "number" ? val : 0;
return ( return (
<div <div
key={ab} key={ab}
className="flex flex-col items-center rounded border px-3 py-2" style={{
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }} 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]} {displayNames[ab]}
</span> </span>
<input <input
@@ -48,14 +55,20 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
min={1} min={1}
max={99} max={99}
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))} 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={{ 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)", backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", 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)} mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
</span> </span>
</div> </div>
@@ -73,34 +86,39 @@ function RaceGenderOverride({ data, onChange }: FieldOverrideProps) {
const genderNum = typeof gender === "number" ? gender : 0; const genderNum = typeof gender === "number" ? gender : 0;
return ( return (
<div className="flex items-center gap-4"> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Race</label> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Race</label>
<input <input
type="number" type="number"
value={raceNum} value={raceNum}
min={0} min={0}
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))} onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", 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) (racialtypes.2da)
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Gender</label> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Gender</label>
<select <select
value={genderNum} value={genderNum}
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))} onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
className="rounded border px-2 py-1.5 text-sm"
style={{ style={{
borderRadius: "0.25rem",
border: "1px solid var(--forge-border)",
padding: "0.375rem 0.5rem",
fontSize: "var(--text-sm)",
backgroundColor: "var(--forge-bg)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
> >
@@ -121,13 +139,13 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
]; ];
return ( return (
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{scripts.map((s) => { {scripts.map((s) => {
const val = getFieldValue(data, s.label); const val = getFieldValue(data, s.label);
const str = typeof val === "string" ? val : ""; const str = typeof val === "string" ? val : "";
return ( return (
<div key={s.label} className="flex items-center gap-3"> <div key={s.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label className="w-28 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ width: "7rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{s.display} {s.display}
</label> </label>
<input <input
@@ -135,10 +153,14 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
value={str} value={str}
maxLength={16} maxLength={16}
onChange={(e) => onChange(s.label, e.target.value)} onChange={(e) => onChange(s.label, e.target.value)}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
placeholder="(none)" placeholder="(none)"
@@ -6,6 +6,7 @@ import {
gffTypeFromPath, gffTypeFromPath,
getLocStringText, getLocStringText,
} from "./GffEditor"; } from "./GffEditor";
import { ChevronRight, ChevronDown } from "lucide-react";
interface DialogEditorProps { interface DialogEditorProps {
repo: string; repo: string;
@@ -48,38 +49,38 @@ function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply"
const sound = getStringVal(node, "Sound"); const sound = getStringVal(node, "Sound");
return ( return (
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<div> <div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
Text Text
</label> </span>
<p className="mt-0.5 text-sm" style={{ color: "var(--forge-text)" }}> <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>} {text || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
{type === "entry" && speaker && ( {type === "entry" && speaker && (
<div> <div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Speaker</label> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Speaker</span>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{speaker}</p> <p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{speaker}</p>
</div> </div>
)} )}
{script && ( {script && (
<div> <div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Action Script</label> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Action Script</span>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{script}</p> <p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{script}</p>
</div> </div>
)} )}
{active && ( {active && (
<div> <div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Condition</label> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Condition</span>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{active}</p> <p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{active}</p>
</div> </div>
)} )}
{sound && ( {sound && (
<div> <div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Sound</label> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Sound</span>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{sound}</p> <p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{sound}</p>
</div> </div>
)} )}
</div> </div>
@@ -103,6 +104,7 @@ function DialogNodeItem({
depth: number; depth: number;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [hovered, setHovered] = useState(false);
const text = getTextVal(node); const text = getTextVal(node);
const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text; const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
@@ -117,40 +119,63 @@ function DialogNodeItem({
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}> <div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
<button <button
onClick={() => setExpanded(!expanded)} 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={{ 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)", 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)" }}> <span style={{ marginTop: "0.125rem", display: "flex", alignItems: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
{expanded ? "▼" : "▶"} {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span> </span>
<span <span
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
style={{ style={{
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20", flexShrink: 0,
color: type === "entry" ? "#60a5fa" : "#4ade80", 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} {type === "entry" ? "E" : "R"}{index}
</span> </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>} {truncated || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
</span> </span>
</button> </button>
{expanded && ( {expanded && (
<div <div
className="ml-6 mt-1 space-y-2 border-l-2 pl-3" style={{
style={{ borderColor: "var(--forge-border)" }} 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} /> <NodeDetail node={node} type={type} />
</div> </div>
{childLinks && childLinks.length > 0 && depth < 4 && ( {childLinks && childLinks.length > 0 && depth < 4 && (
<div className="space-y-1"> <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{childLinks.map((link, li) => { {childLinks.map((link, li) => {
const idx = typeof link === "object" && link !== null const idx = typeof link === "object" && link !== null
? (typeof link.Index === "number" ? link.Index : ? (typeof link.Index === "number" ? link.Index :
@@ -254,31 +279,44 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
}, [schema]); }, [schema]);
return ( 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 */} {/* Toolbar */}
<div <div
className="flex shrink-0 items-center justify-between border-b px-4 py-2" style={{
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }} 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"> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}> <span style={{ fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Dialog Editor Dialog Editor
</span> </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 {entries.length} entries, {replies.length} replies
</span> </span>
{dirty && ( {dirty && (
<span className="text-xs" style={{ color: "var(--forge-accent)" }}> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)" }}>
(unsaved changes) (unsaved changes)
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{onSwitchToRaw && ( {onSwitchToRaw && (
<button <button
onClick={onSwitchToRaw} onClick={onSwitchToRaw}
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80" style={{
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }} 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 Switch to Raw JSON
</button> </button>
@@ -286,8 +324,16 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!dirty || saving} disabled={!dirty || saving}
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40" style={{
style={{ backgroundColor: "var(--forge-accent)", color: "#fff" }} 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"} {saving ? "Saving..." : "Save"}
</button> </button>
@@ -296,17 +342,30 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
{/* Tabs */} {/* Tabs */}
<div <div
className="flex shrink-0 gap-0 border-b" role="tablist"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }} style={{
display: "flex",
flexShrink: 0,
gap: 0,
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
}}
> >
{(["tree", "properties"] as const).map((tab) => ( {(["tree", "properties"] as const).map((tab) => (
<button <button
key={tab} key={tab}
role="tab"
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className="px-4 py-2 text-sm capitalize transition-colors"
style={{ style={{
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
textTransform: "capitalize",
color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)", color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)",
border: "none",
borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent", borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent",
backgroundColor: "transparent",
cursor: "pointer",
}} }}
> >
{tab === "tree" ? "Conversation Tree" : "Properties"} {tab === "tree" ? "Conversation Tree" : "Properties"}
@@ -315,13 +374,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
{error && ( {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" && ( {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.length > 0 ? (
startingEntries.map((link, i) => { startingEntries.map((link, i) => {
const idx = typeof link === "object" && link !== null const idx = typeof link === "object" && link !== null
@@ -353,7 +412,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
depth={0} 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 No dialog entries found
</p> </p>
)} )}
@@ -361,7 +420,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
)} )}
{activeTab === "properties" && ( {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) => { {propertyFields.map((field) => {
const raw = data[field.label]; const raw = data[field.label];
const value = raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>) 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; : raw;
return ( return (
<div key={field.label} className="flex items-center gap-3" title={field.description}> <div key={field.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }} title={field.description}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{field.displayName} {field.displayName}
</label> </label>
{field.type === GffFieldType.ResRef ? ( {field.type === GffFieldType.ResRef ? (
@@ -392,10 +451,14 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
return updated; return updated;
}); });
}} }}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
@@ -418,10 +481,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
return updated; return updated;
}); });
}} }}
className="w-32 rounded border px-2 py-1.5 text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
+140 -277
View File
@@ -1,23 +1,12 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { Code2, Save, ChevronRight, ChevronDown } from "lucide-react";
import { api } from "../../services/api"; import { api } from "../../services/api";
export enum GffFieldType { export enum GffFieldType {
Byte = 0, Byte = 0, Char = 1, Word = 2, Short = 3, Dword = 4, Int = 5,
Char = 1, Dword64 = 6, Int64 = 7, Float = 8, Double = 9,
Word = 2, CExoString = 10, ResRef = 11, CExoLocString = 12, Void = 13,
Short = 3, Struct = 14, List = 15,
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 { export interface GffFieldSchema {
@@ -56,6 +45,11 @@ function getLocStringText(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return value;
if (typeof value === "object") { if (typeof value === "object") {
const v = value as Record<string, unknown>; 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") { if (v.strings && typeof v.strings === "object") {
const strings = v.strings as Record<string, string>; const strings = v.strings as Record<string, string>;
return strings["0"] ?? Object.values(strings)[0] ?? ""; return strings["0"] ?? Object.values(strings)[0] ?? "";
@@ -63,15 +57,15 @@ function getLocStringText(value: unknown): string {
if (v.value && typeof v.value === "object") { if (v.value && typeof v.value === "object") {
return getLocStringText(v.value); 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); return String(value);
} }
function setFieldValue( function setFieldValue(data: Record<string, unknown>, label: string, newValue: unknown): Record<string, unknown> {
data: Record<string, unknown>,
label: string,
newValue: unknown,
): Record<string, unknown> {
const updated = { ...data }; const updated = { ...data };
const existing = data[label]; const existing = data[label];
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) { if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
@@ -82,28 +76,25 @@ function setFieldValue(
return updated; return updated;
} }
function setLocStringValue( function setLocStringValue(data: Record<string, unknown>, label: string, text: string): Record<string, unknown> {
data: Record<string, unknown>,
label: string,
text: string,
): Record<string, unknown> {
const updated = { ...data }; const updated = { ...data };
const existing = data[label]; const existing = data[label];
if (existing && typeof existing === "object") { if (existing && typeof existing === "object") {
const ex = existing as Record<string, unknown>; const ex = existing as Record<string, unknown>;
if ("value" in ex && ex.value && typeof ex.value === "object") { if ("value" in ex && ex.value && typeof ex.value === "object") {
const inner = ex.value as Record<string, unknown>; const inner = ex.value as Record<string, unknown>;
updated[label] = { if (typeof inner["0"] === "string") {
...ex, updated[label] = { ...ex, value: { ...inner, "0": text } };
value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } }, } else {
}; updated[label] = { ...ex, value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } } };
}
} else if ("strings" in ex) { } else if ("strings" in ex) {
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } }; updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
} else { } else {
updated[label] = { ...ex, value: { strings: { "0": text } } }; updated[label] = { ...ex, value: { strings: { "0": text } } };
} }
} else { } else {
updated[label] = { type: "cexolocstring", value: { strings: { "0": text } } }; updated[label] = { type: "cexolocstring", value: { "0": text } };
} }
return updated; return updated;
} }
@@ -112,6 +103,31 @@ function isNumericType(type: GffFieldType): boolean {
return type <= GffFieldType.Double; 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 { interface FieldRendererProps {
field: GffFieldSchema; field: GffFieldSchema;
value: unknown; value: unknown;
@@ -126,14 +142,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
if (field.type === GffFieldType.Void) { if (field.type === GffFieldType.Void) {
const hex = typeof value === "string" ? value : ""; const hex = typeof value === "string" ? value : "";
return ( return (
<div className="flex items-start gap-3"> <div style={{ ...fieldRow, alignItems: "flex-start" }}>
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
{field.displayName} <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)" }}>
</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)" }}
>
{hex || "(empty)"} {hex || "(empty)"}
</code> </code>
</div> </div>
@@ -141,26 +152,7 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
} }
if (field.type === GffFieldType.CExoLocString) { if (field.type === GffFieldType.CExoLocString) {
const text = getLocStringText(value); return <LocStringField field={field} value={value} isReadonly={isReadonly} onLocStringChange={onLocStringChange} />;
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>
);
} }
if (field.type === GffFieldType.List) { if (field.type === GffFieldType.List) {
@@ -176,23 +168,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
const num = typeof value === "number" ? value : 0; const num = typeof value === "number" ? value : 0;
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double; const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
return ( return (
<div className="flex items-center gap-3"> <div style={fieldRow}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>{field.displayName}</label>
{field.displayName} <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" }} />
</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> </div>
); );
} }
@@ -201,54 +179,51 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
const str = typeof value === "string" ? value : ""; const str = typeof value === "string" ? value : "";
const valid = str.length <= 16; const valid = str.length <= 16;
return ( return (
<div className="flex items-center gap-3"> <div style={fieldRow}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>{field.displayName}</label>
{field.displayName} <div style={{ display: "flex", flex: 1, alignItems: "center", gap: "0.5rem" }}>
</label> <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)" }} />
<div className="flex flex-1 items-center gap-2"> <span style={{ fontSize: "var(--text-xs)", color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)", flexShrink: 0 }}>{str.length}/16</span>
<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> </div>
</div> </div>
); );
} }
// CExoString fallback
const str = typeof value === "string" ? value : String(value ?? ""); const str = typeof value === "string" ? value : String(value ?? "");
return ( return (
<div className="flex items-center gap-3"> <div style={fieldRow}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>{field.displayName}</label>
{field.displayName} <input type="text" value={str} readOnly={isReadonly} onChange={(e) => onChange(e.target.value)} style={fieldInput} />
</label> </div>
<input );
type="text" }
value={str}
readOnly={isReadonly} function LocStringField({ field, value, isReadonly, onLocStringChange }: { field: GffFieldSchema; value: unknown; isReadonly: boolean; onLocStringChange?: (text: string) => void }) {
onChange={(e) => onChange(e.target.value)} const text = getLocStringText(value);
className="flex-1 rounded border px-2 py-1.5 text-sm" const isTlkRef = text.startsWith("(TLK #");
style={{ const [resolved, setResolved] = useState<string | null>(null);
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)", useEffect(() => {
color: "var(--forge-text)", 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> </div>
); );
} }
@@ -257,44 +232,25 @@ function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
return ( return (
<div className="flex flex-col gap-1"> <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
<button <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" }}>
onClick={() => setExpanded(!expanded)} {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span> <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)" }}> <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>
{items.length} {items.length === 1 ? "item" : "items"}
</span>
</button> </button>
{expanded && ( {expanded && (
<div <div style={{ marginLeft: "1rem", marginTop: "0.25rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
className="ml-4 mt-1 space-y-2 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
{items.map((item, i) => ( {items.map((item, i) => (
<div key={i} className="rounded p-2" style={{ backgroundColor: "var(--forge-bg)" }}> <div key={i} style={{ borderRadius: "0.375rem", padding: "0.5rem 0.75rem", backgroundColor: "var(--forge-bg)" }}>
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}> <div style={{ fontSize: "var(--text-xs)", fontWeight: 500, color: "var(--forge-text-secondary)", marginBottom: "0.25rem" }}>[{i}]</div>
[{i}]
</div>
{typeof item === "object" && item !== null ? ( {typeof item === "object" && item !== null ? (
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}> <pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(item, null, 2)}</pre>
{JSON.stringify(item, null, 2)}
</pre>
) : ( ) : (
<span className="text-sm" style={{ color: "var(--forge-text)" }}> <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>{String(item)}</span>
{String(item)}
</span>
)} )}
</div> </div>
))} ))}
{items.length === 0 && ( {items.length === 0 && <div style={{ padding: "0.25rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>(empty list)</div>}
<div className="py-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
(empty list)
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -305,23 +261,14 @@ function StructField({ field, value }: { field: GffFieldSchema; value?: Record<s
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
return ( return (
<div className="flex flex-col gap-1"> <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
<button <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" }}>
onClick={() => setExpanded(!expanded)} {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span> <span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
</button> </button>
{expanded && value && ( {expanded && value && (
<div <div style={{ marginLeft: "1rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)" }}>
className="ml-4 mt-1 border-l-2 pl-4" <pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(value, null, 2)}</pre>
style={{ borderColor: "var(--forge-border)" }}
>
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(value, null, 2)}
</pre>
</div> </div>
)} )}
</div> </div>
@@ -345,89 +292,41 @@ export interface FieldOverrideProps {
onChange: (label: string, value: unknown) => void; onChange: (label: string, value: unknown) => void;
} }
export function GffEditor({ export function GffEditor({ repo, filePath, content, onSave, onSwitchToRaw, fieldOverrides, headerSlot }: GffEditorProps) {
repo,
filePath,
content,
onSave,
onSwitchToRaw,
fieldOverrides,
headerSlot,
}: GffEditorProps) {
const [schema, setSchema] = useState<GffTypeSchema | null>(null); const [schema, setSchema] = useState<GffTypeSchema | null>(null);
const [data, setData] = useState<Record<string, unknown>>({}); const [data, setData] = useState<Record<string, unknown>>({});
const [activeCategory, setActiveCategory] = useState<string>(""); const [activeCategory, setActiveCategory] = useState<string>("");
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]); const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
useEffect(() => { useEffect(() => { try { setData(JSON.parse(content)); } catch { setError("Failed to parse JSON content"); } }, [content]);
try {
setData(JSON.parse(content));
} catch {
setError("Failed to parse JSON content");
}
}, [content]);
useEffect(() => { useEffect(() => {
if (!gffType) return; if (!gffType) return;
api.editor 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}`));
.gffSchema(gffType)
.then((s) => {
setSchema(s);
if (s.categories.length > 0) setActiveCategory(s.categories[0]);
})
.catch(() => setError(`Failed to load schema for .${gffType}`));
}, [gffType]); }, [gffType]);
const handleFieldChange = useCallback((label: string, value: unknown) => { const handleFieldChange = useCallback((label: string, value: unknown) => { setData((prev) => { setDirty(true); return setFieldValue(prev, label, value); }); }, []);
setData((prev) => { const handleLocStringChange = useCallback((label: string, text: string) => { setData((prev) => { setDirty(true); return setLocStringValue(prev, label, text); }); }, []);
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 handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setSaving(true); setSaving(true);
try { try { const newContent = JSON.stringify(data, null, 4) + "\n"; await api.editor.writeFile(repo, filePath, newContent); setDirty(false); onSave?.(newContent); }
const newContent = JSON.stringify(data, null, 4) + "\n"; catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
await api.editor.writeFile(repo, filePath, newContent); finally { setSaving(false); }
setDirty(false);
onSave?.(newContent);
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}, [data, repo, filePath, onSave]); }, [data, repo, filePath, onSave]);
const categoryFields = useMemo(() => { const categoryFields = useMemo(() => schema ? schema.fields.filter((f) => f.category === activeCategory && !f.hidden) : [], [schema, activeCategory]);
if (!schema) return [];
return schema.fields.filter((f) => f.category === activeCategory && !f.hidden);
}, [schema, activeCategory]);
if (error && !schema) { if (error && !schema) {
return ( return (
<div className="flex h-full items-center justify-center"> <div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
<div className="text-center"> <div style={{ textAlign: "center" }}>
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p> <p style={{ fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
{onSwitchToRaw && ( {onSwitchToRaw && (
<button <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" }}>
onClick={onSwitchToRaw}
className="mt-3 rounded px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-surface)", color: "var(--forge-text)" }}
>
Open as Raw JSON Open as Raw JSON
</button> </button>
)} )}
@@ -438,52 +337,29 @@ export function GffEditor({
if (!schema) { if (!schema) {
return ( return (
<div className="flex h-full items-center justify-center"> <div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
<p className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Loading schema...</p> <p style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Loading schema...</p>
</div> </div>
); );
} }
return ( 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 */} {/* Toolbar */}
<div <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)" }}>
className="flex shrink-0 items-center justify-between border-b px-4 py-2" <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }} <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>}
<div className="flex items-center gap-3"> {error && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>{error}</span>}
<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> </div>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{onSwitchToRaw && ( {onSwitchToRaw && (
<button <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" }}>
onClick={onSwitchToRaw} <Code2 size={13} /> Raw JSON
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> </button>
)} )}
<button <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 }}>
onClick={handleSave} <Save size={13} /> {saving ? "Saving..." : "Save"}
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> </button>
</div> </div>
</div> </div>
@@ -491,40 +367,29 @@ export function GffEditor({
{headerSlot} {headerSlot}
{/* Category tabs */} {/* Category tabs */}
<div <div style={{ display: "flex", flexShrink: 0, overflowX: "auto", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
className="flex shrink-0 gap-0 overflow-x-auto border-b"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
{schema.categories.map((cat) => ( {schema.categories.map((cat) => (
<button <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" }}>
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",
}}
>
{cat} {cat}
</button> </button>
))} ))}
</div> </div>
{/* Fields */} {/* Fields */}
<div className="flex-1 overflow-y-auto p-4"> <div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
<div className="mx-auto max-w-2xl space-y-4"> <div style={{ maxWidth: "42rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{categoryFields.map((field) => { {categoryFields.map((field) => {
const override = fieldOverrides?.get(field.label); const override = fieldOverrides?.get(field.label);
if (override) { if (override) {
return ( 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 })} {override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
</div> </div>
); );
} }
return ( return (
<div key={field.label} title={field.description}> <div key={field.label} title={field.description} style={{ borderBottom: "1px solid var(--forge-border)" }}>
<FieldRenderer <FieldRenderer
field={field} field={field}
value={getFieldValue(data, field.label)} value={getFieldValue(data, field.label)}
@@ -535,9 +400,7 @@ export function GffEditor({
); );
})} })}
{categoryFields.length === 0 && ( {categoryFields.length === 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 fields in this category</p>
No fields in this category
</p>
)} )}
</div> </div>
</div> </div>
@@ -18,22 +18,25 @@ interface ItemEditorProps {
function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) { function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
const num = typeof value === "number" ? value : 0; const num = typeof value === "number" ? value : 0;
return ( return (
<div className="flex items-center gap-3"> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{field.displayName} {field.displayName}
</label> </label>
<input <input
type="number" type="number"
value={num} value={num}
onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))} onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))}
className="w-24 rounded border px-2 py-1.5 text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", 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) (baseitems.2da row)
</span> </span>
</div> </div>
@@ -51,14 +54,14 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
if (field.label !== "StackSize") return null; if (field.label !== "StackSize") return null;
return ( 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: "StackSize", display: "Stack", value: stackSize, max: 99 },
{ label: "Cost", display: "Cost (gp)", value: cost, max: 999999 }, { label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
{ label: "Charges", display: "Charges", value: charges, max: 255 }, { label: "Charges", display: "Charges", value: charges, max: 255 },
].map((item) => ( ].map((item) => (
<div key={item.label} className="flex items-center gap-2"> <div key={item.label} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{item.display} {item.display}
</label> </label>
<input <input
@@ -67,10 +70,13 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
min={0} min={0}
max={item.max} max={item.max}
onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))} onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))}
className="w-24 rounded border px-2 py-1.5 text-sm"
style={{ 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)", backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
@@ -89,18 +95,17 @@ function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) {
]; ];
return ( return (
<div className="flex items-center gap-6"> <div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
{flags.map((flag) => { {flags.map((flag) => {
const val = getFieldValue(data, flag.label); const val = getFieldValue(data, flag.label);
const checked = typeof val === "number" ? val !== 0 : Boolean(val); const checked = typeof val === "number" ? val !== 0 : Boolean(val);
return ( 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 <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)} onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)}
className="h-4 w-4 rounded" style={{ width: "1rem", height: "1rem", borderRadius: "0.25rem", accentColor: "var(--forge-accent)" }}
style={{ accentColor: "var(--forge-accent)" }}
/> />
<span style={{ color: "var(--forge-text)" }}>{flag.display}</span> <span style={{ color: "var(--forge-text)" }}>{flag.display}</span>
</label> </label>
@@ -114,41 +119,34 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
const list = Array.isArray(value) ? value : []; const list = Array.isArray(value) ? value : [];
return ( return (
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<div className="flex items-center justify-between"> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="text-sm" style={{ color: "var(--forge-text)" }}> <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
Item Properties Item Properties
</span> </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> </div>
{list.map((prop, i) => ( {list.map((prop, i) => (
<div <div
key={i} key={i}
className="flex items-center gap-2 rounded border px-3 py-2" style={{
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }} 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 {typeof prop === "object" && prop !== null
? JSON.stringify(prop).slice(0, 80) ? JSON.stringify(prop).slice(0, 80)
: String(prop)} : String(prop)}
</span> </span>
<button
className="text-xs"
style={{ color: "#ef4444" }}
>
Remove
</button>
</div> </div>
))} ))}
{list.length === 0 && ( {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 No item properties
</p> </p>
)} )}
@@ -185,10 +183,13 @@ export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: I
const headerSlot = ( const headerSlot = (
<div <div
className="border-b px-4 py-3" style={{
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }} 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} {itemName}
</h2> </h2>
</div> </div>
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; 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({ const term = new XTerm({
theme: { theme: {
background: "#121212", background: bg,
foreground: "#f2f2f2", foreground: fg,
cursor: "#946200", cursor: accent,
selectionBackground: "#946200", selectionBackground: accent,
selectionForeground: "#f2f2f2", selectionForeground: fg,
black: "#121212", black: bg,
brightBlack: "#666666", brightBlack: secondary,
white: "#f2f2f2", white: fg,
brightWhite: "#ffffff", brightWhite: fg,
yellow: "#946200", yellow: accent,
brightYellow: "#c48800", brightYellow: accentHover,
}, },
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: 13, fontSize: 13,
cursorBlink: true, cursorBlink: true,
}); });
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full" 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]);
}
+120 -47
View File
@@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { Terminal } from "../components/terminal/Terminal"; import { Terminal } from "../components/terminal/Terminal";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import { useTheme } from "../hooks/useTheme"; 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 = [ const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
{ path: "/editor", label: "Editor", icon: "\u270E" }, { path: "/editor", label: "Editor", Icon: Code2 },
{ path: "/toolset", label: "Toolset", icon: "\u2699" }, { path: "/toolset", label: "Toolset", Icon: Wrench },
{ path: "/build", label: "Build", icon: "\u2692" }, { path: "/build", label: "Build", Icon: Hammer },
{ path: "/server", label: "Server", icon: "\u25B6" }, { path: "/server", label: "Server", Icon: Play },
{ path: "/repos", label: "Repos", icon: "\u2387" }, { path: "/repos", label: "Repos", Icon: GitBranch },
{ path: "/settings", label: "Settings", icon: "\u2318" }, { path: "/settings", label: "Settings", Icon: Settings },
]; ];
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { subscribe } = useWebSocket(); const { subscribe } = useWebSocket();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
useEffect(() => { useEffect(() => {
return subscribe("git:upstream-update", (event) => { return subscribe("git:upstream-update", (event) => {
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
}; };
return ( 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 */} {/* Left sidebar nav */}
<nav <nav
className="flex shrink-0 flex-col" aria-label="Main navigation"
style={{ style={{
display: "flex",
flexDirection: "column",
width: "56px", width: "56px",
flexShrink: 0,
borderRight: "1px solid var(--forge-border)", borderRight: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
}} }}
> >
<Link <Link
to="/" to="/"
className="flex items-center justify-center py-3" style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
title="Dashboard" title="Dashboard"
> >
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} /> <img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
</Link> </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) => { {NAV_ITEMS.map((item) => {
const isActive = const isActive =
item.path === "/" item.path === "/"
@@ -115,20 +133,44 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<Link <Link
key={item.path} key={item.path}
to={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={{ style={{
borderLeft: isActive display: "flex",
? "3px solid var(--forge-accent)" flexDirection: "column",
: "3px solid transparent", alignItems: "center",
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined, 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)", 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} title={item.label}
> >
<span className="text-base">{item.icon}</span> <item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span> <span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
{badge > 0 && ( {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} {badge}
</span> </span>
)} )}
@@ -139,69 +181,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5" style={{
style={{ color: "var(--forge-text-secondary)" }} 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`} 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> </button>
</nav> </nav>
{/* Main content area */} {/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden"> <div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
<header <header
className="flex shrink-0 items-center gap-4 px-4 py-1.5" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} display: "flex",
alignItems: "center",
gap: "1rem",
padding: "0.375rem 1rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
> >
<div className="flex items-center gap-2"> <span
<span style={{
className="text-lg font-bold" fontFamily: "var(--font-heading)",
style={{ fontSize: "var(--text-lg)",
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", fontWeight: 700,
color: "var(--forge-accent)", color: "var(--forge-accent)",
}} }}
> >
Layonara Forge Layonara Forge
</span> </span>
</div>
<div className="flex-1" />
</header> </header>
<div className="flex flex-1 overflow-hidden"> <div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{sidebar && ( {sidebar && showSidebar && (
<aside <aside
className="shrink-0 overflow-hidden"
style={{ style={{
width: "250px", width: "250px",
flexShrink: 0,
overflow: "hidden",
borderRight: "1px solid var(--forge-border)", borderRight: "1px solid var(--forge-border)",
}} }}
> >
{sidebar} {sidebar}
</aside> </aside>
)} )}
<main className="flex-1 overflow-hidden"> <main style={{ flex: 1, overflow: "hidden" }}>
<Outlet /> <Outlet />
</main> </main>
</div> </div>
<button <button
onClick={() => setTerminalOpen((v) => !v)} 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={{ style={{
borderTop: "1px solid var(--forge-border)", display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.75rem",
color: "var(--forge-text-secondary)", 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> <span>Terminal</span>
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button> </button>
{terminalOpen && ( {terminalOpen && (
<div <div
className="shrink-0 overflow-hidden"
style={{ style={{
height: "300px", height: "300px",
flexShrink: 0,
overflow: "hidden",
borderTop: "1px solid var(--forge-border)", borderTop: "1px solid var(--forge-border)",
}} }}
> >
+25 -12
View File
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
export function SetupLayout() { export function SetupLayout() {
return ( return (
<div <div
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
style={{ style={{
minHeight: "100vh",
backgroundColor: "var(--forge-bg)", 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" }}>
<h1 <div style={{ marginBottom: "2rem" }}>
className="mb-8 text-center text-3xl font-bold" <h1
style={{ style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", fontFamily: "var(--font-heading)",
color: "var(--forge-accent)", fontSize: "var(--text-2xl)",
}} fontWeight: 700,
> color: "var(--forge-accent)",
Layonara Forge margin: 0,
</h1> }}
>
Layonara Forge
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Development environment setup
</p>
</div>
<Outlet /> <Outlet />
</div> </div>
</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"
}
}
]
}
+214 -70
View File
@@ -1,6 +1,17 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api"; import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import {
Hammer,
Package,
Cpu,
Play,
Archive,
Upload,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
type BuildStatus = "idle" | "building" | "success" | "failed"; type BuildStatus = "idle" | "building" | "success" | "failed";
@@ -11,15 +22,38 @@ interface BuildSectionState {
} }
function StatusBadge({ status }: { status: BuildStatus }) { function StatusBadge({ status }: { status: BuildStatus }) {
const colors: Record<BuildStatus, string> = { const styles: Record<BuildStatus, React.CSSProperties> = {
idle: "bg-gray-500/20 text-gray-400", idle: {
building: "bg-yellow-500/20 text-yellow-400", backgroundColor: "var(--forge-surface-raised)",
success: "bg-green-500/20 text-green-400", color: "var(--forge-text-secondary)",
failed: "bg-red-500/20 text-red-400", },
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 ( 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} {status}
</span> </span>
); );
@@ -43,31 +77,47 @@ function BuildOutput({
}, [lines, collapsed]); }, [lines, collapsed]);
return ( return (
<div className="mt-2"> <div style={{ marginTop: "0.75rem" }}>
<button <button
onClick={onToggle} onClick={onToggle}
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80" style={{
style={{ color: "var(--forge-text-secondary)" }} 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> <span>Output ({lines.length} lines)</span>
</button> </button>
{!collapsed && ( {!collapsed && (
<div <div
ref={scrollRef} ref={scrollRef}
className="mt-1 max-h-64 overflow-auto rounded p-3"
style={{ style={{
backgroundColor: "#0d1117", marginTop: "0.5rem",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", maxHeight: "16rem",
fontSize: "12px", overflowY: "auto",
lineHeight: "1.5", 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 ? ( {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) => ( lines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}> <div key={i} style={{ color: "var(--forge-log-text)" }}>
{line} {line}
</div> </div>
)) ))
@@ -83,27 +133,29 @@ function ActionButton({
onClick, onClick,
disabled, disabled,
variant = "default", variant = "default",
icon,
}: { }: {
label: string; label: string;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
variant?: "default" | "primary" | "warning"; variant?: "default" | "primary" | "warning";
icon?: React.ReactNode;
}) { }) {
const styles = { const variantStyles: Record<string, React.CSSProperties> = {
default: { default: {
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface-raised)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}, },
primary: { primary: {
backgroundColor: "var(--forge-accent)", backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)", border: "none",
color: "#fff", color: "var(--forge-accent-text)",
}, },
warning: { warning: {
backgroundColor: "#854d0e", backgroundColor: "var(--forge-warning-bg)",
borderColor: "#a16207", border: "1px solid var(--forge-warning-border)",
color: "#fef08a", color: "var(--forge-warning)",
}, },
}; };
@@ -111,9 +163,22 @@ function ActionButton({
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50" style={{
style={styles[variant]} ...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} {label}
</button> </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 ( return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}> <div
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}> style={{
Build Pipeline height: "100%",
</h2> 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-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 */} {/* Module Section */}
<section <section style={cardStyle}>
className="mb-6 rounded-lg border p-4" <div style={sectionHeaderStyle}>
style={{ <div style={sectionTitleStyle}>
backgroundColor: "var(--forge-surface)", <Hammer size={14} />
borderColor: "var(--forge-border)", <span>Module</span>
}} </div>
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Module</h3>
<StatusBadge status={module.status} /> <StatusBadge status={module.status} />
</div> </div>
<div className="flex flex-wrap gap-2"> <div style={buttonRowStyle}>
<ActionButton <ActionButton
label="Compile" label="Compile"
variant="primary" variant="primary"
icon={<Play size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")} onClick={() => handleAction(() => api.build.compileModule(), "module")}
/> />
<ActionButton <ActionButton
label="Pack Module" label="Pack Module"
icon={<Archive size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")} onClick={() => handleAction(() => api.build.packModule(), "module")}
/> />
<ActionButton <ActionButton
label="Deploy to Server" label="Deploy to Server"
variant="warning" variant="warning"
icon={<Upload size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.deploy(), "module")} onClick={() => handleAction(() => api.build.deploy(), "module")}
/> />
@@ -238,21 +365,19 @@ export function Build() {
</section> </section>
{/* Haks Section */} {/* Haks Section */}
<section <section style={cardStyle}>
className="mb-6 rounded-lg border p-4" <div style={sectionHeaderStyle}>
style={{ <div style={sectionTitleStyle}>
backgroundColor: "var(--forge-surface)", <Package size={14} />
borderColor: "var(--forge-border)", <span>Haks</span>
}} </div>
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Haks</h3>
<StatusBadge status={haks.status} /> <StatusBadge status={haks.status} />
</div> </div>
<div className="flex gap-2"> <div style={buttonRowStyle}>
<ActionButton <ActionButton
label="Build Haks" label="Build Haks"
variant="primary" variant="primary"
icon={<Play size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildHaks(), "haks")} onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
/> />
@@ -265,38 +390,49 @@ export function Build() {
</section> </section>
{/* NWNX Section */} {/* NWNX Section */}
<section <section style={cardStyle}>
className="rounded-lg border p-4" <div style={sectionHeaderStyle}>
style={{ <div style={sectionTitleStyle}>
backgroundColor: "var(--forge-surface)", <Cpu size={14} />
borderColor: "var(--forge-border)", <span>NWNX</span>
}} <span
> style={{
<div className="mb-3 flex items-center justify-between"> fontWeight: 400,
<h3 className="text-lg font-semibold"> textTransform: "none",
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span> opacity: 0.6,
</h3> letterSpacing: "normal",
}}
>
(Advanced)
</span>
</div>
<StatusBadge status={nwnx.status} /> <StatusBadge status={nwnx.status} />
</div> </div>
<div className="mb-3 flex flex-wrap gap-2"> <div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
<ActionButton <ActionButton
label="Build All" label="Build All"
variant="primary" variant="primary"
icon={<Play size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")} onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/> />
</div> </div>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input <input
type="text" type="text"
value={nwnxTarget} value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)} onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)" placeholder="Target (e.g. Item, Creature)"
className="rounded border px-3 py-1.5 text-sm"
style={{ style={{
backgroundColor: "var(--forge-bg)", 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)", color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
outline: "none",
flex: "0 1 16rem",
}} }}
/> />
<ActionButton <ActionButton
@@ -308,10 +444,18 @@ export function Build() {
/> />
</div> </div>
<p <p
className="mt-2 text-xs" style={{
style={{ color: "#f59e0b" }} 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> </p>
<BuildOutput <BuildOutput
lines={nwnx.output} lines={nwnx.output}
+119 -103
View File
@@ -1,21 +1,68 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../services/api"; 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 }) { function StatusBadge({ status }: { status: string }) {
const color = const color =
status === "running" status === "running"
? "#4ade80" ? "var(--forge-success)"
: status === "stopped" : status === "stopped" || status === "exited" || status === "not created"
? "#f87171" ? "var(--forge-danger)"
: "#fbbf24"; : "var(--forge-warning)";
return ( return (
<span <span
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold" style={{
style={{ backgroundColor: `${color}20`, color }} 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} {status}
</span> </span>
); );
@@ -57,49 +104,36 @@ function ServerCard() {
} }
}; };
const isRunning = status.nwserver === "running";
return ( return (
<div <div style={card}>
className="rounded-lg p-6" <h3 style={cardTitle}><Server size={14} /> Server</h3>
style={{ <div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
backgroundColor: "var(--forge-surface)", <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
border: "1px solid var(--forge-border)", <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
}} <StatusBadge status={status.nwserver} />
> </div>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
Server Status <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
</h3> <StatusBadge status={status.mariadb} />
<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>
<StatusBadge status={status.nwserver} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
MariaDB
</span>
<StatusBadge status={status.mariadb} />
</div>
</div> </div>
</div> </div>
<div className="mt-4"> <div style={{ marginTop: "1rem" }}>
<button <button
onClick={toggle} onClick={toggle}
disabled={loading} disabled={loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ style={{
backgroundColor: ...primaryBtn,
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)", backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
color: status.nwserver === "running" ? "#fca5a5" : "#000", 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 {loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
? "..."
: status.nwserver === "running"
? "Stop Server"
: "Start Server"}
</button> </button>
</div> </div>
</div> </div>
@@ -120,39 +154,32 @@ function ReposSummary() {
}, []); }, []);
return ( return (
<div <div style={card}>
className="rounded-lg p-6" <h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
style={{ <div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
backgroundColor: "var(--forge-surface)", {repos.map((repo, i) => {
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) => {
const s = repoStatus[repo]; const s = repoStatus[repo];
const branch = (s?.branch as string) || "\u2014"; const branch = (s?.branch as string) || "\u2014";
const clean = s?.clean !== false; const clean = s?.clean !== false;
return ( return (
<div <div
key={repo} key={repo}
className="flex items-center justify-between rounded p-3" style={{
style={{ backgroundColor: "var(--forge-bg)" }} 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} {repo}
</span> </span>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{branch} {branch}
</span> </span>
<span <span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
title={clean ? "Clean" : "Uncommitted changes"}
/>
</div> </div>
</div> </div>
); );
@@ -166,41 +193,38 @@ function QuickActions() {
const navigate = useNavigate(); const navigate = useNavigate();
const actions = [ const actions = [
{ label: "Build Module", onClick: () => navigate("/build") }, { label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
{ label: "Build Haks", onClick: () => navigate("/build") }, { label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
{ label: "Open Editor", onClick: () => navigate("/editor") }, { label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
{
label: "Open Terminal",
onClick: () => {
/* terminal is toggled from IDELayout via Ctrl+` */
navigate("/editor");
},
},
]; ];
return ( return (
<div <div style={card}>
className="rounded-lg p-6" <h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
style={{ <div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
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">
{actions.map((a) => ( {actions.map((a) => (
<button <button
key={a.label} key={a.label}
onClick={a.onClick} onClick={a.onClick}
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
style={{ style={{
backgroundColor: "var(--forge-bg)", display: "flex",
color: "var(--forge-text)", alignItems: "center",
gap: "0.625rem",
padding: "0.5rem 0.75rem",
borderRadius: "0.375rem",
border: "1px solid var(--forge-border)", 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} {a.label}
</button> </button>
))} ))}
@@ -211,24 +235,16 @@ function QuickActions() {
export function Dashboard() { export function Dashboard() {
return ( return (
<div className="h-full overflow-y-auto p-6"> <div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div className="mb-8 text-center"> <div style={{ maxWidth: "56rem", margin: "0 auto" }}>
<h1 <h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
className="text-3xl font-bold" Dashboard
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</h1> </h1>
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
NWN Development Environment Server, repositories, and quick actions
</p> </p>
</div> <div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
<div className="mx-auto max-w-3xl space-y-4"> <ServerCard />
<ServerCard />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<ReposSummary /> <ReposSummary />
<QuickActions /> <QuickActions />
</div> </div>
+60 -12
View File
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
import { CreatureEditor } from "../components/gff/CreatureEditor"; import { CreatureEditor } from "../components/gff/CreatureEditor";
import { AreaEditor } from "../components/gff/AreaEditor"; import { AreaEditor } from "../components/gff/AreaEditor";
import { DialogEditor } from "../components/gff/DialogEditor"; 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"]; 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 { interface EditorProps {
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>; editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
workspacePath?: string;
} }
export function Editor({ editorState }: EditorProps) { export function Editor({ editorState, workspacePath }: EditorProps) {
const { const {
openTabs, openTabs,
activeTab, activeTab,
@@ -50,7 +52,6 @@ export function Editor({ editorState }: EditorProps) {
markClean, markClean,
} = editorState; } = editorState;
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({}); const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
const tabs = useMemo( const tabs = useMemo(
@@ -105,8 +106,28 @@ export function Editor({ editorState }: EditorProps) {
const renderEditor = () => { const renderEditor = () => {
if (!activeTab) { if (!activeTab) {
return ( return (
<div className="flex h-full items-center justify-center"> <div
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg"> 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 Open a file from the File Explorer to start editing
</p> </p>
</div> </div>
@@ -139,27 +160,47 @@ export function Editor({ editorState }: EditorProps) {
} }
return ( return (
<div className="flex h-full flex-col"> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{isActiveGff && activeMode === "raw" && ( {isActiveGff && activeMode === "raw" && (
<div <div
className="flex shrink-0 items-center justify-end border-b px-4 py-1" style={{
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }} display: "flex",
alignItems: "center",
justifyContent: "flex-end",
flexShrink: 0,
padding: "4px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
> >
<button <button
onClick={handleSwitchToVisual} onClick={handleSwitchToVisual}
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80" style={{
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }} 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 Switch to Visual Editor
</button> </button>
</div> </div>
)} )}
<div className="flex-1 overflow-hidden"> <div style={{ flex: 1, overflow: "hidden" }}>
<MonacoEditor <MonacoEditor
key={activeTab} key={activeTab}
filePath={activeFilePath} filePath={activeFilePath}
content={activeContent} content={activeContent}
onChange={handleChange} onChange={handleChange}
workspacePath={workspacePath}
/> />
</div> </div>
</div> </div>
@@ -167,14 +208,21 @@ export function Editor({ editorState }: EditorProps) {
}; };
return ( 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 <EditorTabs
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
onSelect={selectTab} onSelect={selectTab}
onClose={closeFile} onClose={closeFile}
/> />
<div className="flex-1 overflow-hidden"> <div style={{ flex: 1, overflow: "hidden" }}>
{renderEditor()} {renderEditor()}
</div> </div>
</div> </div>
+474 -90
View File
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "../services/api"; import { api } from "../services/api";
import { CommitDialog } from "../components/CommitDialog"; import { CommitDialog } from "../components/CommitDialog";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import {
GitBranch,
GitCommit,
GitPullRequest,
Download,
Upload,
Copy,
FileCode,
AlertCircle,
CheckCircle,
X,
} from "lucide-react";
interface RepoStatus { interface RepoStatus {
modified: string[]; modified: string[];
@@ -26,6 +38,54 @@ interface PrForm {
body: string; 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() { export function Repos() {
const [repos, setRepos] = useState<RepoInfo[]>([]); const [repos, setRepos] = useState<RepoInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -39,7 +99,7 @@ export function Repos() {
const fetchRepos = useCallback(async () => { const fetchRepos = useCallback(async () => {
try { try {
const data = (await api.repos.list()) as RepoInfo[]; const data = (await api.repos.list()) as unknown as RepoInfo[];
setRepos(data); setRepos(data);
} catch { } catch {
setError("Failed to load repos"); setError("Failed to load repos");
@@ -133,106 +193,247 @@ export function Repos() {
const isDirty = (status?: RepoStatus) => const isDirty = (status?: RepoStatus) =>
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0); 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) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}> <div style={{
Loading repositories... display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-sans)",
fontSize: "var(--text-base)",
}}>
Loading repositories
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}> <div style={{
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}> height: "100%",
Repositories overflowY: "auto",
</h2> 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 && ( {error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400"> <div style={{
{error} display: "flex",
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button> 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> </div>
)} )}
{/* PR success banner */}
{prResult && ( {prResult && (
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400"> <div style={{
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a> display: "flex",
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button> 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>
)} )}
<div className="space-y-4"> {/* Repo cards */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{repos.map((repo) => ( {repos.map((repo) => (
<section <section
key={repo.name} key={repo.name}
className="rounded-lg border p-4" style={{
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }} 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"> {/* Card header */}
<div className="flex items-center gap-3"> <div style={{
<h3 className="text-lg font-semibold">{repo.name}</h3> display: "flex",
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}> 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} {repo.branch}
</span> </span>
{repo.cloned && repo.status && ( {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) ? (
{isDirty(repo.status) ? "dirty" : "clean"} <span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
</span> dirty
{repo.status.behind > 0 && ( </span>
<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> </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 && ( {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 {repo.status.ahead} ahead
</span> </span>
)} )}
</> </>
)} )}
</div> </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} {repo.upstream}
</span> </span>
</div> </div>
{/* Actions */}
{!repo.cloned ? ( {!repo.cloned ? (
<button <button
onClick={() => handleClone(repo.name)} onClick={() => handleClone(repo.name)}
disabled={actionLoading[repo.name]} disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
> >
{actionLoading[repo.name] ? "Cloning..." : "Clone"} <Copy size={14} />
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
</button> </button>
) : ( ) : (
<> <>
<div className="mb-3 flex flex-wrap gap-2"> <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
<button <button
onClick={() => handlePull(repo.name)} onClick={() => handlePull(repo.name)}
disabled={actionLoading[repo.name]} disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
> >
<Download size={14} />
Pull Pull
</button> </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 <button
onClick={() => setCommitRepo(repo.name)} onClick={() => setCommitRepo(repo.name)}
disabled={!isDirty(repo.status)} disabled={!isDirty(repo.status)}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }} ...accentBtn,
...disabledStyle(!isDirty(repo.status)),
}}
> >
<GitCommit size={14} />
Commit Commit
</button> </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 <button
onClick={() => onClick={() =>
setPrForm({ setPrForm({
@@ -241,39 +442,104 @@ export function Repos() {
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`, 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={outlineBtn}
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
> >
<GitPullRequest size={14} />
Create PR Create PR
</button> </button>
</div> </div>
{/* Changed files list */}
{repo.status && isDirty(repo.status) && ( {repo.status && isDirty(repo.status) && (
<div className="mt-2"> <div style={{
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}> border: "1px solid var(--forge-border)",
Changes 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>
<div className="space-y-0.5">
<div>
{repo.status.modified.map((f) => ( {repo.status.modified.map((f) => (
<div <div
key={f} 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)} 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={{
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span> fontWeight: 700,
color: "var(--forge-warning)",
width: "1rem",
textAlign: "center",
}}>M</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div> </div>
))} ))}
{repo.status.staged.map((f) => ( {repo.status.staged.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs"> <div
<span className="font-medium text-green-400">S</span> key={f}
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span> 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> </div>
))} ))}
{repo.status.untracked.map((f) => ( {repo.status.untracked.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs"> <div
<span className="font-medium text-gray-400">?</span> key={f}
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span> 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>
))} ))}
</div> </div>
@@ -285,6 +551,7 @@ export function Repos() {
))} ))}
</div> </div>
{/* Commit dialog */}
{commitRepo && ( {commitRepo && (
<CommitDialog <CommitDialog
repo={commitRepo} repo={commitRepo}
@@ -296,71 +563,188 @@ export function Repos() {
/> />
)} )}
{/* PR form modal */}
{prForm && ( {prForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}> <div
onClick={() => setPrForm(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div <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={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "32rem",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
}}
> >
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}> <div style={{
Create Pull Request {prForm.repo} display: "flex",
</h3> 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 <input
type="text" type="text"
value={prForm.title} value={prForm.title}
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })} onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
placeholder="PR Title" placeholder="PR Title"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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 <textarea
value={prForm.body} value={prForm.body}
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })} onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
rows={8} rows={8}
className="mb-4 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }} 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 <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
onClick={() => setPrForm(null)} <button onClick={() => setPrForm(null)} style={outlineBtn}>
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Cancel Cancel
</button> </button>
<button <button
onClick={handleCreatePr} onClick={handleCreatePr}
disabled={!prForm.title.trim() || actionLoading.pr} disabled={!prForm.title.trim() || actionLoading.pr}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }} ...accentBtn,
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
}}
> >
{actionLoading.pr ? "Creating..." : "Submit PR"} <GitPullRequest size={14} />
{actionLoading.pr ? "Creating…" : "Submit PR"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Diff modal */}
{diffView && ( {diffView && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}> <div
onClick={() => setDiffView(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div <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={(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 className="mb-4 flex items-center justify-between"> <div style={{
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}> display: "flex",
Diff {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""} alignItems: "center",
</h3> justifyContent: "space-between",
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}> 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>
</div>
<button
onClick={() => setDiffView(null)}
style={{
...outlineBtn,
padding: "0.3rem 0.6rem",
}}
>
<X size={14} />
Close Close
</button> </button>
</div> </div>
<pre
className="whitespace-pre-wrap text-xs" <pre style={{
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }} 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"} {diffView.diff || "No changes"}
</pre> </pre>
</div> </div>
+449 -165
View File
@@ -1,30 +1,152 @@
import { useState, useEffect, useRef, useCallback } from "react"; 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 { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket"; 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; type ServerState = "running" | "exited" | "not created" | string;
function StatusBadge({ label, state }: { label: string; state: ServerState }) { function StatusBadge({ label, state }: { label: string; state: ServerState }) {
const color = const dotColor =
state === "running" state === "running"
? "bg-green-500/20 text-green-400" ? "var(--forge-success)"
: state === "exited" : state === "exited"
? "bg-red-500/20 text-red-400" ? "var(--forge-danger)"
: "bg-gray-500/20 text-gray-400"; : "var(--forge-warning)";
return ( return (
<div className="flex items-center gap-2"> <div
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}> style={{
{label}: 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>
<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} {state}
</span> </span>
</div> </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() { function ControlsPanel() {
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({ const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
nwserver: "unknown", nwserver: "unknown",
@@ -63,49 +185,75 @@ function ControlsPanel() {
return ( return (
<section <section
className="rounded-lg border p-4"
style={{ style={{
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}} }}
> >
<h3 className="mb-3 text-lg font-semibold" style={{ color: "var(--forge-text)" }}> <SectionHeader icon={<ServerIcon size={16} />} label="Server Controls" />
Server Controls
</h3> <div
<div className="mb-4 flex gap-4"> style={{
display: "flex",
flexWrap: "wrap",
gap: "1.25rem",
marginBottom: "1.25rem",
}}
>
<StatusBadge label="NWN Server" state={status.nwserver} /> <StatusBadge label="NWN Server" state={status.nwserver} />
<StatusBadge label="MariaDB" state={status.mariadb} /> <StatusBadge label="MariaDB" state={status.mariadb} />
</div> </div>
<div className="flex flex-wrap gap-2">
{(["start", "stop", "restart", "config"] as const).map((action) => ( <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<button <HoverButton
key={action} onClick={() => handleAction("start")}
onClick={() => handleAction(action)} disabled={loading !== null}
disabled={loading !== null} bg="var(--forge-accent)"
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50" bgHover="var(--forge-accent-hover)"
style={{ border="var(--forge-accent)"
backgroundColor: color="var(--forge-accent-text)"
action === "start" >
? "var(--forge-accent)" <Play size={14} />
: action === "stop" {loading === "start" ? "Starting..." : "Start"}
? "#991b1b" </HoverButton>
: "var(--forge-surface)",
borderColor: <HoverButton
action === "start" onClick={() => handleAction("stop")}
? "var(--forge-accent)" disabled={loading !== null}
: action === "stop" bg="var(--forge-danger-bg)"
? "#dc2626" bgHover="var(--forge-danger-border)"
: "var(--forge-border)", border="var(--forge-danger-border)"
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)", color="var(--forge-danger)"
}} >
> <Square size={14} />
{loading === action {loading === "stop" ? "Stopping..." : "Stop"}
? "..." </HoverButton>
: action === "config"
? "Generate Config" <HoverButton
: action.charAt(0).toUpperCase() + action.slice(1)} onClick={() => handleAction("restart")}
</button> 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> </div>
</section> </section>
); );
@@ -141,65 +289,119 @@ function LogViewer() {
return ( return (
<section <section
className="flex flex-col rounded-lg border"
style={{ style={{
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
height: "350px", borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}} }}
> >
<div <div
className="flex shrink-0 items-center gap-2 px-4 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
> >
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}> <span style={{ color: "var(--forge-accent)", display: "flex" }}>
Server Logs <ScrollText size={16} />
</h3> </span>
<div className="flex-1" /> <span
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
className="rounded border px-2 py-1 text-xs"
style={{ style={{
backgroundColor: "var(--forge-bg)", fontSize: "var(--text-xs)",
borderColor: "var(--forge-border)", fontWeight: 700,
color: "var(--forge-text)", fontFamily: "var(--font-heading)",
width: "200px", textTransform: "uppercase",
}} letterSpacing: "0.08em",
/>
<button
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)",
}}
>
Auto-scroll
</button>
<button
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)", color: "var(--forge-text-secondary)",
}} }}
> >
Server Logs
</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..."
style={{
backgroundColor: "var(--forge-bg)",
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: 180,
outline: "none",
}}
/>
</div>
<HoverButton
onClick={() => setAutoScroll((v) => !v)}
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
</HoverButton>
<HoverButton
onClick={() => setLines([])}
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 Clear
</button> </HoverButton>
</div> </div>
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 overflow-auto p-3"
style={{ style={{
backgroundColor: "#0d1117", backgroundColor: "var(--forge-log-bg)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", color: "var(--forge-log-text)",
fontSize: "12px", fontFamily: "var(--font-mono)",
lineHeight: "1.5", fontSize: 12,
lineHeight: 1.6,
padding: "0.75rem 1rem",
overflowY: "auto",
height: 350,
borderRadius: "0 0 0.75rem 0.75rem",
}} }}
> >
{filteredLines.length === 0 ? ( {filteredLines.length === 0 ? (
@@ -208,7 +410,7 @@ function LogViewer() {
</span> </span>
) : ( ) : (
filteredLines.map((line, i) => ( filteredLines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}> <div key={i} style={{ color: "var(--forge-log-text)" }}>
{line} {line}
</div> </div>
)) ))
@@ -245,31 +447,57 @@ function SQLConsole() {
return ( return (
<section <section
className="flex flex-col rounded-lg border"
style={{ style={{
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}} }}
> >
<div <div
className="flex shrink-0 items-center gap-2 px-4 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
> >
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}> <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)",
}}
>
SQL Console SQL Console
</h3> </span>
<div className="flex-1" />
<div style={{ flex: 1 }} />
{history.length > 0 && ( {history.length > 0 && (
<select <select
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="rounded border px-2 py-1 text-xs" value=""
style={{ style={{
backgroundColor: "var(--forge-bg)", 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)", color: "var(--forge-text-secondary)",
maxWidth: "200px", maxWidth: 200,
outline: "none",
}} }}
value=""
> >
<option value="" disabled> <option value="" disabled>
History ({history.length}) History ({history.length})
@@ -281,32 +509,30 @@ function SQLConsole() {
))} ))}
</select> </select>
)} )}
<button
<HoverButton
onClick={execute} onClick={execute}
disabled={loading || !query.trim()} disabled={loading || !query.trim()}
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50" bg="var(--forge-accent)"
style={{ bgHover="var(--forge-accent-hover)"
backgroundColor: "var(--forge-accent)", border="var(--forge-accent)"
borderColor: "var(--forge-accent)", color="var(--forge-accent-text)"
color: "#fff",
}}
> >
<Play size={14} />
{loading ? "Running..." : "Execute"} {loading ? "Running..." : "Execute"}
</button> </HoverButton>
</div> </div>
<div style={{ height: "100px" }}> <div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
<ReactMonacoEditor <SimpleEditor
value={query} value={query}
language="sql" language="sql"
theme="vs-dark" onChange={(v) => setQuery(v)}
onChange={(v) => setQuery(v ?? "")}
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: 13,
lineNumbers: "off", lineNumbers: "off",
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: "on", wordWrap: "on",
padding: { top: 4, bottom: 4 }, padding: { top: 4, bottom: 4 },
renderLineHighlight: "none", renderLineHighlight: "none",
@@ -319,71 +545,95 @@ function SQLConsole() {
{error && ( {error && (
<div <div
className="px-4 py-2 text-sm" style={{
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }} 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} {error}
</div> </div>
)} )}
{result && ( {result && (
<div <div style={{ overflowX: "auto" }}>
className="max-h-64 overflow-auto"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
{result.columns.length === 0 ? ( {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) Query executed successfully (no results)
</div> </div>
) : ( ) : (
<table className="w-full text-left text-xs"> <div style={{ maxHeight: 280, overflowY: "auto" }}>
<thead> <table
<tr> style={{
{result.columns.map((col) => ( width: "100%",
<th borderCollapse: "collapse",
key={col} fontSize: "var(--text-xs)",
className="sticky top-0 px-3 py-2 font-medium" }}
style={{ >
backgroundColor: "var(--forge-surface)", <thead>
borderBottom: "1px solid var(--forge-border)", <tr>
color: "var(--forge-accent)",
fontFamily: "'JetBrains Mono', monospace",
}}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr
key={i}
className="transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
}}
>
{result.columns.map((col) => ( {result.columns.map((col) => (
<td <th
key={col} key={col}
className="px-3 py-1.5"
style={{ style={{
color: "var(--forge-text)", position: "sticky",
fontFamily: "'JetBrains Mono', monospace", 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: "var(--font-mono)",
whiteSpace: "nowrap",
}} }}
> >
{row[col]} {col}
</td> </th>
))} ))}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {result.rows.map((row, i) => (
<tr
key={i}
style={{
borderBottom: "1px solid var(--forge-border)",
}}
>
{result.columns.map((col) => (
<td
key={col}
style={{
padding: "0.4rem 0.75rem",
color: "var(--forge-text)",
fontFamily: "var(--font-mono)",
}}
>
{row[col]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)} )}
<div <div
className="px-3 py-1 text-xs" style={{
style={{ color: "var(--forge-text-secondary)" }} 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" : ""} {result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
</div> </div>
@@ -395,11 +645,45 @@ function SQLConsole() {
export function Server() { export function Server() {
return ( return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}> <div
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}> style={{
Server Management height: "100%",
</h2> overflowY: "auto",
<div className="flex flex-col gap-6"> 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>
<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 /> <ControlsPanel />
<LogViewer /> <LogViewer />
<SQLConsole /> <SQLConsole />
+264 -135
View File
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../services/api"; import { api } from "../services/api";
import { useTheme } from "../hooks/useTheme"; 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({ function Section({
title, title,
icon,
children, children,
}: { }: {
title: string; title: string;
icon: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div <div style={sectionCard}>
className="rounded-lg p-5" <h3 style={sectionTitle}>{icon} {title}</h3>
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>
{children} {children}
</div> </div>
); );
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const currentPat = (config.githubPat as string) || ""; const hasPat = Boolean(config.githubPat && config.githubPat !== "***");
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set"; const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
}; };
return ( return (
<Section title="GitHub"> <Section title="Gitea Token" icon={<Key size={14} />}>
<div className="space-y-3"> <div>
<div> <p style={fieldLabel}>Personal Access Token</p>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <p style={fieldValue}>{masked}</p>
Personal Access Token </div>
</p> <div style={{ marginTop: "0.75rem" }}>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{masked}
</p>
</div>
{!editing ? ( {!editing ? (
<button <button onClick={() => setEditing(true)} style={primaryBtn}>
onClick={() => setEditing(true)} Update Token
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Update PAT
</button> </button>
) : ( ) : (
<div className="flex gap-2"> <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input <input
type="password" type="password"
value={pat} value={pat}
onChange={(e) => setPat(e.target.value)} onChange={(e) => setPat(e.target.value)}
placeholder="ghp_..." placeholder="Paste token"
className="flex-1 rounded px-3 py-1.5 text-sm" style={{ flex: 1 }}
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/> />
<button <button
onClick={save} onClick={save}
disabled={!pat || saving} disabled={!pat || saving}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{saving ? "..." : "Save"} {saving ? "Saving\u2026" : "Save"}
</button> </button>
<button <button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
onClick={() => {
setEditing(false);
setPat("");
}}
className="rounded px-3 py-1.5 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
Cancel Cancel
</button> </button>
</div> </div>
)} )}
{msg && (
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</div> </div>
{msg && (
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</Section> </Section>
); );
} }
@@ -118,16 +167,17 @@ function ThemeSection() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
return ( return (
<Section title="Theme"> <Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
<div className="flex items-center gap-4"> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="text-sm" style={{ color: "var(--forge-text)" }}> <div>
{theme === "dark" ? "Dark" : "Light"} Mode <p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
</span> {theme === "dark" ? "Dark" : "Light"} Mode
<button </p>
onClick={toggleTheme} <p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
className="rounded px-3 py-1.5 text-xs font-semibold" {theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} </p>
> </div>
<button onClick={toggleTheme} style={primaryBtn}>
Switch to {theme === "dark" ? "Light" : "Dark"} Switch to {theme === "dark" ? "Light" : "Dark"}
</button> </button>
</div> </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 ( return (
<Section title="Paths"> <div
<div className="space-y-3"> 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> <div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <p style={fieldLabel}>Workspace Path</p>
Workspace Path <PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.WORKSPACE_PATH as string) || "Not set"}
</p>
</div> </div>
<div> <div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <p style={fieldLabel}>NWN Home Path</p>
NWN Home Path <PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</p> </div>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{(config.NWN_HOME_PATH as string) || "Not set"} <button
</p> 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>
</div> </div>
</Section> </Section>
@@ -172,40 +297,31 @@ function DockerSection() {
await api.docker.pull(image); await api.docker.pull(image);
setStatus((s) => ({ ...s, [image]: "Pulled" })); setStatus((s) => ({ ...s, [image]: "Pulled" }));
} catch (err) { } catch (err) {
setStatus((s) => ({ setStatus((s) => ({ ...s, [image]: err instanceof Error ? err.message : "Failed" }));
...s,
[image]: err instanceof Error ? err.message : "Failed",
}));
} finally { } finally {
setPulling((s) => ({ ...s, [image]: false })); setPulling((s) => ({ ...s, [image]: false }));
} }
}; };
return ( return (
<Section title="Docker Images"> <Section title="Docker Images" icon={<Container size={14} />}>
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{images.map((image) => ( {images.map((image) => (
<div <div key={image} style={listRow}>
key={image} <span style={fieldValue}>{image}</span>
className="flex items-center justify-between rounded p-3" <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
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">
{status[image] && ( {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]} {status[image]}
</span> </span>
)} )}
<button <button
onClick={() => pull(image)} onClick={() => pull(image)}
disabled={pulling[image]} disabled={pulling[image]}
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{pulling[image] ? "..." : "Pull Latest"} <Download size={12} />
{pulling[image] ? "Pulling\u2026" : "Pull"}
</button> </button>
</div> </div>
</div> </div>
@@ -225,21 +341,20 @@ const SHORTCUTS = [
function ShortcutsSection() { function ShortcutsSection() {
return ( return (
<Section title="Keyboard Shortcuts"> <Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
<div className="space-y-1"> <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{SHORTCUTS.map((s) => ( {SHORTCUTS.map((s) => (
<div <div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
key={s.keys} <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
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)" }}>
{s.action} {s.action}
</span> </span>
<kbd <kbd
className="rounded px-2 py-0.5 font-mono text-xs"
style={{ 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)", border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
}} }}
@@ -255,11 +370,11 @@ function ShortcutsSection() {
function AboutSection() { function AboutSection() {
return ( return (
<Section title="About"> <Section title="About" icon={<Info size={14} />}>
<p className="text-sm" style={{ color: "var(--forge-text)" }}> <p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Layonara Forge v0.0.1 Layonara Forge v0.0.1
</p> </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 github.com/Layonara/layonara-forge
</p> </p>
</Section> </Section>
@@ -270,6 +385,7 @@ function ResetSection() {
const navigate = useNavigate(); const navigate = useNavigate();
const reset = async () => { const reset = async () => {
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
try { try {
await api.workspace.updateConfig({ setupComplete: false }); await api.workspace.updateConfig({ setupComplete: false });
} catch { } catch {
@@ -279,14 +395,28 @@ function ResetSection() {
}; };
return ( return (
<Section title="Reset"> <Section title="Reset" icon={<RotateCcw size={14} />}>
<button <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
onClick={reset} <p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
className="rounded px-4 py-2 text-sm font-semibold" Clear configuration and re-run the setup wizard
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }} </p>
> <button
Re-run Setup Wizard onClick={reset}
</button> 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,
}}
>
Reset Setup
</button>
</div>
</Section> </Section>
); );
} }
@@ -299,24 +429,23 @@ export function Settings() {
}, []); }, []);
return ( return (
<div className="h-full overflow-y-auto p-6"> <div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<h2 <div style={{ maxWidth: "40rem" }}>
className="mb-6 text-xl font-bold" <h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
style={{ Settings
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", </h1>
color: "var(--forge-accent)", <p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
}} Configuration, theme, and environment
> </p>
Settings <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
</h2> <GitHubSection config={config} />
<div className="max-w-2xl space-y-4"> <ThemeSection />
<GitHubSection config={config} /> <PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
<ThemeSection /> <DockerSection />
<PathsSection config={config} /> <ShortcutsSection />
<DockerSection /> <AboutSection />
<ShortcutsSection /> <ResetSection />
<AboutSection /> </div>
<ResetSection />
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large Load Diff
+530 -219
View File
@@ -1,7 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"; 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 { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import {
Eye,
EyeOff,
FileCode,
Check,
X,
RefreshCw,
Trash2,
ArrowUpCircle,
} from "lucide-react";
interface ChangeEntry { interface ChangeEntry {
filename: string; filename: string;
@@ -16,61 +26,6 @@ interface DiffData {
filename: string; 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 { function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString(); return new Date(ts).toLocaleTimeString();
} }
@@ -222,229 +177,585 @@ export function Toolset() {
}; };
const handleDiscardAll = async () => { const handleDiscardAll = async () => {
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
await api.toolset.discardAll(); await api.toolset.discardAll();
refresh(); refresh();
}; };
return ( return (
<div <div
className="flex h-full flex-col overflow-hidden" style={{
style={{ color: "var(--forge-text)" }} display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
color: "var(--forge-text)",
}}
> >
{/* Status bar */} {/* Page heading */}
<div <div style={{ padding: "24px 28px 0" }}>
className="flex shrink-0 items-center justify-between px-6 py-3" <h1
style={{ borderBottom: "1px solid var(--forge-border)" }} style={{
> fontFamily: "var(--font-heading)",
<div className="flex items-center gap-4"> fontSize: "var(--text-xl)",
<h2 fontWeight: 700,
className="text-xl font-bold" color: "var(--forge-text)",
style={{ color: "var(--forge-accent)" }} margin: 0,
}}
>
Toolset
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "4px 0 0",
}}
>
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 }}
> >
Toolset <div
</h2> style={{ display: "flex", alignItems: "center", gap: 8 }}
<StatusBadge active={active} />
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{changes.length} pending
</span>
{lastChange && (
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
> >
Last: {formatTimestamp(lastChange)} {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> </span>
)} {lastChange && (
</div> <span
<div className="flex gap-2"> style={{
{active ? ( fontSize: "var(--text-xs)",
<ActionButton label="Stop Watcher" onClick={handleStop} /> color: "var(--forge-text-secondary)",
) : ( }}
<ActionButton >
label="Start Watcher" Last change: {formatTimestamp(lastChange)}
onClick={handleStart} </span>
variant="primary" )}
/> </div>
)}
<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 ? (
<>
<EyeOff size={14} />
Stop Watcher
</>
) : (
<>
<Eye size={14} />
Start Watcher
</>
)}
</button>
</div> </div>
</div> </div>
{/* Action bar */} {/* Main content area */}
{changes.length > 0 && ( <div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: "0 28px 20px",
}}
>
{/* Changes card */}
<div <div
className="flex shrink-0 items-center gap-2 px-6 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} 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,
}}
> >
<ActionButton {/* Card header with action bar */}
label="Apply Selected" <div
variant="primary" style={{
disabled={selected.size === 0} display: "flex",
onClick={handleApplySelected} alignItems: "center",
/> justifyContent: "space-between",
<ActionButton padding: "12px 16px",
label="Apply All" borderBottom: "1px solid var(--forge-border)",
variant="primary" backgroundColor: "var(--forge-surface-raised)",
onClick={handleApplyAll} }}
/>
<ActionButton
label="Discard Selected"
variant="danger"
disabled={selected.size === 0}
onClick={handleDiscardSelected}
/>
<ActionButton
label="Discard All"
variant="danger"
onClick={handleDiscardAll}
/>
<span
className="ml-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
> >
{selected.size} selected
</span>
</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%" }}
>
{changes.length === 0 ? (
<div <div
className="flex h-40 items-center justify-center text-sm" style={{ display: "flex", alignItems: "center", gap: 8 }}
style={{ color: "var(--forge-text-secondary)" }}
> >
{active <FileCode
? "Watching for changes in temp0/..." size={16}
: "Start the watcher to detect Toolset changes"} style={{ color: "var(--forge-accent)" }}
</div> />
) : ( <span
<table className="w-full text-sm"> style={{
<thead> fontSize: "var(--text-sm)",
<tr fontWeight: 600,
color: "var(--forge-text)",
}}
>
Pending Changes
</span>
{changes.length > 0 && (
<span
style={{ style={{
borderBottom: "1px solid var(--forge-border)", fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
marginLeft: 4,
}} }}
> >
<th className="px-6 py-2 text-left font-medium"> {selected.size} of {changes.length} selected
<input </span>
type="checkbox" )}
checked={selected.size === changes.length} </div>
onChange={toggleAll}
className="cursor-pointer" {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>
{/* Table or empty state */}
<div style={{ overflow: "auto", flex: 1 }}>
{changes.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--forge-text-secondary)",
}}
>
{active ? (
<>
<RefreshCw
size={28}
style={{
marginBottom: 12,
opacity: 0.4,
animation: "spin 3s linear infinite",
}}
/> />
</th> <span style={{ fontSize: "var(--text-sm)" }}>
<th className="px-2 py-2 text-left font-medium">Filename</th> Watching for changes in temp0/...
<th className="px-2 py-2 text-left font-medium">Type</th> </span>
<th className="px-2 py-2 text-left font-medium"> </>
Repo Path ) : (
</th> <>
<th className="px-2 py-2 text-left font-medium">Time</th> <EyeOff
</tr> size={28}
</thead> style={{ marginBottom: 12, opacity: 0.4 }}
<tbody> />
{changes.map((change) => ( <span style={{ fontSize: "var(--text-sm)" }}>
Start the watcher to detect Toolset changes
</span>
</>
)}
</div>
) : (
<table
style={{
width: "100%",
fontSize: "var(--text-sm)",
borderCollapse: "collapse",
}}
>
<thead>
<tr <tr
key={change.filename}
className="cursor-pointer transition-colors hover:bg-white/5"
style={{ style={{
borderBottom: "1px solid var(--forge-border)", borderBottom: "1px solid var(--forge-border)",
backgroundColor: backgroundColor: "var(--forge-surface-raised)",
diffData?.filename === change.filename color: "var(--forge-text-secondary)",
? "var(--forge-surface)"
: undefined,
}} }}
onClick={() => viewDiff(change)}
> >
<td className="px-6 py-2"> <th
style={{
padding: "8px 16px",
textAlign: "left",
fontWeight: 500,
width: 40,
}}
>
<input <input
type="checkbox" type="checkbox"
checked={selected.has(change.filename)} checked={
onChange={(e) => { selected.size === changes.length &&
e.stopPropagation(); changes.length > 0
toggleSelect(change.filename); }
}} onChange={toggleAll}
onClick={(e) => e.stopPropagation()} style={{ cursor: "pointer" }}
className="cursor-pointer"
/> />
</td> </th>
<td className="px-2 py-2 font-mono">{change.filename}</td> <th
<td className="px-2 py-2"> style={{
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs"> padding: "8px 10px",
{change.gffType} textAlign: "left",
</span> fontWeight: 500,
</td> }}
<td
className="px-2 py-2 font-mono text-xs"
style={{ color: "var(--forge-text-secondary)" }}
> >
{change.repoPath ?? "—"} Filename
</td> </th>
<td <th
className="px-2 py-2 text-xs" style={{
style={{ color: "var(--forge-text-secondary)" }} padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
> >
{formatTimestamp(change.timestamp)} Type
</td> </th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Repo Path
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Time
</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {changes.map((change) => (
)} <tr
key={change.filename}
onClick={() => viewDiff(change)}
style={{
borderBottom: "1px solid var(--forge-border)",
cursor: "pointer",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-accent-subtle)"
: undefined,
}}
>
<td style={{ padding: "8px 16px" }}>
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
style={{ cursor: "pointer" }}
/>
</td>
<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
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{change.repoPath ?? "—"}
</td>
<td
style={{
padding: "8px 10px",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{formatTimestamp(change.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div> </div>
{/* Diff panel */} {/* Diff viewer panel */}
{diffData && ( {diffData && (
<div <div
ref={diffContainerRef} ref={diffContainerRef}
className="flex min-h-0 flex-1 flex-col" style={{
style={{ borderTop: "1px solid var(--forge-border)" }} 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 <div
className="flex shrink-0 items-center justify-between px-4 py-1.5"
style={{ style={{
backgroundColor: "var(--forge-surface)", display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "var(--forge-surface-raised)",
borderBottom: "1px solid var(--forge-border)", borderBottom: "1px solid var(--forge-border)",
}} }}
> >
<span className="text-xs font-medium"> <div
Diff: {diffData.filename} 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 && ( {loading && (
<span style={{ color: "var(--forge-text-secondary)" }}> <span
{" "} style={{
(loading...) fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Loading...
</span> </span>
)} )}
</span> </div>
<button <button
onClick={() => setDiffData(null)} onClick={() => setDiffData(null)}
className="text-xs transition-opacity hover:opacity-80" style={{
style={{ color: "var(--forge-text-secondary)" }} 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 Close
</button> </button>
</div> </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} original={diffData.original}
modified={diffData.modified} modified={diffData.modified}
language="json" language="json"
theme="vs-dark"
options={{ options={{
readOnly: true,
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
renderSideBySide: true,
padding: { top: 4 }, padding: { top: 4 },
}} }}
/> />
+17 -2
View File
@@ -6,8 +6,15 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...options, ...options,
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText })); let message = res.statusText;
throw new Error(body.error || 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(); return res.json();
} }
@@ -41,6 +48,14 @@ export const api = {
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }), }>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
gffSchema: (type: string) => gffSchema: (type: string) =>
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`), 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: { workspace: {
+173 -13
View File
@@ -1,25 +1,185 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@fontsource-variable/manrope";
@import "@fontsource-variable/alegreya";
@import "@fontsource-variable/jetbrains-mono";
:root { :root {
--forge-bg: #121212; --forge-bg: oklch(15% 0.01 65);
--forge-surface: #1e1e2e; --forge-surface: oklch(20% 0.012 65);
--forge-border: #2e2e3e; --forge-surface-raised: oklch(24% 0.014 65);
--forge-accent: #946200; --forge-border: oklch(30% 0.014 65);
--forge-text: #f2f2f2; --forge-accent: oklch(58% 0.155 65);
--forge-text-secondary: #888888; --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 { :root.light {
--forge-bg: #f2f2f2; --forge-bg: oklch(95% 0.008 65);
--forge-surface: #ffffff; --forge-surface: oklch(99% 0.004 65);
--forge-border: #cbcbcb; --forge-surface-raised: oklch(100% 0.002 65);
--forge-accent: #946200; --forge-border: oklch(82% 0.012 65);
--forge-text: #252525; --forge-accent: oklch(50% 0.155 65);
--forge-text-secondary: #666666; --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 { body {
background-color: var(--forge-bg); background-color: var(--forge-bg);
color: var(--forge-text); 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: { forge: {
bg: "var(--forge-bg)", bg: "var(--forge-bg)",
surface: "var(--forge-surface)", surface: "var(--forge-surface)",
"surface-raised": "var(--forge-surface-raised)",
border: "var(--forge-border)", border: "var(--forge-border)",
accent: "var(--forge-accent)", 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: "var(--forge-text)",
"text-secondary": "var(--forge-text-secondary)", "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: { fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"], sans: ["Manrope Variable", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"], heading: ["Alegreya Variable", "Georgia", "serif"],
serif: ["Baskerville", "Georgia", "Palatino", "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": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"jsx": "react-jsx" "jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"noImplicitAny": false
}, },
"include": ["src"] "include": ["src"]
} }
+44
View File
@@ -1,8 +1,52 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import importMetaUrlPlugin from "@codingame/esbuild-import-meta-url-plugin";
export default defineConfig({ export default defineConfig({
plugins: [react()], 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: { server: {
port: 5173, port: 5173,
proxy: { proxy: {