mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2026-10-19 16:14:39 +00:00
feature (plg_widget_console): new console widget
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user