feature (plg_widget_console): new console widget

This commit is contained in:
MickaelK
2026-04-10 16:48:44 +10:00
parent f84326edb9
commit e7a15d05df
9 changed files with 458 additions and 0 deletions
+1
View File
@@ -41,6 +41,7 @@ import (
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_security_svg"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_starter_http"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_video_transcoder"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_widget_console"
)
func init() {
@@ -0,0 +1,47 @@
import { register, all } from "./registry.js";
register({
id: "help",
description: "Show available commands",
run(shell) {
const commands = all();
shell.term.writeln("Available commands:");
const width = Math.max(...commands.map((c) => c.id.length));
for (const cmd of commands) {
shell.term.writeln(` ${cmd.id.padEnd(width + 2)}${cmd.description}`);
}
},
});
register({
id: "about",
description: "Show information about your instance",
run(shell) {
const controller = new AbortController();
fetch("/about", { signal: controller.signal })
.then((res) => res.text())
.then((html) => {
const $doc = new DOMParser().parseFromString(html, "text/html");
$doc.querySelectorAll("style, script").forEach((el) => el.remove());
$doc.body.textContent
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "")
.forEach((line) => shell.term.writeln(line));
})
.catch((err) => {
if (err.name === "AbortError") return;
shell.term.writeln(`about: ${err.message}`);
})
.finally(() => shell.prompt());
return () => controller.abort();
},
});
register({
id: "exit",
description: "Close the shell",
run(shell) {
shell.classList.add("hidden");
},
});
@@ -0,0 +1,13 @@
const commands = new Map();
export function register(cmd) {
commands.set(cmd.id, cmd);
}
export function get(id) {
return commands.get(id);
}
export function all() {
return Array.from(commands.values());
}
@@ -0,0 +1,55 @@
component-shell {
position: fixed;
bottom: 0;
left: 0;
background: rgba(0,0,0,0.75);
backdrop-filter: brightness(0.8) blur(5px);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
color: var(--super-light);
display: block;
padding: 16px 10px 10px;
width: 100%;
z-index: 3;
height: 450px;
box-sizing: border-box;
}
component-shell .component_shell_handle {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 14px;
cursor: ns-resize;
}
component-shell .component_shell_handle::after {
content: "";
position: absolute;
top: 4px;
left: 50%;
width: 64px;
height: 4px;
margin-left: -32px;
border-radius: 5px;
background: rgba(255,255,255,0.5);
}
component-shell .component_shell_terminal {
width: 100%;
height: 100%;
overflow: hidden;
}
component-shell .xterm {
height: 100%;
}
component-shell .xterm .xterm-viewport,
component-shell .xterm .composition-view {
background: transparent;
background-color: transparent;
}
body.shell_resizing {
user-select: none;
cursor: ns-resize;
}
@@ -0,0 +1,223 @@
import { onDestroy } from "../lib/skeleton/index.js";
import { loadCSS, loadJS } from "../helpers/loader.js";
import { getSession } from "../model/session.js";
import { all as allCommands, get as getCommand, register as registerCommand } from "./registry.js";
import("./commands.js");
export function init() {
console.log("INIT");
return ;
}
class ComponentShell extends HTMLElement {
constructor() {
super();
this.$handle = null;
this.$term = null;
this.term = null;
this.home = null;
this.initialized = false;
this.history = [];
this.clear();
}
getCommands() {
return allCommands();
}
static get observedAttributes() {
return ["class", "path"];
}
connectedCallback() {
if (this.innerHTML === "") {
this.innerHTML = `
<div class="component_shell_handle hidden"></div>
<div class="component_shell_terminal"></div>
`;
this.$handle = this.querySelector(".component_shell_handle");
this.$term = this.querySelector(".component_shell_terminal");
}
this.bootstrap();
}
attributeChangedCallback(name) {
this.bootstrap();
}
async bootstrap() {
if (this.classList.contains("hidden")) return;
else if (this.initialized) {
this.term.fit();
this.term.clear();
this.redrawLine();
return;
}
this.initialized = true;
await Promise.all([
getSession().toPromise().then(({ home }) => {
if (home === "/") return;
this.home = home;
}),
loadCSS(import.meta.url, "./shell.css"),
loadCSS(import.meta.url, "../lib/vendor/xterm/xterm.css"),
loadJS(import.meta.url, "../lib/vendor/xterm/xterm.js"),
]);
window.Terminal.applyAddon(window.fit);
this.clear();
this.term = new window.Terminal({
cursorBlink: true,
fontSize: 14,
theme: {
foreground: "#fafafa",
background: "rgba(0, 0, 0, 0)",
cursor: "#fafafa",
},
allowTransparency: true,
});
this.term.open(this.$term);
this.term.write(this.getPrompt());
this.term.fit();
this.term.focus();
this.term.on("key", (key, ev) => this.onKey(key, ev));
}
onKey(key, ev) {
if (ev.ctrlKey && ev.key === "c") {
this.term.write("^C");
if (this.running) {
try { this.running(); } catch (err) { /* ignore */ }
this.running = null;
return;
}
this.prompt();
return;
} else if (this.running) {
return;
}
if (ev.ctrlKey && ev.key === "a") {
this.moveCursor(-this.cursor);
return;
} else if (ev.ctrlKey && ev.key === "e") {
this.moveCursor(this.line.length - this.cursor);
return;
} else if (ev.ctrlKey && ev.key === "l") {
this.term.clear();
this.redrawLine();
return;
} else if (ev.key === "Enter") {
const input = this.line.trim();
if (input !== "") {
this.history.push(input);
this.term.write("\r\n");
const [id, ...args] = input.split(/\s+/);
const command = getCommand(id);
if (command) {
this.running = command.run(this, args);
if (!this.running) this.prompt();
return;
}
this.term.writeln(`command not found: ${id}`);
}
this.prompt();
return;
} else if (ev.key === "Backspace") {
this.deleteAtCursor(-1);
return;
} else if (ev.key === "Delete") {
this.deleteAtCursor(0);
return;
} else if (ev.key === "ArrowLeft") {
this.moveCursor(-1);
return;
} else if (ev.key === "ArrowRight") {
this.moveCursor(1);
return;
} else if (ev.key === "Home") {
this.moveCursor(-this.cursor);
return;
} else if (ev.key === "End") {
this.moveCursor(this.line.length - this.cursor);
return;
} else if (ev.key === "ArrowUp") {
this.navigateHistory(1);
return;
} else if (ev.key === "ArrowDown") {
this.navigateHistory(-1);
return;
} else if (ev.key === "Tab") {
if (ev.preventDefault) ev.preventDefault();
const matches = allCommands().map((c) => c.id).filter((id) => id.startsWith(this.line));
if (matches.length === 1) this.replaceLine(matches[0]);
else if (matches.length > 1) {
this.term.write("\r\n");
this.term.writeln(matches.join(" "));
this.redrawLine();
}
return;
}
if (ev.altKey || ev.metaKey || ev.ctrlKey || key.length !== 1) return;
this.line = this.line.slice(0, this.cursor) + key + this.line.slice(this.cursor);
this.cursor += key.length;
this.redrawLine();
}
getPrompt() {
let path = this.getAttribute("path") || "";
if (!path.endsWith("/")) path = path.slice(0, path.lastIndexOf("/") + 1);
if (this.home && path.startsWith(this.home)) {
path = "~/" + path.slice(this.home.length);
}
return path + " # ";
}
prompt() {
this.running = null;
this.clear();
this.term.write(`\r\n${this.getPrompt()}`);
this.term.fit();
}
redrawLine() {
const prompt = this.getPrompt();
this.term.write("\x1b[2K\r");
this.term.write(prompt + this.line);
const offset = this.line.length - this.cursor;
if (offset > 0) this.term.write(`\x1b[${offset}D`);
}
replaceLine(line) {
this.line = line;
this.cursor = line.length;
this.redrawLine();
}
clear() {
this.line = "";
this.cursor = 0;
this.historyIndex = -1;
}
moveCursor(delta) {
this.cursor = Math.max(0, Math.min(this.line.length, this.cursor + delta));
this.redrawLine();
}
deleteAtCursor(offset) {
const index = this.cursor + offset;
if (index < 0 || index >= this.line.length) return;
this.line = this.line.slice(0, index) + this.line.slice(index + 1);
if (offset < 0) this.cursor -= 1;
this.redrawLine();
}
navigateHistory(delta) {
if (this.history.length === 0) return;
this.historyIndex = Math.max(-1, Math.min(this.history.length - 1, this.historyIndex + delta));
this.replaceLine(this.historyIndex === -1 ? "" : this.history[this.history.length - 1 - this.historyIndex]);
}
}
customElements.define("component-shell", ComponentShell);
@@ -0,0 +1,26 @@
diff --git a/public/assets/components/decorator_shell_filemanager.js b/public/assets/components/decorator_shell_filemanager.js
index a499e5ef..caebd17c 100644
--- a/public/assets/components/decorator_shell_filemanager.js
+++ b/public/assets/components/decorator_shell_filemanager.js
@@ -6,3 +6,4 @@ import { animate, slideYOut } from "../lib/animate.js";
import { qs, safe } from "../lib/dom.js";
import { loadCSS } from "../helpers/loader.js";
import { init as initBreadcrumb } from "../components/breadcrumb.js";
+import { init as initShell } from "../components/shell.js";
@@ -18,3 +19,4 @@ export default function(ctrl) {
<component-breadcrumb path="${safe(urlToPath(history.state.previous))}"></component-breadcrumb>
<main id="main" data-bind="filemanager-children"></main>
</div>
+ <component-shell class="hidden" path="${safe(urlToPath(history.state.previous))}"></component-shell>
@@ -51,3 +53,11 @@ export default function(ctrl) {
navigate(p.join("/") + "/" + location.hash);
}),
));
+
+ // feature4: webshell
+ const $shell = qs($page, "component-shell");
+ $shell.setAttribute("path", urlToPath(fromHref(location.pathname + location.hash)));
+ effect(rxjs.fromEvent(window, "keydown").pipe(
+ rxjs.filter((e) => ["F4", "F9", "F12"].indexOf(e.code) !== -1),
+ rxjs.tap(() => $shell.classList.toggle("hidden")),
+ ));
@@ -0,0 +1,4 @@
The content of this folder is automatically generated by:
```
go generate -x ./server/plugin/plg_widget_console/...
```
@@ -0,0 +1,44 @@
//go:build ignore
package main
import (
"io"
"log"
"net/http"
"os"
)
func main() {
download(
"assets/vendor/xterm.js",
"https://cdnjs.cloudflare.com/ajax/libs/xterm/3.12.2/xterm.js",
"https://cdnjs.cloudflare.com/ajax/libs/xterm/3.12.2/addons/fit/fit.js",
)
download(
"assets/vendor/xterm.css",
"https://cdnjs.cloudflare.com/ajax/libs/xterm/3.12.2/xterm.css",
)
}
func download(dst string, urls ...string) {
f, err := os.Create(dst)
if err != nil {
log.Fatalf("create %s: %v", dst, err)
}
defer f.Close()
for _, url := range urls {
resp, err := http.Get(url)
if err != nil {
log.Fatalf("fetch %s: %v", url, err)
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("fetch %s: status %d", url, resp.StatusCode)
}
if _, err = io.Copy(f, resp.Body); err != nil {
log.Fatalf("write %s: %v", dst, err)
}
resp.Body.Close()
}
log.Printf("wrote %s", dst)
}
+45
View File
@@ -0,0 +1,45 @@
package plg_widget_console
import (
"embed"
"net/http"
. "github.com/mickael-kerjean/filestash/server/common"
"github.com/gorilla/mux"
)
//go:generate go run generator.go
//go:embed assets/*
var assets embed.FS
func init() {
Hooks.Register.StaticPatch(load("assets/shell.patch"))
Hooks.Register.HttpEndpoint(func(r *mux.Router) error {
for _, endpoint := range []struct {
url string
content []byte
mime string
}{
{"/components/shell.js", load("assets/shell.js"), "application/javascript"},
{"/components/shell.css", load("assets/shell.css"), "text/css"},
{"/components/registry.js", load("assets/registry.js"), "application/javascript"},
{"/components/commands.js", load("assets/commands.js"), "application/javascript"},
{"/components/commands.js", load("assets/commands.js"), "application/javascript"},
{"/lib/vendor/xterm/xterm.js", load("assets/vendor/xterm.js"), "application/javascript"},
{"/lib/vendor/xterm/xterm.css", load("assets/vendor/xterm.css"), "text/css"},
} {
r.HandleFunc(WithBase("/assets/"+BUILD_REF+endpoint.url), func(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", endpoint.mime)
res.Write(endpoint.content)
}).Methods("GET")
}
return nil
})
}
func load(path string) []byte {
data, _ := assets.ReadFile(path)
return data
}