added interactive-mode

This commit is contained in:
Stefan Friese 2025-02-11 14:11:22 +00:00
parent ac1179a9b5
commit 761a3a122b
6 changed files with 247 additions and 2 deletions

5
go.mod
View File

@ -1,3 +1,8 @@
module gommand
go 1.23.5
require (
github.com/creack/pty v1.1.24
github.com/gorilla/websocket v1.5.3
)

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

56
main.go
View File

@ -8,6 +8,7 @@ import (
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
@ -16,6 +17,9 @@ import (
"path/filepath"
"regexp"
"strings"
"github.com/creack/pty"
"github.com/gorilla/websocket"
)
type PageData struct {
@ -176,6 +180,7 @@ func executeCommandTree(node *CommandNode, currentDir string) (string, error) {
}
return executeCommandTree(node.Right, currentDir)
// TODO: or operator does not work correctly, fix it
case "||":
leftOutput, err := executeCommandTree(node.Left, currentDir)
if err == nil && leftOutput != "" {
@ -442,8 +447,6 @@ func parseCommandWithQuotes(command string) []string {
func handler(w http.ResponseWriter, r *http.Request) {
currentDir, _ := os.Getwd()
displayDir := filepath.Base(currentDir)
// displayDirList := strings.Split(currentDir, "/")
// displayDir := displayDirList[len(displayDirList)-1] // get the last element of the path
currentUser, _ := user.Current()
currentUsername := currentUser.Username
hostname, _ := os.Hostname()
@ -485,7 +488,56 @@ func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/upload", fileUploadHandler)
http.HandleFunc("/download", fileDownloadHandler)
http.HandleFunc("/terminal", terminalHandler)
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
fmt.Println("Starting server on :8080")
http.ListenAndServe(":8080", nil)
}
var upgrader = websocket.Upgrader {
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
func terminalHandler (w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Websocket upgrade error: %v", err)
return
}
defer ws.Close()
cmd := exec.Command("bash")
ptmx, err := pty.Start(cmd)
if err != nil {
log.Printf("Error starting PTY: %v", err)
return
}
defer func() { _ = ptmx.Close() }()
ptmx.Write([]byte("stty raw -echo\n"))
// ptmx.Write([]byte("export SHELL=bash 1>&2 2>/dev/null; export TERM=xterm-256color 1>&2 2>/dev/null"))
go func() {
buf := make([]byte, 1024)
for {
n, err := ptmx.Read(buf)
if err != nil {
break
}
if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
break
}
}
}()
for {
_, message, err := ws.ReadMessage()
if err != nil {
break
}
if _, err := ptmx.Write(message); err != nil {
break
}
}
}

169
static/start-interactive.js Normal file
View File

@ -0,0 +1,169 @@
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById("command-input");
const terminal = document.getElementById("terminal");
let interactiveWS = null;
let interactiveMode = false;
let lastKeySentTime = 0;
const throttleInterval = 50; // milliseconds
// Holds the users input on the current line.
let currentLineBuffer = "";
let currentLineElem = null;
const ansi_up = new AnsiUp;
input.addEventListener("keydown", function (event) {
if (!interactiveMode && event.key === "Enter") {
const command = input.value.trim();
if (command === "start-interactive") {
startInteractiveSession();
input.value = "";
event.preventDefault();
return;
}
// Otherwise, normal HTTP submission…
}
});
function startInteractiveSession() {
interactiveWS = new WebSocket("ws://" + location.host + "/terminal");
interactiveMode = true;
terminal.insertAdjacentHTML('beforeend', "\n--- Interactive session started ---\n");
input.style.display = "none"; // Hide normal input.
currentLineBuffer = "";
createCurrentLineElem();
interactiveWS.binaryType = "arraybuffer";
interactiveWS.onmessage = function (event) {
// When the shell returns output (for example, after Enter), process it.
let text = new TextDecoder("utf-8").decode(event.data);
text = removeOSCTitle(text);
processPTYOutput(text);
};
interactiveWS.onclose = function () {
interactiveMode = false;
terminal.insertAdjacentHTML('beforeend', "\n--- Interactive session ended ---\n");
input.style.display = "block";
input.focus();
document.removeEventListener("keydown", handleInteractiveKey, true);
};
interactiveWS.onerror = function (err) {
terminal.insertAdjacentHTML('beforeend', "\n--- Error in interactive session ---\n");
console.error("Interactive WS error:", err);
interactiveMode = false;
input.style.display = "block";
document.removeEventListener("keydown", handleInteractiveKey, true);
};
// Listen for all key events while in interactive mode.
document.addEventListener("keydown", handleInteractiveKey, true);
}
// Process output received from the PTY.
// We expect output to include completed lines (with newline) as command results.
function processPTYOutput(text) {
// Split output into lines.
const parts = text.split("\n");
for (let i = 0; i < parts.length; i++) {
const processedPart = processBackspaces(parts[i]);
if (i < parts.length - 1) {
// Completed line.
currentLineBuffer += processedPart;
const finishedLine = document.createElement("div");
finishedLine.innerHTML = ansi_up.ansi_to_html(currentLineBuffer);
terminal.appendChild(finishedLine);
currentLineBuffer = "";
createCurrentLineElem();
} else {
// Incomplete line.
currentLineBuffer += processedPart;
}
}
updateCurrentLine();
}
// Update the current line element with the current buffer and a blinking cursor.
function updateCurrentLine() {
if (currentLineElem) {
currentLineElem.innerHTML = ansi_up.ansi_to_html(currentLineBuffer);
updateCursor();
}
terminal.scrollTop = terminal.scrollHeight;
}
// Create (or recreate) the current line element.
function createCurrentLineElem() {
if (currentLineElem && currentLineElem.parentNode) {
currentLineElem.parentNode.removeChild(currentLineElem);
}
currentLineElem = document.createElement("span");
currentLineElem.id = "current-line";
// Ensure the current line element is inline so the cursor stays on the same line.
currentLineElem.style.display = "inline";
terminal.appendChild(currentLineElem);
}
// Append a blinking cursor to the current line.
function updateCursor() {
const existingCursor = currentLineElem.querySelector(".cursor");
if (existingCursor) {
existingCursor.parentNode.removeChild(existingCursor);
}
currentLineElem.insertAdjacentHTML('beforeend', '<span class="cursor">█</span>');
}
// Throttled key handler that updates the local current line buffer and sends keys to the PTY.
function handleInteractiveKey(event) {
if (!interactiveMode || !interactiveWS || interactiveWS.readyState !== WebSocket.OPEN)
return;
const now = Date.now();
if (now - lastKeySentTime < throttleInterval) {
event.preventDefault();
return;
}
lastKeySentTime = now;
let data = "";
if (event.key === "Enter") {
data = "\n";
// Append newline to the local buffer and clear it
currentLineBuffer += "\n";
updateCurrentLine();
currentLineBuffer = "";
} else if (event.key === "Backspace") {
data = "\x7f"; // DEL
// Update the local buffer: remove the last character.
currentLineBuffer = currentLineBuffer.slice(0, -1);
updateCurrentLine();
} else if (event.key.length === 1) {
data = event.key;
currentLineBuffer += event.key;
updateCurrentLine();
} else {
return;
}
interactiveWS.send(data);
event.preventDefault();
}
// Remove OSC sequences (for terminal title updates).
function removeOSCTitle(text) {
const oscRegex = /\x1b\]0;.*?(?:\x07|\x1b\\)/g;
return text.replace(oscRegex, "");
}
// Process backspace characters in a string (for PTY output).
function processBackspaces(text) {
let result = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "\x7f" || ch === "\b") {
result = result.slice(0, -1);
} else {
result += ch;
}
}
return result;
}
});

View File

@ -41,6 +41,10 @@ body {
/* overflow: hidden; */
/* text-overflow: ellipsis; */
}
/* current-line is for interactive mode */
#current-line {
display: inline;
}
input {
flex-grow: 1;
font-size: 14pt;
@ -54,6 +58,15 @@ input {
input:focus {
outline: none;
}
span.cursor {
display: inline-block;
width: 10px;
animation: blink 1s step-end infinite;
vertical-align: bottom;
}
@keyframes blink {
50% { opacity: 0; }
}
span.command {
color: var(--command-color);
}

View File

@ -7,6 +7,8 @@
<script type="text/javascript" src="static/keyboard-shortcuts.js"></script>
<script type="text/javascript" src="static/download-command.js"></script>
<script type="text/javascript" src="static/switch-themes.js"></script>
<script type="text/javascript" src="static/start-interactive.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ansi_up@5.0.0/ansi_up.min.js"></script>
<link rel="stylesheet" type="text/css" href="static/stylesheet.css">
</head>
<body>