Compare commits
20 Commits
b85f70dc95
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cbd6f12e92 | |||
| 32f68c484c | |||
| d84de509e4 | |||
| 8b9daf0e74 | |||
| a6aba24d78 | |||
| ecf515cecf | |||
| 026b4d1e15 | |||
| ed200713df | |||
| 90d7b05040 | |||
| 5be8299e8e | |||
| 3329d09a33 | |||
| f851d8b8f2 | |||
| f39f1d818b | |||
| cbe51a6e67 | |||
| 8b35c41a52 | |||
| 0de60e6f00 | |||
| a8fa85416d | |||
| d64bf905d3 | |||
| 288c762356 | |||
| 2a97af5ce8 |
@@ -5,3 +5,6 @@ WORKSPACE_PATH=~/layonara-workspace
|
||||
# Windows: C:\Users\<you>\Documents\Neverwinter Nights
|
||||
# Linux: ~/.local/share/Neverwinter Nights
|
||||
NWN_HOME_PATH=
|
||||
|
||||
# Git provider URL (Gitea instance)
|
||||
GIT_PROVIDER_URL=https://gitea.layonara.com
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
@@ -13,8 +13,14 @@ FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# nwn_gff for toolset GFF→JSON conversion (temp0/ watcher)
|
||||
RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
|
||||
| tar xz -C /usr/local/bin/ \
|
||||
&& nwn_gff --version
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY packages/backend/package.json packages/backend/
|
||||
@@ -23,6 +29,7 @@ RUN npm install --omit=dev
|
||||
|
||||
COPY --from=builder /app/packages/backend/dist packages/backend/dist
|
||||
COPY --from=builder /app/packages/frontend/dist packages/frontend/dist
|
||||
COPY db/ db/
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "packages/backend/dist/index.js"]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
+12
-15
@@ -7,28 +7,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
unzip \
|
||||
ca-certificates \
|
||||
libssl-dev \
|
||||
libsqlite3-0 \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Nim via choosenim
|
||||
RUN curl https://nim-lang.org/choosenim/init.sh -sSf | bash -s -- -y
|
||||
ENV PATH="/root/.nimble/bin:${PATH}"
|
||||
RUN choosenim 2.2.0
|
||||
# Pre-built neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
|
||||
RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
|
||||
# Install neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
|
||||
RUN nimble install neverwinter@2.1.2 -y
|
||||
# Pre-built nasher (NWN module build tool)
|
||||
RUN curl -L https://github.com/squattingmonk/nasher.nim/releases/download/1.1.2/nasher_linux.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
|
||||
# Install nasher
|
||||
RUN nimble install nasher -y
|
||||
# Pre-built layonara_nwn (hak builder)
|
||||
RUN curl -L https://github.com/plenarius/layonara_nwn/releases/download/v0.1.1/layonara_nwn-linux-x64.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
|
||||
# Install layonara_nwn (hak builder)
|
||||
RUN nimble install https://github.com/plenarius/layonara_nwn -y
|
||||
|
||||
# Verify tools
|
||||
RUN nwn_gff --version && nasher --version && which nwn_script_comp
|
||||
# Verify all tools
|
||||
RUN nwn_gff --version && nasher --version && which nwn_script_comp && layonara_nwn --help | head -1
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -12,3 +12,4 @@ services:
|
||||
environment:
|
||||
- WORKSPACE_PATH=/workspace
|
||||
- NWN_HOME_PATH=/nwn-home
|
||||
- GIT_PROVIDER_URL=https://gitea.layonara.com
|
||||
|
||||
@@ -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/
|
||||
@@ -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/";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { contextBridge } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("forge", {
|
||||
platform: process.platform,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Submodule lsp/nwscript-language-server updated: 581f129df3...bae6be22dc
Generated
+4828
-38
File diff suppressed because it is too large
Load Diff
+23
-2
@@ -1,14 +1,35 @@
|
||||
{
|
||||
"name": "layonara-forge",
|
||||
"version": "0.1.0",
|
||||
"description": "NWN Development IDE — build, edit, and run a Layonara server with only Docker required",
|
||||
"author": "Layonara <orth@layonara.com>",
|
||||
"private": true,
|
||||
"workspaces": ["packages/*"],
|
||||
"main": "electron/dist/main.js",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"overrides": {
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^25.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev -w packages/backend\" \"npm run dev -w packages/frontend\"",
|
||||
"build": "npm run build -w packages/backend && npm run build -w packages/frontend",
|
||||
"start": "npm start -w packages/backend"
|
||||
"build:electron": "tsc -p electron/tsconfig.json",
|
||||
"build:all": "npm run build && npm run build:electron",
|
||||
"start": "npm start -w packages/backend",
|
||||
"electron:dev": "npm run build:all && electron .",
|
||||
"electron:build": "npm run build:all && electron-builder",
|
||||
"electron:build:win": "npm run build:all && electron-builder --win",
|
||||
"electron:build:mac": "npm run build:all && electron-builder --mac",
|
||||
"electron:build:linux": "npm run build:all && electron-builder --linux"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^41.2.2",
|
||||
"electron-builder": "^26.8.1",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { createServer } from "http";
|
||||
import type { Server } from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { initWebSocket, getClientCount } from "./services/ws.service.js";
|
||||
import { initWebSocket, getClientCount, handleUpgrade as handleEventUpgrade } from "./services/ws.service.js";
|
||||
import workspaceRouter from "./routes/workspace.js";
|
||||
import dockerRouter from "./routes/docker.js";
|
||||
import editorRouter from "./routes/editor.js";
|
||||
@@ -17,41 +18,45 @@ import reposRouter from "./routes/repos.js";
|
||||
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||
import { attachLspWebSocket } from "./services/lsp.service.js";
|
||||
import { startUpstreamPolling } from "./services/git.service.js";
|
||||
import { loadTlkIndex } from "./nwscript/tlk-index.js";
|
||||
import { getRepoPath } from "./services/workspace.service.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
export function startServer(port: number): Promise<Server> {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
initWebSocket(server);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
initWebSocket(server);
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
wsClients: getClientCount(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api/workspace", workspaceRouter);
|
||||
app.use("/api/docker", dockerRouter);
|
||||
app.use("/api/editor", editorRouter);
|
||||
app.use("/api/terminal", terminalRouter);
|
||||
app.use("/api/build", buildRouter);
|
||||
app.use("/api/server", serverRouter);
|
||||
app.use("/api/toolset", toolsetRouter);
|
||||
app.use("/api/github", githubRouter);
|
||||
app.use("/api/repos", reposRouter);
|
||||
app.use("/api/workspace", workspaceRouter);
|
||||
app.use("/api/docker", dockerRouter);
|
||||
app.use("/api/editor", editorRouter);
|
||||
app.use("/api/terminal", terminalRouter);
|
||||
app.use("/api/build", buildRouter);
|
||||
app.use("/api/server", serverRouter);
|
||||
app.use("/api/toolset", toolsetRouter);
|
||||
app.use("/api/github", githubRouter);
|
||||
app.use("/api/repos", reposRouter);
|
||||
|
||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||
app.use(express.static(frontendDist));
|
||||
app.get("*path", (_req, res) => {
|
||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||
app.use(express.static(frontendDist));
|
||||
app.get("*path", (_req, res) => {
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
});
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
|
||||
if (url.pathname === "/ws/lsp") {
|
||||
@@ -72,11 +77,27 @@ server.on("upgrade", (request, socket, head) => {
|
||||
attachWebSocket(sessionId, ws);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
|
||||
if (url.pathname === "/ws") {
|
||||
handleEventUpgrade(request, socket, head);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`Layonara Forge listening on http://0.0.0.0:${port}`);
|
||||
startUpstreamPolling();
|
||||
});
|
||||
const tlkPath = getRepoPath("nwn-haks", "layonara.tlk.json");
|
||||
loadTlkIndex(tlkPath).then(() => console.log(`TLK index loaded`)).catch(() => {});
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!process.env.ELECTRON) {
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
startServer(PORT);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ export async function loadTlkIndex(tlkJsonPath: string): Promise<void> {
|
||||
try {
|
||||
const raw = await fs.readFile(tlkJsonPath, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
if (Array.isArray(data)) {
|
||||
for (const entry of data) {
|
||||
if (entry.id !== undefined && entry.value !== undefined) {
|
||||
tlkStrings.set(Number(entry.id), String(entry.value));
|
||||
}
|
||||
const entries = Array.isArray(data) ? data : Array.isArray(data.entries) ? data.entries : [];
|
||||
for (const entry of entries) {
|
||||
const id = entry.id ?? entry.index;
|
||||
const text = entry.text ?? entry.value;
|
||||
if (id !== undefined && text !== undefined) {
|
||||
tlkStrings.set(Number(id), String(text));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -25,7 +25,8 @@ router.get("/tree/:repo", async (req, res) => {
|
||||
|
||||
router.get("/file/:repo/*path", async (req, res) => {
|
||||
try {
|
||||
const content = await readFile(req.params.repo, req.params.path);
|
||||
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||
const content = await readFile(req.params.repo, filePath);
|
||||
res.json({ content });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
@@ -35,7 +36,8 @@ router.get("/file/:repo/*path", async (req, res) => {
|
||||
|
||||
router.put("/file/:repo/*path", async (req, res) => {
|
||||
try {
|
||||
await writeFile(req.params.repo, req.params.path, req.body.content);
|
||||
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||
await writeFile(req.params.repo, filePath, req.body.content);
|
||||
res.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
@@ -45,7 +47,8 @@ router.put("/file/:repo/*path", async (req, res) => {
|
||||
|
||||
router.delete("/file/:repo/*path", async (req, res) => {
|
||||
try {
|
||||
await deleteFile(req.params.repo, req.params.path);
|
||||
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||
await deleteFile(req.params.repo, filePath);
|
||||
res.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
@@ -143,7 +143,7 @@ router.get("/:repo/diff/*path", async (req, res) => {
|
||||
res.status(400).json({ error: `Unknown repo: ${repoName}` });
|
||||
return;
|
||||
}
|
||||
const filePath = req.params.path;
|
||||
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||
const repoPath = getRepoPath(repoName);
|
||||
const diff = await getDiff(repoPath, filePath);
|
||||
res.json({ diff });
|
||||
|
||||
@@ -9,21 +9,36 @@ import {
|
||||
const router = Router();
|
||||
|
||||
router.get("/config", async (_req, res) => {
|
||||
try {
|
||||
const config = await readConfig();
|
||||
const sanitized = { ...config, githubPat: config.githubPat ? "***" : undefined };
|
||||
res.json(sanitized);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to read config";
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/config", async (req, res) => {
|
||||
try {
|
||||
const current = await readConfig();
|
||||
const updated = { ...current, ...req.body };
|
||||
await writeConfig(updated);
|
||||
res.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save config";
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/init", async (_req, res) => {
|
||||
try {
|
||||
await ensureWorkspaceStructure();
|
||||
res.json({ ok: true, path: getWorkspacePath() });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to initialize workspace";
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { runEphemeralContainer } from "./docker.service.js";
|
||||
import { runEphemeralContainer, getDockerClient } from "./docker.service.js";
|
||||
import {
|
||||
getWorkspacePath,
|
||||
getServerPath,
|
||||
} from "./workspace.service.js";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
const BUILDER_IMAGE = "layonara-builder";
|
||||
|
||||
async function ensureBuilderImage(): Promise<void> {
|
||||
const docker = getDockerClient();
|
||||
try {
|
||||
await docker.getImage(BUILDER_IMAGE).inspect();
|
||||
return;
|
||||
} catch {
|
||||
// image doesn't exist — build it
|
||||
}
|
||||
|
||||
broadcast("build", "info", { message: "Building the builder image (first-time setup, ~45s)..." });
|
||||
|
||||
const builderDir = process.env.ELECTRON
|
||||
? path.join((process as any).resourcesPath ?? __dirname, "builder")
|
||||
: path.resolve(__dirname, "../../../builder");
|
||||
|
||||
const stream = await docker.buildImage(
|
||||
{ context: builderDir, src: ["."] },
|
||||
{ t: BUILDER_IMAGE },
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.modem.followProgress(
|
||||
stream,
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
broadcast("build", "error", { message: `Builder image build failed: ${err.message}` });
|
||||
reject(err);
|
||||
} else {
|
||||
broadcast("build", "info", { message: "Builder image ready." });
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
(event: { stream?: string }) => {
|
||||
if (event.stream) {
|
||||
broadcast("build", "output", { text: event.stream });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildModule(
|
||||
target: string = "bare",
|
||||
mode: "compile" | "pack" = "compile",
|
||||
@@ -18,9 +61,10 @@ export async function buildModule(
|
||||
: ["nasher", "pack", target, "--yes"];
|
||||
|
||||
broadcast("build", "start", { type: "module", target, mode });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd,
|
||||
binds: [
|
||||
`${workspacePath}/repos/nwn-module:/build/nwn-module`,
|
||||
@@ -101,9 +145,10 @@ export async function buildHaks(): Promise<{
|
||||
const workspacePath = getWorkspacePath();
|
||||
|
||||
broadcast("build", "start", { type: "haks" });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd: ["layonara_nwn", "hak", "--yes"],
|
||||
binds: [
|
||||
`${workspacePath}/repos/nwn-haks:/build/nwn-haks`,
|
||||
@@ -127,6 +172,7 @@ export async function buildNWNX(
|
||||
const workspacePath = getWorkspacePath();
|
||||
|
||||
broadcast("build", "start", { type: "nwnx", target });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const cmd = target
|
||||
? [
|
||||
@@ -141,7 +187,7 @@ export async function buildNWNX(
|
||||
];
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd,
|
||||
binds: [`${workspacePath}/repos/unified:/build/unified`],
|
||||
workingDir: "/build/unified",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Docker from "dockerode";
|
||||
import { platform } from "os";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const socketPath = platform() === "win32"
|
||||
? "//./pipe/docker_engine"
|
||||
: "/var/run/docker.sock";
|
||||
const docker = new Docker({ socketPath });
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import simpleGit, { SimpleGit } from "simple-git";
|
||||
import { simpleGit, SimpleGit } from "simple-git";
|
||||
import fs from "fs/promises";
|
||||
import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js";
|
||||
import { getRepoPath, readConfig } from "./workspace.service.js";
|
||||
import { getRepoPath, readConfig, ensureWorkspaceStructure } from "./workspace.service.js";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
function git(repoPath: string): SimpleGit {
|
||||
@@ -128,6 +128,7 @@ function getUpstreamUrl(owner: string, repo: string, provider: string, token?: s
|
||||
}
|
||||
|
||||
export async function setupClone(repoName: RepoName) {
|
||||
await ensureWorkspaceStructure();
|
||||
const config = await readConfig();
|
||||
const pat = config.githubPat;
|
||||
if (!pat) throw new Error("Git provider token not configured");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, ChildProcess } from "child_process";
|
||||
import { WebSocket } from "ws";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getRepoPath } from "./workspace.service.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -21,7 +22,9 @@ export function startLspServer(): ChildProcess {
|
||||
}
|
||||
|
||||
const serverPath = getLspServerPath();
|
||||
const cwd = getRepoPath("nwn-module");
|
||||
lspProcess = spawn("node", [serverPath, "--stdio"], {
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -189,22 +189,33 @@ export async function seedDatabase(cdKey: string, playerName: string): Promise<v
|
||||
const docker = getDockerClient();
|
||||
const container = docker.getContainer(MARIADB_NAME);
|
||||
|
||||
const exec = await container.exec({
|
||||
Cmd: [
|
||||
"bash",
|
||||
"-c",
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`,
|
||||
],
|
||||
const schemaPath = path.resolve(__dirname, "../../../db/schema.sql");
|
||||
let schemaSql: string;
|
||||
try {
|
||||
schemaSql = await fs.readFile(schemaPath, "utf-8");
|
||||
} catch {
|
||||
const altPath = path.resolve(__dirname, "../../db/schema.sql");
|
||||
schemaSql = await fs.readFile(altPath, "utf-8");
|
||||
}
|
||||
|
||||
const schemaExec = await container.exec({
|
||||
Cmd: ["bash", "-c", "mysql -u root -p$MYSQL_ROOT_PASSWORD nwn 2>&1"],
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
await exec.start({});
|
||||
const schemaStream = await schemaExec.start({ hijack: true, stdin: true });
|
||||
schemaStream.write(schemaSql);
|
||||
schemaStream.end();
|
||||
await new Promise<void>((resolve) => schemaStream.on("end", resolve));
|
||||
|
||||
const safeKey = cdKey.replace(/'/g, "''");
|
||||
const safeName = playerName.replace(/'/g, "''");
|
||||
const dmExec = await container.exec({
|
||||
Cmd: [
|
||||
"bash",
|
||||
"-c",
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${cdKey}', '${playerName}', 1);" 2>&1`,
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${safeKey}', '${safeName}', 1);" 2>&1`,
|
||||
],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || path.join(homedir(), "Layonara Forge");
|
||||
|
||||
interface ForgeConfig {
|
||||
githubPat?: string;
|
||||
@@ -51,6 +52,7 @@ export async function readConfig(): Promise<ForgeConfig> {
|
||||
|
||||
export async function writeConfig(config: ForgeConfig): Promise<void> {
|
||||
const configPath = path.join(WORKSPACE_PATH, "config", "forge.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ const clients = new Set<WebSocket>();
|
||||
|
||||
let wss: WebSocketServer;
|
||||
|
||||
export function initWebSocket(server: Server): WebSocketServer {
|
||||
wss = new WebSocketServer({ server, path: "/ws" });
|
||||
export function initWebSocket(_server: Server): WebSocketServer {
|
||||
wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
clients.add(ws);
|
||||
@@ -26,6 +26,12 @@ export function initWebSocket(server: Server): WebSocketServer {
|
||||
return wss;
|
||||
}
|
||||
|
||||
export function handleUpgrade(request: import("http").IncomingMessage, socket: import("stream").Duplex, head: Buffer): void {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
export function broadcast(type: EventType, action: string, data: unknown): void {
|
||||
const event: ForgeEvent = {
|
||||
type,
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Layonara Forge</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,21 +5,24 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@fontsource-variable/alegreya": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@typefox/monaco-editor-react": "^7.7.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"monaco-languageclient": "^10.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"vscode-languageserver-protocol": "^3.17.5",
|
||||
"vscode-ws-jsonrpc": "^3.5.0"
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codingame/esbuild-import-meta-url-plugin": "^1.0.3",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Editor } from "./pages/Editor";
|
||||
import { Build } from "./pages/Build";
|
||||
import { Server } from "./pages/Server";
|
||||
import { Toolset } from "./pages/Toolset";
|
||||
import { Repos } from "./pages/Repos";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { Setup } from "./pages/Setup";
|
||||
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from "react";
|
||||
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
|
||||
const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
|
||||
const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
|
||||
const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
|
||||
const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
|
||||
const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
|
||||
const Settings = lazy(() => import("./pages/Settings").then(m => ({ default: m.Settings })));
|
||||
const Setup = lazy(() => import("./pages/Setup").then(m => ({ default: m.Setup })));
|
||||
import { IDELayout } from "./layouts/IDELayout";
|
||||
import { SetupLayout } from "./layouts/SetupLayout";
|
||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
|
||||
|
||||
const DEFAULT_REPO = "nwn-module";
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span className="text-sm">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupGuard({ children }: { children: React.ReactNode }) {
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
|
||||
.finally(() => setChecking(false));
|
||||
}, []);
|
||||
|
||||
if (checking) return null;
|
||||
if (checking) return (
|
||||
<div className="flex h-screen items-center justify-center" style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontFamily: "var(--font-heading)" }}>Loading Forge…</span>
|
||||
</div>
|
||||
);
|
||||
if (needsSetup) return <Navigate to="/setup" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -42,6 +55,38 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
|
||||
export function App() {
|
||||
const editorState = useEditorState();
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||
const [workspacePath, setWorkspacePath] = useState<string>("");
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.workspace.getConfig().then((cfg) => {
|
||||
const wp = (cfg.workspacePath as string) || "";
|
||||
if (wp) setWorkspacePath(wp);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) return;
|
||||
hydratedRef.current = true;
|
||||
const tabs = editorState.openTabs;
|
||||
if (tabs.length === 0) return;
|
||||
const stale = tabs.filter((t) => editorState.getContent(t) === undefined);
|
||||
if (stale.length === 0) return;
|
||||
Promise.allSettled(
|
||||
stale.map(async (tabKey) => {
|
||||
const idx = tabKey.indexOf(":");
|
||||
if (idx <= 0) return;
|
||||
const repo = tabKey.slice(0, idx);
|
||||
const filePath = tabKey.slice(idx + 1);
|
||||
try {
|
||||
const { content } = await api.editor.readFile(repo, filePath);
|
||||
editorState.openFile(tabKey, content);
|
||||
} catch {
|
||||
editorState.closeFile(tabKey);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (repo: string, filePath: string) => {
|
||||
@@ -73,6 +118,7 @@ export function App() {
|
||||
<ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupLayout />}>
|
||||
<Route index element={<Setup />} />
|
||||
@@ -87,7 +133,7 @@ export function App() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/editor"
|
||||
element={<Editor editorState={editorState} />}
|
||||
element={<Editor editorState={editorState} workspacePath={workspacePath} />}
|
||||
/>
|
||||
<Route path="build" element={<Build />} />
|
||||
<Route path="server" element={<Server />} />
|
||||
@@ -96,6 +142,7 @@ export function App() {
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "../services/api";
|
||||
|
||||
const COMMIT_TYPES = [
|
||||
@@ -19,6 +19,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
const [issueRef, setIssueRef] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
let msg = `${type}${scope ? `(${scope})` : ""}: ${description}`;
|
||||
@@ -29,6 +30,16 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
|
||||
const isValid = description.trim().length > 0 && description.length <= 100;
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
async function handleSubmit(andPush: boolean) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
@@ -46,22 +57,59 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "oklch(0% 0 0 / 0.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="commit-dialog-title"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "32rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "1.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
id="commit-dialog-title"
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 600,
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
|
||||
Commit Changes
|
||||
</h3>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="rounded border px-2 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
{COMMIT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
@@ -72,8 +120,15 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value)}
|
||||
placeholder="scope (optional)"
|
||||
className="w-28 rounded border px-2 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "7rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -82,8 +137,17 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, 100))}
|
||||
placeholder="Description (required, max 100 chars)"
|
||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
@@ -91,8 +155,18 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Body (optional)"
|
||||
rows={3}
|
||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -100,40 +174,91 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
||||
value={issueRef}
|
||||
onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="Issue # (auto-formats as Fixes #NNN)"
|
||||
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.75rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
|
||||
<pre style={{ marginTop: "0.25rem", whiteSpace: "pre-wrap", color: "var(--forge-text)" }}>{preview}</pre>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
color: "var(--forge-danger)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded border px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text)",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={!isValid || loading}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-accent)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
opacity: !isValid || loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={!isValid || loading}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-warning-border)",
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-warning-bg)",
|
||||
color: "var(--forge-warning)",
|
||||
opacity: !isValid || loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Commit & Push
|
||||
</button>
|
||||
|
||||
@@ -28,8 +28,17 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="w-full max-w-lg">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", maxWidth: "32rem" }}>
|
||||
<ErrorDisplay
|
||||
title="Render Error"
|
||||
message={this.state.error.message}
|
||||
|
||||
@@ -22,35 +22,48 @@ export function ErrorDisplay({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
borderRadius: "0.5rem",
|
||||
padding: "1.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid #7f1d1d",
|
||||
border: "1px solid var(--forge-danger-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-semibold" style={{ color: "#fca5a5" }}>
|
||||
<h3 style={{ fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-danger)", margin: 0 }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{fullLog && (
|
||||
<div className="mt-4">
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
textDecoration: "underline",
|
||||
color: "var(--forge-text-secondary)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{expanded ? "Hide Full Log" : "Show Full Log"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre
|
||||
className="mt-2 max-h-60 overflow-auto rounded p-3 text-xs"
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
maxHeight: "15rem",
|
||||
overflow: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{fullLog}
|
||||
@@ -59,22 +72,32 @@ export function ErrorDisplay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="rounded px-4 py-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={copyError}
|
||||
className="rounded px-4 py-2 text-sm"
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
Copy Error
|
||||
|
||||
@@ -29,9 +29,9 @@ export function useToast() {
|
||||
}
|
||||
|
||||
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
|
||||
success: { bg: "#052e16", border: "#166534", text: "#4ade80" },
|
||||
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" },
|
||||
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" },
|
||||
success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
|
||||
error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
|
||||
info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
|
||||
};
|
||||
|
||||
const AUTO_DISMISS: Record<ToastType, number | null> = {
|
||||
@@ -49,7 +49,7 @@ function ToastItem({
|
||||
}) {
|
||||
const { bg, border, text } = COLORS[toast.type];
|
||||
const timeout = AUTO_DISMISS[toast.type];
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeout) {
|
||||
@@ -60,14 +60,37 @@ function ToastItem({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm shadow-lg"
|
||||
style={{ backgroundColor: bg, border: `1px solid ${border}`, color: text }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: bg,
|
||||
border: `1px solid ${border}`,
|
||||
color: text,
|
||||
boxShadow: "0 10px 15px -3px oklch(0% 0 0 / 0.2), 0 4px 6px -4px oklch(0% 0 0 / 0.1)",
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<span style={{ flex: 1 }}>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
className="ml-2 shrink-0 opacity-60 hover:opacity-100"
|
||||
style={{ color: text }}
|
||||
aria-label="Dismiss notification"
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
flexShrink: 0,
|
||||
opacity: 0.6,
|
||||
color: text,
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = "1"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = "0.6"; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -92,7 +115,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
|
||||
<div
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
maxWidth: "360px",
|
||||
}}
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Tab {
|
||||
path: string;
|
||||
dirty: boolean;
|
||||
@@ -11,7 +14,96 @@ interface EditorTabsProps {
|
||||
}
|
||||
|
||||
function filename(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
const parts = path.split(":");
|
||||
const filePart = parts.length > 1 ? parts[1] : path;
|
||||
return filePart.split("/").pop() ?? filePart;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
tab,
|
||||
isActive,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
title={tab.path}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(); } }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: isActive ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
backgroundColor: isActive ? "var(--forge-bg)" : "transparent",
|
||||
borderBottom: isActive ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "color 150ms ease-out, background-color 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "0.375rem" }}>
|
||||
{filename(tab.path)}
|
||||
{tab.dirty && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close tab"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "1.125rem",
|
||||
height: "1.125rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--forge-text-secondary)",
|
||||
opacity: hovered || isActive ? 1 : 0,
|
||||
transition: "opacity 150ms ease-out, background-color 150ms ease-out",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorTabs({
|
||||
@@ -24,51 +116,24 @@ export function EditorTabs({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex overflow-x-auto"
|
||||
role="tablist"
|
||||
style={{
|
||||
display: "flex",
|
||||
overflowX: "auto",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.path === activeTab;
|
||||
return (
|
||||
<button
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.path}
|
||||
title={tab.path}
|
||||
onClick={() => onSelect(tab.path)}
|
||||
className="group relative flex shrink-0 items-center gap-1.5 px-3 py-2 text-sm transition-colors"
|
||||
style={{
|
||||
color: isActive
|
||||
? "var(--forge-text)"
|
||||
: "var(--forge-text-secondary)",
|
||||
borderBottom: isActive
|
||||
? "2px solid var(--forge-accent)"
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{filename(tab.path)}
|
||||
{tab.dirty && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: "var(--forge-accent)" }}
|
||||
tab={tab}
|
||||
isActive={tab.path === activeTab}
|
||||
onSelect={() => onSelect(tab.path)}
|
||||
onClose={() => onClose(tab.path)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.path);
|
||||
}}
|
||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api, type FileNode } from "../../services/api";
|
||||
import {
|
||||
FileCode2,
|
||||
FileJson,
|
||||
FileText,
|
||||
FileType2,
|
||||
FileImage,
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface FileExplorerProps {
|
||||
repo: string;
|
||||
@@ -7,32 +21,29 @@ interface FileExplorerProps {
|
||||
onFileSelect: (repo: string, filePath: string) => void;
|
||||
}
|
||||
|
||||
function getFileIcon(name: string): string {
|
||||
function getFileIcon(name: string): LucideIcon {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "nss":
|
||||
return "S";
|
||||
case "ncs":
|
||||
return FileCode2;
|
||||
case "json":
|
||||
return "J";
|
||||
return FileJson;
|
||||
case "xml":
|
||||
case "html":
|
||||
return "<>";
|
||||
case "md":
|
||||
return "M";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "Y";
|
||||
return FileType2;
|
||||
case "md":
|
||||
return FileText;
|
||||
case "png":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "bmp":
|
||||
case "tga":
|
||||
return "I";
|
||||
return FileImage;
|
||||
case "2da":
|
||||
return "2";
|
||||
case "ncs":
|
||||
return "C";
|
||||
case "git":
|
||||
case "are":
|
||||
case "ifo":
|
||||
@@ -48,9 +59,9 @@ function getFileIcon(name: string): string {
|
||||
case "dlg":
|
||||
case "jrl":
|
||||
case "fac":
|
||||
return "N";
|
||||
return FileText;
|
||||
default:
|
||||
return "F";
|
||||
return File;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +79,7 @@ function FileTreeNode({
|
||||
repo: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isSelected = selectedPath === node.path;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -78,35 +90,56 @@ function FileTreeNode({
|
||||
}
|
||||
}, [node, repo, onFileSelect]);
|
||||
|
||||
const FileIcon = node.type === "directory"
|
||||
? (expanded ? FolderOpen : Folder)
|
||||
: getFileIcon(node.name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex w-full items-center gap-1 px-1 py-0.5 text-left text-sm transition-colors hover:bg-white/5"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
paddingLeft: `${depth * 16 + 8}px`,
|
||||
backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
|
||||
paddingRight: "0.25rem",
|
||||
paddingTop: "0.125rem",
|
||||
paddingBottom: "0.125rem",
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
backgroundColor: isSelected
|
||||
? "var(--forge-surface)"
|
||||
: hovered
|
||||
? "var(--forge-surface-raised)"
|
||||
: "transparent",
|
||||
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
{node.type === "directory" ? (
|
||||
<span
|
||||
className="inline-block w-4 text-center text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
{expanded ? "\u25BC" : "\u25B6"}
|
||||
<span style={{ display: "flex", alignItems: "center", width: "16px", justifyContent: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block w-4 text-center text-xs font-bold"
|
||||
style={{ color: "var(--forge-accent)", fontSize: "10px" }}
|
||||
>
|
||||
{getFileIcon(node.name)}
|
||||
</span>
|
||||
<span style={{ width: "16px", flexShrink: 0 }} />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
<FileIcon
|
||||
size={14}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: node.type === "directory" ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
{node.type === "directory" && expanded && node.children && (
|
||||
<div>
|
||||
@@ -155,44 +188,77 @@ export function FileExplorer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Explorer
|
||||
</span>
|
||||
<button
|
||||
onClick={loadTree}
|
||||
className="rounded p-1 text-xs transition-colors hover:bg-white/10"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
title="Refresh"
|
||||
aria-label="Refresh file tree"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "none",
|
||||
background: "none",
|
||||
color: "var(--forge-text-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
↻
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0.25rem 0" }}>
|
||||
{loading && (
|
||||
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-4 text-sm text-red-400">
|
||||
{error}
|
||||
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{error.includes("ENOENT") ? (
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 500, color: "var(--forge-text)" }}>Repository not cloned</p>
|
||||
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)" }}>
|
||||
Clone repositories from the Repos page or run the setup wizard.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: "var(--forge-danger)" }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && tree.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,208 +1,105 @@
|
||||
import { useRef, useCallback, useState } from "react";
|
||||
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
|
||||
import type { editor } from "monaco-editor";
|
||||
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { LogLevel } from "@codingame/monaco-vscode-api";
|
||||
import {
|
||||
MonacoEditorReactComp,
|
||||
} from "@typefox/monaco-editor-react";
|
||||
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
|
||||
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
|
||||
import type { LanguageClientConfig } from "monaco-languageclient/lcwrapper";
|
||||
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
|
||||
import "../../nwscript-extension/index.js";
|
||||
|
||||
interface MonacoEditorProps {
|
||||
filePath: string;
|
||||
content: string;
|
||||
language?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let nwscriptRegistered = false;
|
||||
|
||||
function registerNWScript(monaco: Parameters<OnMount>[1]) {
|
||||
if (nwscriptRegistered) return;
|
||||
nwscriptRegistered = true;
|
||||
|
||||
monaco.languages.register({ id: "nwscript", extensions: [".nss"] });
|
||||
|
||||
monaco.languages.setMonarchTokensProvider("nwscript", {
|
||||
keywords: [
|
||||
"void",
|
||||
"int",
|
||||
"float",
|
||||
"string",
|
||||
"object",
|
||||
"effect",
|
||||
"itemproperty",
|
||||
"location",
|
||||
"vector",
|
||||
"action",
|
||||
"talent",
|
||||
"event",
|
||||
"struct",
|
||||
"if",
|
||||
"else",
|
||||
"while",
|
||||
"for",
|
||||
"do",
|
||||
"switch",
|
||||
"case",
|
||||
"default",
|
||||
"break",
|
||||
"continue",
|
||||
"return",
|
||||
"const",
|
||||
],
|
||||
|
||||
constants: ["TRUE", "FALSE", "OBJECT_SELF", "OBJECT_INVALID"],
|
||||
|
||||
typeKeywords: [
|
||||
"void",
|
||||
"int",
|
||||
"float",
|
||||
"string",
|
||||
"object",
|
||||
"effect",
|
||||
"itemproperty",
|
||||
"location",
|
||||
"vector",
|
||||
"action",
|
||||
"talent",
|
||||
"event",
|
||||
"struct",
|
||||
],
|
||||
|
||||
operators: [
|
||||
"=",
|
||||
">",
|
||||
"<",
|
||||
"!",
|
||||
"~",
|
||||
"?",
|
||||
":",
|
||||
"==",
|
||||
"<=",
|
||||
">=",
|
||||
"!=",
|
||||
"&&",
|
||||
"||",
|
||||
"++",
|
||||
"--",
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"&",
|
||||
"|",
|
||||
"^",
|
||||
"%",
|
||||
"<<",
|
||||
">>",
|
||||
"+=",
|
||||
"-=",
|
||||
"*=",
|
||||
"/=",
|
||||
"&=",
|
||||
"|=",
|
||||
"^=",
|
||||
"%=",
|
||||
],
|
||||
|
||||
symbols: /[=><!~?:&|+\-*/^%]+/,
|
||||
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#include\b/, "keyword.preprocessor"],
|
||||
[/#define\b/, "keyword.preprocessor"],
|
||||
|
||||
[
|
||||
/[a-zA-Z_]\w*/,
|
||||
{
|
||||
cases: {
|
||||
"@constants": "constant",
|
||||
"@typeKeywords": "type",
|
||||
"@keywords": "keyword",
|
||||
"@default": "identifier",
|
||||
function getVscodeApiConfig(): MonacoVscodeApiConfig {
|
||||
return {
|
||||
$type: "extended",
|
||||
viewsConfig: {
|
||||
$type: "EditorService",
|
||||
},
|
||||
userConfiguration: {
|
||||
json: JSON.stringify({
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"workbench.colorCustomizations": {
|
||||
"editor.background": "#231e17",
|
||||
"editor.foreground": "#ece8e3",
|
||||
"editor.lineHighlightBackground": "#302a2040",
|
||||
"editor.selectionBackground": "#3d3018",
|
||||
"editor.selectionHighlightBackground": "#3d301860",
|
||||
"editor.inactiveSelectionBackground": "#3d301850",
|
||||
"editor.findMatchBackground": "#b07a0040",
|
||||
"editor.findMatchHighlightBackground": "#b07a0025",
|
||||
"editor.hoverHighlightBackground": "#3d301830",
|
||||
"editorCursor.foreground": "#b07a00",
|
||||
"editorWhitespace.foreground": "#4a403550",
|
||||
"editorIndentGuide.background": "#4a403530",
|
||||
"editorIndentGuide.activeBackground": "#4a403580",
|
||||
"editorLineNumber.foreground": "#a69f9650",
|
||||
"editorLineNumber.activeForeground": "#ece8e3",
|
||||
"editorBracketMatch.background": "#3d301840",
|
||||
"editorBracketMatch.border": "#b07a0080",
|
||||
"editorOverviewRuler.border": "#4a4035",
|
||||
"editorGutter.background": "#231e17",
|
||||
"editorWidget.background": "#3b3328",
|
||||
"editorWidget.foreground": "#ece8e3",
|
||||
"editorWidget.border": "#4a4035",
|
||||
"editorSuggestWidget.background": "#3b3328",
|
||||
"editorSuggestWidget.border": "#4a4035",
|
||||
"editorSuggestWidget.foreground": "#ece8e3",
|
||||
"editorSuggestWidget.highlightForeground": "#b07a00",
|
||||
"editorSuggestWidget.selectedBackground": "#3d3018",
|
||||
"editorHoverWidget.background": "#3b3328",
|
||||
"editorHoverWidget.border": "#4a4035",
|
||||
"editorGroupHeader.tabsBackground": "#302a20",
|
||||
"editorGroupHeader.tabsBorder": "#4a4035",
|
||||
"editorGroup.border": "#4a4035",
|
||||
"tab.activeBackground": "#231e17",
|
||||
"tab.activeForeground": "#ece8e3",
|
||||
"tab.activeBorderTop": "#b07a00",
|
||||
"tab.inactiveBackground": "#302a20",
|
||||
"tab.inactiveForeground": "#a69f96",
|
||||
"tab.border": "#4a4035",
|
||||
"input.background": "#231e17",
|
||||
"input.foreground": "#ece8e3",
|
||||
"input.border": "#4a4035",
|
||||
"input.placeholderForeground": "#a69f9680",
|
||||
"inputOption.activeBorder": "#b07a00",
|
||||
"dropdown.background": "#3b3328",
|
||||
"dropdown.foreground": "#ece8e3",
|
||||
"dropdown.border": "#4a4035",
|
||||
"list.activeSelectionBackground": "#3d3018",
|
||||
"list.activeSelectionForeground": "#ece8e3",
|
||||
"list.inactiveSelectionBackground": "#3d301880",
|
||||
"list.hoverBackground": "#302a2080",
|
||||
"list.highlightForeground": "#b07a00",
|
||||
"scrollbarSlider.background": "#4a403540",
|
||||
"scrollbarSlider.hoverBackground": "#4a403580",
|
||||
"scrollbarSlider.activeBackground": "#4a4035a0",
|
||||
"focusBorder": "#b07a0080",
|
||||
"peekView.border": "#b07a00",
|
||||
"peekViewEditor.background": "#231e17",
|
||||
"peekViewResult.background": "#302a20",
|
||||
"peekViewTitle.background": "#302a20",
|
||||
"minimap.selectionHighlight": "#3d3018",
|
||||
"minimap.findMatchHighlight": "#b07a0060",
|
||||
},
|
||||
],
|
||||
|
||||
{ include: "@whitespace" },
|
||||
|
||||
[/[{}()[\]]/, "@brackets"],
|
||||
[/[;,.]/, "delimiter"],
|
||||
|
||||
[
|
||||
/@symbols/,
|
||||
{
|
||||
cases: {
|
||||
"@operators": "operator",
|
||||
"@default": "",
|
||||
"editor.fontSize": 14,
|
||||
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.minimap.enabled": false,
|
||||
"editor.scrollBeyondLastLine": false,
|
||||
"editor.wordWrap": "off",
|
||||
"editor.renderWhitespace": "selection",
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.padding.top": 8,
|
||||
"editor.lineNumbers": "on",
|
||||
"editor.guides.bracketPairsHorizontal": "active",
|
||||
"editor.wordBasedSuggestions": "off",
|
||||
"editor.quickSuggestions": true,
|
||||
"editor.parameterHints.enabled": true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
[/\d*\.\d+([eE][-+]?\d+)?[fF]?/, "number.float"],
|
||||
[/0[xX][0-9a-fA-F]+/, "number.hex"],
|
||||
[/\d+/, "number"],
|
||||
|
||||
[/"([^"\\]|\\.)*$/, "string.invalid"],
|
||||
[/"/, { token: "string.quote", next: "@string" }],
|
||||
],
|
||||
|
||||
string: [
|
||||
[/\b(SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|SET|DELETE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AND|OR|NOT|IN|IS|NULL|AS|ORDER|BY|GROUP|HAVING|LIMIT|COUNT|SUM|AVG|MAX|MIN|DISTINCT|CREATE|TABLE|ALTER|DROP|INDEX|PRIMARY|KEY|LIKE|BETWEEN)\b/i, "keyword.sql"],
|
||||
[/[^\\"]+/, "string"],
|
||||
[/\\./, "string.escape"],
|
||||
[/"/, { token: "string.quote", next: "@pop" }],
|
||||
],
|
||||
|
||||
whitespace: [
|
||||
[/[ \t\r\n]+/, "white"],
|
||||
[/\/\*/, "comment", "@comment"],
|
||||
[/\/\/.*$/, "comment"],
|
||||
],
|
||||
|
||||
comment: [
|
||||
[/[^/*]+/, "comment"],
|
||||
[/\*\//, "comment", "@pop"],
|
||||
[/[/*]/, "comment"],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
|
||||
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
|
||||
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
|
||||
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
|
||||
const textSecondary =
|
||||
style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
|
||||
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
|
||||
|
||||
monaco.editor.defineTheme("forge-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "C586C0" },
|
||||
{ token: "keyword.preprocessor", foreground: "569CD6" },
|
||||
{ token: "type", foreground: "4EC9B0" },
|
||||
{ token: "constant", foreground: "4FC1FF" },
|
||||
{ token: "string", foreground: "CE9178" },
|
||||
{ token: "comment", foreground: "6A9955" },
|
||||
{ token: "number", foreground: "B5CEA8" },
|
||||
{ token: "operator", foreground: "D4D4D4" },
|
||||
{ token: "keyword.sql", foreground: "4ec9b0" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": bg,
|
||||
"editor.foreground": text,
|
||||
"editorCursor.foreground": accent,
|
||||
"editor.lineHighlightBackground": surface,
|
||||
"editorLineNumber.foreground": textSecondary,
|
||||
"editorLineNumber.activeForeground": text,
|
||||
"editor.selectionBackground": "#264f7840",
|
||||
"editorWidget.background": surface,
|
||||
"editorWidget.border": border,
|
||||
"editorSuggestWidget.background": surface,
|
||||
"editorSuggestWidget.border": border,
|
||||
},
|
||||
});
|
||||
monacoWorkerFactory: configureDefaultWorkerFactory,
|
||||
};
|
||||
}
|
||||
|
||||
function languageFromPath(filePath: string): string {
|
||||
@@ -219,69 +116,96 @@ function languageFromPath(filePath: string): string {
|
||||
md: "markdown",
|
||||
txt: "plaintext",
|
||||
"2da": "plaintext",
|
||||
cfg: "ini",
|
||||
sh: "shellscript",
|
||||
};
|
||||
return map[ext ?? ""] ?? "plaintext";
|
||||
}
|
||||
|
||||
interface MonacoEditorProps {
|
||||
filePath: string;
|
||||
content: string;
|
||||
language?: string;
|
||||
onChange?: (value: string) => void;
|
||||
workspacePath?: string;
|
||||
}
|
||||
|
||||
export function MonacoEditor({
|
||||
filePath,
|
||||
content,
|
||||
language,
|
||||
onChange,
|
||||
workspacePath,
|
||||
}: MonacoEditorProps) {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
|
||||
|
||||
const resolvedLang = language ?? languageFromPath(filePath);
|
||||
useLspClient(monacoRef);
|
||||
useLspDocument(editorRef.current, filePath, resolvedLang);
|
||||
const isNwscript = resolvedLang === "nwscript";
|
||||
|
||||
const handleMount: OnMount = useCallback(
|
||||
(editorInstance, monaco) => {
|
||||
editorRef.current = editorInstance;
|
||||
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
|
||||
registerNWScript(monaco);
|
||||
defineForgeTheme(monaco);
|
||||
monaco.editor.setTheme("forge-dark");
|
||||
const fileUri = workspacePath
|
||||
? `file://${workspacePath}/repos/nwn-module/${filePath}`
|
||||
: `file:///workspace/repos/nwn-module/${filePath}`;
|
||||
|
||||
const model = editorInstance.getModel();
|
||||
if (model) {
|
||||
const lang = language ?? languageFromPath(filePath);
|
||||
monaco.editor.setModelLanguage(model, lang);
|
||||
}
|
||||
const editorAppConfig = useMemo<EditorAppConfig>(
|
||||
() => ({
|
||||
codeResources: {
|
||||
modified: {
|
||||
text: content,
|
||||
uri: fileUri,
|
||||
enforceLanguageId: resolvedLang,
|
||||
},
|
||||
[filePath, language],
|
||||
},
|
||||
editorOptions: {
|
||||
automaticLayout: true,
|
||||
},
|
||||
}),
|
||||
[filePath],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
if (value !== undefined) {
|
||||
onChange?.(value);
|
||||
const languageClientConfig = useMemo<LanguageClientConfig | undefined>(() => {
|
||||
if (!isNwscript) return undefined;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return {
|
||||
languageId: "nwscript",
|
||||
connection: {
|
||||
options: {
|
||||
$type: "WebSocketUrl" as const,
|
||||
url: `${protocol}//${window.location.host}/ws/lsp`,
|
||||
},
|
||||
},
|
||||
clientOptions: {
|
||||
documentSelector: ["nwscript"],
|
||||
workspaceFolder: workspacePath
|
||||
? {
|
||||
index: 0,
|
||||
name: "nwn-module",
|
||||
uri: { scheme: "file", path: `${workspacePath}/repos/nwn-module` } as any,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}, [isNwscript, workspacePath]);
|
||||
|
||||
const handleTextChanged = useCallback(
|
||||
(textChanges: TextContents) => {
|
||||
if (textChanges.modified !== undefined) {
|
||||
onChange?.(textChanges.modified);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
console.error("[MonacoEditor]", error.message, error.stack);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactMonacoEditor
|
||||
value={content}
|
||||
language={language ?? languageFromPath(filePath)}
|
||||
theme="vs-dark"
|
||||
onChange={handleChange}
|
||||
onMount={handleMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
wordWrap: "off",
|
||||
renderWhitespace: "selection",
|
||||
bracketPairColorization: { enabled: true },
|
||||
padding: { top: 8 },
|
||||
}}
|
||||
<MonacoEditorReactComp
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
vscodeApiConfig={getVscodeApiConfig()}
|
||||
editorAppConfig={editorAppConfig}
|
||||
languageClientConfig={languageClientConfig}
|
||||
onTextChanged={handleTextChanged}
|
||||
onError={handleError}
|
||||
logLevel={LogLevel.Warning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { api } from "../../services/api";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
interface SearchMatch {
|
||||
file: string;
|
||||
@@ -32,6 +33,8 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
|
||||
const [hoveredMatch, setHoveredMatch] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const doSearch = useCallback(async () => {
|
||||
@@ -82,50 +85,71 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
|
||||
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
backgroundColor: active ? "var(--forge-accent)" : "transparent",
|
||||
color: active ? "#121212" : "var(--forge-text-secondary)",
|
||||
color: active ? "var(--forge-accent-text)" : "var(--forge-text-secondary)",
|
||||
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.375rem",
|
||||
cursor: "pointer",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-3 py-2" style={{ borderBottom: "1px solid var(--forge-border)" }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--forge-border)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 rounded px-2 py-1 text-sm outline-none"
|
||||
aria-label="Search query"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setRegex((v) => !v)}
|
||||
className="rounded px-1.5 py-1"
|
||||
style={toggleBtnStyle(regex)}
|
||||
title="Use Regular Expression"
|
||||
>
|
||||
@@ -133,7 +157,6 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
className="rounded px-1.5 py-1"
|
||||
style={toggleBtnStyle(caseSensitive)}
|
||||
title="Match Case"
|
||||
>
|
||||
@@ -141,27 +164,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||
<input
|
||||
value={includePattern}
|
||||
onChange={(e) => setIncludePattern(e.target.value)}
|
||||
placeholder="Include (e.g. *.nss)"
|
||||
className="flex-1 rounded px-2 py-1 text-xs outline-none"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={excludePattern}
|
||||
onChange={(e) => setExcludePattern(e.target.value)}
|
||||
placeholder="Exclude (e.g. *.json)"
|
||||
className="flex-1 rounded px-2 py-1 text-xs outline-none"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,25 +200,34 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
<button
|
||||
onClick={doSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "#121212",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
cursor: loading || !query.trim() ? "not-allowed" : "pointer",
|
||||
opacity: loading || !query.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-sm text-red-400">{error}</div>
|
||||
<div style={{ padding: "0.5rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{searched && !loading && !error && (
|
||||
<div
|
||||
className="px-3 py-1.5 text-xs"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
@@ -202,24 +242,44 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
<div key={group.file}>
|
||||
<button
|
||||
onClick={() => toggleCollapsed(group.file)}
|
||||
className="flex w-full items-center gap-1 px-3 py-1 text-left text-xs transition-colors hover:bg-white/5"
|
||||
style={{ color: "var(--forge-text)" }}
|
||||
onMouseEnter={() => setHoveredFile(group.file)}
|
||||
onMouseLeave={() => setHoveredFile(null)}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
textAlign: "left",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text)",
|
||||
border: "none",
|
||||
background: hoveredFile === group.file ? "var(--forge-surface-raised)" : "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-3 text-center"
|
||||
style={{ color: "var(--forge-text-secondary)", fontSize: "10px" }}
|
||||
>
|
||||
{collapsed.has(group.file) ? "\u25B6" : "\u25BC"}
|
||||
<span style={{ display: "flex", alignItems: "center", width: "12px", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||
{collapsed.has(group.file) ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1 truncate font-medium"
|
||||
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 500,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{group.file}
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full px-1.5 text-xs"
|
||||
style={{
|
||||
borderRadius: "9999px",
|
||||
padding: "0 0.375rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
@@ -229,18 +289,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
</button>
|
||||
|
||||
{!collapsed.has(group.file) &&
|
||||
group.matches.map((match, i) => (
|
||||
group.matches.map((match, i) => {
|
||||
const matchKey = `${match.file}-${match.line}-${match.column}-${i}`;
|
||||
return (
|
||||
<button
|
||||
key={`${match.line}-${match.column}-${i}`}
|
||||
key={matchKey}
|
||||
onClick={() => onResultClick(match.file, match.line)}
|
||||
className="flex w-full items-start gap-2 px-3 py-0.5 text-left transition-colors hover:bg-white/5"
|
||||
style={{ paddingLeft: "28px" }}
|
||||
onMouseEnter={() => setHoveredMatch(matchKey)}
|
||||
onMouseLeave={() => setHoveredMatch(null)}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
paddingLeft: "28px",
|
||||
paddingRight: "0.75rem",
|
||||
paddingTop: "0.125rem",
|
||||
paddingBottom: "0.125rem",
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
background: hoveredMatch === matchKey ? "var(--forge-surface-raised)" : "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-xs"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "11px",
|
||||
minWidth: "32px",
|
||||
textAlign: "right",
|
||||
@@ -249,9 +326,11 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
{match.line}
|
||||
</span>
|
||||
<span
|
||||
className="truncate text-xs"
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "12px",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
@@ -259,7 +338,8 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
||||
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -283,7 +363,7 @@ function HighlightedLine({
|
||||
return (
|
||||
<>
|
||||
<span>{before}</span>
|
||||
<span className="font-bold" style={{ color: "var(--forge-accent)" }}>
|
||||
<span style={{ fontWeight: 700, color: "var(--forge-accent)" }}>
|
||||
{matched}
|
||||
</span>
|
||||
<span>{after}</span>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
|
||||
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
|
||||
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
|
||||
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
|
||||
|
||||
function getVscodeApiConfig(): MonacoVscodeApiConfig {
|
||||
return {
|
||||
$type: "extended",
|
||||
viewsConfig: { $type: "EditorService" },
|
||||
userConfiguration: {
|
||||
json: JSON.stringify({
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
}),
|
||||
},
|
||||
monacoWorkerFactory: configureDefaultWorkerFactory,
|
||||
};
|
||||
}
|
||||
|
||||
interface SimpleEditorProps {
|
||||
value: string;
|
||||
language?: string;
|
||||
onChange?: (value: string) => void;
|
||||
options?: Record<string, unknown>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function SimpleEditor({
|
||||
value,
|
||||
language = "plaintext",
|
||||
onChange,
|
||||
readOnly,
|
||||
}: SimpleEditorProps) {
|
||||
const uri = useMemo(
|
||||
() => `inmemory://simple-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const editorAppConfig = useMemo<EditorAppConfig>(
|
||||
() => ({
|
||||
codeResources: {
|
||||
modified: {
|
||||
text: value,
|
||||
uri,
|
||||
enforceLanguageId: language,
|
||||
},
|
||||
},
|
||||
editorOptions: {
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTextChanged = useCallback(
|
||||
(textChanges: TextContents) => {
|
||||
if (textChanges.modified !== undefined) {
|
||||
onChange?.(textChanges.modified);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<MonacoEditorReactComp
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
vscodeApiConfig={getVscodeApiConfig()}
|
||||
editorAppConfig={editorAppConfig}
|
||||
onTextChanged={handleTextChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SimpleDiffEditorProps {
|
||||
original: string;
|
||||
modified: string;
|
||||
language?: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function SimpleDiffEditor({
|
||||
original,
|
||||
modified,
|
||||
language = "plaintext",
|
||||
}: SimpleDiffEditorProps) {
|
||||
const baseUri = useMemo(
|
||||
() => `inmemory://diff-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const editorAppConfig = useMemo<EditorAppConfig>(
|
||||
() => ({
|
||||
useDiffEditor: true,
|
||||
codeResources: {
|
||||
original: {
|
||||
text: original,
|
||||
uri: `${baseUri}-original`,
|
||||
enforceLanguageId: language,
|
||||
},
|
||||
modified: {
|
||||
text: modified,
|
||||
uri: `${baseUri}-modified`,
|
||||
enforceLanguageId: language,
|
||||
},
|
||||
},
|
||||
diffEditorOptions: {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
},
|
||||
}),
|
||||
[original, modified, language],
|
||||
);
|
||||
|
||||
return (
|
||||
<MonacoEditorReactComp
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
vscodeApiConfig={getVscodeApiConfig()}
|
||||
editorAppConfig={editorAppConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -39,15 +39,24 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Area Flags
|
||||
</label>
|
||||
<div className="flex items-center gap-6">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
|
||||
{bits.map(({ bit, label }) => {
|
||||
const checked = (flags & (1 << bit)) !== 0;
|
||||
return (
|
||||
<label key={bit} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<label
|
||||
key={bit}
|
||||
style={{
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
@@ -57,15 +66,19 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
: flags & ~(1 << bit);
|
||||
onChange("Flags", newFlags);
|
||||
}}
|
||||
className="h-4 w-4 rounded"
|
||||
style={{ accentColor: "var(--forge-accent)" }}
|
||||
style={{
|
||||
height: "1rem",
|
||||
width: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
accentColor: "var(--forge-accent)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "var(--forge-text)" }}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
Raw value: {flags}
|
||||
</span>
|
||||
</div>
|
||||
@@ -77,19 +90,25 @@ function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
|
||||
const hex = intToHexColor(num);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<input
|
||||
type="color"
|
||||
value={hex}
|
||||
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
|
||||
className="h-8 w-10 cursor-pointer rounded border-0"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
height: "2rem",
|
||||
width: "2.5rem",
|
||||
cursor: "pointer",
|
||||
borderRadius: "0.25rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{hex}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,54 +125,64 @@ function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
const ts = typeof tileset === "string" ? tileset : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Width</label>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Width</label>
|
||||
<input
|
||||
type="number"
|
||||
value={w}
|
||||
min={1}
|
||||
max={32}
|
||||
onChange={(e) => onChange("Width", parseInt(e.target.value, 10))}
|
||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ color: "var(--forge-text-secondary)" }}>×</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Height</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Height</label>
|
||||
<input
|
||||
type="number"
|
||||
value={h}
|
||||
min={1}
|
||||
max={32}
|
||||
onChange={(e) => onChange("Height", parseInt(e.target.value, 10))}
|
||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>tiles</span>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>tiles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Tileset</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Tileset</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ts}
|
||||
maxLength={16}
|
||||
onChange={(e) => onChange("Tileset", e.target.value)}
|
||||
className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
|
||||
style={{
|
||||
width: "11rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -25,21 +25,28 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Ability Scores
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem" }}>
|
||||
{abilities.flat().map((ab) => {
|
||||
const val = getFieldValue(data, ab);
|
||||
const num = typeof val === "number" ? val : 0;
|
||||
return (
|
||||
<div
|
||||
key={ab}
|
||||
className="flex flex-col items-center rounded border px-3 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.5rem 0.75rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-bold" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", fontWeight: 700, color: "var(--forge-text-secondary)" }}>
|
||||
{displayNames[ab]}
|
||||
</span>
|
||||
<input
|
||||
@@ -48,14 +55,20 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
|
||||
min={1}
|
||||
max={99}
|
||||
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))}
|
||||
className="mt-1 w-16 rounded border px-1 py-1 text-center text-lg font-semibold"
|
||||
style={{
|
||||
marginTop: "0.25rem",
|
||||
width: "4rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.25rem",
|
||||
textAlign: "center",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 600,
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="mt-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ marginTop: "0.125rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -73,34 +86,39 @@ function RaceGenderOverride({ data, onChange }: FieldOverrideProps) {
|
||||
const genderNum = typeof gender === "number" ? gender : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Race</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Race</label>
|
||||
<input
|
||||
type="number"
|
||||
value={raceNum}
|
||||
min={0}
|
||||
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
|
||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
(racialtypes.2da)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Gender</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Gender</label>
|
||||
<select
|
||||
value={genderNum}
|
||||
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
|
||||
className="rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
@@ -121,13 +139,13 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{scripts.map((s) => {
|
||||
const val = getFieldValue(data, s.label);
|
||||
const str = typeof val === "string" ? val : "";
|
||||
return (
|
||||
<div key={s.label} className="flex items-center gap-3">
|
||||
<label className="w-28 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div key={s.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<label style={{ width: "7rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{s.display}
|
||||
</label>
|
||||
<input
|
||||
@@ -135,10 +153,14 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
value={str}
|
||||
maxLength={16}
|
||||
onChange={(e) => onChange(s.label, e.target.value)}
|
||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
placeholder="(none)"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
gffTypeFromPath,
|
||||
getLocStringText,
|
||||
} from "./GffEditor";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
interface DialogEditorProps {
|
||||
repo: string;
|
||||
@@ -48,38 +49,38 @@ function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply"
|
||||
const sound = getStringVal(node, "Sound");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
Text
|
||||
</label>
|
||||
<p className="mt-0.5 text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
</span>
|
||||
<p style={{ marginTop: "0.125rem", fontSize: "var(--text-sm)", color: "var(--forge-text)", margin: "0.125rem 0 0" }}>
|
||||
{text || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
||||
{type === "entry" && speaker && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Speaker</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{speaker}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Speaker</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{speaker}</p>
|
||||
</div>
|
||||
)}
|
||||
{script && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Action Script</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{script}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Action Script</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{script}</p>
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Condition</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{active}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Condition</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{active}</p>
|
||||
</div>
|
||||
)}
|
||||
{sound && (
|
||||
<div>
|
||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Sound</label>
|
||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{sound}</p>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Sound</span>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{sound}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,6 +104,7 @@ function DialogNodeItem({
|
||||
depth: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const text = getTextVal(node);
|
||||
const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||
|
||||
@@ -117,40 +119,63 @@ function DialogNodeItem({
|
||||
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:opacity-80"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
backgroundColor: expanded ? "var(--forge-surface)" : "transparent",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.375rem 0.5rem",
|
||||
textAlign: "left",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: expanded ? "var(--forge-surface)" : hovered ? "var(--forge-surface-raised)" : "transparent",
|
||||
color: "var(--forge-text)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span className="mt-0.5 font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{expanded ? "▼" : "▶"}
|
||||
<span style={{ marginTop: "0.125rem", display: "flex", alignItems: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20",
|
||||
color: type === "entry" ? "#60a5fa" : "#4ade80",
|
||||
flexShrink: 0,
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.125rem 0.25rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
backgroundColor: type === "entry" ? "var(--forge-info-bg)" : "var(--forge-success-bg)",
|
||||
color: type === "entry" ? "var(--forge-info)" : "var(--forge-success)",
|
||||
}}
|
||||
>
|
||||
{type === "entry" ? "E" : "R"}{index}
|
||||
</span>
|
||||
<span className="flex-1 truncate">
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{truncated || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="ml-6 mt-1 space-y-2 border-l-2 pl-3"
|
||||
style={{ borderColor: "var(--forge-border)" }}
|
||||
style={{
|
||||
marginLeft: "1.5rem",
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
borderLeft: "2px solid var(--forge-border)",
|
||||
paddingLeft: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div className="rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ borderRadius: "0.25rem", padding: "0.75rem", backgroundColor: "var(--forge-bg)" }}>
|
||||
<NodeDetail node={node} type={type} />
|
||||
</div>
|
||||
|
||||
{childLinks && childLinks.length > 0 && depth < 4 && (
|
||||
<div className="space-y-1">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{childLinks.map((link, li) => {
|
||||
const idx = typeof link === "object" && link !== null
|
||||
? (typeof link.Index === "number" ? link.Index :
|
||||
@@ -254,31 +279,44 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
}, [schema]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ display: "flex", height: "100%", flexDirection: "column", backgroundColor: "var(--forge-bg)" }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||
Dialog Editor
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{entries.length} entries, {replies.length} replies
|
||||
</span>
|
||||
{dirty && (
|
||||
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)" }}>
|
||||
(unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{onSwitchToRaw && (
|
||||
<button
|
||||
onClick={onSwitchToRaw}
|
||||
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
Switch to Raw JSON
|
||||
</button>
|
||||
@@ -286,8 +324,16 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "#fff" }}
|
||||
style={{
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
opacity: !dirty || saving ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
@@ -296,17 +342,30 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="flex shrink-0 gap-0 border-b"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
role="tablist"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
gap: 0,
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
{(["tree", "properties"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="px-4 py-2 text-sm capitalize transition-colors"
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
textTransform: "capitalize",
|
||||
color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
border: "none",
|
||||
borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||
backgroundColor: "transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{tab === "tree" ? "Conversation Tree" : "Properties"}
|
||||
@@ -315,13 +374,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
|
||||
{error && (
|
||||
<p className="mb-4 text-sm" style={{ color: "#ef4444" }}>{error}</p>
|
||||
<p style={{ marginBottom: "1rem", fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
|
||||
)}
|
||||
|
||||
{activeTab === "tree" && (
|
||||
<div className="mx-auto max-w-3xl space-y-1">
|
||||
<div style={{ maxWidth: "48rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{startingEntries.length > 0 ? (
|
||||
startingEntries.map((link, i) => {
|
||||
const idx = typeof link === "object" && link !== null
|
||||
@@ -353,7 +412,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
depth={0}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
No dialog entries found
|
||||
</p>
|
||||
)}
|
||||
@@ -361,7 +420,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
)}
|
||||
|
||||
{activeTab === "properties" && (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<div style={{ maxWidth: "40rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{propertyFields.map((field) => {
|
||||
const raw = data[field.label];
|
||||
const value = raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)
|
||||
@@ -369,8 +428,8 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
: raw;
|
||||
|
||||
return (
|
||||
<div key={field.label} className="flex items-center gap-3" title={field.description}>
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div key={field.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }} title={field.description}>
|
||||
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
{field.type === GffFieldType.ResRef ? (
|
||||
@@ -392,10 +451,14 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
@@ -418,10 +481,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="w-32 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "8rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Code2, Save, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { api } from "../../services/api";
|
||||
|
||||
export enum GffFieldType {
|
||||
Byte = 0,
|
||||
Char = 1,
|
||||
Word = 2,
|
||||
Short = 3,
|
||||
Dword = 4,
|
||||
Int = 5,
|
||||
Dword64 = 6,
|
||||
Int64 = 7,
|
||||
Float = 8,
|
||||
Double = 9,
|
||||
CExoString = 10,
|
||||
ResRef = 11,
|
||||
CExoLocString = 12,
|
||||
Void = 13,
|
||||
Struct = 14,
|
||||
List = 15,
|
||||
Byte = 0, Char = 1, Word = 2, Short = 3, Dword = 4, Int = 5,
|
||||
Dword64 = 6, Int64 = 7, Float = 8, Double = 9,
|
||||
CExoString = 10, ResRef = 11, CExoLocString = 12, Void = 13,
|
||||
Struct = 14, List = 15,
|
||||
}
|
||||
|
||||
export interface GffFieldSchema {
|
||||
@@ -56,6 +45,11 @@ function getLocStringText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v["0"] === "string") return v["0"];
|
||||
for (const [key, val] of Object.entries(v)) {
|
||||
if (key === "id") continue;
|
||||
if (/^\d+$/.test(key) && typeof val === "string") return val;
|
||||
}
|
||||
if (v.strings && typeof v.strings === "object") {
|
||||
const strings = v.strings as Record<string, string>;
|
||||
return strings["0"] ?? Object.values(strings)[0] ?? "";
|
||||
@@ -63,15 +57,15 @@ function getLocStringText(value: unknown): string {
|
||||
if (v.value && typeof v.value === "object") {
|
||||
return getLocStringText(v.value);
|
||||
}
|
||||
if (typeof v.id === "number") {
|
||||
const tlkRow = v.id >= 16777216 ? v.id - 16777216 : v.id;
|
||||
return `(TLK #${tlkRow})`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function setFieldValue(
|
||||
data: Record<string, unknown>,
|
||||
label: string,
|
||||
newValue: unknown,
|
||||
): Record<string, unknown> {
|
||||
function setFieldValue(data: Record<string, unknown>, label: string, newValue: unknown): Record<string, unknown> {
|
||||
const updated = { ...data };
|
||||
const existing = data[label];
|
||||
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
|
||||
@@ -82,28 +76,25 @@ function setFieldValue(
|
||||
return updated;
|
||||
}
|
||||
|
||||
function setLocStringValue(
|
||||
data: Record<string, unknown>,
|
||||
label: string,
|
||||
text: string,
|
||||
): Record<string, unknown> {
|
||||
function setLocStringValue(data: Record<string, unknown>, label: string, text: string): Record<string, unknown> {
|
||||
const updated = { ...data };
|
||||
const existing = data[label];
|
||||
if (existing && typeof existing === "object") {
|
||||
const ex = existing as Record<string, unknown>;
|
||||
if ("value" in ex && ex.value && typeof ex.value === "object") {
|
||||
const inner = ex.value as Record<string, unknown>;
|
||||
updated[label] = {
|
||||
...ex,
|
||||
value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } },
|
||||
};
|
||||
if (typeof inner["0"] === "string") {
|
||||
updated[label] = { ...ex, value: { ...inner, "0": text } };
|
||||
} else {
|
||||
updated[label] = { ...ex, value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } } };
|
||||
}
|
||||
} else if ("strings" in ex) {
|
||||
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
|
||||
} else {
|
||||
updated[label] = { ...ex, value: { strings: { "0": text } } };
|
||||
}
|
||||
} else {
|
||||
updated[label] = { type: "cexolocstring", value: { strings: { "0": text } } };
|
||||
updated[label] = { type: "cexolocstring", value: { "0": text } };
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -112,6 +103,31 @@ function isNumericType(type: GffFieldType): boolean {
|
||||
return type <= GffFieldType.Double;
|
||||
}
|
||||
|
||||
const fieldRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0",
|
||||
};
|
||||
|
||||
const fieldLabel: React.CSSProperties = {
|
||||
width: "11rem",
|
||||
flexShrink: 0,
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
};
|
||||
|
||||
const fieldInput: React.CSSProperties = {
|
||||
flex: 1,
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: GffFieldSchema;
|
||||
value: unknown;
|
||||
@@ -126,14 +142,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
||||
if (field.type === GffFieldType.Void) {
|
||||
const hex = typeof value === "string" ? value : "";
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<code
|
||||
className="flex-1 rounded px-2 py-1.5 text-xs break-all"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
|
||||
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
|
||||
<code style={{ flex: 1, borderRadius: "0.375rem", padding: "0.5rem 0.75rem", fontSize: "var(--text-xs)", wordBreak: "break-all", backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
|
||||
{hex || "(empty)"}
|
||||
</code>
|
||||
</div>
|
||||
@@ -141,26 +152,7 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
||||
}
|
||||
|
||||
if (field.type === GffFieldType.CExoLocString) {
|
||||
const text = getLocStringText(value);
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
readOnly={isReadonly}
|
||||
onChange={(e) => onLocStringChange?.(e.target.value)}
|
||||
className="flex-1 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <LocStringField field={field} value={value} isReadonly={isReadonly} onLocStringChange={onLocStringChange} />;
|
||||
}
|
||||
|
||||
if (field.type === GffFieldType.List) {
|
||||
@@ -176,23 +168,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
||||
const num = typeof value === "number" ? value : 0;
|
||||
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={num}
|
||||
readOnly={isReadonly}
|
||||
step={isFloat ? "0.01" : "1"}
|
||||
onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))}
|
||||
className="w-32 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<div style={fieldRow}>
|
||||
<label style={fieldLabel}>{field.displayName}</label>
|
||||
<input type="number" value={num} readOnly={isReadonly} step={isFloat ? "0.01" : "1"} onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))} style={{ ...fieldInput, flex: "none", width: "8rem" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,54 +179,51 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
||||
const str = typeof value === "string" ? value : "";
|
||||
const valid = str.length <= 16;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={str}
|
||||
readOnly={isReadonly}
|
||||
maxLength={16}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: valid ? "var(--forge-border)" : "#ef4444",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }}
|
||||
>
|
||||
{str.length}/16
|
||||
</span>
|
||||
<div style={fieldRow}>
|
||||
<label style={fieldLabel}>{field.displayName}</label>
|
||||
<div style={{ display: "flex", flex: 1, alignItems: "center", gap: "0.5rem" }}>
|
||||
<input type="text" value={str} readOnly={isReadonly} maxLength={16} onChange={(e) => onChange(e.target.value)} style={{ ...fieldInput, fontFamily: "var(--font-mono)", borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)" }} />
|
||||
<span style={{ fontSize: "var(--text-xs)", color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)", flexShrink: 0 }}>{str.length}/16</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CExoString fallback
|
||||
const str = typeof value === "string" ? value : String(value ?? "");
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={str}
|
||||
readOnly={isReadonly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<div style={fieldRow}>
|
||||
<label style={fieldLabel}>{field.displayName}</label>
|
||||
<input type="text" value={str} readOnly={isReadonly} onChange={(e) => onChange(e.target.value)} style={fieldInput} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocStringField({ field, value, isReadonly, onLocStringChange }: { field: GffFieldSchema; value: unknown; isReadonly: boolean; onLocStringChange?: (text: string) => void }) {
|
||||
const text = getLocStringText(value);
|
||||
const isTlkRef = text.startsWith("(TLK #");
|
||||
const [resolved, setResolved] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTlkRef || !value || typeof value !== "object") return;
|
||||
const v = value as Record<string, unknown>;
|
||||
const id = typeof v.id === "number" ? v.id : undefined;
|
||||
if (id === undefined) return;
|
||||
api.editor.tlkLookup?.(id).then((r) => { if (r) setResolved(r); }).catch(() => {});
|
||||
}, [value, isTlkRef]);
|
||||
|
||||
const displayText = resolved ?? text;
|
||||
|
||||
return (
|
||||
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
|
||||
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
<input type="text" value={displayText} readOnly={isReadonly || isTlkRef} onChange={(e) => onLocStringChange?.(e.target.value)} style={{ ...fieldInput, ...(isTlkRef && !resolved ? { fontStyle: "italic", color: "var(--forge-text-secondary)" } : {}) }} />
|
||||
{isTlkRef && (
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{resolved ? text : "TLK reference — name stored in talk table"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -257,44 +232,25 @@ function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
||||
<span className="rounded px-1.5 py-0.5 text-xs" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
{items.length} {items.length === 1 ? "item" : "items"}
|
||||
</span>
|
||||
<span style={{ fontSize: "var(--text-xs)", backgroundColor: "var(--forge-surface-raised)", borderRadius: "0.25rem", padding: "0.125rem 0.5rem" }}>{items.length} {items.length === 1 ? "item" : "items"}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
className="ml-4 mt-1 space-y-2 border-l-2 pl-4"
|
||||
style={{ borderColor: "var(--forge-border)" }}
|
||||
>
|
||||
<div style={{ marginLeft: "1rem", marginTop: "0.25rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded p-2" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
[{i}]
|
||||
</div>
|
||||
<div key={i} style={{ borderRadius: "0.375rem", padding: "0.5rem 0.75rem", backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ fontSize: "var(--text-xs)", fontWeight: 500, color: "var(--forge-text-secondary)", marginBottom: "0.25rem" }}>[{i}]</div>
|
||||
{typeof item === "object" && item !== null ? (
|
||||
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</pre>
|
||||
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(item, null, 2)}</pre>
|
||||
) : (
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
{String(item)}
|
||||
</span>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>{String(item)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<div className="py-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
(empty list)
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 && <div style={{ padding: "0.25rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>(empty list)</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,23 +261,14 @@ function StructField({ field, value }: { field: GffFieldSchema; value?: Record<s
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
||||
</button>
|
||||
{expanded && value && (
|
||||
<div
|
||||
className="ml-4 mt-1 border-l-2 pl-4"
|
||||
style={{ borderColor: "var(--forge-border)" }}
|
||||
>
|
||||
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
<div style={{ marginLeft: "1rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)" }}>
|
||||
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(value, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -345,89 +292,41 @@ export interface FieldOverrideProps {
|
||||
onChange: (label: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function GffEditor({
|
||||
repo,
|
||||
filePath,
|
||||
content,
|
||||
onSave,
|
||||
onSwitchToRaw,
|
||||
fieldOverrides,
|
||||
headerSlot,
|
||||
}: GffEditorProps) {
|
||||
export function GffEditor({ repo, filePath, content, onSave, onSwitchToRaw, fieldOverrides, headerSlot }: GffEditorProps) {
|
||||
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setData(JSON.parse(content));
|
||||
} catch {
|
||||
setError("Failed to parse JSON content");
|
||||
}
|
||||
}, [content]);
|
||||
useEffect(() => { try { setData(JSON.parse(content)); } catch { setError("Failed to parse JSON content"); } }, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gffType) return;
|
||||
api.editor
|
||||
.gffSchema(gffType)
|
||||
.then((s) => {
|
||||
setSchema(s);
|
||||
if (s.categories.length > 0) setActiveCategory(s.categories[0]);
|
||||
})
|
||||
.catch(() => setError(`Failed to load schema for .${gffType}`));
|
||||
api.editor.gffSchema(gffType).then((s) => { setSchema(s); if (s.categories.length > 0) setActiveCategory(s.categories[0]); }).catch(() => setError(`Failed to load schema for .${gffType}`));
|
||||
}, [gffType]);
|
||||
|
||||
const handleFieldChange = useCallback((label: string, value: unknown) => {
|
||||
setData((prev) => {
|
||||
const updated = setFieldValue(prev, label, value);
|
||||
setDirty(true);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLocStringChange = useCallback((label: string, text: string) => {
|
||||
setData((prev) => {
|
||||
const updated = setLocStringValue(prev, label, text);
|
||||
setDirty(true);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const handleFieldChange = useCallback((label: string, value: unknown) => { setData((prev) => { setDirty(true); return setFieldValue(prev, label, value); }); }, []);
|
||||
const handleLocStringChange = useCallback((label: string, text: string) => { setData((prev) => { setDirty(true); return setLocStringValue(prev, label, text); }); }, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const newContent = JSON.stringify(data, null, 4) + "\n";
|
||||
await api.editor.writeFile(repo, filePath, newContent);
|
||||
setDirty(false);
|
||||
onSave?.(newContent);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
try { const newContent = JSON.stringify(data, null, 4) + "\n"; await api.editor.writeFile(repo, filePath, newContent); setDirty(false); onSave?.(newContent); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
|
||||
finally { setSaving(false); }
|
||||
}, [data, repo, filePath, onSave]);
|
||||
|
||||
const categoryFields = useMemo(() => {
|
||||
if (!schema) return [];
|
||||
return schema.fields.filter((f) => f.category === activeCategory && !f.hidden);
|
||||
}, [schema, activeCategory]);
|
||||
const categoryFields = useMemo(() => schema ? schema.fields.filter((f) => f.category === activeCategory && !f.hidden) : [], [schema, activeCategory]);
|
||||
|
||||
if (error && !schema) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p>
|
||||
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
|
||||
{onSwitchToRaw && (
|
||||
<button
|
||||
onClick={onSwitchToRaw}
|
||||
className="mt-3 rounded px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-surface)", color: "var(--forge-text)" }}
|
||||
>
|
||||
<button onClick={onSwitchToRaw} style={{ marginTop: "0.75rem", borderRadius: "0.375rem", padding: "0.5rem 1rem", fontSize: "var(--text-sm)", backgroundColor: "var(--forge-surface)", color: "var(--forge-text)", border: "1px solid var(--forge-border)", cursor: "pointer" }}>
|
||||
Open as Raw JSON
|
||||
</button>
|
||||
)}
|
||||
@@ -438,52 +337,29 @@ export function GffEditor({
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Loading schema...</p>
|
||||
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Loading schema...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", backgroundColor: "var(--forge-bg)" }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
||||
{schema.displayName} Editor
|
||||
</span>
|
||||
{dirty && (
|
||||
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
|
||||
(unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, padding: "0.625rem 1.25rem", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)" }}>{schema.displayName}</span>
|
||||
{dirty && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)", fontWeight: 500 }}>unsaved</span>}
|
||||
{error && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>{error}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{onSwitchToRaw && (
|
||||
<button
|
||||
onClick={onSwitchToRaw}
|
||||
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
Switch to Raw JSON
|
||||
<button onClick={onSwitchToRaw} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "1px solid var(--forge-border)", backgroundColor: "transparent", color: "var(--forge-text-secondary)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
||||
<Code2 size={13} /> Raw JSON
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
<button onClick={handleSave} disabled={!dirty || saving} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "none", backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)", fontSize: "var(--text-xs)", fontWeight: 600, cursor: dirty && !saving ? "pointer" : "not-allowed", opacity: dirty && !saving ? 1 : 0.4 }}>
|
||||
<Save size={13} /> {saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,40 +367,29 @@ export function GffEditor({
|
||||
{headerSlot}
|
||||
|
||||
{/* Category tabs */}
|
||||
<div
|
||||
className="flex shrink-0 gap-0 overflow-x-auto border-b"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
>
|
||||
<div style={{ display: "flex", flexShrink: 0, overflowX: "auto", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
|
||||
{schema.categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className="shrink-0 px-4 py-2 text-sm transition-colors"
|
||||
style={{
|
||||
color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||
borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
<button key={cat} onClick={() => setActiveCategory(cat)} style={{ flexShrink: 0, padding: "0.625rem 1.25rem", fontSize: "var(--text-sm)", fontWeight: activeCategory === cat ? 600 : 400, color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)", borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent", background: "none", border: "none", borderBottomStyle: "solid", cursor: "pointer", transition: "color 150ms ease-out" }}>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
|
||||
<div style={{ maxWidth: "42rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{categoryFields.map((field) => {
|
||||
const override = fieldOverrides?.get(field.label);
|
||||
if (override) {
|
||||
return (
|
||||
<div key={field.label}>
|
||||
<div key={field.label} style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--forge-border)" }}>
|
||||
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.label} title={field.description}>
|
||||
<div key={field.label} title={field.description} style={{ borderBottom: "1px solid var(--forge-border)" }}>
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={getFieldValue(data, field.label)}
|
||||
@@ -535,9 +400,7 @@ export function GffEditor({
|
||||
);
|
||||
})}
|
||||
{categoryFields.length === 0 && (
|
||||
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
No fields in this category
|
||||
</p>
|
||||
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>No fields in this category</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,22 +18,25 @@ interface ItemEditorProps {
|
||||
function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
|
||||
const num = typeof value === "number" ? value : 0;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{field.displayName}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={num}
|
||||
onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))}
|
||||
className="w-24 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "6rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
(baseitems.2da row)
|
||||
</span>
|
||||
</div>
|
||||
@@ -51,14 +54,14 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
|
||||
if (field.label !== "StackSize") return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
{[
|
||||
{ label: "StackSize", display: "Stack", value: stackSize, max: 99 },
|
||||
{ label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
|
||||
{ label: "Charges", display: "Charges", value: charges, max: 255 },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div key={item.label} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
{item.display}
|
||||
</label>
|
||||
<input
|
||||
@@ -67,10 +70,13 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
|
||||
min={0}
|
||||
max={item.max}
|
||||
onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))}
|
||||
className="w-24 rounded border px-2 py-1.5 text-sm"
|
||||
style={{
|
||||
width: "6rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.375rem 0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
/>
|
||||
@@ -89,18 +95,17 @@ function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
|
||||
{flags.map((flag) => {
|
||||
const val = getFieldValue(data, flag.label);
|
||||
const checked = typeof val === "number" ? val !== 0 : Boolean(val);
|
||||
return (
|
||||
<label key={flag.label} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<label key={flag.label} style={{ display: "flex", cursor: "pointer", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)}
|
||||
className="h-4 w-4 rounded"
|
||||
style={{ accentColor: "var(--forge-accent)" }}
|
||||
style={{ width: "1rem", height: "1rem", borderRadius: "0.25rem", accentColor: "var(--forge-accent)" }}
|
||||
/>
|
||||
<span style={{ color: "var(--forge-text)" }}>{flag.display}</span>
|
||||
</label>
|
||||
@@ -114,41 +119,34 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
|
||||
const list = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||
Item Properties
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
+ Add Property
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{list.map((prop, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded border px-3 py-2"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
padding: "0.5rem 0.75rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 font-mono text-xs" style={{ color: "var(--forge-text)" }}>
|
||||
<span style={{ flex: 1, fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)" }}>
|
||||
{typeof prop === "object" && prop !== null
|
||||
? JSON.stringify(prop).slice(0, 80)
|
||||
: String(prop)}
|
||||
</span>
|
||||
<button
|
||||
className="text-xs"
|
||||
style={{ color: "#ef4444" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<p className="py-2 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<p style={{ padding: "0.5rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)", margin: 0 }}>
|
||||
No item properties
|
||||
</p>
|
||||
)}
|
||||
@@ -185,10 +183,13 @@ export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: I
|
||||
|
||||
const headerSlot = (
|
||||
<div
|
||||
className="border-b px-4 py-3"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||
<h2 style={{ fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)", margin: 0 }}>
|
||||
{itemName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const bg = style.getPropertyValue("--forge-bg").trim();
|
||||
const fg = style.getPropertyValue("--forge-text").trim();
|
||||
const accent = style.getPropertyValue("--forge-accent").trim();
|
||||
const secondary = style.getPropertyValue("--forge-text-secondary").trim();
|
||||
const accentHover = style.getPropertyValue("--forge-accent-hover").trim();
|
||||
|
||||
const term = new XTerm({
|
||||
theme: {
|
||||
background: "#121212",
|
||||
foreground: "#f2f2f2",
|
||||
cursor: "#946200",
|
||||
selectionBackground: "#946200",
|
||||
selectionForeground: "#f2f2f2",
|
||||
black: "#121212",
|
||||
brightBlack: "#666666",
|
||||
white: "#f2f2f2",
|
||||
brightWhite: "#ffffff",
|
||||
yellow: "#946200",
|
||||
brightYellow: "#c48800",
|
||||
background: bg,
|
||||
foreground: fg,
|
||||
cursor: accent,
|
||||
selectionBackground: accent,
|
||||
selectionForeground: fg,
|
||||
black: bg,
|
||||
brightBlack: secondary,
|
||||
white: fg,
|
||||
brightWhite: fg,
|
||||
yellow: accent,
|
||||
brightYellow: accentHover,
|
||||
},
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
});
|
||||
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: "#121212" }}
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getLspClient, type LspStatus } from "../lib/lspClient.js";
|
||||
import type { editor } from "monaco-editor";
|
||||
|
||||
export function useLspClient(monaco: typeof import("monaco-editor") | null) {
|
||||
const [status, setStatus] = useState<LspStatus>("disconnected");
|
||||
const connectingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monaco || connectingRef.current) return;
|
||||
|
||||
const client = getLspClient();
|
||||
if (client.status === "ready") {
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
connectingRef.current = true;
|
||||
const unsub = client.onStatusChange(setStatus);
|
||||
|
||||
client.connect(monaco).catch((err) => {
|
||||
console.error("[LSP] Connection failed:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [monaco]);
|
||||
|
||||
return { lspClient: getLspClient(), status };
|
||||
}
|
||||
|
||||
export function useLspDocument(
|
||||
editorInstance: editor.IStandaloneCodeEditor | null,
|
||||
filePath: string,
|
||||
language: string,
|
||||
) {
|
||||
const prevPathRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorInstance) return;
|
||||
|
||||
const client = getLspClient();
|
||||
if (client.status !== "ready") return;
|
||||
|
||||
const model = editorInstance.getModel();
|
||||
if (!model) return;
|
||||
|
||||
if (prevPathRef.current && prevPathRef.current !== filePath) {
|
||||
const prevUri = model.uri;
|
||||
client.notifyDidClose(prevUri);
|
||||
}
|
||||
|
||||
client.notifyDidOpen(model.uri, language, model.getValue());
|
||||
prevPathRef.current = filePath;
|
||||
|
||||
const changeDisposable = model.onDidChangeContent(() => {
|
||||
client.notifyDidChange(model.uri, model.getValue());
|
||||
});
|
||||
|
||||
return () => {
|
||||
changeDisposable.dispose();
|
||||
client.notifyDidClose(model.uri);
|
||||
};
|
||||
}, [editorInstance, filePath, language]);
|
||||
}
|
||||
@@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Terminal } from "../components/terminal/Terminal";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import {
|
||||
Code2,
|
||||
Wrench,
|
||||
Hammer,
|
||||
Play,
|
||||
GitBranch,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Terminal as TerminalIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: "/editor", label: "Editor", icon: "\u270E" },
|
||||
{ path: "/toolset", label: "Toolset", icon: "\u2699" },
|
||||
{ path: "/build", label: "Build", icon: "\u2692" },
|
||||
{ path: "/server", label: "Server", icon: "\u25B6" },
|
||||
{ path: "/repos", label: "Repos", icon: "\u2387" },
|
||||
{ path: "/settings", label: "Settings", icon: "\u2318" },
|
||||
const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
|
||||
{ path: "/editor", label: "Editor", Icon: Code2 },
|
||||
{ path: "/toolset", label: "Toolset", Icon: Wrench },
|
||||
{ path: "/build", label: "Build", Icon: Hammer },
|
||||
{ path: "/server", label: "Server", Icon: Play },
|
||||
{ path: "/repos", label: "Repos", Icon: GitBranch },
|
||||
{ path: "/settings", label: "Settings", Icon: Settings },
|
||||
];
|
||||
|
||||
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const { subscribe } = useWebSocket();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
|
||||
|
||||
useEffect(() => {
|
||||
return subscribe("git:upstream-update", (event) => {
|
||||
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden", backgroundColor: "var(--forge-bg)" }}>
|
||||
{/* Left sidebar nav */}
|
||||
<nav
|
||||
className="flex shrink-0 flex-col"
|
||||
aria-label="Main navigation"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "56px",
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center py-3"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
|
||||
title="Dashboard"
|
||||
>
|
||||
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} />
|
||||
<img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
|
||||
</Link>
|
||||
|
||||
<div className="mt-2 flex flex-1 flex-col">
|
||||
<div style={{ marginTop: "0.25rem", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive =
|
||||
item.path === "/"
|
||||
@@ -115,20 +133,44 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className="relative flex flex-col items-center justify-center py-2.5 text-center transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderLeft: isActive
|
||||
? "3px solid var(--forge-accent)"
|
||||
: "3px solid transparent",
|
||||
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.625rem 0",
|
||||
position: "relative",
|
||||
textDecoration: "none",
|
||||
transition: "background-color 150ms, color 150ms",
|
||||
backgroundColor: isActive ? "var(--forge-accent-subtle)" : undefined,
|
||||
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? "var(--forge-accent-subtle)" : ""; }}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span>
|
||||
<item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
|
||||
{badge > 0 && (
|
||||
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[8px] font-bold text-black">
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "0.25rem",
|
||||
top: "0.25rem",
|
||||
display: "flex",
|
||||
height: "0.875rem",
|
||||
minWidth: "0.875rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "9999px",
|
||||
padding: "0 0.125rem",
|
||||
fontSize: "8px",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -139,69 +181,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.625rem 0",
|
||||
color: "var(--forge-text-secondary)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
transition: "background-color 150ms, color 150ms",
|
||||
}}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
|
||||
>
|
||||
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{theme === "dark" ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
|
||||
<header
|
||||
className="flex shrink-0 items-center gap-4 px-4 py-1.5"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{
|
||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
padding: "0.375rem 1rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
Layonara Forge
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebar && (
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
{sidebar && showSidebar && (
|
||||
<aside
|
||||
className="shrink-0 overflow-hidden"
|
||||
style={{
|
||||
width: "250px",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<main style={{ flex: 1, overflow: "hidden" }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setTerminalOpen((v) => !v)}
|
||||
className="flex shrink-0 items-center gap-1 px-3 py-0.5 text-xs transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderTop: "1px solid var(--forge-border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.375rem 0.75rem",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
borderTop: "1px solid var(--forge-border)",
|
||||
width: "100%",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 150ms",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
|
||||
>
|
||||
<span>{terminalOpen ? "\u25BC" : "\u25B2"}</span>
|
||||
<TerminalIcon size={12} />
|
||||
<span>Terminal</span>
|
||||
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||
</button>
|
||||
|
||||
{terminalOpen && (
|
||||
<div
|
||||
className="shrink-0 overflow-hidden"
|
||||
style={{
|
||||
height: "300px",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
borderTop: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
|
||||
export function SetupLayout() {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
backgroundImage: "linear-gradient(rgba(0,0,0,0.75), rgba(0,0,0,0.85)), url('/page-bg.jpg')",
|
||||
backgroundImage: "linear-gradient(oklch(15% 0.015 65 / 0.85), oklch(12% 0.01 65 / 0.92)), url('/page-bg.jpg')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
padding: "2rem",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl">
|
||||
<div style={{ width: "100%", maxWidth: "52rem", marginTop: "4vh" }}>
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h1
|
||||
className="mb-8 text-center text-3xl font-bold"
|
||||
style={{
|
||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontSize: "var(--text-2xl)",
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-accent)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Layonara Forge
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Development environment setup
|
||||
</p>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { api } from "../services/api";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import {
|
||||
Hammer,
|
||||
Package,
|
||||
Cpu,
|
||||
Play,
|
||||
Archive,
|
||||
Upload,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
type BuildStatus = "idle" | "building" | "success" | "failed";
|
||||
|
||||
@@ -11,15 +22,38 @@ interface BuildSectionState {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||
const colors: Record<BuildStatus, string> = {
|
||||
idle: "bg-gray-500/20 text-gray-400",
|
||||
building: "bg-yellow-500/20 text-yellow-400",
|
||||
success: "bg-green-500/20 text-green-400",
|
||||
failed: "bg-red-500/20 text-red-400",
|
||||
const styles: Record<BuildStatus, React.CSSProperties> = {
|
||||
idle: {
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
},
|
||||
building: {
|
||||
backgroundColor: "var(--forge-warning-bg)",
|
||||
color: "var(--forge-warning)",
|
||||
},
|
||||
success: {
|
||||
backgroundColor: "var(--forge-success-bg)",
|
||||
color: "var(--forge-success)",
|
||||
},
|
||||
failed: {
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
color: "var(--forge-danger)",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
|
||||
<span
|
||||
style={{
|
||||
...styles[status],
|
||||
borderRadius: "9999px",
|
||||
padding: "0.125rem 0.625rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-mono)",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.03em",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
@@ -43,31 +77,47 @@ function BuildOutput({
|
||||
}, [lines, collapsed]);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem 0",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
|
||||
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
<span>Output ({lines.length} lines)</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="mt-1 max-h-64 overflow-auto rounded p-3"
|
||||
style={{
|
||||
backgroundColor: "#0d1117",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.5",
|
||||
marginTop: "0.5rem",
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.875rem 1rem",
|
||||
backgroundColor: "var(--forge-log-bg)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
|
||||
<span style={{ color: "var(--forge-text-secondary)", fontStyle: "italic" }}>
|
||||
No output yet
|
||||
</span>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} style={{ color: "#c9d1d9" }}>
|
||||
<div key={i} style={{ color: "var(--forge-log-text)" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
@@ -83,27 +133,29 @@ function ActionButton({
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "default" | "primary" | "warning";
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
const styles = {
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
default: {
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
borderColor: "var(--forge-accent)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
color: "var(--forge-accent-text)",
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: "#854d0e",
|
||||
borderColor: "#a16207",
|
||||
color: "#fef08a",
|
||||
backgroundColor: "var(--forge-warning-bg)",
|
||||
border: "1px solid var(--forge-warning-border)",
|
||||
color: "var(--forge-warning)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,9 +163,22 @@ function ActionButton({
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={styles[variant]}
|
||||
style={{
|
||||
...variantStyles[variant],
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
transition: "opacity 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -191,41 +256,103 @@ export function Build() {
|
||||
[],
|
||||
);
|
||||
|
||||
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
|
||||
const isBuilding =
|
||||
module.status === "building" || haks.status === "building" || nwnx.status === "building";
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.25rem",
|
||||
marginBottom: "1rem",
|
||||
};
|
||||
|
||||
const sectionHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "1rem",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
textTransform: "uppercase",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-heading)",
|
||||
};
|
||||
|
||||
const buttonRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
||||
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
||||
Build Pipeline
|
||||
</h2>
|
||||
|
||||
{/* Module Section */}
|
||||
<section
|
||||
className="mb-6 rounded-lg border p-4"
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "1.5rem",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Module</h3>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontSize: "var(--text-xl)",
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Build Pipeline
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
margin: "0.375rem 0 0 0",
|
||||
}}
|
||||
>
|
||||
Compile, pack, and deploy module resources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Module Section */}
|
||||
<section style={cardStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<div style={sectionTitleStyle}>
|
||||
<Hammer size={14} />
|
||||
<span>Module</span>
|
||||
</div>
|
||||
<StatusBadge status={module.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div style={buttonRowStyle}>
|
||||
<ActionButton
|
||||
label="Compile"
|
||||
variant="primary"
|
||||
icon={<Play size={14} />}
|
||||
disabled={isBuilding}
|
||||
onClick={() => handleAction(() => api.build.compileModule(), "module")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Pack Module"
|
||||
icon={<Archive size={14} />}
|
||||
disabled={isBuilding}
|
||||
onClick={() => handleAction(() => api.build.packModule(), "module")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Deploy to Server"
|
||||
variant="warning"
|
||||
icon={<Upload size={14} />}
|
||||
disabled={isBuilding}
|
||||
onClick={() => handleAction(() => api.build.deploy(), "module")}
|
||||
/>
|
||||
@@ -238,21 +365,19 @@ export function Build() {
|
||||
</section>
|
||||
|
||||
{/* Haks Section */}
|
||||
<section
|
||||
className="mb-6 rounded-lg border p-4"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Haks</h3>
|
||||
<section style={cardStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<div style={sectionTitleStyle}>
|
||||
<Package size={14} />
|
||||
<span>Haks</span>
|
||||
</div>
|
||||
<StatusBadge status={haks.status} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div style={buttonRowStyle}>
|
||||
<ActionButton
|
||||
label="Build Haks"
|
||||
variant="primary"
|
||||
icon={<Play size={14} />}
|
||||
disabled={isBuilding}
|
||||
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
|
||||
/>
|
||||
@@ -265,38 +390,49 @@ export function Build() {
|
||||
</section>
|
||||
|
||||
{/* NWNX Section */}
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
<section style={cardStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<div style={sectionTitleStyle}>
|
||||
<Cpu size={14} />
|
||||
<span>NWNX</span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
fontWeight: 400,
|
||||
textTransform: "none",
|
||||
opacity: 0.6,
|
||||
letterSpacing: "normal",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
|
||||
</h3>
|
||||
(Advanced)
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={nwnx.status} />
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
|
||||
<ActionButton
|
||||
label="Build All"
|
||||
variant="primary"
|
||||
icon={<Play size={14} />}
|
||||
disabled={isBuilding}
|
||||
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={nwnxTarget}
|
||||
onChange={(e) => setNwnxTarget(e.target.value)}
|
||||
placeholder="Target (e.g. Item, Creature)"
|
||||
className="rounded border px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
outline: "none",
|
||||
flex: "0 1 16rem",
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
@@ -308,10 +444,18 @@ export function Build() {
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-xs"
|
||||
style={{ color: "#f59e0b" }}
|
||||
style={{
|
||||
marginTop: "0.75rem",
|
||||
marginBottom: 0,
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-warning)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
}}
|
||||
>
|
||||
⚠ Requires server restart to pick up changes
|
||||
<AlertTriangle size={12} />
|
||||
Requires server restart to pick up changes
|
||||
</p>
|
||||
<BuildOutput
|
||||
lines={nwnx.output}
|
||||
|
||||
@@ -1,21 +1,68 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../services/api";
|
||||
import { Server, GitBranch, Hammer, Code2, Terminal, Database, ArrowRight } from "lucide-react";
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.25rem",
|
||||
};
|
||||
|
||||
const cardTitle: React.CSSProperties = {
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
};
|
||||
|
||||
const statusDot = (color: string): React.CSSProperties => ({
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
transition: "background-color 150ms",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "running"
|
||||
? "#4ade80"
|
||||
: status === "stopped"
|
||||
? "#f87171"
|
||||
: "#fbbf24";
|
||||
? "var(--forge-success)"
|
||||
: status === "stopped" || status === "exited" || status === "not created"
|
||||
? "var(--forge-danger)"
|
||||
: "var(--forge-warning)";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span style={statusDot(color)} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
@@ -57,49 +104,36 @@ function ServerCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = status.nwserver === "running";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
Server Status
|
||||
</h3>
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
NWServer
|
||||
</span>
|
||||
<div style={card}>
|
||||
<h3 style={cardTitle}><Server size={14} /> Server</h3>
|
||||
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
|
||||
<StatusBadge status={status.nwserver} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
MariaDB
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
|
||||
<StatusBadge status={status.mariadb} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)",
|
||||
color: status.nwserver === "running" ? "#fca5a5" : "#000",
|
||||
...primaryBtn,
|
||||
backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
|
||||
color: isRunning ? "var(--forge-danger)" : "var(--forge-accent-text)",
|
||||
border: isRunning ? "1px solid var(--forge-danger-border)" : "none",
|
||||
opacity: loading ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.opacity = "0.85"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = loading ? "0.5" : "1"; }}
|
||||
>
|
||||
{loading
|
||||
? "..."
|
||||
: status.nwserver === "running"
|
||||
? "Stop Server"
|
||||
: "Start Server"}
|
||||
{loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,39 +154,32 @@ function ReposSummary() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
Repositories
|
||||
</h3>
|
||||
<div className="mt-4 space-y-2">
|
||||
{repos.map((repo) => {
|
||||
<div style={card}>
|
||||
<h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
|
||||
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
|
||||
{repos.map((repo, i) => {
|
||||
const s = repoStatus[repo];
|
||||
const branch = (s?.branch as string) || "\u2014";
|
||||
const clean = s?.clean !== false;
|
||||
return (
|
||||
<div
|
||||
key={repo}
|
||||
className="flex items-center justify-between rounded p-3"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.5rem 0",
|
||||
borderTop: i > 0 ? "1px solid var(--forge-border)" : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||
{repo}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{branch}
|
||||
</span>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
|
||||
title={clean ? "Clean" : "Uncommitted changes"}
|
||||
/>
|
||||
<span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -166,41 +193,38 @@ function QuickActions() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const actions = [
|
||||
{ label: "Build Module", onClick: () => navigate("/build") },
|
||||
{ label: "Build Haks", onClick: () => navigate("/build") },
|
||||
{ label: "Open Editor", onClick: () => navigate("/editor") },
|
||||
{
|
||||
label: "Open Terminal",
|
||||
onClick: () => {
|
||||
/* terminal is toggled from IDELayout via Ctrl+` */
|
||||
navigate("/editor");
|
||||
},
|
||||
},
|
||||
{ label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
|
||||
{ label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
|
||||
{ label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<div style={card}>
|
||||
<h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
|
||||
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
onClick={a.onClick}
|
||||
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.625rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
background: "none",
|
||||
color: "var(--forge-text)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
textAlign: "left" as const,
|
||||
transition: "background-color 150ms, border-color 150ms",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; e.currentTarget.style.borderColor = "var(--forge-text-secondary)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; e.currentTarget.style.borderColor = "var(--forge-border)"; }}
|
||||
>
|
||||
<a.Icon size={15} style={{ color: "var(--forge-text-secondary)" }} />
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -211,24 +235,16 @@ function QuickActions() {
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="mb-8 text-center">
|
||||
<h1
|
||||
className="text-3xl font-bold"
|
||||
style={{
|
||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
Layonara Forge
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
|
||||
<div style={{ maxWidth: "56rem", margin: "0 auto" }}>
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
NWN Development Environment
|
||||
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Server, repositories, and quick actions
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
|
||||
<ServerCard />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<ReposSummary />
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
|
||||
import { CreatureEditor } from "../components/gff/CreatureEditor";
|
||||
import { AreaEditor } from "../components/gff/AreaEditor";
|
||||
import { DialogEditor } from "../components/gff/DialogEditor";
|
||||
import { FileCode, Code2, Eye } from "lucide-react";
|
||||
|
||||
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
|
||||
|
||||
@@ -36,9 +37,10 @@ function filePathFromTabKey(tabKey: string): string {
|
||||
|
||||
interface EditorProps {
|
||||
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
|
||||
workspacePath?: string;
|
||||
}
|
||||
|
||||
export function Editor({ editorState }: EditorProps) {
|
||||
export function Editor({ editorState, workspacePath }: EditorProps) {
|
||||
const {
|
||||
openTabs,
|
||||
activeTab,
|
||||
@@ -50,7 +52,6 @@ export function Editor({ editorState }: EditorProps) {
|
||||
markClean,
|
||||
} = editorState;
|
||||
|
||||
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
|
||||
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
|
||||
|
||||
const tabs = useMemo(
|
||||
@@ -105,8 +106,28 @@ export function Editor({ editorState }: EditorProps) {
|
||||
const renderEditor = () => {
|
||||
if (!activeTab) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<FileCode
|
||||
size={48}
|
||||
style={{ color: "var(--forge-text-secondary)", opacity: 0.4 }}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontFamily: "var(--font-heading)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Open a file from the File Explorer to start editing
|
||||
</p>
|
||||
</div>
|
||||
@@ -139,27 +160,47 @@ export function Editor({ editorState }: EditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{isActiveGff && activeMode === "raw" && (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-end border-b px-4 py-1"
|
||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexShrink: 0,
|
||||
padding: "4px 16px",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleSwitchToVisual}
|
||||
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 12px",
|
||||
borderRadius: 4,
|
||||
border: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Eye size={13} />
|
||||
Switch to Visual Editor
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<MonacoEditor
|
||||
key={activeTab}
|
||||
filePath={activeFilePath}
|
||||
content={activeContent}
|
||||
onChange={handleChange}
|
||||
workspacePath={workspacePath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,14 +208,21 @@ export function Editor({ editorState }: EditorProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
}}
|
||||
>
|
||||
<EditorTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onSelect={selectTab}
|
||||
onClose={closeFile}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
{renderEditor()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../services/api";
|
||||
import { CommitDialog } from "../components/CommitDialog";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
Download,
|
||||
Upload,
|
||||
Copy,
|
||||
FileCode,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface RepoStatus {
|
||||
modified: string[];
|
||||
@@ -26,6 +38,54 @@ interface PrForm {
|
||||
body: string;
|
||||
}
|
||||
|
||||
const badge = (
|
||||
bg: string,
|
||||
fg: string,
|
||||
extra?: React.CSSProperties,
|
||||
): React.CSSProperties => ({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.3rem",
|
||||
padding: "0.15rem 0.55rem",
|
||||
borderRadius: "9999px",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
whiteSpace: "nowrap",
|
||||
...extra,
|
||||
});
|
||||
|
||||
const btnBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
border: "1px solid",
|
||||
cursor: "pointer",
|
||||
transition: "opacity 0.15s",
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const outlineBtn: React.CSSProperties = {
|
||||
...btnBase,
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
};
|
||||
|
||||
const accentBtn: React.CSSProperties = {
|
||||
...btnBase,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
borderColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
};
|
||||
|
||||
export function Repos() {
|
||||
const [repos, setRepos] = useState<RepoInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -39,7 +99,7 @@ export function Repos() {
|
||||
|
||||
const fetchRepos = useCallback(async () => {
|
||||
try {
|
||||
const data = (await api.repos.list()) as RepoInfo[];
|
||||
const data = (await api.repos.list()) as unknown as RepoInfo[];
|
||||
setRepos(data);
|
||||
} catch {
|
||||
setError("Failed to load repos");
|
||||
@@ -133,106 +193,247 @@ export function Repos() {
|
||||
const isDirty = (status?: RepoStatus) =>
|
||||
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
|
||||
|
||||
const disabledStyle = (disabled: boolean | undefined): React.CSSProperties =>
|
||||
disabled ? { opacity: 0.45, pointerEvents: "none" } : {};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
Loading repositories...
|
||||
<div style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: "var(--text-base)",
|
||||
}}>
|
||||
Loading repositories…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
||||
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
||||
<div style={{
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "1.75rem 2rem",
|
||||
color: "var(--forge-text)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
{/* Page heading */}
|
||||
<div style={{ marginBottom: "1.75rem" }}>
|
||||
<h2 style={{
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontSize: "var(--text-xl)",
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-text)",
|
||||
margin: 0,
|
||||
}}>
|
||||
Repositories
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: "0.3rem 0 0",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}>
|
||||
Clone, sync, and manage your Layonara repos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
padding: "0.65rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--forge-danger-border)",
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
color: "var(--forge-danger)",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}>
|
||||
<AlertCircle size={16} />
|
||||
<span style={{ flex: 1 }}>{error}</span>
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--forge-danger)",
|
||||
cursor: "pointer",
|
||||
padding: "0.2rem",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR success banner */}
|
||||
{prResult && (
|
||||
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400">
|
||||
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a>
|
||||
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
padding: "0.65rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "var(--forge-success-bg)",
|
||||
color: "var(--forge-success)",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}>
|
||||
<CheckCircle size={16} />
|
||||
<span style={{ flex: 1 }}>
|
||||
PR created:{" "}
|
||||
<a
|
||||
href={prResult.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "inherit", textDecoration: "underline" }}
|
||||
>
|
||||
{prResult.url}
|
||||
</a>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPrResult(null)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--forge-success)",
|
||||
cursor: "pointer",
|
||||
padding: "0.2rem",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Repo cards */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{repos.map((repo) => (
|
||||
<section
|
||||
key={repo.name}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.25rem",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold">{repo.name}</h3>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{/* Card header */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "0.85rem",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-heading)",
|
||||
}}>
|
||||
{repo.name}
|
||||
</h3>
|
||||
|
||||
<span style={badge("var(--forge-accent-subtle)", "var(--forge-accent)")}>
|
||||
<GitBranch size={12} />
|
||||
{repo.branch}
|
||||
</span>
|
||||
|
||||
{repo.cloned && repo.status && (
|
||||
<>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${isDirty(repo.status) ? "bg-yellow-500/20 text-yellow-400" : "bg-green-500/20 text-green-400"}`}>
|
||||
{isDirty(repo.status) ? "dirty" : "clean"}
|
||||
{isDirty(repo.status) ? (
|
||||
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
|
||||
dirty
|
||||
</span>
|
||||
{repo.status.behind > 0 && (
|
||||
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
|
||||
{repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream
|
||||
) : (
|
||||
<span style={badge("var(--forge-success-bg)", "var(--forge-success)")}>
|
||||
<CheckCircle size={11} />
|
||||
clean
|
||||
</span>
|
||||
)}
|
||||
|
||||
{repo.status.behind > 0 && (
|
||||
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
|
||||
<Download size={11} />
|
||||
{repo.status.behind} behind
|
||||
</span>
|
||||
)}
|
||||
|
||||
{repo.status.ahead > 0 && (
|
||||
<span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-xs font-medium text-blue-400">
|
||||
<span style={badge("var(--forge-info-bg)", "var(--forge-info)")}>
|
||||
<Upload size={11} />
|
||||
{repo.status.ahead} ahead
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
|
||||
<span style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}>
|
||||
{repo.upstream}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!repo.cloned ? (
|
||||
<button
|
||||
onClick={() => handleClone(repo.name)}
|
||||
disabled={actionLoading[repo.name]}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
||||
style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
|
||||
>
|
||||
{actionLoading[repo.name] ? "Cloning..." : "Clone"}
|
||||
<Copy size={14} />
|
||||
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
|
||||
<button
|
||||
onClick={() => handlePull(repo.name)}
|
||||
disabled={actionLoading[repo.name]}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
|
||||
>
|
||||
<Download size={14} />
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePush(repo.name)}
|
||||
disabled={actionLoading[repo.name] || !repo.status?.ahead}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
>
|
||||
Push
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCommitRepo(repo.name)}
|
||||
disabled={!isDirty(repo.status)}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
||||
style={{
|
||||
...accentBtn,
|
||||
...disabledStyle(!isDirty(repo.status)),
|
||||
}}
|
||||
>
|
||||
<GitCommit size={14} />
|
||||
Commit
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handlePush(repo.name)}
|
||||
disabled={actionLoading[repo.name] || !repo.status?.ahead}
|
||||
style={{
|
||||
...outlineBtn,
|
||||
...disabledStyle(actionLoading[repo.name] || !repo.status?.ahead),
|
||||
}}
|
||||
>
|
||||
<Upload size={14} />
|
||||
Push
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setPrForm({
|
||||
@@ -241,39 +442,104 @@ export function Repos() {
|
||||
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
|
||||
})
|
||||
}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity"
|
||||
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
|
||||
style={outlineBtn}
|
||||
>
|
||||
<GitPullRequest size={14} />
|
||||
Create PR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Changed files list */}
|
||||
{repo.status && isDirty(repo.status) && (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div style={{
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.5rem",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
}}>
|
||||
<FileCode size={13} style={{ color: "var(--forge-text-secondary)" }} />
|
||||
<span style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
color: "var(--forge-text-secondary)",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.04em",
|
||||
}}>
|
||||
Changes
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
|
||||
<div>
|
||||
{repo.status.modified.map((f) => (
|
||||
<div
|
||||
key={f}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 text-xs transition-colors hover:bg-white/5"
|
||||
onClick={() => handleShowDiff(repo.name, f)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-yellow-400">M</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-warning)",
|
||||
width: "1rem",
|
||||
textAlign: "center",
|
||||
}}>M</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
{repo.status.staged.map((f) => (
|
||||
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
|
||||
<span className="font-medium text-green-400">S</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
|
||||
<div
|
||||
key={f}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-success)",
|
||||
width: "1rem",
|
||||
textAlign: "center",
|
||||
}}>S</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
{repo.status.untracked.map((f) => (
|
||||
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
|
||||
<span className="font-medium text-gray-400">?</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
|
||||
<div
|
||||
key={f}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-text-secondary)",
|
||||
width: "1rem",
|
||||
textAlign: "center",
|
||||
}}>?</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -285,6 +551,7 @@ export function Repos() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Commit dialog */}
|
||||
{commitRepo && (
|
||||
<CommitDialog
|
||||
repo={commitRepo}
|
||||
@@ -296,71 +563,188 @@ export function Repos() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PR form modal */}
|
||||
{prForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={() => setPrForm(null)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
|
||||
Create Pull Request — {prForm.repo}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "32rem",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1.25rem",
|
||||
}}>
|
||||
<GitPullRequest size={18} style={{ color: "var(--forge-accent)" }} />
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-heading)",
|
||||
color: "var(--forge-accent)",
|
||||
}}>
|
||||
Create Pull Request
|
||||
</h3>
|
||||
<span style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
marginLeft: "0.25rem",
|
||||
}}>
|
||||
— {prForm.repo}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={prForm.title}
|
||||
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
|
||||
placeholder="PR Title"
|
||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxSizing: "border-box",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={prForm.body}
|
||||
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
|
||||
rows={8}
|
||||
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
|
||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
marginBottom: "1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
color: "var(--forge-text)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
boxSizing: "border-box",
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPrForm(null)}
|
||||
className="rounded border px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
||||
>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||
<button onClick={() => setPrForm(null)} style={outlineBtn}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreatePr}
|
||||
disabled={!prForm.title.trim() || actionLoading.pr}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
||||
style={{
|
||||
...accentBtn,
|
||||
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
|
||||
}}
|
||||
>
|
||||
{actionLoading.pr ? "Creating..." : "Submit PR"}
|
||||
<GitPullRequest size={14} />
|
||||
{actionLoading.pr ? "Creating…" : "Submit PR"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff modal */}
|
||||
{diffView && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}>
|
||||
<div
|
||||
className="h-3/4 w-3/4 overflow-auto rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={() => setDiffView(null)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
|
||||
Diff — {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "75%",
|
||||
height: "75%",
|
||||
overflowY: "auto",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "1rem",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<FileCode size={18} style={{ color: "var(--forge-accent)" }} />
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-heading)",
|
||||
color: "var(--forge-accent)",
|
||||
}}>
|
||||
{diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
|
||||
</h3>
|
||||
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDiffView(null)}
|
||||
style={{
|
||||
...outlineBtn,
|
||||
padding: "0.3rem 0.6rem",
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
className="whitespace-pre-wrap text-xs"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }}
|
||||
>
|
||||
|
||||
<pre style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "var(--forge-log-bg)",
|
||||
color: "var(--forge-log-text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
whiteSpace: "pre-wrap",
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
{diffView.diff || "No changes"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,152 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
|
||||
import { SimpleEditor } from "../components/editor/SimpleEditor";
|
||||
import { api } from "../services/api";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import {
|
||||
Server as ServerIcon,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
FileCode,
|
||||
ScrollText,
|
||||
Database,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
type ServerState = "running" | "exited" | "not created" | string;
|
||||
|
||||
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
|
||||
const color =
|
||||
const dotColor =
|
||||
state === "running"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
? "var(--forge-success)"
|
||||
: state === "exited"
|
||||
? "bg-red-500/20 text-red-400"
|
||||
: "bg-gray-500/20 text-gray-400";
|
||||
? "var(--forge-danger)"
|
||||
: "var(--forge-warning)";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
||||
{label}:
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dotColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{state}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
bg,
|
||||
bgHover,
|
||||
border,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
bg: string;
|
||||
bgHover: string;
|
||||
border: string;
|
||||
color: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
padding: "0.4rem 0.85rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
borderRadius: "0.5rem",
|
||||
border: `1px solid ${border}`,
|
||||
backgroundColor: hovered && !disabled ? bgHover : bg,
|
||||
color,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
transition: "background-color 0.15s, opacity 0.15s",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-heading)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlsPanel() {
|
||||
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
|
||||
nwserver: "unknown",
|
||||
@@ -63,49 +185,75 @@ function ControlsPanel() {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.25rem",
|
||||
}}
|
||||
>
|
||||
<SectionHeader icon={<ServerIcon size={16} />} label="Server Controls" />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "1.25rem",
|
||||
marginBottom: "1.25rem",
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-3 text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||
Server Controls
|
||||
</h3>
|
||||
<div className="mb-4 flex gap-4">
|
||||
<StatusBadge label="NWN Server" state={status.nwserver} />
|
||||
<StatusBadge label="MariaDB" state={status.mariadb} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["start", "stop", "restart", "config"] as const).map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
onClick={() => handleAction(action)}
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
<HoverButton
|
||||
onClick={() => handleAction("start")}
|
||||
disabled={loading !== null}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor:
|
||||
action === "start"
|
||||
? "var(--forge-accent)"
|
||||
: action === "stop"
|
||||
? "#991b1b"
|
||||
: "var(--forge-surface)",
|
||||
borderColor:
|
||||
action === "start"
|
||||
? "var(--forge-accent)"
|
||||
: action === "stop"
|
||||
? "#dc2626"
|
||||
: "var(--forge-border)",
|
||||
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)",
|
||||
}}
|
||||
bg="var(--forge-accent)"
|
||||
bgHover="var(--forge-accent-hover)"
|
||||
border="var(--forge-accent)"
|
||||
color="var(--forge-accent-text)"
|
||||
>
|
||||
{loading === action
|
||||
? "..."
|
||||
: action === "config"
|
||||
? "Generate Config"
|
||||
: action.charAt(0).toUpperCase() + action.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
<Play size={14} />
|
||||
{loading === "start" ? "Starting..." : "Start"}
|
||||
</HoverButton>
|
||||
|
||||
<HoverButton
|
||||
onClick={() => handleAction("stop")}
|
||||
disabled={loading !== null}
|
||||
bg="var(--forge-danger-bg)"
|
||||
bgHover="var(--forge-danger-border)"
|
||||
border="var(--forge-danger-border)"
|
||||
color="var(--forge-danger)"
|
||||
>
|
||||
<Square size={14} />
|
||||
{loading === "stop" ? "Stopping..." : "Stop"}
|
||||
</HoverButton>
|
||||
|
||||
<HoverButton
|
||||
onClick={() => handleAction("restart")}
|
||||
disabled={loading !== null}
|
||||
bg="transparent"
|
||||
bgHover="var(--forge-surface-raised)"
|
||||
border="var(--forge-border)"
|
||||
color="var(--forge-text)"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{loading === "restart" ? "Restarting..." : "Restart"}
|
||||
</HoverButton>
|
||||
|
||||
<HoverButton
|
||||
onClick={() => handleAction("config")}
|
||||
disabled={loading !== null}
|
||||
bg="transparent"
|
||||
bgHover="var(--forge-surface-raised)"
|
||||
border="var(--forge-border)"
|
||||
color="var(--forge-text)"
|
||||
>
|
||||
<FileCode size={14} />
|
||||
{loading === "config" ? "Generating..." : "Generate Config"}
|
||||
</HoverButton>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -141,65 +289,119 @@ function LogViewer() {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
height: "350px",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 px-4 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem 1.25rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
|
||||
<ScrollText size={16} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-heading)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||
Server Logs
|
||||
</h3>
|
||||
<div className="flex-1" />
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={13}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
color: "var(--forge-text-secondary)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter logs..."
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.3rem 0.5rem 0.3rem 1.75rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--forge-text)",
|
||||
width: "200px",
|
||||
width: 180,
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
</div>
|
||||
|
||||
<HoverButton
|
||||
onClick={() => setAutoScroll((v) => !v)}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: autoScroll ? "var(--forge-accent)" : "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: autoScroll ? "#fff" : "var(--forge-text-secondary)",
|
||||
}}
|
||||
bg={autoScroll ? "var(--forge-accent)" : "var(--forge-bg)"}
|
||||
bgHover={
|
||||
autoScroll ? "var(--forge-accent-hover)" : "var(--forge-surface-raised)"
|
||||
}
|
||||
border={autoScroll ? "var(--forge-accent)" : "var(--forge-border)"}
|
||||
color={
|
||||
autoScroll
|
||||
? "var(--forge-accent-text)"
|
||||
: "var(--forge-text-secondary)"
|
||||
}
|
||||
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.6rem" }}
|
||||
>
|
||||
Auto-scroll
|
||||
</button>
|
||||
<button
|
||||
</HoverButton>
|
||||
|
||||
<HoverButton
|
||||
onClick={() => setLines([])}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
bg="var(--forge-bg)"
|
||||
bgHover="var(--forge-surface-raised)"
|
||||
border="var(--forge-border)"
|
||||
color="var(--forge-text-secondary)"
|
||||
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.5rem" }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Clear
|
||||
</button>
|
||||
</HoverButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto p-3"
|
||||
style={{
|
||||
backgroundColor: "#0d1117",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.5",
|
||||
backgroundColor: "var(--forge-log-bg)",
|
||||
color: "var(--forge-log-text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
padding: "0.75rem 1rem",
|
||||
overflowY: "auto",
|
||||
height: 350,
|
||||
borderRadius: "0 0 0.75rem 0.75rem",
|
||||
}}
|
||||
>
|
||||
{filteredLines.length === 0 ? (
|
||||
@@ -208,7 +410,7 @@ function LogViewer() {
|
||||
</span>
|
||||
) : (
|
||||
filteredLines.map((line, i) => (
|
||||
<div key={i} style={{ color: "#c9d1d9" }}>
|
||||
<div key={i} style={{ color: "var(--forge-log-text)" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
@@ -245,31 +447,57 @@ function SQLConsole() {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 px-4 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem 1.25rem",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
|
||||
<Database size={16} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-heading)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||
SQL Console
|
||||
</h3>
|
||||
<div className="flex-1" />
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{history.length > 0 && (
|
||||
<select
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value=""
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
borderColor: "var(--forge-border)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.3rem 0.5rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
maxWidth: "200px",
|
||||
maxWidth: 200,
|
||||
outline: "none",
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
History ({history.length})
|
||||
@@ -281,32 +509,30 @@ function SQLConsole() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
|
||||
<HoverButton
|
||||
onClick={execute}
|
||||
disabled={loading || !query.trim()}
|
||||
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
borderColor: "var(--forge-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
bg="var(--forge-accent)"
|
||||
bgHover="var(--forge-accent-hover)"
|
||||
border="var(--forge-accent)"
|
||||
color="var(--forge-accent-text)"
|
||||
>
|
||||
<Play size={14} />
|
||||
{loading ? "Running..." : "Execute"}
|
||||
</button>
|
||||
</HoverButton>
|
||||
</div>
|
||||
|
||||
<div style={{ height: "100px" }}>
|
||||
<ReactMonacoEditor
|
||||
<div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
|
||||
<SimpleEditor
|
||||
value={query}
|
||||
language="sql"
|
||||
theme="vs-dark"
|
||||
onChange={(v) => setQuery(v ?? "")}
|
||||
onChange={(v) => setQuery(v)}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
wordWrap: "on",
|
||||
padding: { top: 4, bottom: 4 },
|
||||
renderLineHighlight: "none",
|
||||
@@ -319,35 +545,55 @@ function SQLConsole() {
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="px-4 py-2 text-sm"
|
||||
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
padding: "0.75rem 1.25rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-danger)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className="max-h-64 overflow-auto"
|
||||
style={{ borderTop: "1px solid var(--forge-border)" }}
|
||||
>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
{result.columns.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.25rem",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Query executed successfully (no results)
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs">
|
||||
<div style={{ maxHeight: 280, overflowY: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "var(--text-xs)",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{result.columns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="sticky top-0 px-3 py-2 font-medium"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontWeight: 600,
|
||||
textAlign: "left",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-accent)",
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{col}
|
||||
@@ -359,7 +605,6 @@ function SQLConsole() {
|
||||
{result.rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
@@ -367,10 +612,10 @@ function SQLConsole() {
|
||||
{result.columns.map((col) => (
|
||||
<td
|
||||
key={col}
|
||||
className="px-3 py-1.5"
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
color: "var(--forge-text)",
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{row[col]}
|
||||
@@ -380,10 +625,15 @@ function SQLConsole() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-3 py-1 text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
padding: "0.4rem 1rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
borderTop: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
@@ -395,11 +645,45 @@ function SQLConsole() {
|
||||
|
||||
export function Server() {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
||||
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "1.5rem",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontSize: "var(--text-xl)",
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-accent)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Server Management
|
||||
</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<p
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
margin: "0.25rem 0 0",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
Control server processes, view logs, and query the database
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.25rem",
|
||||
}}
|
||||
>
|
||||
<ControlsPanel />
|
||||
<LogViewer />
|
||||
<SQLConsole />
|
||||
|
||||
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../services/api";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import {
|
||||
Key,
|
||||
Sun,
|
||||
Moon,
|
||||
FolderOpen,
|
||||
Container,
|
||||
Keyboard,
|
||||
Info,
|
||||
RotateCcw,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
|
||||
const sectionCard: React.CSSProperties = {
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "1.25rem",
|
||||
};
|
||||
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--forge-text-secondary)",
|
||||
margin: "0 0 1rem 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
};
|
||||
|
||||
const fieldLabel: React.CSSProperties = {
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
color: "var(--forge-text-secondary)",
|
||||
margin: "0 0 0.25rem 0",
|
||||
};
|
||||
|
||||
const fieldValue: React.CSSProperties = {
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text)",
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
border: "none",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.4rem 0.875rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const ghostBtn: React.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
padding: "0.4rem 0.625rem",
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
const listRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.625rem 0.875rem",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
};
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-4 text-sm font-semibold" style={{ color: "var(--forge-accent)" }}>
|
||||
{title}
|
||||
</h3>
|
||||
<div style={sectionCard}>
|
||||
<h3 style={sectionTitle}>{icon} {title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const currentPat = (config.githubPat as string) || "";
|
||||
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set";
|
||||
const hasPat = Boolean(config.githubPat && config.githubPat !== "***");
|
||||
const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="GitHub">
|
||||
<div className="space-y-3">
|
||||
<Section title="Gitea Token" icon={<Key size={14} />}>
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
Personal Access Token
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
{masked}
|
||||
</p>
|
||||
<p style={fieldLabel}>Personal Access Token</p>
|
||||
<p style={fieldValue}>{masked}</p>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
{!editing ? (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
||||
>
|
||||
Update PAT
|
||||
<button onClick={() => setEditing(true)} style={primaryBtn}>
|
||||
Update Token
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<input
|
||||
type="password"
|
||||
value={pat}
|
||||
onChange={(e) => setPat(e.target.value)}
|
||||
placeholder="ghp_..."
|
||||
className="flex-1 rounded px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
placeholder="Paste token"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!pat || saving}
|
||||
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
||||
style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
|
||||
>
|
||||
{saving ? "..." : "Save"}
|
||||
{saving ? "Saving\u2026" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setPat("");
|
||||
}}
|
||||
className="rounded px-3 py-1.5 text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
>
|
||||
<button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{msg && (
|
||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -118,16 +167,17 @@ function ThemeSection() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Section title="Theme">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||
{theme === "dark" ? "Dark" : "Light"} Mode
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
||||
>
|
||||
</p>
|
||||
<p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={toggleTheme} style={primaryBtn}>
|
||||
Switch to {theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,25 +185,100 @@ function ThemeSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function PathsSection({ config }: { config: Record<string, unknown> }) {
|
||||
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
|
||||
return (
|
||||
<Section title="Paths">
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "2.5rem",
|
||||
alignSelf: "stretch",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
background: "none",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
padding: "0.5rem 0.75rem",
|
||||
color: "var(--forge-text)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathsSection({ config, onUpdate }: { config: Record<string, unknown>; onUpdate: () => void }) {
|
||||
const [wsPath, setWsPath] = useState((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
|
||||
const [nwnPath, setNwnPath] = useState((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setWsPath((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
|
||||
setNwnPath((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
|
||||
}, [config]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setMsg("");
|
||||
try {
|
||||
await api.workspace.updateConfig({ workspacePath: wsPath, nwnHomePath: nwnPath });
|
||||
setMsg("Paths saved");
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Paths" icon={<FolderOpen size={14} />}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
Workspace Path
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
{(config.WORKSPACE_PATH as string) || "Not set"}
|
||||
</p>
|
||||
<p style={fieldLabel}>Workspace Path</p>
|
||||
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
NWN Home Path
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
{(config.NWN_HOME_PATH as string) || "Not set"}
|
||||
</p>
|
||||
<p style={fieldLabel}>NWN Home Path</p>
|
||||
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
style={{ ...primaryBtn, opacity: saving ? 0.4 : 1 }}
|
||||
>
|
||||
{saving ? "Saving\u2026" : "Save Paths"}
|
||||
</button>
|
||||
{msg && (
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>{msg}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -172,40 +297,31 @@ function DockerSection() {
|
||||
await api.docker.pull(image);
|
||||
setStatus((s) => ({ ...s, [image]: "Pulled" }));
|
||||
} catch (err) {
|
||||
setStatus((s) => ({
|
||||
...s,
|
||||
[image]: err instanceof Error ? err.message : "Failed",
|
||||
}));
|
||||
setStatus((s) => ({ ...s, [image]: err instanceof Error ? err.message : "Failed" }));
|
||||
} finally {
|
||||
setPulling((s) => ({ ...s, [image]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Docker Images">
|
||||
<div className="space-y-2">
|
||||
<Section title="Docker Images" icon={<Container size={14} />}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image}
|
||||
className="flex items-center justify-between rounded p-3"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
>
|
||||
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
{image}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div key={image} style={listRow}>
|
||||
<span style={fieldValue}>{image}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{status[image] && (
|
||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
{status[image]}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => pull(image)}
|
||||
disabled={pulling[image]}
|
||||
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40"
|
||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
||||
style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
|
||||
>
|
||||
{pulling[image] ? "..." : "Pull Latest"}
|
||||
<Download size={12} />
|
||||
{pulling[image] ? "Pulling\u2026" : "Pull"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,21 +341,20 @@ const SHORTCUTS = [
|
||||
|
||||
function ShortcutsSection() {
|
||||
return (
|
||||
<Section title="Keyboard Shortcuts">
|
||||
<div className="space-y-1">
|
||||
<Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{SHORTCUTS.map((s) => (
|
||||
<div
|
||||
key={s.keys}
|
||||
className="flex items-center justify-between rounded px-3 py-2"
|
||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||
>
|
||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||
{s.action}
|
||||
</span>
|
||||
<kbd
|
||||
className="rounded px-2 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: "var(--forge-bg)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
@@ -255,11 +370,11 @@ function ShortcutsSection() {
|
||||
|
||||
function AboutSection() {
|
||||
return (
|
||||
<Section title="About">
|
||||
<p className="text-sm" style={{ color: "var(--forge-text)" }}>
|
||||
<Section title="About" icon={<Info size={14} />}>
|
||||
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||
Layonara Forge v0.0.1
|
||||
</p>
|
||||
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||
github.com/Layonara/layonara-forge
|
||||
</p>
|
||||
</Section>
|
||||
@@ -270,6 +385,7 @@ function ResetSection() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const reset = async () => {
|
||||
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
|
||||
try {
|
||||
await api.workspace.updateConfig({ setupComplete: false });
|
||||
} catch {
|
||||
@@ -279,14 +395,28 @@ function ResetSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Reset">
|
||||
<Section title="Reset" icon={<RotateCcw size={14} />}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Clear configuration and re-run the setup wizard
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded px-4 py-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }}
|
||||
style={{
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
color: "var(--forge-danger)",
|
||||
border: "1px solid var(--forge-danger-border)",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.4rem 0.875rem",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Re-run Setup Wizard
|
||||
Reset Setup
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -299,25 +429,24 @@ export function Settings() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<h2
|
||||
className="mb-6 text-xl font-bold"
|
||||
style={{
|
||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
|
||||
Settings
|
||||
</h2>
|
||||
<div className="max-w-2xl space-y-4">
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||
Configuration, theme, and environment
|
||||
</p>
|
||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<GitHubSection config={config} />
|
||||
<ThemeSection />
|
||||
<PathsSection config={config} />
|
||||
<PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
|
||||
<DockerSection />
|
||||
<ShortcutsSection />
|
||||
<AboutSection />
|
||||
<ResetSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { SimpleDiffEditor } from "../components/editor/SimpleEditor";
|
||||
import { api } from "../services/api";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileCode,
|
||||
Check,
|
||||
X,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ArrowUpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ChangeEntry {
|
||||
filename: string;
|
||||
@@ -16,61 +26,6 @@ interface DiffData {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
active
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-gray-500/20 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "default" | "primary" | "danger";
|
||||
}) {
|
||||
const styles = {
|
||||
default: {
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
borderColor: "var(--forge-border)",
|
||||
color: "var(--forge-text)",
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
borderColor: "var(--forge-accent)",
|
||||
color: "#fff",
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: "#7f1d1d",
|
||||
borderColor: "#991b1b",
|
||||
color: "#fca5a5",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={styles[variant]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
}
|
||||
@@ -222,149 +177,428 @@ export function Toolset() {
|
||||
};
|
||||
|
||||
const handleDiscardAll = async () => {
|
||||
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
|
||||
await api.toolset.discardAll();
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
style={{ color: "var(--forge-text)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
{/* Status bar */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-6 py-3"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--forge-accent)" }}
|
||||
{/* Page heading */}
|
||||
<div style={{ padding: "24px 28px 0" }}>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontSize: "var(--text-xl)",
|
||||
fontWeight: 700,
|
||||
color: "var(--forge-text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Toolset
|
||||
</h2>
|
||||
<StatusBadge active={active} />
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{changes.length} pending
|
||||
Watch for NWN Toolset changes and apply them to the repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Watcher status card */}
|
||||
<div style={{ padding: "16px 28px" }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: 8,
|
||||
padding: "16px 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 16 }}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
{active ? (
|
||||
<Eye size={16} style={{ color: "var(--forge-success)" }} />
|
||||
) : (
|
||||
<EyeOff
|
||||
size={16}
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 600,
|
||||
backgroundColor: active
|
||||
? "var(--forge-success-bg)"
|
||||
: "var(--forge-surface-raised)",
|
||||
color: active
|
||||
? "var(--forge-success)"
|
||||
: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{changes.length} pending change{changes.length !== 1 && "s"}
|
||||
</span>
|
||||
{lastChange && (
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Last: {formatTimestamp(lastChange)}
|
||||
Last change: {formatTimestamp(lastChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
<button
|
||||
onClick={active ? handleStop : handleStart}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 14px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
backgroundColor: active
|
||||
? "var(--forge-surface)"
|
||||
: "var(--forge-accent)",
|
||||
borderColor: active
|
||||
? "var(--forge-border)"
|
||||
: "var(--forge-accent)",
|
||||
color: active
|
||||
? "var(--forge-text)"
|
||||
: "var(--forge-accent-text)",
|
||||
}}
|
||||
>
|
||||
{active ? (
|
||||
<ActionButton label="Stop Watcher" onClick={handleStop} />
|
||||
<>
|
||||
<EyeOff size={14} />
|
||||
Stop Watcher
|
||||
</>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Start Watcher"
|
||||
onClick={handleStart}
|
||||
variant="primary"
|
||||
/>
|
||||
<>
|
||||
<Eye size={14} />
|
||||
Start Watcher
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
{changes.length > 0 && (
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 px-6 py-2"
|
||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
padding: "0 28px 20px",
|
||||
}}
|
||||
>
|
||||
<ActionButton
|
||||
label="Apply Selected"
|
||||
variant="primary"
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleApplySelected}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Apply All"
|
||||
variant="primary"
|
||||
onClick={handleApplyAll}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Discard Selected"
|
||||
variant="danger"
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleDiscardSelected}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Discard All"
|
||||
variant="danger"
|
||||
onClick={handleDiscardAll}
|
||||
{/* Changes card */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: diffData ? "0 0 auto" : 1,
|
||||
maxHeight: diffData ? "40%" : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Card header with action bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<FileCode
|
||||
size={16}
|
||||
style={{ color: "var(--forge-accent)" }}
|
||||
/>
|
||||
<span
|
||||
className="ml-2 text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
{selected.size} selected
|
||||
Pending Changes
|
||||
</span>
|
||||
{changes.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{selected.size} of {changes.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{changes.length > 0 && (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleApplySelected}
|
||||
disabled={selected.size === 0}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 5,
|
||||
border: "none",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
cursor: selected.size === 0 ? "not-allowed" : "pointer",
|
||||
opacity: selected.size === 0 ? 0.5 : 1,
|
||||
backgroundColor: "var(--forge-accent)",
|
||||
color: "var(--forge-accent-text)",
|
||||
}}
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplyAll}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 5,
|
||||
border: "1px solid var(--forge-accent)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
Apply All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardSelected}
|
||||
disabled={selected.size === 0}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 5,
|
||||
border: "none",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
cursor: selected.size === 0 ? "not-allowed" : "pointer",
|
||||
opacity: selected.size === 0 ? 0.5 : 1,
|
||||
backgroundColor: "var(--forge-danger-bg)",
|
||||
color: "var(--forge-danger)",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Discard Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAll}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 5,
|
||||
border: "1px solid var(--forge-danger-border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--forge-danger)",
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
Discard All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content: table + diff */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Changes table */}
|
||||
<div
|
||||
className="shrink-0 overflow-auto"
|
||||
style={{ maxHeight: diffData ? "40%" : "100%" }}
|
||||
>
|
||||
{/* Table or empty state */}
|
||||
<div style={{ overflow: "auto", flex: 1 }}>
|
||||
{changes.length === 0 ? (
|
||||
<div
|
||||
className="flex h-40 items-center justify-center text-sm"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "48px 24px",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{active
|
||||
? "Watching for changes in temp0/..."
|
||||
: "Start the watcher to detect Toolset changes"}
|
||||
{active ? (
|
||||
<>
|
||||
<RefreshCw
|
||||
size={28}
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
opacity: 0.4,
|
||||
animation: "spin 3s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "var(--text-sm)" }}>
|
||||
Watching for changes in temp0/...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff
|
||||
size={28}
|
||||
style={{ marginBottom: 12, opacity: 0.4 }}
|
||||
/>
|
||||
<span style={{ fontSize: "var(--text-sm)" }}>
|
||||
Start the watcher to detect Toolset changes
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "var(--text-sm)",
|
||||
borderCollapse: "collapse",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<th className="px-6 py-2 text-left font-medium">
|
||||
<th
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
width: 40,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === changes.length}
|
||||
checked={
|
||||
selected.size === changes.length &&
|
||||
changes.length > 0
|
||||
}
|
||||
onChange={toggleAll}
|
||||
className="cursor-pointer"
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Filename</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Type</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
<th
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Filename
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Repo Path
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Time</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Time
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((change) => (
|
||||
<tr
|
||||
key={change.filename}
|
||||
className="cursor-pointer transition-colors hover:bg-white/5"
|
||||
onClick={() => viewDiff(change)}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
diffData?.filename === change.filename
|
||||
? "var(--forge-surface)"
|
||||
? "var(--forge-accent-subtle)"
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() => viewDiff(change)}
|
||||
>
|
||||
<td className="px-6 py-2">
|
||||
<td style={{ padding: "8px 16px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(change.filename)}
|
||||
@@ -373,24 +607,49 @@ export function Toolset() {
|
||||
toggleSelect(change.filename);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="cursor-pointer"
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono">{change.filename}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs">
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
{change.filename}
|
||||
</td>
|
||||
<td style={{ padding: "8px 10px" }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "var(--forge-accent-subtle)",
|
||||
color: "var(--forge-accent)",
|
||||
}}
|
||||
>
|
||||
{change.gffType}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-2 font-mono text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{change.repoPath ?? "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-2 text-xs"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(change.timestamp)}
|
||||
</td>
|
||||
@@ -400,51 +659,103 @@ export function Toolset() {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff panel */}
|
||||
{/* Diff viewer panel */}
|
||||
{diffData && (
|
||||
<div
|
||||
ref={diffContainerRef}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
style={{ borderTop: "1px solid var(--forge-border)" }}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-4 py-1.5"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginTop: 16,
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
border: "1px solid var(--forge-border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Diff header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "var(--forge-surface-raised)",
|
||||
borderBottom: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium">
|
||||
Diff: {diffData.filename}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<FileCode
|
||||
size={14}
|
||||
style={{ color: "var(--forge-accent)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: "var(--forge-text)",
|
||||
}}
|
||||
>
|
||||
{diffData.filename}
|
||||
</span>
|
||||
{loading && (
|
||||
<span style={{ color: "var(--forge-text-secondary)" }}>
|
||||
{" "}
|
||||
(loading...)
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDiffData(null)}
|
||||
className="text-xs transition-opacity hover:opacity-80"
|
||||
style={{ color: "var(--forge-text-secondary)" }}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 5,
|
||||
border: "1px solid var(--forge-border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
backgroundColor: "var(--forge-surface)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<DiffEditor
|
||||
|
||||
{/* Diff content */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: "var(--forge-log-bg)",
|
||||
}}
|
||||
>
|
||||
<SimpleDiffEditor
|
||||
original={diffData.original}
|
||||
modified={diffData.modified}
|
||||
language="json"
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
renderSideBySide: true,
|
||||
padding: { top: 4 },
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,8 +6,15 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || res.statusText);
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
message = body.error || body.message || message;
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "");
|
||||
if (text && text.length < 200 && !text.includes("<")) message = text;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -41,6 +48,14 @@ export const api = {
|
||||
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
|
||||
gffSchema: (type: string) =>
|
||||
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
|
||||
tlkLookup: async (id: number): Promise<string | null> => {
|
||||
try {
|
||||
const result = await request<{ text: string }>(`/editor/tlk/${id}`);
|
||||
return result.text ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
workspace: {
|
||||
|
||||
@@ -1,25 +1,185 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/manrope";
|
||||
@import "@fontsource-variable/alegreya";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
:root {
|
||||
--forge-bg: #121212;
|
||||
--forge-surface: #1e1e2e;
|
||||
--forge-border: #2e2e3e;
|
||||
--forge-accent: #946200;
|
||||
--forge-text: #f2f2f2;
|
||||
--forge-text-secondary: #888888;
|
||||
--forge-bg: oklch(15% 0.01 65);
|
||||
--forge-surface: oklch(20% 0.012 65);
|
||||
--forge-surface-raised: oklch(24% 0.014 65);
|
||||
--forge-border: oklch(30% 0.014 65);
|
||||
--forge-accent: oklch(58% 0.155 65);
|
||||
--forge-accent-hover: oklch(63% 0.16 65);
|
||||
--forge-accent-subtle: oklch(25% 0.04 65);
|
||||
--forge-accent-text: oklch(15% 0.03 65);
|
||||
--forge-text: oklch(93% 0.006 65);
|
||||
--forge-text-secondary: oklch(68% 0.01 65);
|
||||
|
||||
--forge-success: oklch(62% 0.14 150);
|
||||
--forge-success-bg: oklch(22% 0.03 150);
|
||||
--forge-success-border: oklch(35% 0.06 150);
|
||||
--forge-danger: oklch(68% 0.14 25);
|
||||
--forge-danger-bg: oklch(22% 0.04 25);
|
||||
--forge-danger-border: oklch(35% 0.08 25);
|
||||
--forge-danger-strong: oklch(55% 0.18 25);
|
||||
--forge-warning: oklch(72% 0.14 80);
|
||||
--forge-warning-bg: oklch(25% 0.04 80);
|
||||
--forge-warning-border: oklch(40% 0.07 80);
|
||||
--forge-info: oklch(62% 0.08 230);
|
||||
--forge-info-bg: oklch(22% 0.02 230);
|
||||
|
||||
--forge-log-bg: oklch(13% 0.008 65);
|
||||
--forge-log-text: oklch(82% 0.008 65);
|
||||
|
||||
--font-sans: "Manrope Variable", system-ui, sans-serif;
|
||||
--font-heading: "Alegreya Variable", Georgia, serif;
|
||||
--font-mono: "JetBrains Mono Variable", "Fira Code", monospace;
|
||||
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.875rem;
|
||||
--text-lg: 1.0625rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.75rem;
|
||||
|
||||
--leading-tight: 1.2;
|
||||
--leading-normal: 1.55;
|
||||
--leading-relaxed: 1.7;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--forge-bg: #f2f2f2;
|
||||
--forge-surface: #ffffff;
|
||||
--forge-border: #cbcbcb;
|
||||
--forge-accent: #946200;
|
||||
--forge-text: #252525;
|
||||
--forge-text-secondary: #666666;
|
||||
--forge-bg: oklch(95% 0.008 65);
|
||||
--forge-surface: oklch(99% 0.004 65);
|
||||
--forge-surface-raised: oklch(100% 0.002 65);
|
||||
--forge-border: oklch(82% 0.012 65);
|
||||
--forge-accent: oklch(50% 0.155 65);
|
||||
--forge-accent-hover: oklch(45% 0.16 65);
|
||||
--forge-accent-subtle: oklch(90% 0.04 65);
|
||||
--forge-accent-text: oklch(99% 0.005 65);
|
||||
--forge-text: oklch(20% 0.012 65);
|
||||
--forge-text-secondary: oklch(45% 0.015 65);
|
||||
|
||||
--forge-success: oklch(45% 0.14 150);
|
||||
--forge-success-bg: oklch(92% 0.03 150);
|
||||
--forge-success-border: oklch(70% 0.08 150);
|
||||
--forge-danger: oklch(50% 0.16 25);
|
||||
--forge-danger-bg: oklch(92% 0.03 25);
|
||||
--forge-danger-border: oklch(70% 0.08 25);
|
||||
--forge-danger-strong: oklch(45% 0.18 25);
|
||||
--forge-warning: oklch(55% 0.14 80);
|
||||
--forge-warning-bg: oklch(92% 0.04 80);
|
||||
--forge-warning-border: oklch(70% 0.07 80);
|
||||
--forge-info: oklch(45% 0.08 230);
|
||||
--forge-info-bg: oklch(92% 0.02 230);
|
||||
|
||||
--forge-log-bg: oklch(96% 0.006 65);
|
||||
--forge-log-text: oklch(30% 0.01 65);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--forge-bg);
|
||||
color: var(--forge-text);
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
font-kerning: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.font-heading {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--forge-accent-subtle);
|
||||
color: var(--forge-text);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--forge-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--forge-border) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--forge-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--forge-text-secondary);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
input[type="email"],
|
||||
input[type="number"],
|
||||
input[type="search"],
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--forge-bg);
|
||||
color: var(--forge-text);
|
||||
border: 1px solid var(--forge-border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
transition: border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--forge-accent);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--forge-text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease-out, color 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
a {
|
||||
transition: color 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,42 @@ export default {
|
||||
forge: {
|
||||
bg: "var(--forge-bg)",
|
||||
surface: "var(--forge-surface)",
|
||||
"surface-raised": "var(--forge-surface-raised)",
|
||||
border: "var(--forge-border)",
|
||||
accent: "var(--forge-accent)",
|
||||
"accent-hover": "var(--forge-accent-hover)",
|
||||
"accent-subtle": "var(--forge-accent-subtle)",
|
||||
"accent-text": "var(--forge-accent-text)",
|
||||
text: "var(--forge-text)",
|
||||
"text-secondary": "var(--forge-text-secondary)",
|
||||
success: "var(--forge-success)",
|
||||
"success-bg": "var(--forge-success-bg)",
|
||||
"success-border": "var(--forge-success-border)",
|
||||
danger: "var(--forge-danger)",
|
||||
"danger-bg": "var(--forge-danger-bg)",
|
||||
"danger-border": "var(--forge-danger-border)",
|
||||
"danger-strong": "var(--forge-danger-strong)",
|
||||
warning: "var(--forge-warning)",
|
||||
"warning-bg": "var(--forge-warning-bg)",
|
||||
"warning-border": "var(--forge-warning-border)",
|
||||
info: "var(--forge-info)",
|
||||
"info-bg": "var(--forge-info-bg)",
|
||||
"log-bg": "var(--forge-log-bg)",
|
||||
"log-text": "var(--forge-log-text)",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "Fira Code", "monospace"],
|
||||
serif: ["Baskerville", "Georgia", "Palatino", "serif"],
|
||||
sans: ["Manrope Variable", "system-ui", "sans-serif"],
|
||||
heading: ["Alegreya Variable", "Georgia", "serif"],
|
||||
mono: ["JetBrains Mono Variable", "Fira Code", "monospace"],
|
||||
},
|
||||
fontSize: {
|
||||
xs: "var(--text-xs)",
|
||||
sm: "var(--text-sm)",
|
||||
base: "var(--text-base)",
|
||||
lg: "var(--text-lg)",
|
||||
xl: "var(--text-xl)",
|
||||
"2xl": "var(--text-2xl)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,52 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import importMetaUrlPlugin from "@codingame/esbuild-import-meta-url-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
dedupe: ["vscode"],
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
plugins: [importMetaUrlPlugin],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("@codingame/monaco-vscode-editor-api") ||
|
||||
id.includes("@codingame/monaco-vscode-api")) {
|
||||
return "monaco-editor";
|
||||
}
|
||||
if (id.includes("@codingame/")) {
|
||||
return "vscode-services";
|
||||
}
|
||||
if (id.includes("vscode/")) {
|
||||
return "vscode-core";
|
||||
}
|
||||
if (id.includes("lucide-react")) {
|
||||
return "icons";
|
||||
}
|
||||
if (id.includes("node_modules/react/") ||
|
||||
id.includes("node_modules/react-dom/") ||
|
||||
id.includes("node_modules/react-router")) {
|
||||
return "react";
|
||||
}
|
||||
if (id.includes("monaco-languageclient") ||
|
||||
id.includes("vscode-languageclient") ||
|
||||
id.includes("vscode-jsonrpc") ||
|
||||
id.includes("vscode-languageserver")) {
|
||||
return "lsp";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user