Files
brass-and-sigil/server/wwwroot/modules/autocomplete.js
T
Matt Sijbers a1331212cb Initial commit: Brass & Sigil monorepo
Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
2026-05-05 00:19:05 +01:00

264 lines
10 KiB
JavaScript

// Tab-completion + suggestion dropdown for the console command input.
// - Ghost text shows the top match inline (Tab to accept)
// - A dropdown list shows up to N matches with their argument signature
// (MC-style: <required> [optional] <a|b|c> for choices)
// - Click a list item to insert it; arrow keys + Enter also navigate the list
// when it's open
"use strict";
import { state } from "./state.js";
import { escapeHtml } from "./api.js";
const COMMANDS = [
"help", "list", "say", "tell", "msg", "me", "w",
"op", "deop",
"whitelist", "ban", "ban-ip", "pardon", "pardon-ip", "banlist",
"kick",
"tp", "teleport",
"give", "clear", "kill",
"gamemode", "gamerule", "difficulty",
"weather", "time", "seed", "spawnpoint", "setworldspawn",
"save-all", "save-on", "save-off", "stop", "reload",
"xp", "experience", "effect", "enchant",
"summon", "data", "execute", "fill", "setblock", "locate", "tag",
"ftbchunks", "ftbteams",
"chunky",
"kubejs", "kjs",
];
const SUBCOMMANDS = {
whitelist: ["add", "remove", "list", "reload", "on", "off"],
gamemode: ["survival", "creative", "adventure", "spectator"],
weather: ["clear", "rain", "thunder"],
difficulty: ["peaceful", "easy", "normal", "hard"],
time: ["set", "add", "query"],
chunky: ["start", "cancel", "pause", "continue", "world", "shape", "center", "radius", "force_load", "force_unload", "trim", "help"],
ftbchunks: ["claim", "unclaim", "load", "unload", "admin"],
};
const TAKES_PLAYER_AT = {
"op": 1, "deop": 1, "tp": 1, "teleport": 1, "kick": 1,
"ban": 1, "pardon": 1, "kill": 1,
"tell": 1, "msg": 1, "w": 1,
"give": 1, "clear": 1, "effect": 1, "enchant": 1, "xp": 1, "experience": 1,
"whitelist add": 2, "whitelist remove": 2,
"gamemode survival": 2, "gamemode creative": 2, "gamemode adventure": 2, "gamemode spectator": 2,
};
// MC-style argument signatures for each command. Shown as a hint after the name
// in the suggestion list. <required> [optional] <a|b|c> for enum choices.
const SIGNATURES = {
help: "[command]",
list: "",
say: "<message>",
tell: "<player> <message>",
msg: "<player> <message>",
me: "<action>",
w: "<player> <message>",
op: "<player>",
deop: "<player>",
whitelist: "<add|remove|list|reload|on|off>",
"whitelist add": "<player>",
"whitelist remove": "<player>",
"whitelist list": "",
"whitelist on": "",
"whitelist off": "",
"whitelist reload": "",
ban: "<player> [reason…]",
"ban-ip": "<ip|player> [reason…]",
pardon: "<player>",
"pardon-ip": "<ip>",
banlist: "[ips|players]",
kick: "<player> [reason…]",
tp: "<target> [destination]",
teleport: "<target> [destination]",
give: "<player> <item> [count]",
clear: "[player] [item]",
kill: "[target]",
gamemode: "<mode> [player]",
"gamemode survival": "[player]",
"gamemode creative": "[player]",
"gamemode adventure": "[player]",
"gamemode spectator": "[player]",
gamerule: "<rule> [value]",
difficulty: "<peaceful|easy|normal|hard>",
weather: "<clear|rain|thunder> [duration]",
time: "<set|add|query> <value>",
seed: "",
spawnpoint: "[player] [pos]",
setworldspawn: "[pos]",
"save-all": "[flush]",
"save-on": "",
"save-off": "",
stop: "",
reload: "",
xp: "<amount> [player]",
experience: "<add|set|query> <player> <amount>",
effect: "<give|clear> <player> <effect>",
enchant: "<player> <enchantment> [level]",
summon: "<entity> [pos]",
fill: "<from> <to> <block>",
setblock: "<pos> <block>",
locate: "<biome|structure> <id>",
tag: "<target> <add|remove|list> [tag]",
chunky: "<start|cancel|pause|continue|world|shape|center|radius|trim|...>",
"chunky start": "[world] [shape] [center_x] [center_z] [radius]",
"chunky cancel": "",
"chunky pause": "",
"chunky continue": "",
"chunky world": "<world>",
"chunky shape": "<square|circle|...>",
"chunky center": "<x> <z>",
"chunky radius": "<radius>",
"chunky trim": "[world] [radius] [trim_radius]",
ftbchunks: "<claim|unclaim|load|unload|admin>",
ftbteams: "<list|info|invite|...>",
kubejs: "<reload|hand|stages|...>",
kjs: "<reload|hand|stages|...>",
};
const MAX_SUGGESTIONS = 8;
let activeIndex = 0;
let currentSuggestions = [];
export function setupAutocomplete() {
const cmdInput = document.getElementById("cmdInput");
const cmdGhost = document.getElementById("cmdGhost");
const cmdSuggest = document.getElementById("cmdSuggest");
function refresh() {
const v = cmdInput.value;
currentSuggestions = computeAllSuggestions(v).slice(0, MAX_SUGGESTIONS);
activeIndex = 0;
// Inline ghost = top suggestion (only if it extends what they typed)
const top = currentSuggestions[0];
if (top && top.text.startsWith(v) && top.text !== v) {
const suffix = top.text.substring(v.length);
cmdGhost.innerHTML = `<span class="typed">${escapeHtml(v)}</span>${escapeHtml(suffix)}`;
cmdInput.dataset.suggestion = top.text;
} else {
cmdGhost.innerHTML = "";
cmdInput.dataset.suggestion = "";
}
renderList(cmdSuggest, currentSuggestions);
}
cmdInput.addEventListener("input", refresh);
cmdInput.addEventListener("focus", refresh);
cmdInput.addEventListener("blur", () => {
// Delay so a click on a list item registers before we hide
setTimeout(() => cmdSuggest.classList.remove("show"), 150);
});
cmdInput.addEventListener("keydown", e => {
if (e.key === "Tab") {
const sug = currentSuggestions[activeIndex];
if (sug) {
e.preventDefault();
cmdInput.value = sug.text + " ";
refresh();
}
} else if (e.key === "Escape") {
cmdGhost.innerHTML = "";
cmdInput.dataset.suggestion = "";
cmdSuggest.classList.remove("show");
} else if (e.key === "ArrowDown" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
e.preventDefault();
activeIndex = (activeIndex + 1) % currentSuggestions.length;
highlightActive(cmdSuggest);
} else if (e.key === "ArrowUp" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
e.preventDefault();
activeIndex = (activeIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
highlightActive(cmdSuggest);
}
// Note: Enter is handled by console.js (sends the command)
});
cmdSuggest.addEventListener("mousedown", e => {
// mousedown (not click) so we beat the input blur handler
const item = e.target.closest(".suggest-item");
if (!item) return;
e.preventDefault();
const idx = parseInt(item.dataset.idx, 10);
const sug = currentSuggestions[idx];
if (sug) {
cmdInput.value = sug.text + " ";
cmdInput.focus();
refresh();
}
});
}
function highlightActive(listEl) {
[...listEl.querySelectorAll(".suggest-item")].forEach((el, i) => {
el.classList.toggle("active", i === activeIndex);
if (i === activeIndex) el.scrollIntoView({ block: "nearest" });
});
}
function renderList(listEl, suggestions) {
if (suggestions.length === 0) {
listEl.classList.remove("show");
listEl.innerHTML = "";
return;
}
listEl.innerHTML = suggestions.map((s, i) => {
const args = s.args ? `<span class="args">${escapeHtml(s.args)}</span>` : "";
return `<div class="suggest-item${i === activeIndex ? " active" : ""}" data-idx="${i}">` +
`<span>${escapeHtml(s.text)}</span>${args}</div>`;
}).join("");
listEl.classList.add("show");
}
// Returns an array of {text, args} suggestions ordered by relevance.
// args is the MC-style hint shown next to the name.
function computeAllSuggestions(input) {
if (!input) return [];
const stripped = input.startsWith("/") ? input.substring(1) : input;
const tokens = stripped.split(" ");
const partial = tokens[tokens.length - 1].toLowerCase();
const completed = tokens.slice(0, -1);
const prefix = input.startsWith("/") ? "/" : "";
// First token: command name
if (completed.length === 0) {
const matches = COMMANDS.filter(c => c.startsWith(partial)).sort();
return matches.map(name => ({
text: prefix + name,
args: SIGNATURES[name] ?? "",
}));
}
// Subcommands
const headLower = completed[0].toLowerCase();
if (completed.length === 1 && SUBCOMMANDS[headLower]) {
const subs = SUBCOMMANDS[headLower].filter(s => s.startsWith(partial));
return subs.map(sub => ({
text: prefix + [...completed, sub].join(" "),
args: SIGNATURES[`${headLower} ${sub}`] ?? "",
}));
}
// Player-name positions
const cmdKey = completed.join(" ").toLowerCase();
const playerSlot = TAKES_PLAYER_AT[cmdKey];
if (playerSlot !== undefined && tokens.length === playerSlot + 1) {
const matches = state.knownPlayers.filter(p => p.toLowerCase().startsWith(partial));
return matches.map(p => ({
text: prefix + [...completed, p].join(" "),
args: "",
}));
}
// No structured suggestion at this position -- but still show the current
// command's signature as a contextual hint
const sig = SIGNATURES[cmdKey];
if (sig) {
return [{ text: input, args: sig }];
}
return [];
}