Compare commits
20 Commits
b85f70dc95
...
master
| 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
|
# Windows: C:\Users\<you>\Documents\Neverwinter Nights
|
||||||
# Linux: ~/.local/share/Neverwinter Nights
|
# Linux: ~/.local/share/Neverwinter Nights
|
||||||
NWN_HOME_PATH=
|
NWN_HOME_PATH=
|
||||||
|
|
||||||
|
# Git provider URL (Gitea instance)
|
||||||
|
GIT_PROVIDER_URL=https://gitea.layonara.com
|
||||||
|
|||||||
@@ -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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
|
git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# nwn_gff for toolset GFF→JSON conversion (temp0/ watcher)
|
||||||
|
RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
|
||||||
|
| tar xz -C /usr/local/bin/ \
|
||||||
|
&& nwn_gff --version
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json tsconfig.base.json ./
|
COPY package.json tsconfig.base.json ./
|
||||||
COPY packages/backend/package.json packages/backend/
|
COPY packages/backend/package.json packages/backend/
|
||||||
@@ -23,6 +29,7 @@ RUN npm install --omit=dev
|
|||||||
|
|
||||||
COPY --from=builder /app/packages/backend/dist packages/backend/dist
|
COPY --from=builder /app/packages/backend/dist packages/backend/dist
|
||||||
COPY --from=builder /app/packages/frontend/dist packages/frontend/dist
|
COPY --from=builder /app/packages/frontend/dist packages/frontend/dist
|
||||||
|
COPY db/ db/
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "packages/backend/dist/index.js"]
|
CMD ["node", "packages/backend/dist/index.js"]
|
||||||
|
|||||||
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 \
|
cmake \
|
||||||
git \
|
git \
|
||||||
curl \
|
curl \
|
||||||
wget \
|
|
||||||
unzip \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
|
libsqlite3-0 \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Nim via choosenim
|
# Pre-built neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
|
||||||
RUN curl https://nim-lang.org/choosenim/init.sh -sSf | bash -s -- -y
|
RUN curl -L https://github.com/layonara/neverwinter.nim/releases/download/v2.1.2-layonara/neverwinter-tools-linux-x64.tar.gz \
|
||||||
ENV PATH="/root/.nimble/bin:${PATH}"
|
| tar xz -C /usr/local/bin/
|
||||||
RUN choosenim 2.2.0
|
|
||||||
|
|
||||||
# Install neverwinter.nim tools (nwn_gff, nwn_script_comp, etc.)
|
# Pre-built nasher (NWN module build tool)
|
||||||
RUN nimble install neverwinter@2.1.2 -y
|
RUN curl -L https://github.com/squattingmonk/nasher.nim/releases/download/1.1.2/nasher_linux.tar.gz \
|
||||||
|
| tar xz -C /usr/local/bin/
|
||||||
|
|
||||||
# Install nasher
|
# Pre-built layonara_nwn (hak builder)
|
||||||
RUN nimble install nasher -y
|
RUN curl -L https://github.com/plenarius/layonara_nwn/releases/download/v0.1.1/layonara_nwn-linux-x64.tar.gz \
|
||||||
|
| tar xz -C /usr/local/bin/
|
||||||
|
|
||||||
# Install layonara_nwn (hak builder)
|
# Verify all tools
|
||||||
RUN nimble install https://github.com/plenarius/layonara_nwn -y
|
RUN nwn_gff --version && nasher --version && which nwn_script_comp && layonara_nwn --help | head -1
|
||||||
|
|
||||||
# Verify tools
|
|
||||||
RUN nwn_gff --version && nasher --version && which nwn_script_comp
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- WORKSPACE_PATH=/workspace
|
- WORKSPACE_PATH=/workspace
|
||||||
- NWN_HOME_PATH=/nwn-home
|
- NWN_HOME_PATH=/nwn-home
|
||||||
|
- GIT_PROVIDER_URL=https://gitea.layonara.com
|
||||||
|
|||||||
@@ -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",
|
"name": "layonara-forge",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "NWN Development IDE — build, edit, and run a Layonara server with only Docker required",
|
||||||
|
"author": "Layonara <orth@layonara.com>",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["packages/*"],
|
"main": "electron/dist/main.js",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^25.1.2"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev -w packages/backend\" \"npm run dev -w packages/frontend\"",
|
"dev": "concurrently \"npm run dev -w packages/backend\" \"npm run dev -w packages/frontend\"",
|
||||||
"build": "npm run build -w packages/backend && npm run build -w packages/frontend",
|
"build": "npm run build -w packages/backend && npm run build -w packages/frontend",
|
||||||
"start": "npm start -w packages/backend"
|
"build:electron": "tsc -p electron/tsconfig.json",
|
||||||
|
"build:all": "npm run build && npm run build:electron",
|
||||||
|
"start": "npm start -w packages/backend",
|
||||||
|
"electron:dev": "npm run build:all && electron .",
|
||||||
|
"electron:build": "npm run build:all && electron-builder",
|
||||||
|
"electron:build:win": "npm run build:all && electron-builder --win",
|
||||||
|
"electron:build:mac": "npm run build:all && electron-builder --mac",
|
||||||
|
"electron:build:linux": "npm run build:all && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
|
"electron": "^41.2.2",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-updater": "^6.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
|
import type { Server } from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { initWebSocket, getClientCount } from "./services/ws.service.js";
|
import { initWebSocket, getClientCount, handleUpgrade as handleEventUpgrade } from "./services/ws.service.js";
|
||||||
import workspaceRouter from "./routes/workspace.js";
|
import workspaceRouter from "./routes/workspace.js";
|
||||||
import dockerRouter from "./routes/docker.js";
|
import dockerRouter from "./routes/docker.js";
|
||||||
import editorRouter from "./routes/editor.js";
|
import editorRouter from "./routes/editor.js";
|
||||||
@@ -17,41 +18,45 @@ import reposRouter from "./routes/repos.js";
|
|||||||
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||||
import { attachLspWebSocket } from "./services/lsp.service.js";
|
import { attachLspWebSocket } from "./services/lsp.service.js";
|
||||||
import { startUpstreamPolling } from "./services/git.service.js";
|
import { startUpstreamPolling } from "./services/git.service.js";
|
||||||
|
import { loadTlkIndex } from "./nwscript/tlk-index.js";
|
||||||
|
import { getRepoPath } from "./services/workspace.service.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
|
||||||
const server = createServer(app);
|
|
||||||
|
|
||||||
app.use(cors());
|
export function startServer(port: number): Promise<Server> {
|
||||||
app.use(express.json());
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
initWebSocket(server);
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
app.get("/api/health", (_req, res) => {
|
initWebSocket(server);
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
wsClients: getClientCount(),
|
wsClients: getClientCount(),
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use("/api/workspace", workspaceRouter);
|
app.use("/api/workspace", workspaceRouter);
|
||||||
app.use("/api/docker", dockerRouter);
|
app.use("/api/docker", dockerRouter);
|
||||||
app.use("/api/editor", editorRouter);
|
app.use("/api/editor", editorRouter);
|
||||||
app.use("/api/terminal", terminalRouter);
|
app.use("/api/terminal", terminalRouter);
|
||||||
app.use("/api/build", buildRouter);
|
app.use("/api/build", buildRouter);
|
||||||
app.use("/api/server", serverRouter);
|
app.use("/api/server", serverRouter);
|
||||||
app.use("/api/toolset", toolsetRouter);
|
app.use("/api/toolset", toolsetRouter);
|
||||||
app.use("/api/github", githubRouter);
|
app.use("/api/github", githubRouter);
|
||||||
app.use("/api/repos", reposRouter);
|
app.use("/api/repos", reposRouter);
|
||||||
|
|
||||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||||
app.use(express.static(frontendDist));
|
app.use(express.static(frontendDist));
|
||||||
app.get("*path", (_req, res) => {
|
app.get("*path", (_req, res) => {
|
||||||
res.sendFile(path.join(frontendDist, "index.html"));
|
res.sendFile(path.join(frontendDist, "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("upgrade", (request, socket, head) => {
|
server.on("upgrade", (request, socket, head) => {
|
||||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||||
|
|
||||||
if (url.pathname === "/ws/lsp") {
|
if (url.pathname === "/ws/lsp") {
|
||||||
@@ -72,11 +77,27 @@ server.on("upgrade", (request, socket, head) => {
|
|||||||
attachWebSocket(sessionId, ws);
|
attachWebSocket(sessionId, ws);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
if (url.pathname === "/ws") {
|
||||||
server.listen(PORT, "0.0.0.0", () => {
|
handleEventUpgrade(request, socket, head);
|
||||||
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
|
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();
|
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 {
|
try {
|
||||||
const raw = await fs.readFile(tlkJsonPath, "utf-8");
|
const raw = await fs.readFile(tlkJsonPath, "utf-8");
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
if (Array.isArray(data)) {
|
const entries = Array.isArray(data) ? data : Array.isArray(data.entries) ? data.entries : [];
|
||||||
for (const entry of data) {
|
for (const entry of entries) {
|
||||||
if (entry.id !== undefined && entry.value !== undefined) {
|
const id = entry.id ?? entry.index;
|
||||||
tlkStrings.set(Number(entry.id), String(entry.value));
|
const text = entry.text ?? entry.value;
|
||||||
}
|
if (id !== undefined && text !== undefined) {
|
||||||
|
tlkStrings.set(Number(id), String(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ router.get("/tree/:repo", async (req, res) => {
|
|||||||
|
|
||||||
router.get("/file/:repo/*path", async (req, res) => {
|
router.get("/file/:repo/*path", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(req.params.repo, req.params.path);
|
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||||
|
const content = await readFile(req.params.repo, filePath);
|
||||||
res.json({ content });
|
res.json({ content });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
@@ -35,7 +36,8 @@ router.get("/file/:repo/*path", async (req, res) => {
|
|||||||
|
|
||||||
router.put("/file/:repo/*path", async (req, res) => {
|
router.put("/file/:repo/*path", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await writeFile(req.params.repo, req.params.path, req.body.content);
|
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||||
|
await writeFile(req.params.repo, filePath, req.body.content);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
@@ -45,7 +47,8 @@ router.put("/file/:repo/*path", async (req, res) => {
|
|||||||
|
|
||||||
router.delete("/file/:repo/*path", async (req, res) => {
|
router.delete("/file/:repo/*path", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await deleteFile(req.params.repo, req.params.path);
|
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||||
|
await deleteFile(req.params.repo, filePath);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ router.get("/:repo/diff/*path", async (req, res) => {
|
|||||||
res.status(400).json({ error: `Unknown repo: ${repoName}` });
|
res.status(400).json({ error: `Unknown repo: ${repoName}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filePath = req.params.path;
|
const filePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
|
||||||
const repoPath = getRepoPath(repoName);
|
const repoPath = getRepoPath(repoName);
|
||||||
const diff = await getDiff(repoPath, filePath);
|
const diff = await getDiff(repoPath, filePath);
|
||||||
res.json({ diff });
|
res.json({ diff });
|
||||||
|
|||||||
@@ -9,21 +9,36 @@ import {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/config", async (_req, res) => {
|
router.get("/config", async (_req, res) => {
|
||||||
|
try {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
const sanitized = { ...config, githubPat: config.githubPat ? "***" : undefined };
|
const sanitized = { ...config, githubPat: config.githubPat ? "***" : undefined };
|
||||||
res.json(sanitized);
|
res.json(sanitized);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to read config";
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/config", async (req, res) => {
|
router.put("/config", async (req, res) => {
|
||||||
|
try {
|
||||||
const current = await readConfig();
|
const current = await readConfig();
|
||||||
const updated = { ...current, ...req.body };
|
const updated = { ...current, ...req.body };
|
||||||
await writeConfig(updated);
|
await writeConfig(updated);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to save config";
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/init", async (_req, res) => {
|
router.post("/init", async (_req, res) => {
|
||||||
|
try {
|
||||||
await ensureWorkspaceStructure();
|
await ensureWorkspaceStructure();
|
||||||
res.json({ ok: true, path: getWorkspacePath() });
|
res.json({ ok: true, path: getWorkspacePath() });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to initialize workspace";
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { runEphemeralContainer } from "./docker.service.js";
|
import { runEphemeralContainer, getDockerClient } from "./docker.service.js";
|
||||||
import {
|
import {
|
||||||
getWorkspacePath,
|
getWorkspacePath,
|
||||||
getServerPath,
|
getServerPath,
|
||||||
} from "./workspace.service.js";
|
} from "./workspace.service.js";
|
||||||
import { broadcast } from "./ws.service.js";
|
import { broadcast } from "./ws.service.js";
|
||||||
|
|
||||||
|
const BUILDER_IMAGE = "layonara-builder";
|
||||||
|
|
||||||
|
async function ensureBuilderImage(): Promise<void> {
|
||||||
|
const docker = getDockerClient();
|
||||||
|
try {
|
||||||
|
await docker.getImage(BUILDER_IMAGE).inspect();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// image doesn't exist — build it
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast("build", "info", { message: "Building the builder image (first-time setup, ~45s)..." });
|
||||||
|
|
||||||
|
const builderDir = process.env.ELECTRON
|
||||||
|
? path.join((process as any).resourcesPath ?? __dirname, "builder")
|
||||||
|
: path.resolve(__dirname, "../../../builder");
|
||||||
|
|
||||||
|
const stream = await docker.buildImage(
|
||||||
|
{ context: builderDir, src: ["."] },
|
||||||
|
{ t: BUILDER_IMAGE },
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
docker.modem.followProgress(
|
||||||
|
stream,
|
||||||
|
(err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
broadcast("build", "error", { message: `Builder image build failed: ${err.message}` });
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
broadcast("build", "info", { message: "Builder image ready." });
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(event: { stream?: string }) => {
|
||||||
|
if (event.stream) {
|
||||||
|
broadcast("build", "output", { text: event.stream });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildModule(
|
export async function buildModule(
|
||||||
target: string = "bare",
|
target: string = "bare",
|
||||||
mode: "compile" | "pack" = "compile",
|
mode: "compile" | "pack" = "compile",
|
||||||
@@ -18,9 +61,10 @@ export async function buildModule(
|
|||||||
: ["nasher", "pack", target, "--yes"];
|
: ["nasher", "pack", target, "--yes"];
|
||||||
|
|
||||||
broadcast("build", "start", { type: "module", target, mode });
|
broadcast("build", "start", { type: "module", target, mode });
|
||||||
|
await ensureBuilderImage();
|
||||||
|
|
||||||
const result = await runEphemeralContainer({
|
const result = await runEphemeralContainer({
|
||||||
image: "layonara-builder",
|
image: BUILDER_IMAGE,
|
||||||
cmd,
|
cmd,
|
||||||
binds: [
|
binds: [
|
||||||
`${workspacePath}/repos/nwn-module:/build/nwn-module`,
|
`${workspacePath}/repos/nwn-module:/build/nwn-module`,
|
||||||
@@ -101,9 +145,10 @@ export async function buildHaks(): Promise<{
|
|||||||
const workspacePath = getWorkspacePath();
|
const workspacePath = getWorkspacePath();
|
||||||
|
|
||||||
broadcast("build", "start", { type: "haks" });
|
broadcast("build", "start", { type: "haks" });
|
||||||
|
await ensureBuilderImage();
|
||||||
|
|
||||||
const result = await runEphemeralContainer({
|
const result = await runEphemeralContainer({
|
||||||
image: "layonara-builder",
|
image: BUILDER_IMAGE,
|
||||||
cmd: ["layonara_nwn", "hak", "--yes"],
|
cmd: ["layonara_nwn", "hak", "--yes"],
|
||||||
binds: [
|
binds: [
|
||||||
`${workspacePath}/repos/nwn-haks:/build/nwn-haks`,
|
`${workspacePath}/repos/nwn-haks:/build/nwn-haks`,
|
||||||
@@ -127,6 +172,7 @@ export async function buildNWNX(
|
|||||||
const workspacePath = getWorkspacePath();
|
const workspacePath = getWorkspacePath();
|
||||||
|
|
||||||
broadcast("build", "start", { type: "nwnx", target });
|
broadcast("build", "start", { type: "nwnx", target });
|
||||||
|
await ensureBuilderImage();
|
||||||
|
|
||||||
const cmd = target
|
const cmd = target
|
||||||
? [
|
? [
|
||||||
@@ -141,7 +187,7 @@ export async function buildNWNX(
|
|||||||
];
|
];
|
||||||
|
|
||||||
const result = await runEphemeralContainer({
|
const result = await runEphemeralContainer({
|
||||||
image: "layonara-builder",
|
image: BUILDER_IMAGE,
|
||||||
cmd,
|
cmd,
|
||||||
binds: [`${workspacePath}/repos/unified:/build/unified`],
|
binds: [`${workspacePath}/repos/unified:/build/unified`],
|
||||||
workingDir: "/build/unified",
|
workingDir: "/build/unified",
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
|
import { platform } from "os";
|
||||||
import { broadcast } from "./ws.service.js";
|
import { broadcast } from "./ws.service.js";
|
||||||
|
|
||||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
const socketPath = platform() === "win32"
|
||||||
|
? "//./pipe/docker_engine"
|
||||||
|
: "/var/run/docker.sock";
|
||||||
|
const docker = new Docker({ socketPath });
|
||||||
|
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import simpleGit, { SimpleGit } from "simple-git";
|
import { simpleGit, SimpleGit } from "simple-git";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js";
|
import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js";
|
||||||
import { getRepoPath, readConfig } from "./workspace.service.js";
|
import { getRepoPath, readConfig, ensureWorkspaceStructure } from "./workspace.service.js";
|
||||||
import { broadcast } from "./ws.service.js";
|
import { broadcast } from "./ws.service.js";
|
||||||
|
|
||||||
function git(repoPath: string): SimpleGit {
|
function git(repoPath: string): SimpleGit {
|
||||||
@@ -128,6 +128,7 @@ function getUpstreamUrl(owner: string, repo: string, provider: string, token?: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setupClone(repoName: RepoName) {
|
export async function setupClone(repoName: RepoName) {
|
||||||
|
await ensureWorkspaceStructure();
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
const pat = config.githubPat;
|
const pat = config.githubPat;
|
||||||
if (!pat) throw new Error("Git provider token not configured");
|
if (!pat) throw new Error("Git provider token not configured");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn, ChildProcess } from "child_process";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { getRepoPath } from "./workspace.service.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -21,7 +22,9 @@ export function startLspServer(): ChildProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverPath = getLspServerPath();
|
const serverPath = getLspServerPath();
|
||||||
|
const cwd = getRepoPath("nwn-module");
|
||||||
lspProcess = spawn("node", [serverPath, "--stdio"], {
|
lspProcess = spawn("node", [serverPath, "--stdio"], {
|
||||||
|
cwd,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,22 +189,33 @@ export async function seedDatabase(cdKey: string, playerName: string): Promise<v
|
|||||||
const docker = getDockerClient();
|
const docker = getDockerClient();
|
||||||
const container = docker.getContainer(MARIADB_NAME);
|
const container = docker.getContainer(MARIADB_NAME);
|
||||||
|
|
||||||
const exec = await container.exec({
|
const schemaPath = path.resolve(__dirname, "../../../db/schema.sql");
|
||||||
Cmd: [
|
let schemaSql: string;
|
||||||
"bash",
|
try {
|
||||||
"-c",
|
schemaSql = await fs.readFile(schemaPath, "utf-8");
|
||||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`,
|
} catch {
|
||||||
],
|
const altPath = path.resolve(__dirname, "../../db/schema.sql");
|
||||||
|
schemaSql = await fs.readFile(altPath, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaExec = await container.exec({
|
||||||
|
Cmd: ["bash", "-c", "mysql -u root -p$MYSQL_ROOT_PASSWORD nwn 2>&1"],
|
||||||
|
AttachStdin: true,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
});
|
});
|
||||||
await exec.start({});
|
const schemaStream = await schemaExec.start({ hijack: true, stdin: true });
|
||||||
|
schemaStream.write(schemaSql);
|
||||||
|
schemaStream.end();
|
||||||
|
await new Promise<void>((resolve) => schemaStream.on("end", resolve));
|
||||||
|
|
||||||
|
const safeKey = cdKey.replace(/'/g, "''");
|
||||||
|
const safeName = playerName.replace(/'/g, "''");
|
||||||
const dmExec = await container.exec({
|
const dmExec = await container.exec({
|
||||||
Cmd: [
|
Cmd: [
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${cdKey}', '${playerName}', 1);" 2>&1`,
|
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${safeKey}', '${safeName}', 1);" 2>&1`,
|
||||||
],
|
],
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { homedir } from "os";
|
||||||
|
|
||||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || path.join(homedir(), "Layonara Forge");
|
||||||
|
|
||||||
interface ForgeConfig {
|
interface ForgeConfig {
|
||||||
githubPat?: string;
|
githubPat?: string;
|
||||||
@@ -51,6 +52,7 @@ export async function readConfig(): Promise<ForgeConfig> {
|
|||||||
|
|
||||||
export async function writeConfig(config: ForgeConfig): Promise<void> {
|
export async function writeConfig(config: ForgeConfig): Promise<void> {
|
||||||
const configPath = path.join(WORKSPACE_PATH, "config", "forge.json");
|
const configPath = path.join(WORKSPACE_PATH, "config", "forge.json");
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const clients = new Set<WebSocket>();
|
|||||||
|
|
||||||
let wss: WebSocketServer;
|
let wss: WebSocketServer;
|
||||||
|
|
||||||
export function initWebSocket(server: Server): WebSocketServer {
|
export function initWebSocket(_server: Server): WebSocketServer {
|
||||||
wss = new WebSocketServer({ server, path: "/ws" });
|
wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
wss.on("connection", (ws) => {
|
||||||
clients.add(ws);
|
clients.add(ws);
|
||||||
@@ -26,6 +26,12 @@ export function initWebSocket(server: Server): WebSocketServer {
|
|||||||
return wss;
|
return wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleUpgrade(request: import("http").IncomingMessage, socket: import("stream").Duplex, head: Buffer): void {
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function broadcast(type: EventType, action: string, data: unknown): void {
|
export function broadcast(type: EventType, action: string, data: unknown): void {
|
||||||
const event: ForgeEvent = {
|
const event: ForgeEvent = {
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Layonara Forge</title>
|
<title>Layonara Forge</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,21 +5,24 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@fontsource-variable/alegreya": "^5.2.8",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource-variable/manrope": "^5.2.8",
|
||||||
|
"@typefox/monaco-editor-react": "^7.7.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"lucide-react": "^1.8.0",
|
||||||
|
"monaco-languageclient": "^10.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0"
|
||||||
"vscode-languageserver-protocol": "^3.17.5",
|
|
||||||
"vscode-ws-jsonrpc": "^3.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codingame/esbuild-import-meta-url-plugin": "^1.0.3",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from "react";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
|
||||||
import { Editor } from "./pages/Editor";
|
const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
|
||||||
import { Build } from "./pages/Build";
|
const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
|
||||||
import { Server } from "./pages/Server";
|
const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
|
||||||
import { Toolset } from "./pages/Toolset";
|
const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
|
||||||
import { Repos } from "./pages/Repos";
|
const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
|
||||||
import { Settings } from "./pages/Settings";
|
const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
|
||||||
import { Setup } from "./pages/Setup";
|
const Settings = lazy(() => import("./pages/Settings").then(m => ({ default: m.Settings })));
|
||||||
|
const Setup = lazy(() => import("./pages/Setup").then(m => ({ default: m.Setup })));
|
||||||
import { IDELayout } from "./layouts/IDELayout";
|
import { IDELayout } from "./layouts/IDELayout";
|
||||||
import { SetupLayout } from "./layouts/SetupLayout";
|
import { SetupLayout } from "./layouts/SetupLayout";
|
||||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||||
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
|
|||||||
|
|
||||||
const DEFAULT_REPO = "nwn-module";
|
const DEFAULT_REPO = "nwn-module";
|
||||||
|
|
||||||
|
function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
|
||||||
|
<span className="text-sm">Loading…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SetupGuard({ children }: { children: React.ReactNode }) {
|
function SetupGuard({ children }: { children: React.ReactNode }) {
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
const [needsSetup, setNeedsSetup] = useState(false);
|
const [needsSetup, setNeedsSetup] = useState(false);
|
||||||
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
|
|||||||
.finally(() => setChecking(false));
|
.finally(() => setChecking(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (checking) return null;
|
if (checking) return (
|
||||||
|
<div className="flex h-screen items-center justify-center" style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
|
||||||
|
<span style={{ fontFamily: "var(--font-heading)" }}>Loading Forge…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
if (needsSetup) return <Navigate to="/setup" replace />;
|
if (needsSetup) return <Navigate to="/setup" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -42,6 +55,38 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const editorState = useEditorState();
|
const editorState = useEditorState();
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||||
|
const [workspacePath, setWorkspacePath] = useState<string>("");
|
||||||
|
const hydratedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.workspace.getConfig().then((cfg) => {
|
||||||
|
const wp = (cfg.workspacePath as string) || "";
|
||||||
|
if (wp) setWorkspacePath(wp);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydratedRef.current) return;
|
||||||
|
hydratedRef.current = true;
|
||||||
|
const tabs = editorState.openTabs;
|
||||||
|
if (tabs.length === 0) return;
|
||||||
|
const stale = tabs.filter((t) => editorState.getContent(t) === undefined);
|
||||||
|
if (stale.length === 0) return;
|
||||||
|
Promise.allSettled(
|
||||||
|
stale.map(async (tabKey) => {
|
||||||
|
const idx = tabKey.indexOf(":");
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const repo = tabKey.slice(0, idx);
|
||||||
|
const filePath = tabKey.slice(idx + 1);
|
||||||
|
try {
|
||||||
|
const { content } = await api.editor.readFile(repo, filePath);
|
||||||
|
editorState.openFile(tabKey, content);
|
||||||
|
} catch {
|
||||||
|
editorState.closeFile(tabKey);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(
|
const handleFileSelect = useCallback(
|
||||||
async (repo: string, filePath: string) => {
|
async (repo: string, filePath: string) => {
|
||||||
@@ -73,6 +118,7 @@ export function App() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/setup" element={<SetupLayout />}>
|
<Route path="/setup" element={<SetupLayout />}>
|
||||||
<Route index element={<Setup />} />
|
<Route index element={<Setup />} />
|
||||||
@@ -87,7 +133,7 @@ export function App() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route
|
<Route
|
||||||
path="/editor"
|
path="/editor"
|
||||||
element={<Editor editorState={editorState} />}
|
element={<Editor editorState={editorState} workspacePath={workspacePath} />}
|
||||||
/>
|
/>
|
||||||
<Route path="build" element={<Build />} />
|
<Route path="build" element={<Build />} />
|
||||||
<Route path="server" element={<Server />} />
|
<Route path="server" element={<Server />} />
|
||||||
@@ -96,6 +142,7 @@ export function App() {
|
|||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
|
|
||||||
const COMMIT_TYPES = [
|
const COMMIT_TYPES = [
|
||||||
@@ -19,6 +19,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
const [issueRef, setIssueRef] = useState("");
|
const [issueRef, setIssueRef] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const preview = useMemo(() => {
|
const preview = useMemo(() => {
|
||||||
let msg = `${type}${scope ? `(${scope})` : ""}: ${description}`;
|
let msg = `${type}${scope ? `(${scope})` : ""}: ${description}`;
|
||||||
@@ -29,6 +30,16 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
|
|
||||||
const isValid = description.trim().length > 0 && description.length <= 100;
|
const isValid = description.trim().length > 0 && description.length <= 100;
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
async function handleSubmit(andPush: boolean) {
|
async function handleSubmit(andPush: boolean) {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -46,22 +57,59 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-lg rounded-lg border p-6"
|
onClick={onClose}
|
||||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
style={{
|
||||||
onClick={(e) => e.stopPropagation()}
|
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
|
Commit Changes
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="mb-3 flex gap-2">
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value)}
|
onChange={(e) => setType(e.target.value)}
|
||||||
className="rounded border px-2 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{COMMIT_TYPES.map((t) => (
|
{COMMIT_TYPES.map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
@@ -72,8 +120,15 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
value={scope}
|
value={scope}
|
||||||
onChange={(e) => setScope(e.target.value)}
|
onChange={(e) => setScope(e.target.value)}
|
||||||
placeholder="scope (optional)"
|
placeholder="scope (optional)"
|
||||||
className="w-28 rounded border px-2 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
width: "7rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,8 +137,17 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value.slice(0, 100))}
|
onChange={(e) => setDescription(e.target.value.slice(0, 100))}
|
||||||
placeholder="Description (required, max 100 chars)"
|
placeholder="Description (required, max 100 chars)"
|
||||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
width: "100%",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -91,8 +155,18 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
placeholder="Body (optional)"
|
placeholder="Body (optional)"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
width: "100%",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
resize: "vertical",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -100,40 +174,91 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
|
|||||||
value={issueRef}
|
value={issueRef}
|
||||||
onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))}
|
onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))}
|
||||||
placeholder="Issue # (auto-formats as Fixes #NNN)"
|
placeholder="Issue # (auto-formats as Fixes #NNN)"
|
||||||
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
width: "100%",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "'JetBrains Mono', monospace" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
|
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
|
||||||
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
|
<pre style={{ marginTop: "0.25rem", whiteSpace: "pre-wrap", color: "var(--forge-text)" }}>{preview}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-3 rounded bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</div>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-danger-bg)",
|
||||||
|
color: "var(--forge-danger)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded border px-3 py-1.5 text-sm"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmit(false)}
|
onClick={() => handleSubmit(false)}
|
||||||
disabled={!isValid || loading}
|
disabled={!isValid || loading}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-accent)",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
opacity: !isValid || loading ? 0.5 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Commit
|
Commit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmit(true)}
|
onClick={() => handleSubmit(true)}
|
||||||
disabled={!isValid || loading}
|
disabled={!isValid || loading}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
style={{
|
||||||
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-warning-border)",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: "var(--forge-warning-bg)",
|
||||||
|
color: "var(--forge-warning)",
|
||||||
|
opacity: !isValid || loading ? 0.5 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Commit & Push
|
Commit & Push
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,8 +28,17 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError && this.state.error) {
|
if (this.state.hasError && this.state.error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-8">
|
<div
|
||||||
<div className="w-full max-w-lg">
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: "100%", maxWidth: "32rem" }}>
|
||||||
<ErrorDisplay
|
<ErrorDisplay
|
||||||
title="Render Error"
|
title="Render Error"
|
||||||
message={this.state.error.message}
|
message={this.state.error.message}
|
||||||
|
|||||||
@@ -22,35 +22,48 @@ export function ErrorDisplay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-6"
|
|
||||||
style={{
|
style={{
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
padding: "1.5rem",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
border: "1px solid #7f1d1d",
|
border: "1px solid var(--forge-danger-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold" style={{ color: "#fca5a5" }}>
|
<h3 style={{ fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-danger)", margin: 0 }}>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
|
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{fullLog && (
|
{fullLog && (
|
||||||
<div className="mt-4">
|
<div style={{ marginTop: "1rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
className="text-xs underline"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
fontSize: "var(--text-xs)",
|
||||||
|
textDecoration: "underline",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{expanded ? "Hide Full Log" : "Show Full Log"}
|
{expanded ? "Hide Full Log" : "Show Full Log"}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<pre
|
<pre
|
||||||
className="mt-2 max-h-60 overflow-auto rounded p-3 text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
maxHeight: "15rem",
|
||||||
|
overflow: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fullLog}
|
{fullLog}
|
||||||
@@ -59,22 +72,32 @@ export function ErrorDisplay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2">
|
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
className="rounded px-4 py-2 text-sm font-semibold"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={copyError}
|
onClick={copyError}
|
||||||
className="rounded px-4 py-2 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
border: "1px solid var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy Error
|
Copy Error
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export function useToast() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
|
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
|
||||||
success: { bg: "#052e16", border: "#166534", text: "#4ade80" },
|
success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
|
||||||
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" },
|
error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
|
||||||
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" },
|
info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_DISMISS: Record<ToastType, number | null> = {
|
const AUTO_DISMISS: Record<ToastType, number | null> = {
|
||||||
@@ -49,7 +49,7 @@ function ToastItem({
|
|||||||
}) {
|
}) {
|
||||||
const { bg, border, text } = COLORS[toast.type];
|
const { bg, border, text } = COLORS[toast.type];
|
||||||
const timeout = AUTO_DISMISS[toast.type];
|
const timeout = AUTO_DISMISS[toast.type];
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
@@ -60,14 +60,37 @@ function ToastItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm shadow-lg"
|
style={{
|
||||||
style={{ backgroundColor: bg, border: `1px solid ${border}`, color: text }}
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "0.5rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: bg,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
color: text,
|
||||||
|
boxShadow: "0 10px 15px -3px oklch(0% 0 0 / 0.2), 0 4px 6px -4px oklch(0% 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex-1">{toast.message}</span>
|
<span style={{ flex: 1 }}>{toast.message}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDismiss(toast.id)}
|
onClick={() => onDismiss(toast.id)}
|
||||||
className="ml-2 shrink-0 opacity-60 hover:opacity-100"
|
aria-label="Dismiss notification"
|
||||||
style={{ color: text }}
|
style={{
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0.6,
|
||||||
|
color: text,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: "1rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = "1"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = "0.6"; }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -92,7 +115,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ showToast }}>
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
{children}
|
{children}
|
||||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.5rem",
|
||||||
|
maxWidth: "360px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
path: string;
|
path: string;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
@@ -11,7 +14,96 @@ interface EditorTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filename(path: string): string {
|
function filename(path: string): string {
|
||||||
return path.split("/").pop() ?? path;
|
const parts = path.split(":");
|
||||||
|
const filePart = parts.length > 1 ? parts[1] : path;
|
||||||
|
return filePart.split("/").pop() ?? filePart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
tab,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
tab: Tab;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
tabIndex={0}
|
||||||
|
title={tab.path}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(); } }}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: isActive ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||||
|
backgroundColor: isActive ? "var(--forge-bg)" : "transparent",
|
||||||
|
borderBottom: isActive ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||||
|
borderRight: "1px solid var(--forge-border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "color 150ms ease-out, background-color 150ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "0.375rem" }}>
|
||||||
|
{filename(tab.path)}
|
||||||
|
{tab.dirty && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "0.5rem",
|
||||||
|
height: "0.5rem",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close tab"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "1.125rem",
|
||||||
|
height: "1.125rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
opacity: hovered || isActive ? 1 : 0,
|
||||||
|
transition: "opacity 150ms ease-out, background-color 150ms ease-out",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorTabs({
|
export function EditorTabs({
|
||||||
@@ -24,51 +116,24 @@ export function EditorTabs({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex overflow-x-auto"
|
role="tablist"
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
overflowX: "auto",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
borderBottom: "1px solid var(--forge-border)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => (
|
||||||
const isActive = tab.path === activeTab;
|
<TabButton
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.path}
|
key={tab.path}
|
||||||
title={tab.path}
|
tab={tab}
|
||||||
onClick={() => onSelect(tab.path)}
|
isActive={tab.path === activeTab}
|
||||||
className="group relative flex shrink-0 items-center gap-1.5 px-3 py-2 text-sm transition-colors"
|
onSelect={() => onSelect(tab.path)}
|
||||||
style={{
|
onClose={() => onClose(tab.path)}
|
||||||
color: isActive
|
|
||||||
? "var(--forge-text)"
|
|
||||||
: "var(--forge-text-secondary)",
|
|
||||||
borderBottom: isActive
|
|
||||||
? "2px solid var(--forge-accent)"
|
|
||||||
: "2px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{filename(tab.path)}
|
|
||||||
{tab.dirty && (
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: "var(--forge-accent)" }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose(tab.path);
|
|
||||||
}}
|
|
||||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
|
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { api, type FileNode } from "../../services/api";
|
import { api, type FileNode } from "../../services/api";
|
||||||
|
import {
|
||||||
|
FileCode2,
|
||||||
|
FileJson,
|
||||||
|
FileText,
|
||||||
|
FileType2,
|
||||||
|
FileImage,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
interface FileExplorerProps {
|
interface FileExplorerProps {
|
||||||
repo: string;
|
repo: string;
|
||||||
@@ -7,32 +21,29 @@ interface FileExplorerProps {
|
|||||||
onFileSelect: (repo: string, filePath: string) => void;
|
onFileSelect: (repo: string, filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileIcon(name: string): string {
|
function getFileIcon(name: string): LucideIcon {
|
||||||
const ext = name.split(".").pop()?.toLowerCase();
|
const ext = name.split(".").pop()?.toLowerCase();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case "nss":
|
case "nss":
|
||||||
return "S";
|
case "ncs":
|
||||||
|
return FileCode2;
|
||||||
case "json":
|
case "json":
|
||||||
return "J";
|
return FileJson;
|
||||||
case "xml":
|
case "xml":
|
||||||
case "html":
|
case "html":
|
||||||
return "<>";
|
|
||||||
case "md":
|
|
||||||
return "M";
|
|
||||||
case "yml":
|
case "yml":
|
||||||
case "yaml":
|
case "yaml":
|
||||||
return "Y";
|
return FileType2;
|
||||||
|
case "md":
|
||||||
|
return FileText;
|
||||||
case "png":
|
case "png":
|
||||||
case "jpg":
|
case "jpg":
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
case "gif":
|
case "gif":
|
||||||
case "bmp":
|
case "bmp":
|
||||||
case "tga":
|
case "tga":
|
||||||
return "I";
|
return FileImage;
|
||||||
case "2da":
|
case "2da":
|
||||||
return "2";
|
|
||||||
case "ncs":
|
|
||||||
return "C";
|
|
||||||
case "git":
|
case "git":
|
||||||
case "are":
|
case "are":
|
||||||
case "ifo":
|
case "ifo":
|
||||||
@@ -48,9 +59,9 @@ function getFileIcon(name: string): string {
|
|||||||
case "dlg":
|
case "dlg":
|
||||||
case "jrl":
|
case "jrl":
|
||||||
case "fac":
|
case "fac":
|
||||||
return "N";
|
return FileText;
|
||||||
default:
|
default:
|
||||||
return "F";
|
return File;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +79,7 @@ function FileTreeNode({
|
|||||||
repo: string;
|
repo: string;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(depth === 0);
|
const [expanded, setExpanded] = useState(depth === 0);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
const isSelected = selectedPath === node.path;
|
const isSelected = selectedPath === node.path;
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
@@ -78,35 +90,56 @@ function FileTreeNode({
|
|||||||
}
|
}
|
||||||
}, [node, repo, onFileSelect]);
|
}, [node, repo, onFileSelect]);
|
||||||
|
|
||||||
|
const FileIcon = node.type === "directory"
|
||||||
|
? (expanded ? FolderOpen : Folder)
|
||||||
|
: getFileIcon(node.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="flex w-full items-center gap-1 px-1 py-0.5 text-left text-sm transition-colors hover:bg-white/5"
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
paddingLeft: `${depth * 16 + 8}px`,
|
paddingLeft: `${depth * 16 + 8}px`,
|
||||||
backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
|
paddingRight: "0.25rem",
|
||||||
|
paddingTop: "0.125rem",
|
||||||
|
paddingBottom: "0.125rem",
|
||||||
|
textAlign: "left",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? "var(--forge-surface)"
|
||||||
|
: hovered
|
||||||
|
? "var(--forge-surface-raised)"
|
||||||
|
: "transparent",
|
||||||
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 100ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.type === "directory" ? (
|
{node.type === "directory" ? (
|
||||||
<span
|
<span style={{ display: "flex", alignItems: "center", width: "16px", justifyContent: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||||
className="inline-block w-4 text-center text-xs"
|
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{expanded ? "\u25BC" : "\u25B6"}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span style={{ width: "16px", flexShrink: 0 }} />
|
||||||
className="inline-block w-4 text-center text-xs font-bold"
|
|
||||||
style={{ color: "var(--forge-accent)", fontSize: "10px" }}
|
|
||||||
>
|
|
||||||
{getFileIcon(node.name)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{node.name}</span>
|
<FileIcon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
color: node.type === "directory" ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{node.type === "directory" && expanded && node.children && (
|
{node.type === "directory" && expanded && node.children && (
|
||||||
<div>
|
<div>
|
||||||
@@ -155,44 +188,77 @@ export function FileExplorer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full flex-col overflow-hidden"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
display: "flex",
|
||||||
|
height: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-3 py-2"
|
style={{
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-semibold uppercase tracking-wider"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Explorer
|
Explorer
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={loadTree}
|
onClick={loadTree}
|
||||||
className="rounded p-1 text-xs transition-colors hover:bg-white/10"
|
aria-label="Refresh file tree"
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
style={{
|
||||||
title="Refresh"
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "0.25rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
↻
|
<RefreshCw size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div style={{ flex: 1, overflowY: "auto", padding: "0.25rem 0" }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 py-4 text-sm text-red-400">
|
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
{error}
|
{error.includes("ENOENT") ? (
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: 0, fontWeight: 500, color: "var(--forge-text)" }}>Repository not cloned</p>
|
||||||
|
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)" }}>
|
||||||
|
Clone repositories from the Repos page or run the setup wizard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: 0, color: "var(--forge-danger)" }}>{error}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && tree.length === 0 && (
|
{!loading && !error && tree.length === 0 && (
|
||||||
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
No files found
|
No files found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,208 +1,105 @@
|
|||||||
import { useRef, useCallback, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
|
import { LogLevel } from "@codingame/monaco-vscode-api";
|
||||||
import type { editor } from "monaco-editor";
|
import {
|
||||||
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js";
|
MonacoEditorReactComp,
|
||||||
|
} from "@typefox/monaco-editor-react";
|
||||||
|
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
|
||||||
|
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
|
||||||
|
import type { LanguageClientConfig } from "monaco-languageclient/lcwrapper";
|
||||||
|
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
|
||||||
|
import "../../nwscript-extension/index.js";
|
||||||
|
|
||||||
interface MonacoEditorProps {
|
function getVscodeApiConfig(): MonacoVscodeApiConfig {
|
||||||
filePath: string;
|
return {
|
||||||
content: string;
|
$type: "extended",
|
||||||
language?: string;
|
viewsConfig: {
|
||||||
onChange?: (value: string) => void;
|
$type: "EditorService",
|
||||||
}
|
|
||||||
|
|
||||||
let nwscriptRegistered = false;
|
|
||||||
|
|
||||||
function registerNWScript(monaco: Parameters<OnMount>[1]) {
|
|
||||||
if (nwscriptRegistered) return;
|
|
||||||
nwscriptRegistered = true;
|
|
||||||
|
|
||||||
monaco.languages.register({ id: "nwscript", extensions: [".nss"] });
|
|
||||||
|
|
||||||
monaco.languages.setMonarchTokensProvider("nwscript", {
|
|
||||||
keywords: [
|
|
||||||
"void",
|
|
||||||
"int",
|
|
||||||
"float",
|
|
||||||
"string",
|
|
||||||
"object",
|
|
||||||
"effect",
|
|
||||||
"itemproperty",
|
|
||||||
"location",
|
|
||||||
"vector",
|
|
||||||
"action",
|
|
||||||
"talent",
|
|
||||||
"event",
|
|
||||||
"struct",
|
|
||||||
"if",
|
|
||||||
"else",
|
|
||||||
"while",
|
|
||||||
"for",
|
|
||||||
"do",
|
|
||||||
"switch",
|
|
||||||
"case",
|
|
||||||
"default",
|
|
||||||
"break",
|
|
||||||
"continue",
|
|
||||||
"return",
|
|
||||||
"const",
|
|
||||||
],
|
|
||||||
|
|
||||||
constants: ["TRUE", "FALSE", "OBJECT_SELF", "OBJECT_INVALID"],
|
|
||||||
|
|
||||||
typeKeywords: [
|
|
||||||
"void",
|
|
||||||
"int",
|
|
||||||
"float",
|
|
||||||
"string",
|
|
||||||
"object",
|
|
||||||
"effect",
|
|
||||||
"itemproperty",
|
|
||||||
"location",
|
|
||||||
"vector",
|
|
||||||
"action",
|
|
||||||
"talent",
|
|
||||||
"event",
|
|
||||||
"struct",
|
|
||||||
],
|
|
||||||
|
|
||||||
operators: [
|
|
||||||
"=",
|
|
||||||
">",
|
|
||||||
"<",
|
|
||||||
"!",
|
|
||||||
"~",
|
|
||||||
"?",
|
|
||||||
":",
|
|
||||||
"==",
|
|
||||||
"<=",
|
|
||||||
">=",
|
|
||||||
"!=",
|
|
||||||
"&&",
|
|
||||||
"||",
|
|
||||||
"++",
|
|
||||||
"--",
|
|
||||||
"+",
|
|
||||||
"-",
|
|
||||||
"*",
|
|
||||||
"/",
|
|
||||||
"&",
|
|
||||||
"|",
|
|
||||||
"^",
|
|
||||||
"%",
|
|
||||||
"<<",
|
|
||||||
">>",
|
|
||||||
"+=",
|
|
||||||
"-=",
|
|
||||||
"*=",
|
|
||||||
"/=",
|
|
||||||
"&=",
|
|
||||||
"|=",
|
|
||||||
"^=",
|
|
||||||
"%=",
|
|
||||||
],
|
|
||||||
|
|
||||||
symbols: /[=><!~?:&|+\-*/^%]+/,
|
|
||||||
|
|
||||||
tokenizer: {
|
|
||||||
root: [
|
|
||||||
[/#include\b/, "keyword.preprocessor"],
|
|
||||||
[/#define\b/, "keyword.preprocessor"],
|
|
||||||
|
|
||||||
[
|
|
||||||
/[a-zA-Z_]\w*/,
|
|
||||||
{
|
|
||||||
cases: {
|
|
||||||
"@constants": "constant",
|
|
||||||
"@typeKeywords": "type",
|
|
||||||
"@keywords": "keyword",
|
|
||||||
"@default": "identifier",
|
|
||||||
},
|
},
|
||||||
|
userConfiguration: {
|
||||||
|
json: JSON.stringify({
|
||||||
|
"workbench.colorTheme": "Default Dark Modern",
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"editor.background": "#231e17",
|
||||||
|
"editor.foreground": "#ece8e3",
|
||||||
|
"editor.lineHighlightBackground": "#302a2040",
|
||||||
|
"editor.selectionBackground": "#3d3018",
|
||||||
|
"editor.selectionHighlightBackground": "#3d301860",
|
||||||
|
"editor.inactiveSelectionBackground": "#3d301850",
|
||||||
|
"editor.findMatchBackground": "#b07a0040",
|
||||||
|
"editor.findMatchHighlightBackground": "#b07a0025",
|
||||||
|
"editor.hoverHighlightBackground": "#3d301830",
|
||||||
|
"editorCursor.foreground": "#b07a00",
|
||||||
|
"editorWhitespace.foreground": "#4a403550",
|
||||||
|
"editorIndentGuide.background": "#4a403530",
|
||||||
|
"editorIndentGuide.activeBackground": "#4a403580",
|
||||||
|
"editorLineNumber.foreground": "#a69f9650",
|
||||||
|
"editorLineNumber.activeForeground": "#ece8e3",
|
||||||
|
"editorBracketMatch.background": "#3d301840",
|
||||||
|
"editorBracketMatch.border": "#b07a0080",
|
||||||
|
"editorOverviewRuler.border": "#4a4035",
|
||||||
|
"editorGutter.background": "#231e17",
|
||||||
|
"editorWidget.background": "#3b3328",
|
||||||
|
"editorWidget.foreground": "#ece8e3",
|
||||||
|
"editorWidget.border": "#4a4035",
|
||||||
|
"editorSuggestWidget.background": "#3b3328",
|
||||||
|
"editorSuggestWidget.border": "#4a4035",
|
||||||
|
"editorSuggestWidget.foreground": "#ece8e3",
|
||||||
|
"editorSuggestWidget.highlightForeground": "#b07a00",
|
||||||
|
"editorSuggestWidget.selectedBackground": "#3d3018",
|
||||||
|
"editorHoverWidget.background": "#3b3328",
|
||||||
|
"editorHoverWidget.border": "#4a4035",
|
||||||
|
"editorGroupHeader.tabsBackground": "#302a20",
|
||||||
|
"editorGroupHeader.tabsBorder": "#4a4035",
|
||||||
|
"editorGroup.border": "#4a4035",
|
||||||
|
"tab.activeBackground": "#231e17",
|
||||||
|
"tab.activeForeground": "#ece8e3",
|
||||||
|
"tab.activeBorderTop": "#b07a00",
|
||||||
|
"tab.inactiveBackground": "#302a20",
|
||||||
|
"tab.inactiveForeground": "#a69f96",
|
||||||
|
"tab.border": "#4a4035",
|
||||||
|
"input.background": "#231e17",
|
||||||
|
"input.foreground": "#ece8e3",
|
||||||
|
"input.border": "#4a4035",
|
||||||
|
"input.placeholderForeground": "#a69f9680",
|
||||||
|
"inputOption.activeBorder": "#b07a00",
|
||||||
|
"dropdown.background": "#3b3328",
|
||||||
|
"dropdown.foreground": "#ece8e3",
|
||||||
|
"dropdown.border": "#4a4035",
|
||||||
|
"list.activeSelectionBackground": "#3d3018",
|
||||||
|
"list.activeSelectionForeground": "#ece8e3",
|
||||||
|
"list.inactiveSelectionBackground": "#3d301880",
|
||||||
|
"list.hoverBackground": "#302a2080",
|
||||||
|
"list.highlightForeground": "#b07a00",
|
||||||
|
"scrollbarSlider.background": "#4a403540",
|
||||||
|
"scrollbarSlider.hoverBackground": "#4a403580",
|
||||||
|
"scrollbarSlider.activeBackground": "#4a4035a0",
|
||||||
|
"focusBorder": "#b07a0080",
|
||||||
|
"peekView.border": "#b07a00",
|
||||||
|
"peekViewEditor.background": "#231e17",
|
||||||
|
"peekViewResult.background": "#302a20",
|
||||||
|
"peekViewTitle.background": "#302a20",
|
||||||
|
"minimap.selectionHighlight": "#3d3018",
|
||||||
|
"minimap.findMatchHighlight": "#b07a0060",
|
||||||
},
|
},
|
||||||
],
|
"editor.fontSize": 14,
|
||||||
|
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
|
||||||
{ include: "@whitespace" },
|
"editor.tabSize": 4,
|
||||||
|
"editor.insertSpaces": true,
|
||||||
[/[{}()[\]]/, "@brackets"],
|
"editor.minimap.enabled": false,
|
||||||
[/[;,.]/, "delimiter"],
|
"editor.scrollBeyondLastLine": false,
|
||||||
|
"editor.wordWrap": "off",
|
||||||
[
|
"editor.renderWhitespace": "selection",
|
||||||
/@symbols/,
|
"editor.bracketPairColorization.enabled": true,
|
||||||
{
|
"editor.padding.top": 8,
|
||||||
cases: {
|
"editor.lineNumbers": "on",
|
||||||
"@operators": "operator",
|
"editor.guides.bracketPairsHorizontal": "active",
|
||||||
"@default": "",
|
"editor.wordBasedSuggestions": "off",
|
||||||
|
"editor.quickSuggestions": true,
|
||||||
|
"editor.parameterHints.enabled": true,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
monacoWorkerFactory: configureDefaultWorkerFactory,
|
||||||
],
|
};
|
||||||
|
|
||||||
[/\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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function languageFromPath(filePath: string): string {
|
function languageFromPath(filePath: string): string {
|
||||||
@@ -219,69 +116,96 @@ function languageFromPath(filePath: string): string {
|
|||||||
md: "markdown",
|
md: "markdown",
|
||||||
txt: "plaintext",
|
txt: "plaintext",
|
||||||
"2da": "plaintext",
|
"2da": "plaintext",
|
||||||
|
cfg: "ini",
|
||||||
|
sh: "shellscript",
|
||||||
};
|
};
|
||||||
return map[ext ?? ""] ?? "plaintext";
|
return map[ext ?? ""] ?? "plaintext";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MonacoEditorProps {
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
language?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
workspacePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function MonacoEditor({
|
export function MonacoEditor({
|
||||||
filePath,
|
filePath,
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
onChange,
|
onChange,
|
||||||
|
workspacePath,
|
||||||
}: MonacoEditorProps) {
|
}: MonacoEditorProps) {
|
||||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
|
||||||
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
|
|
||||||
|
|
||||||
const resolvedLang = language ?? languageFromPath(filePath);
|
const resolvedLang = language ?? languageFromPath(filePath);
|
||||||
useLspClient(monacoRef);
|
const isNwscript = resolvedLang === "nwscript";
|
||||||
useLspDocument(editorRef.current, filePath, resolvedLang);
|
|
||||||
|
|
||||||
const handleMount: OnMount = useCallback(
|
const fileUri = workspacePath
|
||||||
(editorInstance, monaco) => {
|
? `file://${workspacePath}/repos/nwn-module/${filePath}`
|
||||||
editorRef.current = editorInstance;
|
: `file:///workspace/repos/nwn-module/${filePath}`;
|
||||||
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
|
|
||||||
registerNWScript(monaco);
|
|
||||||
defineForgeTheme(monaco);
|
|
||||||
monaco.editor.setTheme("forge-dark");
|
|
||||||
|
|
||||||
const model = editorInstance.getModel();
|
const editorAppConfig = useMemo<EditorAppConfig>(
|
||||||
if (model) {
|
() => ({
|
||||||
const lang = language ?? languageFromPath(filePath);
|
codeResources: {
|
||||||
monaco.editor.setModelLanguage(model, lang);
|
modified: {
|
||||||
}
|
text: content,
|
||||||
|
uri: fileUri,
|
||||||
|
enforceLanguageId: resolvedLang,
|
||||||
},
|
},
|
||||||
[filePath, language],
|
},
|
||||||
|
editorOptions: {
|
||||||
|
automaticLayout: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[filePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const languageClientConfig = useMemo<LanguageClientConfig | undefined>(() => {
|
||||||
(value: string | undefined) => {
|
if (!isNwscript) return undefined;
|
||||||
if (value !== undefined) {
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
onChange?.(value);
|
return {
|
||||||
|
languageId: "nwscript",
|
||||||
|
connection: {
|
||||||
|
options: {
|
||||||
|
$type: "WebSocketUrl" as const,
|
||||||
|
url: `${protocol}//${window.location.host}/ws/lsp`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientOptions: {
|
||||||
|
documentSelector: ["nwscript"],
|
||||||
|
workspaceFolder: workspacePath
|
||||||
|
? {
|
||||||
|
index: 0,
|
||||||
|
name: "nwn-module",
|
||||||
|
uri: { scheme: "file", path: `${workspacePath}/repos/nwn-module` } as any,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [isNwscript, workspacePath]);
|
||||||
|
|
||||||
|
const handleTextChanged = useCallback(
|
||||||
|
(textChanges: TextContents) => {
|
||||||
|
if (textChanges.modified !== undefined) {
|
||||||
|
onChange?.(textChanges.modified);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleError = useCallback((error: Error) => {
|
||||||
|
console.error("[MonacoEditor]", error.message, error.stack);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMonacoEditor
|
<MonacoEditorReactComp
|
||||||
value={content}
|
style={{ width: "100%", height: "100%" }}
|
||||||
language={language ?? languageFromPath(filePath)}
|
vscodeApiConfig={getVscodeApiConfig()}
|
||||||
theme="vs-dark"
|
editorAppConfig={editorAppConfig}
|
||||||
onChange={handleChange}
|
languageClientConfig={languageClientConfig}
|
||||||
onMount={handleMount}
|
onTextChanged={handleTextChanged}
|
||||||
options={{
|
onError={handleError}
|
||||||
minimap: { enabled: false },
|
logLevel={LogLevel.Warning}
|
||||||
fontSize: 14,
|
|
||||||
lineNumbers: "on",
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
automaticLayout: true,
|
|
||||||
tabSize: 4,
|
|
||||||
insertSpaces: true,
|
|
||||||
wordWrap: "off",
|
|
||||||
renderWhitespace: "selection",
|
|
||||||
bracketPairColorization: { enabled: true },
|
|
||||||
padding: { top: 8 },
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { api } from "../../services/api";
|
import { api } from "../../services/api";
|
||||||
|
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
interface SearchMatch {
|
interface SearchMatch {
|
||||||
file: string;
|
file: string;
|
||||||
@@ -32,6 +33,8 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||||
|
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
|
||||||
|
const [hoveredMatch, setHoveredMatch] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const doSearch = useCallback(async () => {
|
const doSearch = useCallback(async () => {
|
||||||
@@ -82,50 +85,71 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
|
|
||||||
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
|
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||||
backgroundColor: active ? "var(--forge-accent)" : "transparent",
|
backgroundColor: active ? "var(--forge-accent)" : "transparent",
|
||||||
color: active ? "#121212" : "var(--forge-text-secondary)",
|
color: active ? "var(--forge-accent-text)" : "var(--forge-text-secondary)",
|
||||||
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
|
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
lineHeight: "1",
|
lineHeight: "1",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.375rem",
|
||||||
|
cursor: "pointer",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full flex-col overflow-hidden"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
display: "flex",
|
||||||
|
height: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-3 py-2"
|
style={{
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-semibold uppercase tracking-wider"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 px-3 py-2" style={{ borderBottom: "1px solid var(--forge-border)" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--forge-border)" }}>
|
||||||
<div className="flex items-center gap-1">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
className="flex-1 rounded px-2 py-1 text-sm outline-none"
|
aria-label="Search query"
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
border: "1px solid var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRegex((v) => !v)}
|
onClick={() => setRegex((v) => !v)}
|
||||||
className="rounded px-1.5 py-1"
|
|
||||||
style={toggleBtnStyle(regex)}
|
style={toggleBtnStyle(regex)}
|
||||||
title="Use Regular Expression"
|
title="Use Regular Expression"
|
||||||
>
|
>
|
||||||
@@ -133,7 +157,6 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCaseSensitive((v) => !v)}
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
className="rounded px-1.5 py-1"
|
|
||||||
style={toggleBtnStyle(caseSensitive)}
|
style={toggleBtnStyle(caseSensitive)}
|
||||||
title="Match Case"
|
title="Match Case"
|
||||||
>
|
>
|
||||||
@@ -141,27 +164,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
<input
|
<input
|
||||||
value={includePattern}
|
value={includePattern}
|
||||||
onChange={(e) => setIncludePattern(e.target.value)}
|
onChange={(e) => setIncludePattern(e.target.value)}
|
||||||
placeholder="Include (e.g. *.nss)"
|
placeholder="Include (e.g. *.nss)"
|
||||||
className="flex-1 rounded px-2 py-1 text-xs outline-none"
|
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
border: "1px solid var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={excludePattern}
|
value={excludePattern}
|
||||||
onChange={(e) => setExcludePattern(e.target.value)}
|
onChange={(e) => setExcludePattern(e.target.value)}
|
||||||
placeholder="Exclude (e.g. *.json)"
|
placeholder="Exclude (e.g. *.json)"
|
||||||
className="flex-1 rounded px-2 py-1 text-xs outline-none"
|
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
border: "1px solid var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,25 +200,34 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={doSearch}
|
onClick={doSearch}
|
||||||
disabled={loading || !query.trim()}
|
disabled={loading || !query.trim()}
|
||||||
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 500,
|
||||||
backgroundColor: "var(--forge-accent)",
|
backgroundColor: "var(--forge-accent)",
|
||||||
color: "#121212",
|
color: "var(--forge-accent-text)",
|
||||||
|
border: "none",
|
||||||
|
cursor: loading || !query.trim() ? "not-allowed" : "pointer",
|
||||||
|
opacity: loading || !query.trim() ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? "Searching..." : "Search"}
|
{loading ? "Searching..." : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 py-2 text-sm text-red-400">{error}</div>
|
<div style={{ padding: "0.5rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searched && !loading && !error && (
|
{searched && !loading && !error && (
|
||||||
<div
|
<div
|
||||||
className="px-3 py-1.5 text-xs"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
borderBottom: "1px solid var(--forge-border)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
}}
|
}}
|
||||||
@@ -202,24 +242,44 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
<div key={group.file}>
|
<div key={group.file}>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCollapsed(group.file)}
|
onClick={() => toggleCollapsed(group.file)}
|
||||||
className="flex w-full items-center gap-1 px-3 py-1 text-left text-xs transition-colors hover:bg-white/5"
|
onMouseEnter={() => setHoveredFile(group.file)}
|
||||||
style={{ color: "var(--forge-text)" }}
|
onMouseLeave={() => setHoveredFile(null)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
border: "none",
|
||||||
|
background: hoveredFile === group.file ? "var(--forge-surface-raised)" : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 100ms ease-out",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span style={{ display: "flex", alignItems: "center", width: "12px", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||||
className="inline-block w-3 text-center"
|
{collapsed.has(group.file) ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||||
style={{ color: "var(--forge-text-secondary)", fontSize: "10px" }}
|
|
||||||
>
|
|
||||||
{collapsed.has(group.file) ? "\u25B6" : "\u25BC"}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="flex-1 truncate font-medium"
|
style={{
|
||||||
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }}
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{group.file}
|
{group.file}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="rounded-full px-1.5 text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
|
borderRadius: "9999px",
|
||||||
|
padding: "0 0.375rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
}}
|
}}
|
||||||
@@ -229,18 +289,35 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed.has(group.file) &&
|
{!collapsed.has(group.file) &&
|
||||||
group.matches.map((match, i) => (
|
group.matches.map((match, i) => {
|
||||||
|
const matchKey = `${match.file}-${match.line}-${match.column}-${i}`;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${match.line}-${match.column}-${i}`}
|
key={matchKey}
|
||||||
onClick={() => onResultClick(match.file, match.line)}
|
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"
|
onMouseEnter={() => setHoveredMatch(matchKey)}
|
||||||
style={{ paddingLeft: "28px" }}
|
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
|
<span
|
||||||
className="shrink-0 text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
fontSize: "11px",
|
fontSize: "11px",
|
||||||
minWidth: "32px",
|
minWidth: "32px",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
@@ -249,9 +326,11 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
|
|||||||
{match.line}
|
{match.line}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="truncate text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
color: "var(--forge-text-secondary)",
|
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} />
|
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +363,7 @@ function HighlightedLine({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span>{before}</span>
|
<span>{before}</span>
|
||||||
<span className="font-bold" style={{ color: "var(--forge-accent)" }}>
|
<span style={{ fontWeight: 700, color: "var(--forge-accent)" }}>
|
||||||
{matched}
|
{matched}
|
||||||
</span>
|
</span>
|
||||||
<span>{after}</span>
|
<span>{after}</span>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
|
||||||
|
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
|
||||||
|
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
|
||||||
|
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
|
||||||
|
|
||||||
|
function getVscodeApiConfig(): MonacoVscodeApiConfig {
|
||||||
|
return {
|
||||||
|
$type: "extended",
|
||||||
|
viewsConfig: { $type: "EditorService" },
|
||||||
|
userConfiguration: {
|
||||||
|
json: JSON.stringify({
|
||||||
|
"workbench.colorTheme": "Default Dark Modern",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
monacoWorkerFactory: configureDefaultWorkerFactory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleEditorProps {
|
||||||
|
value: string;
|
||||||
|
language?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleEditor({
|
||||||
|
value,
|
||||||
|
language = "plaintext",
|
||||||
|
onChange,
|
||||||
|
readOnly,
|
||||||
|
}: SimpleEditorProps) {
|
||||||
|
const uri = useMemo(
|
||||||
|
() => `inmemory://simple-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorAppConfig = useMemo<EditorAppConfig>(
|
||||||
|
() => ({
|
||||||
|
codeResources: {
|
||||||
|
modified: {
|
||||||
|
text: value,
|
||||||
|
uri,
|
||||||
|
enforceLanguageId: language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editorOptions: {
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
readOnly,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTextChanged = useCallback(
|
||||||
|
(textChanges: TextContents) => {
|
||||||
|
if (textChanges.modified !== undefined) {
|
||||||
|
onChange?.(textChanges.modified);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonacoEditorReactComp
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
vscodeApiConfig={getVscodeApiConfig()}
|
||||||
|
editorAppConfig={editorAppConfig}
|
||||||
|
onTextChanged={handleTextChanged}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleDiffEditorProps {
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
language?: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleDiffEditor({
|
||||||
|
original,
|
||||||
|
modified,
|
||||||
|
language = "plaintext",
|
||||||
|
}: SimpleDiffEditorProps) {
|
||||||
|
const baseUri = useMemo(
|
||||||
|
() => `inmemory://diff-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorAppConfig = useMemo<EditorAppConfig>(
|
||||||
|
() => ({
|
||||||
|
useDiffEditor: true,
|
||||||
|
codeResources: {
|
||||||
|
original: {
|
||||||
|
text: original,
|
||||||
|
uri: `${baseUri}-original`,
|
||||||
|
enforceLanguageId: language,
|
||||||
|
},
|
||||||
|
modified: {
|
||||||
|
text: modified,
|
||||||
|
uri: `${baseUri}-modified`,
|
||||||
|
enforceLanguageId: language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diffEditorOptions: {
|
||||||
|
automaticLayout: true,
|
||||||
|
readOnly: true,
|
||||||
|
renderSideBySide: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[original, modified, language],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonacoEditorReactComp
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
vscodeApiConfig={getVscodeApiConfig()}
|
||||||
|
editorAppConfig={editorAppConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,15 +39,24 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
Area Flags
|
Area Flags
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-6">
|
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
|
||||||
{bits.map(({ bit, label }) => {
|
{bits.map(({ bit, label }) => {
|
||||||
const checked = (flags & (1 << bit)) !== 0;
|
const checked = (flags & (1 << bit)) !== 0;
|
||||||
return (
|
return (
|
||||||
<label key={bit} className="flex cursor-pointer items-center gap-2 text-sm">
|
<label
|
||||||
|
key={bit}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -57,15 +66,19 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
: flags & ~(1 << bit);
|
: flags & ~(1 << bit);
|
||||||
onChange("Flags", newFlags);
|
onChange("Flags", newFlags);
|
||||||
}}
|
}}
|
||||||
className="h-4 w-4 rounded"
|
style={{
|
||||||
style={{ accentColor: "var(--forge-accent)" }}
|
height: "1rem",
|
||||||
|
width: "1rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
accentColor: "var(--forge-accent)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: "var(--forge-text)" }}>{label}</span>
|
<span style={{ color: "var(--forge-text)" }}>{label}</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
Raw value: {flags}
|
Raw value: {flags}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,19 +90,25 @@ function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
|
|||||||
const hex = intToHexColor(num);
|
const hex = intToHexColor(num);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
{field.displayName}
|
{field.displayName}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={hex}
|
value={hex}
|
||||||
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
|
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
|
||||||
className="h-8 w-10 cursor-pointer rounded border-0"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
height: "2rem",
|
||||||
|
width: "2.5rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
{hex}
|
{hex}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,54 +125,64 @@ function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
const ts = typeof tileset === "string" ? tileset : "";
|
const ts = typeof tileset === "string" ? tileset : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
<div className="flex items-center gap-4">
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Width</label>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Width</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={w}
|
value={w}
|
||||||
min={1}
|
min={1}
|
||||||
max={32}
|
max={32}
|
||||||
onChange={(e) => onChange("Width", parseInt(e.target.value, 10))}
|
onChange={(e) => onChange("Width", parseInt(e.target.value, 10))}
|
||||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: "var(--forge-text-secondary)" }}>×</span>
|
<span style={{ color: "var(--forge-text-secondary)" }}>×</span>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Height</label>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Height</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={h}
|
value={h}
|
||||||
min={1}
|
min={1}
|
||||||
max={32}
|
max={32}
|
||||||
onChange={(e) => onChange("Height", parseInt(e.target.value, 10))}
|
onChange={(e) => onChange("Height", parseInt(e.target.value, 10))}
|
||||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>tiles</span>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>tiles</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Tileset</label>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Tileset</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={ts}
|
value={ts}
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
onChange={(e) => onChange("Tileset", e.target.value)}
|
onChange={(e) => onChange("Tileset", e.target.value)}
|
||||||
className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "11rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,21 +25,28 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
Ability Scores
|
Ability Scores
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem" }}>
|
||||||
{abilities.flat().map((ab) => {
|
{abilities.flat().map((ab) => {
|
||||||
const val = getFieldValue(data, ab);
|
const val = getFieldValue(data, ab);
|
||||||
const num = typeof val === "number" ? val : 0;
|
const num = typeof val === "number" ? val : 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ab}
|
key={ab}
|
||||||
className="flex flex-col items-center rounded border px-3 py-2"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", fontWeight: 700, color: "var(--forge-text-secondary)" }}>
|
||||||
{displayNames[ab]}
|
{displayNames[ab]}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -48,14 +55,20 @@ function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
max={99}
|
max={99}
|
||||||
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))}
|
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))}
|
||||||
className="mt-1 w-16 rounded border px-1 py-1 text-center text-lg font-semibold"
|
|
||||||
style={{
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
width: "4rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.25rem",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "var(--text-lg)",
|
||||||
|
fontWeight: 600,
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="mt-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ marginTop: "0.125rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
|
mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,34 +86,39 @@ function RaceGenderOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
const genderNum = typeof gender === "number" ? gender : 0;
|
const genderNum = typeof gender === "number" ? gender : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Race</label>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Race</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={raceNum}
|
value={raceNum}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
|
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
|
||||||
className="w-20 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
(racialtypes.2da)
|
(racialtypes.2da)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Gender</label>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Gender</label>
|
||||||
<select
|
<select
|
||||||
value={genderNum}
|
value={genderNum}
|
||||||
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
|
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
|
||||||
className="rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -121,13 +139,13 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
{scripts.map((s) => {
|
{scripts.map((s) => {
|
||||||
const val = getFieldValue(data, s.label);
|
const val = getFieldValue(data, s.label);
|
||||||
const str = typeof val === "string" ? val : "";
|
const str = typeof val === "string" ? val : "";
|
||||||
return (
|
return (
|
||||||
<div key={s.label} className="flex items-center gap-3">
|
<div key={s.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
<label className="w-28 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ width: "7rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
{s.display}
|
{s.display}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -135,10 +153,14 @@ function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
value={str}
|
value={str}
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
onChange={(e) => onChange(s.label, e.target.value)}
|
onChange={(e) => onChange(s.label, e.target.value)}
|
||||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
placeholder="(none)"
|
placeholder="(none)"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
gffTypeFromPath,
|
gffTypeFromPath,
|
||||||
getLocStringText,
|
getLocStringText,
|
||||||
} from "./GffEditor";
|
} from "./GffEditor";
|
||||||
|
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
interface DialogEditorProps {
|
interface DialogEditorProps {
|
||||||
repo: string;
|
repo: string;
|
||||||
@@ -48,38 +49,38 @@ function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply"
|
|||||||
const sound = getStringVal(node, "Sound");
|
const sound = getStringVal(node, "Sound");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
Text
|
Text
|
||||||
</label>
|
</span>
|
||||||
<p className="mt-0.5 text-sm" style={{ color: "var(--forge-text)" }}>
|
<p style={{ marginTop: "0.125rem", fontSize: "var(--text-sm)", color: "var(--forge-text)", margin: "0.125rem 0 0" }}>
|
||||||
{text || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
{text || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
||||||
{type === "entry" && speaker && (
|
{type === "entry" && speaker && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Speaker</label>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Speaker</span>
|
||||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{speaker}</p>
|
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{speaker}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{script && (
|
{script && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Action Script</label>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Action Script</span>
|
||||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{script}</p>
|
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{script}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{active && (
|
{active && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Condition</label>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Condition</span>
|
||||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{active}</p>
|
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{active}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sound && (
|
{sound && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Sound</label>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>Sound</span>
|
||||||
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{sound}</p>
|
<p style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{sound}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +104,7 @@ function DialogNodeItem({
|
|||||||
depth: number;
|
depth: number;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
const text = getTextVal(node);
|
const text = getTextVal(node);
|
||||||
const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||||
|
|
||||||
@@ -117,40 +119,63 @@ function DialogNodeItem({
|
|||||||
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
|
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:opacity-80"
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: expanded ? "var(--forge-surface)" : "transparent",
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "0.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: expanded ? "var(--forge-surface)" : hovered ? "var(--forge-surface-raised)" : "transparent",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 100ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="mt-0.5 font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ marginTop: "0.125rem", display: "flex", alignItems: "center", color: "var(--forge-text-secondary)", flexShrink: 0 }}>
|
||||||
{expanded ? "▼" : "▶"}
|
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20",
|
flexShrink: 0,
|
||||||
color: type === "entry" ? "#60a5fa" : "#4ade80",
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.125rem 0.25rem",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
backgroundColor: type === "entry" ? "var(--forge-info-bg)" : "var(--forge-success-bg)",
|
||||||
|
color: type === "entry" ? "var(--forge-info)" : "var(--forge-success)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{type === "entry" ? "E" : "R"}{index}
|
{type === "entry" ? "E" : "R"}{index}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">
|
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{truncated || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
{truncated || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div
|
<div
|
||||||
className="ml-6 mt-1 space-y-2 border-l-2 pl-3"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)" }}
|
marginLeft: "1.5rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.5rem",
|
||||||
|
borderLeft: "2px solid var(--forge-border)",
|
||||||
|
paddingLeft: "0.75rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div style={{ borderRadius: "0.25rem", padding: "0.75rem", backgroundColor: "var(--forge-bg)" }}>
|
||||||
<NodeDetail node={node} type={type} />
|
<NodeDetail node={node} type={type} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{childLinks && childLinks.length > 0 && depth < 4 && (
|
{childLinks && childLinks.length > 0 && depth < 4 && (
|
||||||
<div className="space-y-1">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
{childLinks.map((link, li) => {
|
{childLinks.map((link, li) => {
|
||||||
const idx = typeof link === "object" && link !== null
|
const idx = typeof link === "object" && link !== null
|
||||||
? (typeof link.Index === "number" ? link.Index :
|
? (typeof link.Index === "number" ? link.Index :
|
||||||
@@ -254,31 +279,44 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
}, [schema]);
|
}, [schema]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div style={{ display: "flex", height: "100%", flexDirection: "column", backgroundColor: "var(--forge-bg)" }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
display: "flex",
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||||
Dialog Editor
|
Dialog Editor
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
{entries.length} entries, {replies.length} replies
|
{entries.length} entries, {replies.length} replies
|
||||||
</span>
|
</span>
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)" }}>
|
||||||
(unsaved changes)
|
(unsaved changes)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
{onSwitchToRaw && (
|
{onSwitchToRaw && (
|
||||||
<button
|
<button
|
||||||
onClick={onSwitchToRaw}
|
onClick={onSwitchToRaw}
|
||||||
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Switch to Raw JSON
|
Switch to Raw JSON
|
||||||
</button>
|
</button>
|
||||||
@@ -286,8 +324,16 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!dirty || saving}
|
disabled={!dirty || saving}
|
||||||
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-accent)", color: "#fff" }}
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
border: "none",
|
||||||
|
opacity: !dirty || saving ? 0.4 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
@@ -296,17 +342,30 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 gap-0 border-b"
|
role="tablist"
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: 0,
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(["tree", "properties"] as const).map((tab) => (
|
{(["tree", "properties"] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className="px-4 py-2 text-sm capitalize transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
textTransform: "capitalize",
|
||||||
color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
||||||
|
border: "none",
|
||||||
borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab === "tree" ? "Conversation Tree" : "Properties"}
|
{tab === "tree" ? "Conversation Tree" : "Properties"}
|
||||||
@@ -315,13 +374,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mb-4 text-sm" style={{ color: "#ef4444" }}>{error}</p>
|
<p style={{ marginBottom: "1rem", fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "tree" && (
|
{activeTab === "tree" && (
|
||||||
<div className="mx-auto max-w-3xl space-y-1">
|
<div style={{ maxWidth: "48rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
{startingEntries.length > 0 ? (
|
{startingEntries.length > 0 ? (
|
||||||
startingEntries.map((link, i) => {
|
startingEntries.map((link, i) => {
|
||||||
const idx = typeof link === "object" && link !== null
|
const idx = typeof link === "object" && link !== null
|
||||||
@@ -353,7 +412,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
depth={0}
|
depth={0}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
No dialog entries found
|
No dialog entries found
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -361,7 +420,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "properties" && (
|
{activeTab === "properties" && (
|
||||||
<div className="mx-auto max-w-2xl space-y-4">
|
<div style={{ maxWidth: "40rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{propertyFields.map((field) => {
|
{propertyFields.map((field) => {
|
||||||
const raw = data[field.label];
|
const raw = data[field.label];
|
||||||
const value = raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)
|
const value = raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)
|
||||||
@@ -369,8 +428,8 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
: raw;
|
: raw;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.label} className="flex items-center gap-3" title={field.description}>
|
<div key={field.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }} title={field.description}>
|
||||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
{field.displayName}
|
{field.displayName}
|
||||||
</label>
|
</label>
|
||||||
{field.type === GffFieldType.ResRef ? (
|
{field.type === GffFieldType.ResRef ? (
|
||||||
@@ -392,10 +451,14 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -418,10 +481,13 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-32 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "8rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Code2, Save, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import { api } from "../../services/api";
|
import { api } from "../../services/api";
|
||||||
|
|
||||||
export enum GffFieldType {
|
export enum GffFieldType {
|
||||||
Byte = 0,
|
Byte = 0, Char = 1, Word = 2, Short = 3, Dword = 4, Int = 5,
|
||||||
Char = 1,
|
Dword64 = 6, Int64 = 7, Float = 8, Double = 9,
|
||||||
Word = 2,
|
CExoString = 10, ResRef = 11, CExoLocString = 12, Void = 13,
|
||||||
Short = 3,
|
Struct = 14, List = 15,
|
||||||
Dword = 4,
|
|
||||||
Int = 5,
|
|
||||||
Dword64 = 6,
|
|
||||||
Int64 = 7,
|
|
||||||
Float = 8,
|
|
||||||
Double = 9,
|
|
||||||
CExoString = 10,
|
|
||||||
ResRef = 11,
|
|
||||||
CExoLocString = 12,
|
|
||||||
Void = 13,
|
|
||||||
Struct = 14,
|
|
||||||
List = 15,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GffFieldSchema {
|
export interface GffFieldSchema {
|
||||||
@@ -56,6 +45,11 @@ function getLocStringText(value: unknown): string {
|
|||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
const v = value as Record<string, unknown>;
|
const v = value as Record<string, unknown>;
|
||||||
|
if (typeof v["0"] === "string") return v["0"];
|
||||||
|
for (const [key, val] of Object.entries(v)) {
|
||||||
|
if (key === "id") continue;
|
||||||
|
if (/^\d+$/.test(key) && typeof val === "string") return val;
|
||||||
|
}
|
||||||
if (v.strings && typeof v.strings === "object") {
|
if (v.strings && typeof v.strings === "object") {
|
||||||
const strings = v.strings as Record<string, string>;
|
const strings = v.strings as Record<string, string>;
|
||||||
return strings["0"] ?? Object.values(strings)[0] ?? "";
|
return strings["0"] ?? Object.values(strings)[0] ?? "";
|
||||||
@@ -63,15 +57,15 @@ function getLocStringText(value: unknown): string {
|
|||||||
if (v.value && typeof v.value === "object") {
|
if (v.value && typeof v.value === "object") {
|
||||||
return getLocStringText(v.value);
|
return getLocStringText(v.value);
|
||||||
}
|
}
|
||||||
|
if (typeof v.id === "number") {
|
||||||
|
const tlkRow = v.id >= 16777216 ? v.id - 16777216 : v.id;
|
||||||
|
return `(TLK #${tlkRow})`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldValue(
|
function setFieldValue(data: Record<string, unknown>, label: string, newValue: unknown): Record<string, unknown> {
|
||||||
data: Record<string, unknown>,
|
|
||||||
label: string,
|
|
||||||
newValue: unknown,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const updated = { ...data };
|
const updated = { ...data };
|
||||||
const existing = data[label];
|
const existing = data[label];
|
||||||
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
|
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
|
||||||
@@ -82,28 +76,25 @@ function setFieldValue(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLocStringValue(
|
function setLocStringValue(data: Record<string, unknown>, label: string, text: string): Record<string, unknown> {
|
||||||
data: Record<string, unknown>,
|
|
||||||
label: string,
|
|
||||||
text: string,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const updated = { ...data };
|
const updated = { ...data };
|
||||||
const existing = data[label];
|
const existing = data[label];
|
||||||
if (existing && typeof existing === "object") {
|
if (existing && typeof existing === "object") {
|
||||||
const ex = existing as Record<string, unknown>;
|
const ex = existing as Record<string, unknown>;
|
||||||
if ("value" in ex && ex.value && typeof ex.value === "object") {
|
if ("value" in ex && ex.value && typeof ex.value === "object") {
|
||||||
const inner = ex.value as Record<string, unknown>;
|
const inner = ex.value as Record<string, unknown>;
|
||||||
updated[label] = {
|
if (typeof inner["0"] === "string") {
|
||||||
...ex,
|
updated[label] = { ...ex, value: { ...inner, "0": text } };
|
||||||
value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } },
|
} else {
|
||||||
};
|
updated[label] = { ...ex, value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } } };
|
||||||
|
}
|
||||||
} else if ("strings" in ex) {
|
} else if ("strings" in ex) {
|
||||||
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
|
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
|
||||||
} else {
|
} else {
|
||||||
updated[label] = { ...ex, value: { strings: { "0": text } } };
|
updated[label] = { ...ex, value: { strings: { "0": text } } };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updated[label] = { type: "cexolocstring", value: { strings: { "0": text } } };
|
updated[label] = { type: "cexolocstring", value: { "0": text } };
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -112,6 +103,31 @@ function isNumericType(type: GffFieldType): boolean {
|
|||||||
return type <= GffFieldType.Double;
|
return type <= GffFieldType.Double;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldRow: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
padding: "0.5rem 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel: React.CSSProperties = {
|
||||||
|
width: "11rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldInput: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
};
|
||||||
|
|
||||||
interface FieldRendererProps {
|
interface FieldRendererProps {
|
||||||
field: GffFieldSchema;
|
field: GffFieldSchema;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -126,14 +142,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
|||||||
if (field.type === GffFieldType.Void) {
|
if (field.type === GffFieldType.Void) {
|
||||||
const hex = typeof value === "string" ? value : "";
|
const hex = typeof value === "string" ? value : "";
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
|
||||||
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
|
||||||
{field.displayName}
|
<code style={{ flex: 1, borderRadius: "0.375rem", padding: "0.5rem 0.75rem", fontSize: "var(--text-xs)", wordBreak: "break-all", backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
|
||||||
</label>
|
|
||||||
<code
|
|
||||||
className="flex-1 rounded px-2 py-1.5 text-xs break-all"
|
|
||||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{hex || "(empty)"}
|
{hex || "(empty)"}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,26 +152,7 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === GffFieldType.CExoLocString) {
|
if (field.type === GffFieldType.CExoLocString) {
|
||||||
const text = getLocStringText(value);
|
return <LocStringField field={field} value={value} isReadonly={isReadonly} onLocStringChange={onLocStringChange} />;
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
|
||||||
{field.displayName}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={text}
|
|
||||||
readOnly={isReadonly}
|
|
||||||
onChange={(e) => onLocStringChange?.(e.target.value)}
|
|
||||||
className="flex-1 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--forge-bg)",
|
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === GffFieldType.List) {
|
if (field.type === GffFieldType.List) {
|
||||||
@@ -176,23 +168,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
|||||||
const num = typeof value === "number" ? value : 0;
|
const num = typeof value === "number" ? value : 0;
|
||||||
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
|
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div style={fieldRow}>
|
||||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={fieldLabel}>{field.displayName}</label>
|
||||||
{field.displayName}
|
<input type="number" value={num} readOnly={isReadonly} step={isFloat ? "0.01" : "1"} onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))} style={{ ...fieldInput, flex: "none", width: "8rem" }} />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={num}
|
|
||||||
readOnly={isReadonly}
|
|
||||||
step={isFloat ? "0.01" : "1"}
|
|
||||||
onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))}
|
|
||||||
className="w-32 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--forge-bg)",
|
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,54 +179,51 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
|
|||||||
const str = typeof value === "string" ? value : "";
|
const str = typeof value === "string" ? value : "";
|
||||||
const valid = str.length <= 16;
|
const valid = str.length <= 16;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div style={fieldRow}>
|
||||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={fieldLabel}>{field.displayName}</label>
|
||||||
{field.displayName}
|
<div style={{ display: "flex", flex: 1, alignItems: "center", gap: "0.5rem" }}>
|
||||||
</label>
|
<input type="text" value={str} readOnly={isReadonly} maxLength={16} onChange={(e) => onChange(e.target.value)} style={{ ...fieldInput, fontFamily: "var(--font-mono)", borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)" }} />
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<span style={{ fontSize: "var(--text-xs)", color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)", flexShrink: 0 }}>{str.length}/16</span>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str}
|
|
||||||
readOnly={isReadonly}
|
|
||||||
maxLength={16}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--forge-bg)",
|
|
||||||
borderColor: valid ? "var(--forge-border)" : "#ef4444",
|
|
||||||
color: "var(--forge-text)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }}
|
|
||||||
>
|
|
||||||
{str.length}/16
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CExoString fallback
|
|
||||||
const str = typeof value === "string" ? value : String(value ?? "");
|
const str = typeof value === "string" ? value : String(value ?? "");
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div style={fieldRow}>
|
||||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={fieldLabel}>{field.displayName}</label>
|
||||||
{field.displayName}
|
<input type="text" value={str} readOnly={isReadonly} onChange={(e) => onChange(e.target.value)} style={fieldInput} />
|
||||||
</label>
|
</div>
|
||||||
<input
|
);
|
||||||
type="text"
|
}
|
||||||
value={str}
|
|
||||||
readOnly={isReadonly}
|
function LocStringField({ field, value, isReadonly, onLocStringChange }: { field: GffFieldSchema; value: unknown; isReadonly: boolean; onLocStringChange?: (text: string) => void }) {
|
||||||
onChange={(e) => onChange(e.target.value)}
|
const text = getLocStringText(value);
|
||||||
className="flex-1 rounded border px-2 py-1.5 text-sm"
|
const isTlkRef = text.startsWith("(TLK #");
|
||||||
style={{
|
const [resolved, setResolved] = useState<string | null>(null);
|
||||||
backgroundColor: "var(--forge-bg)",
|
|
||||||
borderColor: "var(--forge-border)",
|
useEffect(() => {
|
||||||
color: "var(--forge-text)",
|
if (!isTlkRef || !value || typeof value !== "object") return;
|
||||||
}}
|
const v = value as Record<string, unknown>;
|
||||||
/>
|
const id = typeof v.id === "number" ? v.id : undefined;
|
||||||
|
if (id === undefined) return;
|
||||||
|
api.editor.tlkLookup?.(id).then((r) => { if (r) setResolved(r); }).catch(() => {});
|
||||||
|
}, [value, isTlkRef]);
|
||||||
|
|
||||||
|
const displayText = resolved ?? text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
|
||||||
|
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
|
<input type="text" value={displayText} readOnly={isReadonly || isTlkRef} onChange={(e) => onLocStringChange?.(e.target.value)} style={{ ...fieldInput, ...(isTlkRef && !resolved ? { fontStyle: "italic", color: "var(--forge-text-secondary)" } : {}) }} />
|
||||||
|
{isTlkRef && (
|
||||||
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
|
{resolved ? text : "TLK reference — name stored in talk table"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,44 +232,25 @@ function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
<button
|
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
|
||||||
onClick={() => setExpanded(!expanded)}
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
|
|
||||||
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
||||||
<span className="rounded px-1.5 py-0.5 text-xs" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<span style={{ fontSize: "var(--text-xs)", backgroundColor: "var(--forge-surface-raised)", borderRadius: "0.25rem", padding: "0.125rem 0.5rem" }}>{items.length} {items.length === 1 ? "item" : "items"}</span>
|
||||||
{items.length} {items.length === 1 ? "item" : "items"}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div
|
<div style={{ marginLeft: "1rem", marginTop: "0.25rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
className="ml-4 mt-1 space-y-2 border-l-2 pl-4"
|
|
||||||
style={{ borderColor: "var(--forge-border)" }}
|
|
||||||
>
|
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div key={i} className="rounded p-2" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div key={i} style={{ borderRadius: "0.375rem", padding: "0.5rem 0.75rem", backgroundColor: "var(--forge-bg)" }}>
|
||||||
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
|
<div style={{ fontSize: "var(--text-xs)", fontWeight: 500, color: "var(--forge-text-secondary)", marginBottom: "0.25rem" }}>[{i}]</div>
|
||||||
[{i}]
|
|
||||||
</div>
|
|
||||||
{typeof item === "object" && item !== null ? (
|
{typeof item === "object" && item !== null ? (
|
||||||
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
|
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(item, null, 2)}</pre>
|
||||||
{JSON.stringify(item, null, 2)}
|
|
||||||
</pre>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>{String(item)}</span>
|
||||||
{String(item)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <div style={{ padding: "0.25rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>(empty list)</div>}
|
||||||
<div className="py-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
|
||||||
(empty list)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -305,23 +261,14 @@ function StructField({ field, value }: { field: GffFieldSchema; value?: Record<s
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
<button
|
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
|
||||||
onClick={() => setExpanded(!expanded)}
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
|
|
||||||
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && value && (
|
{expanded && value && (
|
||||||
<div
|
<div style={{ marginLeft: "1rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)" }}>
|
||||||
className="ml-4 mt-1 border-l-2 pl-4"
|
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(value, null, 2)}</pre>
|
||||||
style={{ borderColor: "var(--forge-border)" }}
|
|
||||||
>
|
|
||||||
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
|
|
||||||
{JSON.stringify(value, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -345,89 +292,41 @@ export interface FieldOverrideProps {
|
|||||||
onChange: (label: string, value: unknown) => void;
|
onChange: (label: string, value: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GffEditor({
|
export function GffEditor({ repo, filePath, content, onSave, onSwitchToRaw, fieldOverrides, headerSlot }: GffEditorProps) {
|
||||||
repo,
|
|
||||||
filePath,
|
|
||||||
content,
|
|
||||||
onSave,
|
|
||||||
onSwitchToRaw,
|
|
||||||
fieldOverrides,
|
|
||||||
headerSlot,
|
|
||||||
}: GffEditorProps) {
|
|
||||||
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
|
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
|
||||||
const [data, setData] = useState<Record<string, unknown>>({});
|
const [data, setData] = useState<Record<string, unknown>>({});
|
||||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
|
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { try { setData(JSON.parse(content)); } catch { setError("Failed to parse JSON content"); } }, [content]);
|
||||||
try {
|
|
||||||
setData(JSON.parse(content));
|
|
||||||
} catch {
|
|
||||||
setError("Failed to parse JSON content");
|
|
||||||
}
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gffType) return;
|
if (!gffType) return;
|
||||||
api.editor
|
api.editor.gffSchema(gffType).then((s) => { setSchema(s); if (s.categories.length > 0) setActiveCategory(s.categories[0]); }).catch(() => setError(`Failed to load schema for .${gffType}`));
|
||||||
.gffSchema(gffType)
|
|
||||||
.then((s) => {
|
|
||||||
setSchema(s);
|
|
||||||
if (s.categories.length > 0) setActiveCategory(s.categories[0]);
|
|
||||||
})
|
|
||||||
.catch(() => setError(`Failed to load schema for .${gffType}`));
|
|
||||||
}, [gffType]);
|
}, [gffType]);
|
||||||
|
|
||||||
const handleFieldChange = useCallback((label: string, value: unknown) => {
|
const handleFieldChange = useCallback((label: string, value: unknown) => { setData((prev) => { setDirty(true); return setFieldValue(prev, label, value); }); }, []);
|
||||||
setData((prev) => {
|
const handleLocStringChange = useCallback((label: string, text: string) => { setData((prev) => { setDirty(true); return setLocStringValue(prev, label, text); }); }, []);
|
||||||
const updated = setFieldValue(prev, label, value);
|
|
||||||
setDirty(true);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLocStringChange = useCallback((label: string, text: string) => {
|
|
||||||
setData((prev) => {
|
|
||||||
const updated = setLocStringValue(prev, label, text);
|
|
||||||
setDirty(true);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try { const newContent = JSON.stringify(data, null, 4) + "\n"; await api.editor.writeFile(repo, filePath, newContent); setDirty(false); onSave?.(newContent); }
|
||||||
const newContent = JSON.stringify(data, null, 4) + "\n";
|
catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
|
||||||
await api.editor.writeFile(repo, filePath, newContent);
|
finally { setSaving(false); }
|
||||||
setDirty(false);
|
|
||||||
onSave?.(newContent);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Save failed");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, [data, repo, filePath, onSave]);
|
}, [data, repo, filePath, onSave]);
|
||||||
|
|
||||||
const categoryFields = useMemo(() => {
|
const categoryFields = useMemo(() => schema ? schema.fields.filter((f) => f.category === activeCategory && !f.hidden) : [], [schema, activeCategory]);
|
||||||
if (!schema) return [];
|
|
||||||
return schema.fields.filter((f) => f.category === activeCategory && !f.hidden);
|
|
||||||
}, [schema, activeCategory]);
|
|
||||||
|
|
||||||
if (error && !schema) {
|
if (error && !schema) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
|
||||||
<div className="text-center">
|
<div style={{ textAlign: "center" }}>
|
||||||
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p>
|
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
|
||||||
{onSwitchToRaw && (
|
{onSwitchToRaw && (
|
||||||
<button
|
<button onClick={onSwitchToRaw} style={{ marginTop: "0.75rem", borderRadius: "0.375rem", padding: "0.5rem 1rem", fontSize: "var(--text-sm)", backgroundColor: "var(--forge-surface)", color: "var(--forge-text)", border: "1px solid var(--forge-border)", cursor: "pointer" }}>
|
||||||
onClick={onSwitchToRaw}
|
|
||||||
className="mt-3 rounded px-3 py-1.5 text-sm"
|
|
||||||
style={{ backgroundColor: "var(--forge-surface)", color: "var(--forge-text)" }}
|
|
||||||
>
|
|
||||||
Open as Raw JSON
|
Open as Raw JSON
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -438,52 +337,29 @@ export function GffEditor({
|
|||||||
|
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
|
||||||
<p className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Loading schema...</p>
|
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Loading schema...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", backgroundColor: "var(--forge-bg)" }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, padding: "0.625rem 1.25rem", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
|
||||||
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
<span style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)" }}>{schema.displayName}</span>
|
||||||
>
|
{dirty && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)", fontWeight: 500 }}>unsaved</span>}
|
||||||
<div className="flex items-center gap-3">
|
{error && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>{error}</span>}
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
|
||||||
{schema.displayName} Editor
|
|
||||||
</span>
|
|
||||||
{dirty && (
|
|
||||||
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
|
|
||||||
(unsaved changes)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
{onSwitchToRaw && (
|
{onSwitchToRaw && (
|
||||||
<button
|
<button onClick={onSwitchToRaw} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "1px solid var(--forge-border)", backgroundColor: "transparent", color: "var(--forge-text-secondary)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
||||||
onClick={onSwitchToRaw}
|
<Code2 size={13} /> Raw JSON
|
||||||
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
|
|
||||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
Switch to Raw JSON
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={handleSave} disabled={!dirty || saving} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "none", backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)", fontSize: "var(--text-xs)", fontWeight: 600, cursor: dirty && !saving ? "pointer" : "not-allowed", opacity: dirty && !saving ? 1 : 0.4 }}>
|
||||||
onClick={handleSave}
|
<Save size={13} /> {saving ? "Saving..." : "Save"}
|
||||||
disabled={!dirty || saving}
|
|
||||||
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--forge-accent)",
|
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,40 +367,29 @@ export function GffEditor({
|
|||||||
{headerSlot}
|
{headerSlot}
|
||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
<div
|
<div style={{ display: "flex", flexShrink: 0, overflowX: "auto", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
|
||||||
className="flex shrink-0 gap-0 overflow-x-auto border-b"
|
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
|
||||||
>
|
|
||||||
{schema.categories.map((cat) => (
|
{schema.categories.map((cat) => (
|
||||||
<button
|
<button key={cat} onClick={() => setActiveCategory(cat)} style={{ flexShrink: 0, padding: "0.625rem 1.25rem", fontSize: "var(--text-sm)", fontWeight: activeCategory === cat ? 600 : 400, color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)", borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent", background: "none", border: "none", borderBottomStyle: "solid", cursor: "pointer", transition: "color 150ms ease-out" }}>
|
||||||
key={cat}
|
|
||||||
onClick={() => setActiveCategory(cat)}
|
|
||||||
className="shrink-0 px-4 py-2 text-sm transition-colors"
|
|
||||||
style={{
|
|
||||||
color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)",
|
|
||||||
borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
|
||||||
<div className="mx-auto max-w-2xl space-y-4">
|
<div style={{ maxWidth: "42rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
{categoryFields.map((field) => {
|
{categoryFields.map((field) => {
|
||||||
const override = fieldOverrides?.get(field.label);
|
const override = fieldOverrides?.get(field.label);
|
||||||
if (override) {
|
if (override) {
|
||||||
return (
|
return (
|
||||||
<div key={field.label}>
|
<div key={field.label} style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--forge-border)" }}>
|
||||||
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
|
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.label} title={field.description}>
|
<div key={field.label} title={field.description} style={{ borderBottom: "1px solid var(--forge-border)" }}>
|
||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
field={field}
|
field={field}
|
||||||
value={getFieldValue(data, field.label)}
|
value={getFieldValue(data, field.label)}
|
||||||
@@ -535,9 +400,7 @@ export function GffEditor({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{categoryFields.length === 0 && (
|
{categoryFields.length === 0 && (
|
||||||
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>No fields in this category</p>
|
||||||
No fields in this category
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,22 +18,25 @@ interface ItemEditorProps {
|
|||||||
function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
|
function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
|
||||||
const num = typeof value === "number" ? value : 0;
|
const num = typeof value === "number" ? value : 0;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
{field.displayName}
|
{field.displayName}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={num}
|
value={num}
|
||||||
onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))}
|
onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))}
|
||||||
className="w-24 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "6rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
(baseitems.2da row)
|
(baseitems.2da row)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,14 +54,14 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
|
|||||||
if (field.label !== "StackSize") return null;
|
if (field.label !== "StackSize") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||||
{[
|
{[
|
||||||
{ label: "StackSize", display: "Stack", value: stackSize, max: 99 },
|
{ label: "StackSize", display: "Stack", value: stackSize, max: 99 },
|
||||||
{ label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
|
{ label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
|
||||||
{ label: "Charges", display: "Charges", value: charges, max: 255 },
|
{ label: "Charges", display: "Charges", value: charges, max: 255 },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="flex items-center gap-2">
|
<div key={item.label} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
{item.display}
|
{item.display}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -67,10 +70,13 @@ function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideP
|
|||||||
min={0}
|
min={0}
|
||||||
max={item.max}
|
max={item.max}
|
||||||
onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))}
|
onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))}
|
||||||
className="w-24 rounded border px-2 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: "6rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -89,18 +95,17 @@ function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-6">
|
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
|
||||||
{flags.map((flag) => {
|
{flags.map((flag) => {
|
||||||
const val = getFieldValue(data, flag.label);
|
const val = getFieldValue(data, flag.label);
|
||||||
const checked = typeof val === "number" ? val !== 0 : Boolean(val);
|
const checked = typeof val === "number" ? val !== 0 : Boolean(val);
|
||||||
return (
|
return (
|
||||||
<label key={flag.label} className="flex cursor-pointer items-center gap-2 text-sm">
|
<label key={flag.label} style={{ display: "flex", cursor: "pointer", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)" }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)}
|
onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)}
|
||||||
className="h-4 w-4 rounded"
|
style={{ width: "1rem", height: "1rem", borderRadius: "0.25rem", accentColor: "var(--forge-accent)" }}
|
||||||
style={{ accentColor: "var(--forge-accent)" }}
|
|
||||||
/>
|
/>
|
||||||
<span style={{ color: "var(--forge-text)" }}>{flag.display}</span>
|
<span style={{ color: "var(--forge-text)" }}>{flag.display}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -114,41 +119,34 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
|
|||||||
const list = Array.isArray(value) ? value : [];
|
const list = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
<div className="flex items-center justify-between">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||||
Item Properties
|
Item Properties
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded px-2 py-1 text-xs"
|
|
||||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
+ Add Property
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{list.map((prop, i) => (
|
{list.map((prop, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center gap-2 rounded border px-3 py-2"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex-1 font-mono text-xs" style={{ color: "var(--forge-text)" }}>
|
<span style={{ flex: 1, fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text)" }}>
|
||||||
{typeof prop === "object" && prop !== null
|
{typeof prop === "object" && prop !== null
|
||||||
? JSON.stringify(prop).slice(0, 80)
|
? JSON.stringify(prop).slice(0, 80)
|
||||||
: String(prop)}
|
: String(prop)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: "#ef4444" }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{list.length === 0 && (
|
{list.length === 0 && (
|
||||||
<p className="py-2 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={{ padding: "0.5rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)", margin: 0 }}>
|
||||||
No item properties
|
No item properties
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -185,10 +183,13 @@ export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: I
|
|||||||
|
|
||||||
const headerSlot = (
|
const headerSlot = (
|
||||||
<div
|
<div
|
||||||
className="border-b px-4 py-3"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
padding: "0.75rem 1rem",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
<h2 style={{ fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)", margin: 0 }}>
|
||||||
{itemName}
|
{itemName}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const bg = style.getPropertyValue("--forge-bg").trim();
|
||||||
|
const fg = style.getPropertyValue("--forge-text").trim();
|
||||||
|
const accent = style.getPropertyValue("--forge-accent").trim();
|
||||||
|
const secondary = style.getPropertyValue("--forge-text-secondary").trim();
|
||||||
|
const accentHover = style.getPropertyValue("--forge-accent-hover").trim();
|
||||||
|
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
theme: {
|
theme: {
|
||||||
background: "#121212",
|
background: bg,
|
||||||
foreground: "#f2f2f2",
|
foreground: fg,
|
||||||
cursor: "#946200",
|
cursor: accent,
|
||||||
selectionBackground: "#946200",
|
selectionBackground: accent,
|
||||||
selectionForeground: "#f2f2f2",
|
selectionForeground: fg,
|
||||||
black: "#121212",
|
black: bg,
|
||||||
brightBlack: "#666666",
|
brightBlack: secondary,
|
||||||
white: "#f2f2f2",
|
white: fg,
|
||||||
brightWhite: "#ffffff",
|
brightWhite: fg,
|
||||||
yellow: "#946200",
|
yellow: accent,
|
||||||
brightYellow: "#c48800",
|
brightYellow: accentHover,
|
||||||
},
|
},
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
});
|
});
|
||||||
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{ backgroundColor: "#121212" }}
|
style={{ backgroundColor: "var(--forge-bg)" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { getLspClient, type LspStatus } from "../lib/lspClient.js";
|
|
||||||
import type { editor } from "monaco-editor";
|
|
||||||
|
|
||||||
export function useLspClient(monaco: typeof import("monaco-editor") | null) {
|
|
||||||
const [status, setStatus] = useState<LspStatus>("disconnected");
|
|
||||||
const connectingRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!monaco || connectingRef.current) return;
|
|
||||||
|
|
||||||
const client = getLspClient();
|
|
||||||
if (client.status === "ready") {
|
|
||||||
setStatus("ready");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectingRef.current = true;
|
|
||||||
const unsub = client.onStatusChange(setStatus);
|
|
||||||
|
|
||||||
client.connect(monaco).catch((err) => {
|
|
||||||
console.error("[LSP] Connection failed:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsub();
|
|
||||||
};
|
|
||||||
}, [monaco]);
|
|
||||||
|
|
||||||
return { lspClient: getLspClient(), status };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLspDocument(
|
|
||||||
editorInstance: editor.IStandaloneCodeEditor | null,
|
|
||||||
filePath: string,
|
|
||||||
language: string,
|
|
||||||
) {
|
|
||||||
const prevPathRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editorInstance) return;
|
|
||||||
|
|
||||||
const client = getLspClient();
|
|
||||||
if (client.status !== "ready") return;
|
|
||||||
|
|
||||||
const model = editorInstance.getModel();
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
if (prevPathRef.current && prevPathRef.current !== filePath) {
|
|
||||||
const prevUri = model.uri;
|
|
||||||
client.notifyDidClose(prevUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.notifyDidOpen(model.uri, language, model.getValue());
|
|
||||||
prevPathRef.current = filePath;
|
|
||||||
|
|
||||||
const changeDisposable = model.onDidChangeContent(() => {
|
|
||||||
client.notifyDidChange(model.uri, model.getValue());
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
changeDisposable.dispose();
|
|
||||||
client.notifyDidClose(model.uri);
|
|
||||||
};
|
|
||||||
}, [editorInstance, filePath, language]);
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { Terminal } from "../components/terminal/Terminal";
|
import { Terminal } from "../components/terminal/Terminal";
|
||||||
import { useWebSocket } from "../hooks/useWebSocket";
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
Wrench,
|
||||||
|
Hammer,
|
||||||
|
Play,
|
||||||
|
GitBranch,
|
||||||
|
Settings,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
|
||||||
{ path: "/editor", label: "Editor", icon: "\u270E" },
|
{ path: "/editor", label: "Editor", Icon: Code2 },
|
||||||
{ path: "/toolset", label: "Toolset", icon: "\u2699" },
|
{ path: "/toolset", label: "Toolset", Icon: Wrench },
|
||||||
{ path: "/build", label: "Build", icon: "\u2692" },
|
{ path: "/build", label: "Build", Icon: Hammer },
|
||||||
{ path: "/server", label: "Server", icon: "\u25B6" },
|
{ path: "/server", label: "Server", Icon: Play },
|
||||||
{ path: "/repos", label: "Repos", icon: "\u2387" },
|
{ path: "/repos", label: "Repos", Icon: GitBranch },
|
||||||
{ path: "/settings", label: "Settings", icon: "\u2318" },
|
{ path: "/settings", label: "Settings", Icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||||
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { subscribe } = useWebSocket();
|
const { subscribe } = useWebSocket();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribe("git:upstream-update", (event) => {
|
return subscribe("git:upstream-update", (event) => {
|
||||||
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div style={{ display: "flex", height: "100vh", overflow: "hidden", backgroundColor: "var(--forge-bg)" }}>
|
||||||
{/* Left sidebar nav */}
|
{/* Left sidebar nav */}
|
||||||
<nav
|
<nav
|
||||||
className="flex shrink-0 flex-col"
|
aria-label="Main navigation"
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
width: "56px",
|
width: "56px",
|
||||||
|
flexShrink: 0,
|
||||||
borderRight: "1px solid var(--forge-border)",
|
borderRight: "1px solid var(--forge-border)",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="flex items-center justify-center py-3"
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
>
|
>
|
||||||
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} />
|
<img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-1 flex-col">
|
<div style={{ marginTop: "0.25rem", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.path === "/"
|
item.path === "/"
|
||||||
@@ -115,20 +133,44 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className="relative flex flex-col items-center justify-center py-2.5 text-center transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
style={{
|
||||||
borderLeft: isActive
|
display: "flex",
|
||||||
? "3px solid var(--forge-accent)"
|
flexDirection: "column",
|
||||||
: "3px solid transparent",
|
alignItems: "center",
|
||||||
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined,
|
justifyContent: "center",
|
||||||
|
padding: "0.625rem 0",
|
||||||
|
position: "relative",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "background-color 150ms, color 150ms",
|
||||||
|
backgroundColor: isActive ? "var(--forge-accent-subtle)" : undefined,
|
||||||
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||||
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? "var(--forge-accent-subtle)" : ""; }}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
>
|
>
|
||||||
<span className="text-base">{item.icon}</span>
|
<item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
||||||
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span>
|
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
|
||||||
{badge > 0 && (
|
{badge > 0 && (
|
||||||
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[8px] font-bold text-black">
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "0.25rem",
|
||||||
|
top: "0.25rem",
|
||||||
|
display: "flex",
|
||||||
|
height: "0.875rem",
|
||||||
|
minWidth: "0.875rem",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
padding: "0 0.125rem",
|
||||||
|
fontSize: "8px",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -139,69 +181,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "0.625rem 0",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
width: "100%",
|
||||||
|
transition: "background-color 150ms, color 150ms",
|
||||||
|
}}
|
||||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
|
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{theme === "dark" ? "Light" : "Dark"}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
|
||||||
<header
|
<header
|
||||||
className="flex shrink-0 items-center gap-4 px-4 py-1.5"
|
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-lg font-bold"
|
|
||||||
style={{
|
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)",
|
color: "var(--forge-accent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Layonara Forge
|
Layonara Forge
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="flex-1" />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||||
{sidebar && (
|
{sidebar && showSidebar && (
|
||||||
<aside
|
<aside
|
||||||
className="shrink-0 overflow-hidden"
|
|
||||||
style={{
|
style={{
|
||||||
width: "250px",
|
width: "250px",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
borderRight: "1px solid var(--forge-border)",
|
borderRight: "1px solid var(--forge-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
<main className="flex-1 overflow-hidden">
|
<main style={{ flex: 1, overflow: "hidden" }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setTerminalOpen((v) => !v)}
|
onClick={() => setTerminalOpen((v) => !v)}
|
||||||
className="flex shrink-0 items-center gap-1 px-3 py-0.5 text-xs transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid var(--forge-border)",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
borderTop: "1px solid var(--forge-border)",
|
||||||
|
width: "100%",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 150ms",
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
|
||||||
>
|
>
|
||||||
<span>{terminalOpen ? "\u25BC" : "\u25B2"}</span>
|
<TerminalIcon size={12} />
|
||||||
<span>Terminal</span>
|
<span>Terminal</span>
|
||||||
|
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{terminalOpen && (
|
{terminalOpen && (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 overflow-hidden"
|
|
||||||
style={{
|
style={{
|
||||||
height: "300px",
|
height: "300px",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
borderTop: "1px solid var(--forge-border)",
|
borderTop: "1px solid var(--forge-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
|
|||||||
export function SetupLayout() {
|
export function SetupLayout() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
|
|
||||||
style={{
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
backgroundImage: "linear-gradient(rgba(0,0,0,0.75), rgba(0,0,0,0.85)), url('/page-bg.jpg')",
|
backgroundImage: "linear-gradient(oklch(15% 0.015 65 / 0.85), oklch(12% 0.01 65 / 0.92)), url('/page-bg.jpg')",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-2xl">
|
<div style={{ width: "100%", maxWidth: "52rem", marginTop: "4vh" }}>
|
||||||
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<h1
|
<h1
|
||||||
className="mb-8 text-center text-3xl font-bold"
|
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
fontFamily: "var(--font-heading)",
|
||||||
|
fontSize: "var(--text-2xl)",
|
||||||
|
fontWeight: 700,
|
||||||
color: "var(--forge-accent)",
|
color: "var(--forge-accent)",
|
||||||
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Layonara Forge
|
Layonara Forge
|
||||||
</h1>
|
</h1>
|
||||||
|
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
|
Development environment setup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { useWebSocket } from "../hooks/useWebSocket";
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
import {
|
||||||
|
Hammer,
|
||||||
|
Package,
|
||||||
|
Cpu,
|
||||||
|
Play,
|
||||||
|
Archive,
|
||||||
|
Upload,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
type BuildStatus = "idle" | "building" | "success" | "failed";
|
type BuildStatus = "idle" | "building" | "success" | "failed";
|
||||||
|
|
||||||
@@ -11,15 +22,38 @@ interface BuildSectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||||
const colors: Record<BuildStatus, string> = {
|
const styles: Record<BuildStatus, React.CSSProperties> = {
|
||||||
idle: "bg-gray-500/20 text-gray-400",
|
idle: {
|
||||||
building: "bg-yellow-500/20 text-yellow-400",
|
backgroundColor: "var(--forge-surface-raised)",
|
||||||
success: "bg-green-500/20 text-green-400",
|
color: "var(--forge-text-secondary)",
|
||||||
failed: "bg-red-500/20 text-red-400",
|
},
|
||||||
|
building: {
|
||||||
|
backgroundColor: "var(--forge-warning-bg)",
|
||||||
|
color: "var(--forge-warning)",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
backgroundColor: "var(--forge-success-bg)",
|
||||||
|
color: "var(--forge-success)",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
backgroundColor: "var(--forge-danger-bg)",
|
||||||
|
color: "var(--forge-danger)",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
|
<span
|
||||||
|
style={{
|
||||||
|
...styles[status],
|
||||||
|
borderRadius: "9999px",
|
||||||
|
padding: "0.125rem 0.625rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.03em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -43,31 +77,47 @@ function BuildOutput({
|
|||||||
}, [lines, collapsed]);
|
}, [lines, collapsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div style={{ marginTop: "0.75rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.25rem 0",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
|
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
<span>Output ({lines.length} lines)</span>
|
<span>Output ({lines.length} lines)</span>
|
||||||
</button>
|
</button>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="mt-1 max-h-64 overflow-auto rounded p-3"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#0d1117",
|
marginTop: "0.5rem",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
maxHeight: "16rem",
|
||||||
fontSize: "12px",
|
overflowY: "auto",
|
||||||
lineHeight: "1.5",
|
borderRadius: "0.5rem",
|
||||||
|
padding: "0.875rem 1rem",
|
||||||
|
backgroundColor: "var(--forge-log-bg)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
lineHeight: "1.6",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lines.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
|
<span style={{ color: "var(--forge-text-secondary)", fontStyle: "italic" }}>
|
||||||
|
No output yet
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
lines.map((line, i) => (
|
lines.map((line, i) => (
|
||||||
<div key={i} style={{ color: "#c9d1d9" }}>
|
<div key={i} style={{ color: "var(--forge-log-text)" }}>
|
||||||
{line}
|
{line}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -83,27 +133,29 @@ function ActionButton({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
icon,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
variant?: "default" | "primary" | "warning";
|
variant?: "default" | "primary" | "warning";
|
||||||
|
icon?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const styles = {
|
const variantStyles: Record<string, React.CSSProperties> = {
|
||||||
default: {
|
default: {
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface-raised)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
backgroundColor: "var(--forge-accent)",
|
backgroundColor: "var(--forge-accent)",
|
||||||
borderColor: "var(--forge-accent)",
|
border: "none",
|
||||||
color: "#fff",
|
color: "var(--forge-accent-text)",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
backgroundColor: "#854d0e",
|
backgroundColor: "var(--forge-warning-bg)",
|
||||||
borderColor: "#a16207",
|
border: "1px solid var(--forge-warning-border)",
|
||||||
color: "#fef08a",
|
color: "var(--forge-warning)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,9 +163,22 @@ function ActionButton({
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
style={{
|
||||||
style={styles[variant]}
|
...variantStyles[variant],
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
transition: "opacity 0.15s ease",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -191,41 +256,103 @@ export function Build() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
|
const isBuilding =
|
||||||
|
module.status === "building" || haks.status === "building" || nwnx.status === "building";
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
padding: "1.25rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionHeaderStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitleStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontFamily: "var(--font-heading)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonRowStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
<div
|
||||||
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
|
||||||
Build Pipeline
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Module Section */}
|
|
||||||
<section
|
|
||||||
className="mb-6 rounded-lg border p-4"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-surface)",
|
height: "100%",
|
||||||
borderColor: "var(--forge-border)",
|
overflowY: "auto",
|
||||||
|
padding: "1.5rem",
|
||||||
|
color: "var(--forge-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
<h3 className="text-lg font-semibold">Module</h3>
|
<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} />
|
<StatusBadge status={module.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div style={buttonRowStyle}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Compile"
|
label="Compile"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
icon={<Play size={14} />}
|
||||||
disabled={isBuilding}
|
disabled={isBuilding}
|
||||||
onClick={() => handleAction(() => api.build.compileModule(), "module")}
|
onClick={() => handleAction(() => api.build.compileModule(), "module")}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Pack Module"
|
label="Pack Module"
|
||||||
|
icon={<Archive size={14} />}
|
||||||
disabled={isBuilding}
|
disabled={isBuilding}
|
||||||
onClick={() => handleAction(() => api.build.packModule(), "module")}
|
onClick={() => handleAction(() => api.build.packModule(), "module")}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Deploy to Server"
|
label="Deploy to Server"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
|
icon={<Upload size={14} />}
|
||||||
disabled={isBuilding}
|
disabled={isBuilding}
|
||||||
onClick={() => handleAction(() => api.build.deploy(), "module")}
|
onClick={() => handleAction(() => api.build.deploy(), "module")}
|
||||||
/>
|
/>
|
||||||
@@ -238,21 +365,19 @@ export function Build() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Haks Section */}
|
{/* Haks Section */}
|
||||||
<section
|
<section style={cardStyle}>
|
||||||
className="mb-6 rounded-lg border p-4"
|
<div style={sectionHeaderStyle}>
|
||||||
style={{
|
<div style={sectionTitleStyle}>
|
||||||
backgroundColor: "var(--forge-surface)",
|
<Package size={14} />
|
||||||
borderColor: "var(--forge-border)",
|
<span>Haks</span>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">Haks</h3>
|
|
||||||
<StatusBadge status={haks.status} />
|
<StatusBadge status={haks.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div style={buttonRowStyle}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Build Haks"
|
label="Build Haks"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
icon={<Play size={14} />}
|
||||||
disabled={isBuilding}
|
disabled={isBuilding}
|
||||||
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
|
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
|
||||||
/>
|
/>
|
||||||
@@ -265,38 +390,49 @@ export function Build() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* NWNX Section */}
|
{/* NWNX Section */}
|
||||||
<section
|
<section style={cardStyle}>
|
||||||
className="rounded-lg border p-4"
|
<div style={sectionHeaderStyle}>
|
||||||
|
<div style={sectionTitleStyle}>
|
||||||
|
<Cpu size={14} />
|
||||||
|
<span>NWNX</span>
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-surface)",
|
fontWeight: 400,
|
||||||
borderColor: "var(--forge-border)",
|
textTransform: "none",
|
||||||
|
opacity: 0.6,
|
||||||
|
letterSpacing: "normal",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
(Advanced)
|
||||||
<h3 className="text-lg font-semibold">
|
</span>
|
||||||
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
|
</div>
|
||||||
</h3>
|
|
||||||
<StatusBadge status={nwnx.status} />
|
<StatusBadge status={nwnx.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Build All"
|
label="Build All"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
icon={<Play size={14} />}
|
||||||
disabled={isBuilding}
|
disabled={isBuilding}
|
||||||
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
|
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={nwnxTarget}
|
value={nwnxTarget}
|
||||||
onChange={(e) => setNwnxTarget(e.target.value)}
|
onChange={(e) => setNwnxTarget(e.target.value)}
|
||||||
placeholder="Target (e.g. Item, Creature)"
|
placeholder="Target (e.g. Item, Creature)"
|
||||||
className="rounded border px-3 py-1.5 text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
outline: "none",
|
||||||
|
flex: "0 1 16rem",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -308,10 +444,18 @@ export function Build() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="mt-2 text-xs"
|
style={{
|
||||||
style={{ color: "#f59e0b" }}
|
marginTop: "0.75rem",
|
||||||
|
marginBottom: 0,
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-warning)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
⚠ Requires server restart to pick up changes
|
<AlertTriangle size={12} />
|
||||||
|
Requires server restart to pick up changes
|
||||||
</p>
|
</p>
|
||||||
<BuildOutput
|
<BuildOutput
|
||||||
lines={nwnx.output}
|
lines={nwnx.output}
|
||||||
|
|||||||
@@ -1,21 +1,68 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
|
import { Server, GitBranch, Hammer, Code2, Terminal, Database, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
const card: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
padding: "1.25rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardTitle: React.CSSProperties = {
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
margin: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusDot = (color: string): React.CSSProperties => ({
|
||||||
|
width: "0.5rem",
|
||||||
|
height: "0.5rem",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: color,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
transition: "background-color 150ms",
|
||||||
|
};
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const color =
|
const color =
|
||||||
status === "running"
|
status === "running"
|
||||||
? "#4ade80"
|
? "var(--forge-success)"
|
||||||
: status === "stopped"
|
: status === "stopped" || status === "exited" || status === "not created"
|
||||||
? "#f87171"
|
? "var(--forge-danger)"
|
||||||
: "#fbbf24";
|
: "var(--forge-warning)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold"
|
style={{
|
||||||
style={{ backgroundColor: `${color}20`, color }}
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 500,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
<span style={statusDot(color)} />
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -57,49 +104,36 @@ function ServerCard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRunning = status.nwserver === "running";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={card}>
|
||||||
className="rounded-lg p-6"
|
<h3 style={cardTitle}><Server size={14} /> Server</h3>
|
||||||
style={{
|
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
|
||||||
backgroundColor: "var(--forge-surface)",
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
border: "1px solid var(--forge-border)",
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<StatusBadge status={status.nwserver} />
|
<StatusBadge status={status.nwserver} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
|
||||||
MariaDB
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={status.mariadb} />
|
<StatusBadge status={status.mariadb} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style={{ marginTop: "1rem" }}>
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
...primaryBtn,
|
||||||
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)",
|
backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
|
||||||
color: status.nwserver === "running" ? "#fca5a5" : "#000",
|
color: isRunning ? "var(--forge-danger)" : "var(--forge-accent-text)",
|
||||||
|
border: isRunning ? "1px solid var(--forge-danger-border)" : "none",
|
||||||
|
opacity: loading ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.opacity = "0.85"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = loading ? "0.5" : "1"; }}
|
||||||
>
|
>
|
||||||
{loading
|
{loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
|
||||||
? "..."
|
|
||||||
: status.nwserver === "running"
|
|
||||||
? "Stop Server"
|
|
||||||
: "Start Server"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,39 +154,32 @@ function ReposSummary() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={card}>
|
||||||
className="rounded-lg p-6"
|
<h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
|
||||||
style={{
|
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
|
||||||
backgroundColor: "var(--forge-surface)",
|
{repos.map((repo, i) => {
|
||||||
border: "1px solid var(--forge-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
|
|
||||||
Repositories
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{repos.map((repo) => {
|
|
||||||
const s = repoStatus[repo];
|
const s = repoStatus[repo];
|
||||||
const branch = (s?.branch as string) || "\u2014";
|
const branch = (s?.branch as string) || "\u2014";
|
||||||
const clean = s?.clean !== false;
|
const clean = s?.clean !== false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={repo}
|
key={repo}
|
||||||
className="flex items-center justify-between rounded p-3"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0.5rem 0",
|
||||||
|
borderTop: i > 0 ? "1px solid var(--forge-border)" : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||||
{repo}
|
{repo}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
{branch}
|
{branch}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
|
|
||||||
title={clean ? "Clean" : "Uncommitted changes"}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -166,41 +193,38 @@ function QuickActions() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: "Build Module", onClick: () => navigate("/build") },
|
{ label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
|
||||||
{ label: "Build Haks", onClick: () => navigate("/build") },
|
{ label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
|
||||||
{ label: "Open Editor", onClick: () => navigate("/editor") },
|
{ label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
|
||||||
{
|
|
||||||
label: "Open Terminal",
|
|
||||||
onClick: () => {
|
|
||||||
/* terminal is toggled from IDELayout via Ctrl+` */
|
|
||||||
navigate("/editor");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={card}>
|
||||||
className="rounded-lg p-6"
|
<h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
|
||||||
style={{
|
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
||||||
backgroundColor: "var(--forge-surface)",
|
|
||||||
border: "1px solid var(--forge-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
|
|
||||||
Quick Actions
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
||||||
{actions.map((a) => (
|
{actions.map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a.label}
|
key={a.label}
|
||||||
onClick={a.onClick}
|
onClick={a.onClick}
|
||||||
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-bg)",
|
display: "flex",
|
||||||
color: "var(--forge-text)",
|
alignItems: "center",
|
||||||
|
gap: "0.625rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
border: "1px solid var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
background: "none",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left" as const,
|
||||||
|
transition: "background-color 150ms, border-color 150ms",
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; e.currentTarget.style.borderColor = "var(--forge-text-secondary)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; e.currentTarget.style.borderColor = "var(--forge-border)"; }}
|
||||||
>
|
>
|
||||||
|
<a.Icon size={15} style={{ color: "var(--forge-text-secondary)" }} />
|
||||||
{a.label}
|
{a.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -211,24 +235,16 @@ function QuickActions() {
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto p-6">
|
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
|
||||||
<div className="mb-8 text-center">
|
<div style={{ maxWidth: "56rem", margin: "0 auto" }}>
|
||||||
<h1
|
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
|
||||||
className="text-3xl font-bold"
|
Dashboard
|
||||||
style={{
|
|
||||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
|
||||||
color: "var(--forge-accent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Layonara Forge
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
||||||
NWN Development Environment
|
Server, repositories, and quick actions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
|
||||||
<ServerCard />
|
<ServerCard />
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<ReposSummary />
|
<ReposSummary />
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
|
|||||||
import { CreatureEditor } from "../components/gff/CreatureEditor";
|
import { CreatureEditor } from "../components/gff/CreatureEditor";
|
||||||
import { AreaEditor } from "../components/gff/AreaEditor";
|
import { AreaEditor } from "../components/gff/AreaEditor";
|
||||||
import { DialogEditor } from "../components/gff/DialogEditor";
|
import { DialogEditor } from "../components/gff/DialogEditor";
|
||||||
|
import { FileCode, Code2, Eye } from "lucide-react";
|
||||||
|
|
||||||
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
|
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
|
||||||
|
|
||||||
@@ -36,9 +37,10 @@ function filePathFromTabKey(tabKey: string): string {
|
|||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
|
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
|
||||||
|
workspacePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ editorState }: EditorProps) {
|
export function Editor({ editorState, workspacePath }: EditorProps) {
|
||||||
const {
|
const {
|
||||||
openTabs,
|
openTabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
@@ -50,7 +52,6 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
markClean,
|
markClean,
|
||||||
} = editorState;
|
} = editorState;
|
||||||
|
|
||||||
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
|
|
||||||
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
|
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
|
||||||
|
|
||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
@@ -105,8 +106,28 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
const renderEditor = () => {
|
const renderEditor = () => {
|
||||||
if (!activeTab) {
|
if (!activeTab) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div
|
||||||
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileCode
|
||||||
|
size={48}
|
||||||
|
style={{ color: "var(--forge-text-secondary)", opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontSize: "var(--text-lg)",
|
||||||
|
fontFamily: "var(--font-heading)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Open a file from the File Explorer to start editing
|
Open a file from the File Explorer to start editing
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,27 +160,47 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
{isActiveGff && activeMode === "raw" && (
|
{isActiveGff && activeMode === "raw" && (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-end border-b px-4 py-1"
|
style={{
|
||||||
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: "4px 16px",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-surface-raised)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleSwitchToVisual}
|
onClick={handleSwitchToVisual}
|
||||||
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<Eye size={13} />
|
||||||
Switch to Visual Editor
|
Switch to Visual Editor
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||||
<MonacoEditor
|
<MonacoEditor
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
filePath={activeFilePath}
|
filePath={activeFilePath}
|
||||||
content={activeContent}
|
content={activeContent}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
workspacePath={workspacePath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,14 +208,21 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<EditorTabs
|
<EditorTabs
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onSelect={selectTab}
|
onSelect={selectTab}
|
||||||
onClose={closeFile}
|
onClose={closeFile}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||||
{renderEditor()}
|
{renderEditor()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { CommitDialog } from "../components/CommitDialog";
|
import { CommitDialog } from "../components/CommitDialog";
|
||||||
import { useWebSocket } from "../hooks/useWebSocket";
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
GitCommit,
|
||||||
|
GitPullRequest,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Copy,
|
||||||
|
FileCode,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface RepoStatus {
|
interface RepoStatus {
|
||||||
modified: string[];
|
modified: string[];
|
||||||
@@ -26,6 +38,54 @@ interface PrForm {
|
|||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const badge = (
|
||||||
|
bg: string,
|
||||||
|
fg: string,
|
||||||
|
extra?: React.CSSProperties,
|
||||||
|
): React.CSSProperties => ({
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.3rem",
|
||||||
|
padding: "0.15rem 0.55rem",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
backgroundColor: bg,
|
||||||
|
color: fg,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnBase: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.4rem",
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
border: "1px solid",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const outlineBtn: React.CSSProperties = {
|
||||||
|
...btnBase,
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const accentBtn: React.CSSProperties = {
|
||||||
|
...btnBase,
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
borderColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
};
|
||||||
|
|
||||||
export function Repos() {
|
export function Repos() {
|
||||||
const [repos, setRepos] = useState<RepoInfo[]>([]);
|
const [repos, setRepos] = useState<RepoInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -39,7 +99,7 @@ export function Repos() {
|
|||||||
|
|
||||||
const fetchRepos = useCallback(async () => {
|
const fetchRepos = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = (await api.repos.list()) as RepoInfo[];
|
const data = (await api.repos.list()) as unknown as RepoInfo[];
|
||||||
setRepos(data);
|
setRepos(data);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load repos");
|
setError("Failed to load repos");
|
||||||
@@ -133,106 +193,247 @@ export function Repos() {
|
|||||||
const isDirty = (status?: RepoStatus) =>
|
const isDirty = (status?: RepoStatus) =>
|
||||||
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
|
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
|
||||||
|
|
||||||
|
const disabledStyle = (disabled: boolean | undefined): React.CSSProperties =>
|
||||||
|
disabled ? { opacity: 0.45, pointerEvents: "none" } : {};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
|
<div style={{
|
||||||
Loading repositories...
|
display: "flex",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: "var(--text-base)",
|
||||||
|
}}>
|
||||||
|
Loading repositories…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
<div style={{
|
||||||
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
height: "100%",
|
||||||
|
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
|
Repositories
|
||||||
</h2>
|
</h2>
|
||||||
|
<p style={{
|
||||||
|
margin: "0.3rem 0 0",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}>
|
||||||
|
Clone, sync, and manage your Layonara repos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
<div style={{
|
||||||
{error}
|
display: "flex",
|
||||||
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
padding: "0.65rem 1rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid var(--forge-danger-border)",
|
||||||
|
backgroundColor: "var(--forge-danger-bg)",
|
||||||
|
color: "var(--forge-danger)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span style={{ flex: 1 }}>{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setError("")}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--forge-danger)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.2rem",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PR success banner */}
|
||||||
{prResult && (
|
{prResult && (
|
||||||
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400">
|
<div style={{
|
||||||
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a>
|
display: "flex",
|
||||||
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button>
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
padding: "0.65rem 1rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "var(--forge-success-bg)",
|
||||||
|
color: "var(--forge-success)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
PR created:{" "}
|
||||||
|
<a
|
||||||
|
href={prResult.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: "inherit", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{prResult.url}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPrResult(null)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--forge-success)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.2rem",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Repo cards */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{repos.map((repo) => (
|
{repos.map((repo) => (
|
||||||
<section
|
<section
|
||||||
key={repo.name}
|
key={repo.name}
|
||||||
className="rounded-lg border p-4"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
padding: "1.25rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
{/* Card header */}
|
||||||
<div className="flex items-center gap-3">
|
<div style={{
|
||||||
<h3 className="text-lg font-semibold">{repo.name}</h3>
|
display: "flex",
|
||||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "0.85rem",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "var(--text-lg)",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: "var(--font-heading)",
|
||||||
|
}}>
|
||||||
|
{repo.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<span style={badge("var(--forge-accent-subtle)", "var(--forge-accent)")}>
|
||||||
|
<GitBranch size={12} />
|
||||||
{repo.branch}
|
{repo.branch}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{repo.cloned && repo.status && (
|
{repo.cloned && repo.status && (
|
||||||
<>
|
<>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${isDirty(repo.status) ? "bg-yellow-500/20 text-yellow-400" : "bg-green-500/20 text-green-400"}`}>
|
{isDirty(repo.status) ? (
|
||||||
{isDirty(repo.status) ? "dirty" : "clean"}
|
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
|
||||||
|
dirty
|
||||||
</span>
|
</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">
|
<span style={badge("var(--forge-success-bg)", "var(--forge-success)")}>
|
||||||
{repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream
|
<CheckCircle size={11} />
|
||||||
|
clean
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{repo.status.behind > 0 && (
|
||||||
|
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
|
||||||
|
<Download size={11} />
|
||||||
|
{repo.status.behind} behind
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{repo.status.ahead > 0 && (
|
{repo.status.ahead > 0 && (
|
||||||
<span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-xs font-medium text-blue-400">
|
<span style={badge("var(--forge-info-bg)", "var(--forge-info)")}>
|
||||||
|
<Upload size={11} />
|
||||||
{repo.status.ahead} ahead
|
{repo.status.ahead} ahead
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
|
||||||
|
<span style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}>
|
||||||
{repo.upstream}
|
{repo.upstream}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
{!repo.cloned ? (
|
{!repo.cloned ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClone(repo.name)}
|
onClick={() => handleClone(repo.name)}
|
||||||
disabled={actionLoading[repo.name]}
|
disabled={actionLoading[repo.name]}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
|
||||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
|
||||||
>
|
>
|
||||||
{actionLoading[repo.name] ? "Cloning..." : "Clone"}
|
<Copy size={14} />
|
||||||
|
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePull(repo.name)}
|
onClick={() => handlePull(repo.name)}
|
||||||
disabled={actionLoading[repo.name]}
|
disabled={actionLoading[repo.name]}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
|
||||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
|
||||||
>
|
>
|
||||||
|
<Download size={14} />
|
||||||
Pull
|
Pull
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handlePush(repo.name)}
|
|
||||||
disabled={actionLoading[repo.name] || !repo.status?.ahead}
|
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
|
||||||
>
|
|
||||||
Push
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCommitRepo(repo.name)}
|
onClick={() => setCommitRepo(repo.name)}
|
||||||
disabled={!isDirty(repo.status)}
|
disabled={!isDirty(repo.status)}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
...accentBtn,
|
||||||
|
...disabledStyle(!isDirty(repo.status)),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<GitCommit size={14} />
|
||||||
Commit
|
Commit
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handlePush(repo.name)}
|
||||||
|
disabled={actionLoading[repo.name] || !repo.status?.ahead}
|
||||||
|
style={{
|
||||||
|
...outlineBtn,
|
||||||
|
...disabledStyle(actionLoading[repo.name] || !repo.status?.ahead),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Push
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPrForm({
|
setPrForm({
|
||||||
@@ -241,39 +442,104 @@ export function Repos() {
|
|||||||
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
|
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity"
|
style={outlineBtn}
|
||||||
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
|
|
||||||
>
|
>
|
||||||
|
<GitPullRequest size={14} />
|
||||||
Create PR
|
Create PR
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Changed files list */}
|
||||||
{repo.status && isDirty(repo.status) && (
|
{repo.status && isDirty(repo.status) && (
|
||||||
<div className="mt-2">
|
<div style={{
|
||||||
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
|
border: "1px solid var(--forge-border)",
|
||||||
|
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
|
Changes
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
|
||||||
|
<div>
|
||||||
{repo.status.modified.map((f) => (
|
{repo.status.modified.map((f) => (
|
||||||
<div
|
<div
|
||||||
key={f}
|
key={f}
|
||||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 text-xs transition-colors hover:bg-white/5"
|
|
||||||
onClick={() => handleShowDiff(repo.name, f)}
|
onClick={() => handleShowDiff(repo.name, f)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.35rem 0.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-yellow-400">M</span>
|
<span style={{
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
|
fontWeight: 700,
|
||||||
|
color: "var(--forge-warning)",
|
||||||
|
width: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}>M</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{repo.status.staged.map((f) => (
|
{repo.status.staged.map((f) => (
|
||||||
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
|
<div
|
||||||
<span className="font-medium text-green-400">S</span>
|
key={f}
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.35rem 0.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--forge-success)",
|
||||||
|
width: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}>S</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{repo.status.untracked.map((f) => (
|
{repo.status.untracked.map((f) => (
|
||||||
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
|
<div
|
||||||
<span className="font-medium text-gray-400">?</span>
|
key={f}
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.35rem 0.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
width: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}>?</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,6 +551,7 @@ export function Repos() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Commit dialog */}
|
||||||
{commitRepo && (
|
{commitRepo && (
|
||||||
<CommitDialog
|
<CommitDialog
|
||||||
repo={commitRepo}
|
repo={commitRepo}
|
||||||
@@ -296,71 +563,188 @@ export function Repos() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PR form modal */}
|
||||||
{prForm && (
|
{prForm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}>
|
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-lg rounded-lg border p-6"
|
onClick={() => setPrForm(null)}
|
||||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
style={{
|
||||||
onClick={(e) => e.stopPropagation()}
|
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)" }}>
|
<div
|
||||||
Create Pull Request — {prForm.repo}
|
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>
|
</h3>
|
||||||
|
<span style={{
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
marginLeft: "0.25rem",
|
||||||
|
}}>
|
||||||
|
— {prForm.repo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={prForm.title}
|
value={prForm.title}
|
||||||
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
|
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
|
||||||
placeholder="PR Title"
|
placeholder="PR Title"
|
||||||
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={prForm.body}
|
value={prForm.body}
|
||||||
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
|
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
|
||||||
rows={8}
|
rows={8}
|
||||||
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }}
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
resize: "vertical",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||||
onClick={() => setPrForm(null)}
|
<button onClick={() => setPrForm(null)} style={outlineBtn}>
|
||||||
className="rounded border px-3 py-1.5 text-sm"
|
|
||||||
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreatePr}
|
onClick={handleCreatePr}
|
||||||
disabled={!prForm.title.trim() || actionLoading.pr}
|
disabled={!prForm.title.trim() || actionLoading.pr}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
|
style={{
|
||||||
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
|
...accentBtn,
|
||||||
|
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{actionLoading.pr ? "Creating..." : "Submit PR"}
|
<GitPullRequest size={14} />
|
||||||
|
{actionLoading.pr ? "Creating…" : "Submit PR"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Diff modal */}
|
||||||
{diffView && (
|
{diffView && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}>
|
|
||||||
<div
|
<div
|
||||||
className="h-3/4 w-3/4 overflow-auto rounded-lg border p-6"
|
onClick={() => setDiffView(null)}
|
||||||
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
|
style={{
|
||||||
onClick={(e) => e.stopPropagation()}
|
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">
|
<div
|
||||||
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
|
onClick={(e) => e.stopPropagation()}
|
||||||
Diff — {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
|
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>
|
</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
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre
|
|
||||||
className="whitespace-pre-wrap text-xs"
|
<pre style={{
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }}
|
flex: 1,
|
||||||
>
|
overflowY: "auto",
|
||||||
|
margin: 0,
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "var(--forge-log-bg)",
|
||||||
|
color: "var(--forge-log-text)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
{diffView.diff || "No changes"}
|
{diffView.diff || "No changes"}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,152 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
|
import { SimpleEditor } from "../components/editor/SimpleEditor";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { useWebSocket } from "../hooks/useWebSocket";
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
import {
|
||||||
|
Server as ServerIcon,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
RotateCcw,
|
||||||
|
FileCode,
|
||||||
|
ScrollText,
|
||||||
|
Database,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
type ServerState = "running" | "exited" | "not created" | string;
|
type ServerState = "running" | "exited" | "not created" | string;
|
||||||
|
|
||||||
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
|
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
|
||||||
const color =
|
const dotColor =
|
||||||
state === "running"
|
state === "running"
|
||||||
? "bg-green-500/20 text-green-400"
|
? "var(--forge-success)"
|
||||||
: state === "exited"
|
: state === "exited"
|
||||||
? "bg-red-500/20 text-red-400"
|
? "var(--forge-danger)"
|
||||||
: "bg-gray-500/20 text-gray-400";
|
: "var(--forge-warning)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
style={{
|
||||||
{label}:
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: dotColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{state}
|
{state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HoverButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
bg,
|
||||||
|
bgHover,
|
||||||
|
border,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
bg: string;
|
||||||
|
bgHover: string;
|
||||||
|
border: string;
|
||||||
|
color: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
backgroundColor: hovered && !disabled ? bgHover : bg,
|
||||||
|
color,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
transition: "background-color 0.15s, opacity 0.15s",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: "var(--font-heading)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ControlsPanel() {
|
function ControlsPanel() {
|
||||||
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
|
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
|
||||||
nwserver: "unknown",
|
nwserver: "unknown",
|
||||||
@@ -63,49 +185,75 @@ function ControlsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="rounded-lg border p-4"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
padding: "1.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="NWN Server" state={status.nwserver} />
|
||||||
<StatusBadge label="MariaDB" state={status.mariadb} />
|
<StatusBadge label="MariaDB" state={status.mariadb} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(["start", "stop", "restart", "config"] as const).map((action) => (
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
<button
|
<HoverButton
|
||||||
key={action}
|
onClick={() => handleAction("start")}
|
||||||
onClick={() => handleAction(action)}
|
|
||||||
disabled={loading !== null}
|
disabled={loading !== null}
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
bg="var(--forge-accent)"
|
||||||
style={{
|
bgHover="var(--forge-accent-hover)"
|
||||||
backgroundColor:
|
border="var(--forge-accent)"
|
||||||
action === "start"
|
color="var(--forge-accent-text)"
|
||||||
? "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)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading === action
|
<Play size={14} />
|
||||||
? "..."
|
{loading === "start" ? "Starting..." : "Start"}
|
||||||
: action === "config"
|
</HoverButton>
|
||||||
? "Generate Config"
|
|
||||||
: action.charAt(0).toUpperCase() + action.slice(1)}
|
<HoverButton
|
||||||
</button>
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -141,65 +289,119 @@ function LogViewer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="flex flex-col rounded-lg border"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
height: "350px",
|
borderRadius: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-2 px-4 py-2"
|
style={{
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.75rem 1.25rem",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
Server Logs
|
||||||
</h3>
|
</span>
|
||||||
<div className="flex-1" />
|
|
||||||
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
placeholder="Filter logs..."
|
placeholder="Filter logs..."
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.3rem 0.5rem 0.3rem 1.75rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
width: "200px",
|
width: 180,
|
||||||
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
|
|
||||||
|
<HoverButton
|
||||||
onClick={() => setAutoScroll((v) => !v)}
|
onClick={() => setAutoScroll((v) => !v)}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
bg={autoScroll ? "var(--forge-accent)" : "var(--forge-bg)"}
|
||||||
style={{
|
bgHover={
|
||||||
backgroundColor: autoScroll ? "var(--forge-accent)" : "var(--forge-bg)",
|
autoScroll ? "var(--forge-accent-hover)" : "var(--forge-surface-raised)"
|
||||||
borderColor: "var(--forge-border)",
|
}
|
||||||
color: autoScroll ? "#fff" : "var(--forge-text-secondary)",
|
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
|
Auto-scroll
|
||||||
</button>
|
</HoverButton>
|
||||||
<button
|
|
||||||
|
<HoverButton
|
||||||
onClick={() => setLines([])}
|
onClick={() => setLines([])}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
bg="var(--forge-bg)"
|
||||||
style={{
|
bgHover="var(--forge-surface-raised)"
|
||||||
backgroundColor: "var(--forge-bg)",
|
border="var(--forge-border)"
|
||||||
borderColor: "var(--forge-border)",
|
color="var(--forge-text-secondary)"
|
||||||
color: "var(--forge-text-secondary)",
|
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.5rem" }}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</HoverButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex-1 overflow-auto p-3"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#0d1117",
|
backgroundColor: "var(--forge-log-bg)",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
color: "var(--forge-log-text)",
|
||||||
fontSize: "12px",
|
fontFamily: "var(--font-mono)",
|
||||||
lineHeight: "1.5",
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
overflowY: "auto",
|
||||||
|
height: 350,
|
||||||
|
borderRadius: "0 0 0.75rem 0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredLines.length === 0 ? (
|
{filteredLines.length === 0 ? (
|
||||||
@@ -208,7 +410,7 @@ function LogViewer() {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
filteredLines.map((line, i) => (
|
filteredLines.map((line, i) => (
|
||||||
<div key={i} style={{ color: "#c9d1d9" }}>
|
<div key={i} style={{ color: "var(--forge-log-text)" }}>
|
||||||
{line}
|
{line}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -245,31 +447,57 @@ function SQLConsole() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="flex flex-col rounded-lg border"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-2 px-4 py-2"
|
style={{
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.75rem 1.25rem",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
SQL Console
|
||||||
</h3>
|
</span>
|
||||||
<div className="flex-1" />
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<select
|
<select
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
value=""
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-bg)",
|
backgroundColor: "var(--forge-bg)",
|
||||||
borderColor: "var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.3rem 0.5rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
maxWidth: "200px",
|
maxWidth: 200,
|
||||||
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
value=""
|
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
History ({history.length})
|
History ({history.length})
|
||||||
@@ -281,32 +509,30 @@ function SQLConsole() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
|
<HoverButton
|
||||||
onClick={execute}
|
onClick={execute}
|
||||||
disabled={loading || !query.trim()}
|
disabled={loading || !query.trim()}
|
||||||
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
bg="var(--forge-accent)"
|
||||||
style={{
|
bgHover="var(--forge-accent-hover)"
|
||||||
backgroundColor: "var(--forge-accent)",
|
border="var(--forge-accent)"
|
||||||
borderColor: "var(--forge-accent)",
|
color="var(--forge-accent-text)"
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<Play size={14} />
|
||||||
{loading ? "Running..." : "Execute"}
|
{loading ? "Running..." : "Execute"}
|
||||||
</button>
|
</HoverButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ height: "100px" }}>
|
<div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
|
||||||
<ReactMonacoEditor
|
<SimpleEditor
|
||||||
value={query}
|
value={query}
|
||||||
language="sql"
|
language="sql"
|
||||||
theme="vs-dark"
|
onChange={(v) => setQuery(v)}
|
||||||
onChange={(v) => setQuery(v ?? "")}
|
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineNumbers: "off",
|
lineNumbers: "off",
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
automaticLayout: true,
|
|
||||||
wordWrap: "on",
|
wordWrap: "on",
|
||||||
padding: { top: 4, bottom: 4 },
|
padding: { top: 4, bottom: 4 },
|
||||||
renderLineHighlight: "none",
|
renderLineHighlight: "none",
|
||||||
@@ -319,35 +545,55 @@ function SQLConsole() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="px-4 py-2 text-sm"
|
style={{
|
||||||
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }}
|
padding: "0.75rem 1.25rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-danger)",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-danger-bg)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div style={{ overflowX: "auto" }}>
|
||||||
className="max-h-64 overflow-auto"
|
|
||||||
style={{ borderTop: "1px solid var(--forge-border)" }}
|
|
||||||
>
|
|
||||||
{result.columns.length === 0 ? (
|
{result.columns.length === 0 ? (
|
||||||
<div className="px-4 py-3 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Query executed successfully (no results)
|
Query executed successfully (no results)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-left text-xs">
|
<div style={{ maxHeight: 280, overflowY: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{result.columns.map((col) => (
|
{result.columns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="sticky top-0 px-3 py-2 font-medium"
|
|
||||||
style={{
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: "left",
|
||||||
backgroundColor: "var(--forge-surface)",
|
backgroundColor: "var(--forge-surface)",
|
||||||
borderBottom: "1px solid var(--forge-border)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
color: "var(--forge-accent)",
|
color: "var(--forge-accent)",
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{col}
|
{col}
|
||||||
@@ -359,7 +605,6 @@ function SQLConsole() {
|
|||||||
{result.rows.map((row, i) => (
|
{result.rows.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={i}
|
key={i}
|
||||||
className="transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--forge-border)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
}}
|
}}
|
||||||
@@ -367,10 +612,10 @@ function SQLConsole() {
|
|||||||
{result.columns.map((col) => (
|
{result.columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col}
|
key={col}
|
||||||
className="px-3 py-1.5"
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
color: "var(--forge-text)",
|
color: "var(--forge-text)",
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row[col]}
|
{row[col]}
|
||||||
@@ -380,10 +625,15 @@ function SQLConsole() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="px-3 py-1 text-xs"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
padding: "0.4rem 1rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
borderTop: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
|
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -395,11 +645,45 @@ function SQLConsole() {
|
|||||||
|
|
||||||
export function Server() {
|
export function Server() {
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
<div
|
||||||
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
style={{
|
||||||
|
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
|
Server Management
|
||||||
</h2>
|
</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 />
|
<ControlsPanel />
|
||||||
<LogViewer />
|
<LogViewer />
|
||||||
<SQLConsole />
|
<SQLConsole />
|
||||||
|
|||||||
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
|
import {
|
||||||
|
Key,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
FolderOpen,
|
||||||
|
Container,
|
||||||
|
Keyboard,
|
||||||
|
Info,
|
||||||
|
RotateCcw,
|
||||||
|
Download,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const sectionCard: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
padding: "1.25rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitle: React.CSSProperties = {
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
margin: "0 0 1rem 0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel: React.CSSProperties = {
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
margin: "0 0 0.25rem 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldValue: React.CSSProperties = {
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
margin: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
color: "var(--forge-accent-text)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
padding: "0.4rem 0.875rem",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ghostBtn: React.CSSProperties = {
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.4rem 0.625rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const listRow: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0.625rem 0.875rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
};
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
title,
|
title,
|
||||||
|
icon,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={sectionCard}>
|
||||||
className="rounded-lg p-5"
|
<h3 style={sectionTitle}>{icon} {title}</h3>
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--forge-surface)",
|
|
||||||
border: "1px solid var(--forge-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 className="mb-4 text-sm font-semibold" style={{ color: "var(--forge-accent)" }}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
const currentPat = (config.githubPat as string) || "";
|
const hasPat = Boolean(config.githubPat && config.githubPat !== "***");
|
||||||
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set";
|
const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="GitHub">
|
<Section title="Gitea Token" icon={<Key size={14} />}>
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={fieldLabel}>Personal Access Token</p>
|
||||||
Personal Access Token
|
<p style={fieldValue}>{masked}</p>
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
|
||||||
{masked}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: "0.75rem" }}>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<button
|
<button onClick={() => setEditing(true)} style={primaryBtn}>
|
||||||
onClick={() => setEditing(true)}
|
Update Token
|
||||||
className="rounded px-3 py-1.5 text-xs font-semibold"
|
|
||||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
|
||||||
>
|
|
||||||
Update PAT
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={pat}
|
value={pat}
|
||||||
onChange={(e) => setPat(e.target.value)}
|
onChange={(e) => setPat(e.target.value)}
|
||||||
placeholder="ghp_..."
|
placeholder="Paste token"
|
||||||
className="flex-1 rounded px-3 py-1.5 text-sm"
|
style={{ flex: 1 }}
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--forge-bg)",
|
|
||||||
border: "1px solid var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!pat || saving}
|
disabled={!pat || saving}
|
||||||
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
|
style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
|
||||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
|
||||||
>
|
>
|
||||||
{saving ? "..." : "Save"}
|
{saving ? "Saving\u2026" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
|
||||||
onClick={() => {
|
|
||||||
setEditing(false);
|
|
||||||
setPat("");
|
|
||||||
}}
|
|
||||||
className="rounded px-3 py-1.5 text-xs"
|
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{msg && (
|
{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}
|
{msg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,16 +167,17 @@ function ThemeSection() {
|
|||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Theme">
|
<Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
|
||||||
<div className="flex items-center gap-4">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
<div>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||||
{theme === "dark" ? "Dark" : "Light"} Mode
|
{theme === "dark" ? "Dark" : "Light"} Mode
|
||||||
</span>
|
</p>
|
||||||
<button
|
<p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
onClick={toggleTheme}
|
{theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
|
||||||
className="rounded px-3 py-1.5 text-xs font-semibold"
|
</p>
|
||||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
</div>
|
||||||
>
|
<button onClick={toggleTheme} style={primaryBtn}>
|
||||||
Switch to {theme === "dark" ? "Light" : "Dark"}
|
Switch to {theme === "dark" ? "Light" : "Dark"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,25 +185,100 @@ function ThemeSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PathsSection({ config }: { config: Record<string, unknown> }) {
|
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
|
||||||
return (
|
return (
|
||||||
<Section title="Paths">
|
<div
|
||||||
<div className="space-y-3">
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "2.5rem",
|
||||||
|
alignSelf: "stretch",
|
||||||
|
backgroundColor: "var(--forge-surface-raised)",
|
||||||
|
borderRight: "1px solid var(--forge-border)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathsSection({ config, onUpdate }: { config: Record<string, unknown>; onUpdate: () => void }) {
|
||||||
|
const [wsPath, setWsPath] = useState((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
|
||||||
|
const [nwnPath, setNwnPath] = useState((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWsPath((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
|
||||||
|
setNwnPath((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg("");
|
||||||
|
try {
|
||||||
|
await api.workspace.updateConfig({ workspacePath: wsPath, nwnHomePath: nwnPath });
|
||||||
|
setMsg("Paths saved");
|
||||||
|
onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(err instanceof Error ? err.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Paths" icon={<FolderOpen size={14} />}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={fieldLabel}>Workspace Path</p>
|
||||||
Workspace Path
|
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
|
||||||
{(config.WORKSPACE_PATH as string) || "Not set"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={fieldLabel}>NWN Home Path</p>
|
||||||
NWN Home Path
|
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
|
||||||
</p>
|
</div>
|
||||||
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
{(config.NWN_HOME_PATH as string) || "Not set"}
|
<button
|
||||||
</p>
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ ...primaryBtn, opacity: saving ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving\u2026" : "Save Paths"}
|
||||||
|
</button>
|
||||||
|
{msg && (
|
||||||
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>{msg}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -172,40 +297,31 @@ function DockerSection() {
|
|||||||
await api.docker.pull(image);
|
await api.docker.pull(image);
|
||||||
setStatus((s) => ({ ...s, [image]: "Pulled" }));
|
setStatus((s) => ({ ...s, [image]: "Pulled" }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus((s) => ({
|
setStatus((s) => ({ ...s, [image]: err instanceof Error ? err.message : "Failed" }));
|
||||||
...s,
|
|
||||||
[image]: err instanceof Error ? err.message : "Failed",
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
setPulling((s) => ({ ...s, [image]: false }));
|
setPulling((s) => ({ ...s, [image]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Docker Images">
|
<Section title="Docker Images" icon={<Container size={14} />}>
|
||||||
<div className="space-y-2">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
<div
|
<div key={image} style={listRow}>
|
||||||
key={image}
|
<span style={fieldValue}>{image}</span>
|
||||||
className="flex items-center justify-between rounded p-3"
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
|
|
||||||
{image}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{status[image] && (
|
{status[image] && (
|
||||||
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
{status[image]}
|
{status[image]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => pull(image)}
|
onClick={() => pull(image)}
|
||||||
disabled={pulling[image]}
|
disabled={pulling[image]}
|
||||||
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40"
|
style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
|
||||||
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
|
|
||||||
>
|
>
|
||||||
{pulling[image] ? "..." : "Pull Latest"}
|
<Download size={12} />
|
||||||
|
{pulling[image] ? "Pulling\u2026" : "Pull"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,21 +341,20 @@ const SHORTCUTS = [
|
|||||||
|
|
||||||
function ShortcutsSection() {
|
function ShortcutsSection() {
|
||||||
return (
|
return (
|
||||||
<Section title="Keyboard Shortcuts">
|
<Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
|
||||||
<div className="space-y-1">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||||
{SHORTCUTS.map((s) => (
|
{SHORTCUTS.map((s) => (
|
||||||
<div
|
<div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
|
||||||
key={s.keys}
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
|
||||||
className="flex items-center justify-between rounded px-3 py-2"
|
|
||||||
style={{ backgroundColor: "var(--forge-bg)" }}
|
|
||||||
>
|
|
||||||
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
|
|
||||||
{s.action}
|
{s.action}
|
||||||
</span>
|
</span>
|
||||||
<kbd
|
<kbd
|
||||||
className="rounded px-2 py-0.5 font-mono text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--forge-surface)",
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
padding: "0.2rem 0.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
border: "1px solid var(--forge-border)",
|
border: "1px solid var(--forge-border)",
|
||||||
color: "var(--forge-text-secondary)",
|
color: "var(--forge-text-secondary)",
|
||||||
}}
|
}}
|
||||||
@@ -255,11 +370,11 @@ function ShortcutsSection() {
|
|||||||
|
|
||||||
function AboutSection() {
|
function AboutSection() {
|
||||||
return (
|
return (
|
||||||
<Section title="About">
|
<Section title="About" icon={<Info size={14} />}>
|
||||||
<p className="text-sm" style={{ color: "var(--forge-text)" }}>
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
|
||||||
Layonara Forge v0.0.1
|
Layonara Forge v0.0.1
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
||||||
github.com/Layonara/layonara-forge
|
github.com/Layonara/layonara-forge
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -270,6 +385,7 @@ function ResetSection() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
|
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
|
||||||
try {
|
try {
|
||||||
await api.workspace.updateConfig({ setupComplete: false });
|
await api.workspace.updateConfig({ setupComplete: false });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -279,14 +395,28 @@ function ResetSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Reset">
|
<Section title="Reset" icon={<RotateCcw size={14} />}>
|
||||||
|
<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
|
<button
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="rounded px-4 py-2 text-sm font-semibold"
|
style={{
|
||||||
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }}
|
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>
|
</button>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -299,25 +429,24 @@ export function Settings() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto p-6">
|
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
|
||||||
<h2
|
<div style={{ maxWidth: "40rem" }}>
|
||||||
className="mb-6 text-xl font-bold"
|
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
|
||||||
style={{
|
|
||||||
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
|
|
||||||
color: "var(--forge-accent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h1>
|
||||||
<div className="max-w-2xl space-y-4">
|
<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} />
|
<GitHubSection config={config} />
|
||||||
<ThemeSection />
|
<ThemeSection />
|
||||||
<PathsSection config={config} />
|
<PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
|
||||||
<DockerSection />
|
<DockerSection />
|
||||||
<ShortcutsSection />
|
<ShortcutsSection />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<ResetSection />
|
<ResetSection />
|
||||||
</div>
|
</div>
|
||||||
</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 { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { DiffEditor } from "@monaco-editor/react";
|
import { SimpleDiffEditor } from "../components/editor/SimpleEditor";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { useWebSocket } from "../hooks/useWebSocket";
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileCode,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
ArrowUpCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface ChangeEntry {
|
interface ChangeEntry {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -16,61 +26,6 @@ interface DiffData {
|
|||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
||||||
active
|
|
||||||
? "bg-green-500/20 text-green-400"
|
|
||||||
: "bg-gray-500/20 text-gray-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{active ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
variant = "default",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
variant?: "default" | "primary" | "danger";
|
|
||||||
}) {
|
|
||||||
const styles = {
|
|
||||||
default: {
|
|
||||||
backgroundColor: "var(--forge-surface)",
|
|
||||||
borderColor: "var(--forge-border)",
|
|
||||||
color: "var(--forge-text)",
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
backgroundColor: "var(--forge-accent)",
|
|
||||||
borderColor: "var(--forge-accent)",
|
|
||||||
color: "#fff",
|
|
||||||
},
|
|
||||||
danger: {
|
|
||||||
backgroundColor: "#7f1d1d",
|
|
||||||
borderColor: "#991b1b",
|
|
||||||
color: "#fca5a5",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
style={styles[variant]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
return new Date(ts).toLocaleTimeString();
|
return new Date(ts).toLocaleTimeString();
|
||||||
}
|
}
|
||||||
@@ -222,149 +177,428 @@ export function Toolset() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardAll = async () => {
|
const handleDiscardAll = async () => {
|
||||||
|
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
|
||||||
await api.toolset.discardAll();
|
await api.toolset.discardAll();
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full flex-col overflow-hidden"
|
style={{
|
||||||
style={{ color: "var(--forge-text)" }}
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Status bar */}
|
{/* Page heading */}
|
||||||
<div
|
<div style={{ padding: "24px 28px 0" }}>
|
||||||
className="flex shrink-0 items-center justify-between px-6 py-3"
|
<h1
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
style={{
|
||||||
>
|
fontFamily: "var(--font-heading)",
|
||||||
<div className="flex items-center gap-4">
|
fontSize: "var(--text-xl)",
|
||||||
<h2
|
fontWeight: 700,
|
||||||
className="text-xl font-bold"
|
color: "var(--forge-text)",
|
||||||
style={{ color: "var(--forge-accent)" }}
|
margin: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Toolset
|
Toolset
|
||||||
</h2>
|
</h1>
|
||||||
<StatusBadge active={active} />
|
<p
|
||||||
<span
|
style={{
|
||||||
className="text-xs"
|
fontSize: "var(--text-sm)",
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
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>
|
</span>
|
||||||
{lastChange && (
|
{lastChange && (
|
||||||
<span
|
<span
|
||||||
className="text-xs"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Last: {formatTimestamp(lastChange)}
|
Last change: {formatTimestamp(lastChange)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{active ? (
|
||||||
<ActionButton label="Stop Watcher" onClick={handleStop} />
|
<>
|
||||||
|
<EyeOff size={14} />
|
||||||
|
Stop Watcher
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ActionButton
|
<>
|
||||||
label="Start Watcher"
|
<Eye size={14} />
|
||||||
onClick={handleStart}
|
Start Watcher
|
||||||
variant="primary"
|
</>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Main content area */}
|
||||||
{changes.length > 0 && (
|
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-2 px-6 py-2"
|
style={{
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: "0 28px 20px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ActionButton
|
{/* Changes card */}
|
||||||
label="Apply Selected"
|
<div
|
||||||
variant="primary"
|
style={{
|
||||||
disabled={selected.size === 0}
|
backgroundColor: "var(--forge-surface)",
|
||||||
onClick={handleApplySelected}
|
border: "1px solid var(--forge-border)",
|
||||||
/>
|
borderRadius: 8,
|
||||||
<ActionButton
|
overflow: "hidden",
|
||||||
label="Apply All"
|
display: "flex",
|
||||||
variant="primary"
|
flexDirection: "column",
|
||||||
onClick={handleApplyAll}
|
flex: diffData ? "0 0 auto" : 1,
|
||||||
/>
|
maxHeight: diffData ? "40%" : undefined,
|
||||||
<ActionButton
|
}}
|
||||||
label="Discard Selected"
|
>
|
||||||
variant="danger"
|
{/* Card header with action bar */}
|
||||||
disabled={selected.size === 0}
|
<div
|
||||||
onClick={handleDiscardSelected}
|
style={{
|
||||||
/>
|
display: "flex",
|
||||||
<ActionButton
|
alignItems: "center",
|
||||||
label="Discard All"
|
justifyContent: "space-between",
|
||||||
variant="danger"
|
padding: "12px 16px",
|
||||||
onClick={handleDiscardAll}
|
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
|
<span
|
||||||
className="ml-2 text-xs"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selected.size} selected
|
Pending Changes
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main content: table + diff */}
|
{/* Table or empty state */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div style={{ overflow: "auto", flex: 1 }}>
|
||||||
{/* Changes table */}
|
|
||||||
<div
|
|
||||||
className="shrink-0 overflow-auto"
|
|
||||||
style={{ maxHeight: diffData ? "40%" : "100%" }}
|
|
||||||
>
|
|
||||||
{changes.length === 0 ? (
|
{changes.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex h-40 items-center justify-center text-sm"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "48px 24px",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{active
|
{active ? (
|
||||||
? "Watching for changes in temp0/..."
|
<>
|
||||||
: "Start the watcher to detect Toolset changes"}
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr
|
<tr
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--forge-border)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-surface-raised)",
|
||||||
color: "var(--forge-text-secondary)",
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.size === changes.length}
|
checked={
|
||||||
|
selected.size === changes.length &&
|
||||||
|
changes.length > 0
|
||||||
|
}
|
||||||
onChange={toggleAll}
|
onChange={toggleAll}
|
||||||
className="cursor-pointer"
|
style={{ cursor: "pointer" }}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-2 py-2 text-left font-medium">Filename</th>
|
<th
|
||||||
<th className="px-2 py-2 text-left font-medium">Type</th>
|
style={{
|
||||||
<th className="px-2 py-2 text-left font-medium">
|
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
|
Repo Path
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{changes.map((change) => (
|
{changes.map((change) => (
|
||||||
<tr
|
<tr
|
||||||
key={change.filename}
|
key={change.filename}
|
||||||
className="cursor-pointer transition-colors hover:bg-white/5"
|
onClick={() => viewDiff(change)}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--forge-border)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
cursor: "pointer",
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
diffData?.filename === change.filename
|
diffData?.filename === change.filename
|
||||||
? "var(--forge-surface)"
|
? "var(--forge-accent-subtle)"
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
onClick={() => viewDiff(change)}
|
|
||||||
>
|
>
|
||||||
<td className="px-6 py-2">
|
<td style={{ padding: "8px 16px" }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(change.filename)}
|
checked={selected.has(change.filename)}
|
||||||
@@ -373,24 +607,49 @@ export function Toolset() {
|
|||||||
toggleSelect(change.filename);
|
toggleSelect(change.filename);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="cursor-pointer"
|
style={{ cursor: "pointer" }}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 font-mono">{change.filename}</td>
|
<td
|
||||||
<td className="px-2 py-2">
|
style={{
|
||||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs">
|
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}
|
{change.gffType}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="px-2 py-2 font-mono text-xs"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
padding: "8px 10px",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{change.repoPath ?? "—"}
|
{change.repoPath ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="px-2 py-2 text-xs"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
padding: "8px 10px",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{formatTimestamp(change.timestamp)}
|
{formatTimestamp(change.timestamp)}
|
||||||
</td>
|
</td>
|
||||||
@@ -400,51 +659,103 @@ export function Toolset() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Diff panel */}
|
{/* Diff viewer panel */}
|
||||||
{diffData && (
|
{diffData && (
|
||||||
<div
|
<div
|
||||||
ref={diffContainerRef}
|
ref={diffContainerRef}
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
style={{ borderTop: "1px solid var(--forge-border)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center justify-between px-4 py-1.5"
|
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
marginTop: 16,
|
||||||
backgroundColor: "var(--forge-surface)",
|
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)",
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium">
|
<div
|
||||||
Diff: {diffData.filename}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileCode
|
||||||
|
size={14}
|
||||||
|
style={{ color: "var(--forge-accent)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diffData.filename}
|
||||||
|
</span>
|
||||||
{loading && (
|
{loading && (
|
||||||
<span style={{ color: "var(--forge-text-secondary)" }}>
|
<span
|
||||||
{" "}
|
style={{
|
||||||
(loading...)
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDiffData(null)}
|
onClick={() => setDiffData(null)}
|
||||||
className="text-xs transition-opacity hover:opacity-80"
|
style={{
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 5,
|
||||||
|
border: "1px solid var(--forge-border)",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<X size={12} />
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
<DiffEditor
|
{/* Diff content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: "var(--forge-log-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleDiffEditor
|
||||||
original={diffData.original}
|
original={diffData.original}
|
||||||
modified={diffData.modified}
|
modified={diffData.modified}
|
||||||
language="json"
|
language="json"
|
||||||
theme="vs-dark"
|
|
||||||
options={{
|
options={{
|
||||||
readOnly: true,
|
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
automaticLayout: true,
|
|
||||||
renderSideBySide: true,
|
|
||||||
padding: { top: 4 },
|
padding: { top: 4 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
let message = res.statusText;
|
||||||
throw new Error(body.error || res.statusText);
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
message = body.error || body.message || message;
|
||||||
|
} catch {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
if (text && text.length < 200 && !text.includes("<")) message = text;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -41,6 +48,14 @@ export const api = {
|
|||||||
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
|
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
|
||||||
gffSchema: (type: string) =>
|
gffSchema: (type: string) =>
|
||||||
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
|
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
|
||||||
|
tlkLookup: async (id: number): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const result = await request<{ text: string }>(`/editor/tlk/${id}`);
|
||||||
|
return result.text ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
workspace: {
|
workspace: {
|
||||||
|
|||||||
@@ -1,25 +1,185 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "@fontsource-variable/manrope";
|
||||||
|
@import "@fontsource-variable/alegreya";
|
||||||
|
@import "@fontsource-variable/jetbrains-mono";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--forge-bg: #121212;
|
--forge-bg: oklch(15% 0.01 65);
|
||||||
--forge-surface: #1e1e2e;
|
--forge-surface: oklch(20% 0.012 65);
|
||||||
--forge-border: #2e2e3e;
|
--forge-surface-raised: oklch(24% 0.014 65);
|
||||||
--forge-accent: #946200;
|
--forge-border: oklch(30% 0.014 65);
|
||||||
--forge-text: #f2f2f2;
|
--forge-accent: oklch(58% 0.155 65);
|
||||||
--forge-text-secondary: #888888;
|
--forge-accent-hover: oklch(63% 0.16 65);
|
||||||
|
--forge-accent-subtle: oklch(25% 0.04 65);
|
||||||
|
--forge-accent-text: oklch(15% 0.03 65);
|
||||||
|
--forge-text: oklch(93% 0.006 65);
|
||||||
|
--forge-text-secondary: oklch(68% 0.01 65);
|
||||||
|
|
||||||
|
--forge-success: oklch(62% 0.14 150);
|
||||||
|
--forge-success-bg: oklch(22% 0.03 150);
|
||||||
|
--forge-success-border: oklch(35% 0.06 150);
|
||||||
|
--forge-danger: oklch(68% 0.14 25);
|
||||||
|
--forge-danger-bg: oklch(22% 0.04 25);
|
||||||
|
--forge-danger-border: oklch(35% 0.08 25);
|
||||||
|
--forge-danger-strong: oklch(55% 0.18 25);
|
||||||
|
--forge-warning: oklch(72% 0.14 80);
|
||||||
|
--forge-warning-bg: oklch(25% 0.04 80);
|
||||||
|
--forge-warning-border: oklch(40% 0.07 80);
|
||||||
|
--forge-info: oklch(62% 0.08 230);
|
||||||
|
--forge-info-bg: oklch(22% 0.02 230);
|
||||||
|
|
||||||
|
--forge-log-bg: oklch(13% 0.008 65);
|
||||||
|
--forge-log-text: oklch(82% 0.008 65);
|
||||||
|
|
||||||
|
--font-sans: "Manrope Variable", system-ui, sans-serif;
|
||||||
|
--font-heading: "Alegreya Variable", Georgia, serif;
|
||||||
|
--font-mono: "JetBrains Mono Variable", "Fira Code", monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.6875rem;
|
||||||
|
--text-sm: 0.8125rem;
|
||||||
|
--text-base: 0.875rem;
|
||||||
|
--text-lg: 1.0625rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.75rem;
|
||||||
|
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-normal: 1.55;
|
||||||
|
--leading-relaxed: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.light {
|
:root.light {
|
||||||
--forge-bg: #f2f2f2;
|
--forge-bg: oklch(95% 0.008 65);
|
||||||
--forge-surface: #ffffff;
|
--forge-surface: oklch(99% 0.004 65);
|
||||||
--forge-border: #cbcbcb;
|
--forge-surface-raised: oklch(100% 0.002 65);
|
||||||
--forge-accent: #946200;
|
--forge-border: oklch(82% 0.012 65);
|
||||||
--forge-text: #252525;
|
--forge-accent: oklch(50% 0.155 65);
|
||||||
--forge-text-secondary: #666666;
|
--forge-accent-hover: oklch(45% 0.16 65);
|
||||||
|
--forge-accent-subtle: oklch(90% 0.04 65);
|
||||||
|
--forge-accent-text: oklch(99% 0.005 65);
|
||||||
|
--forge-text: oklch(20% 0.012 65);
|
||||||
|
--forge-text-secondary: oklch(45% 0.015 65);
|
||||||
|
|
||||||
|
--forge-success: oklch(45% 0.14 150);
|
||||||
|
--forge-success-bg: oklch(92% 0.03 150);
|
||||||
|
--forge-success-border: oklch(70% 0.08 150);
|
||||||
|
--forge-danger: oklch(50% 0.16 25);
|
||||||
|
--forge-danger-bg: oklch(92% 0.03 25);
|
||||||
|
--forge-danger-border: oklch(70% 0.08 25);
|
||||||
|
--forge-danger-strong: oklch(45% 0.18 25);
|
||||||
|
--forge-warning: oklch(55% 0.14 80);
|
||||||
|
--forge-warning-bg: oklch(92% 0.04 80);
|
||||||
|
--forge-warning-border: oklch(70% 0.07 80);
|
||||||
|
--forge-info: oklch(45% 0.08 230);
|
||||||
|
--forge-info-bg: oklch(92% 0.02 230);
|
||||||
|
|
||||||
|
--forge-log-bg: oklch(96% 0.006 65);
|
||||||
|
--forge-log-text: oklch(30% 0.01 65);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--forge-bg);
|
background-color: var(--forge-bg);
|
||||||
color: var(--forge-text);
|
color: var(--forge-text);
|
||||||
font-family: "Inter", system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
font-kerning: normal;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-heading {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabular-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--forge-accent-subtle);
|
||||||
|
color: var(--forge-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--forge-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--forge-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--forge-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--forge-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="search"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background-color: var(--forge-bg);
|
||||||
|
color: var(--forge-text);
|
||||||
|
border: 1px solid var(--forge-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--forge-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--forge-text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease-out, color 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
transition: color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,42 @@ export default {
|
|||||||
forge: {
|
forge: {
|
||||||
bg: "var(--forge-bg)",
|
bg: "var(--forge-bg)",
|
||||||
surface: "var(--forge-surface)",
|
surface: "var(--forge-surface)",
|
||||||
|
"surface-raised": "var(--forge-surface-raised)",
|
||||||
border: "var(--forge-border)",
|
border: "var(--forge-border)",
|
||||||
accent: "var(--forge-accent)",
|
accent: "var(--forge-accent)",
|
||||||
|
"accent-hover": "var(--forge-accent-hover)",
|
||||||
|
"accent-subtle": "var(--forge-accent-subtle)",
|
||||||
|
"accent-text": "var(--forge-accent-text)",
|
||||||
text: "var(--forge-text)",
|
text: "var(--forge-text)",
|
||||||
"text-secondary": "var(--forge-text-secondary)",
|
"text-secondary": "var(--forge-text-secondary)",
|
||||||
|
success: "var(--forge-success)",
|
||||||
|
"success-bg": "var(--forge-success-bg)",
|
||||||
|
"success-border": "var(--forge-success-border)",
|
||||||
|
danger: "var(--forge-danger)",
|
||||||
|
"danger-bg": "var(--forge-danger-bg)",
|
||||||
|
"danger-border": "var(--forge-danger-border)",
|
||||||
|
"danger-strong": "var(--forge-danger-strong)",
|
||||||
|
warning: "var(--forge-warning)",
|
||||||
|
"warning-bg": "var(--forge-warning-bg)",
|
||||||
|
"warning-border": "var(--forge-warning-border)",
|
||||||
|
info: "var(--forge-info)",
|
||||||
|
"info-bg": "var(--forge-info-bg)",
|
||||||
|
"log-bg": "var(--forge-log-bg)",
|
||||||
|
"log-text": "var(--forge-log-text)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Inter", "system-ui", "sans-serif"],
|
sans: ["Manrope Variable", "system-ui", "sans-serif"],
|
||||||
mono: ["JetBrains Mono", "Fira Code", "monospace"],
|
heading: ["Alegreya Variable", "Georgia", "serif"],
|
||||||
serif: ["Baskerville", "Georgia", "Palatino", "serif"],
|
mono: ["JetBrains Mono Variable", "Fira Code", "monospace"],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: "var(--text-xs)",
|
||||||
|
sm: "var(--text-sm)",
|
||||||
|
base: "var(--text-base)",
|
||||||
|
lg: "var(--text-lg)",
|
||||||
|
xl: "var(--text-xl)",
|
||||||
|
"2xl": "var(--text-2xl)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noImplicitAny": false
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,52 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import importMetaUrlPlugin from "@codingame/esbuild-import-meta-url-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
dedupe: ["vscode"],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
format: "es",
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
plugins: [importMetaUrlPlugin],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes("@codingame/monaco-vscode-editor-api") ||
|
||||||
|
id.includes("@codingame/monaco-vscode-api")) {
|
||||||
|
return "monaco-editor";
|
||||||
|
}
|
||||||
|
if (id.includes("@codingame/")) {
|
||||||
|
return "vscode-services";
|
||||||
|
}
|
||||||
|
if (id.includes("vscode/")) {
|
||||||
|
return "vscode-core";
|
||||||
|
}
|
||||||
|
if (id.includes("lucide-react")) {
|
||||||
|
return "icons";
|
||||||
|
}
|
||||||
|
if (id.includes("node_modules/react/") ||
|
||||||
|
id.includes("node_modules/react-dom/") ||
|
||||||
|
id.includes("node_modules/react-router")) {
|
||||||
|
return "react";
|
||||||
|
}
|
||||||
|
if (id.includes("monaco-languageclient") ||
|
||||||
|
id.includes("vscode-languageclient") ||
|
||||||
|
id.includes("vscode-jsonrpc") ||
|
||||||
|
id.includes("vscode-languageserver")) {
|
||||||
|
return "lsp";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user