added interactive-mode
This commit is contained in:
parent
ac1179a9b5
commit
761a3a122b
5
go.mod
5
go.mod
|
@ -1,3 +1,8 @@
|
||||||
module gommand
|
module gommand
|
||||||
|
|
||||||
go 1.23.5
|
go 1.23.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
)
|
||||||
|
|
|
@ -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
56
main.go
|
@ -8,6 +8,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
@ -16,6 +17,9 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
|
@ -176,6 +180,7 @@ func executeCommandTree(node *CommandNode, currentDir string) (string, error) {
|
||||||
}
|
}
|
||||||
return executeCommandTree(node.Right, currentDir)
|
return executeCommandTree(node.Right, currentDir)
|
||||||
|
|
||||||
|
// TODO: or operator does not work correctly, fix it
|
||||||
case "||":
|
case "||":
|
||||||
leftOutput, err := executeCommandTree(node.Left, currentDir)
|
leftOutput, err := executeCommandTree(node.Left, currentDir)
|
||||||
if err == nil && leftOutput != "" {
|
if err == nil && leftOutput != "" {
|
||||||
|
@ -442,8 +447,6 @@ func parseCommandWithQuotes(command string) []string {
|
||||||
func handler(w http.ResponseWriter, r *http.Request) {
|
func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
currentDir, _ := os.Getwd()
|
currentDir, _ := os.Getwd()
|
||||||
displayDir := filepath.Base(currentDir)
|
displayDir := filepath.Base(currentDir)
|
||||||
// displayDirList := strings.Split(currentDir, "/")
|
|
||||||
// displayDir := displayDirList[len(displayDirList)-1] // get the last element of the path
|
|
||||||
currentUser, _ := user.Current()
|
currentUser, _ := user.Current()
|
||||||
currentUsername := currentUser.Username
|
currentUsername := currentUser.Username
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
|
@ -485,7 +488,56 @@ func main() {
|
||||||
http.HandleFunc("/", handler)
|
http.HandleFunc("/", handler)
|
||||||
http.HandleFunc("/upload", fileUploadHandler)
|
http.HandleFunc("/upload", fileUploadHandler)
|
||||||
http.HandleFunc("/download", fileDownloadHandler)
|
http.HandleFunc("/download", fileDownloadHandler)
|
||||||
|
http.HandleFunc("/terminal", terminalHandler)
|
||||||
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
||||||
fmt.Println("Starting server on :8080")
|
fmt.Println("Starting server on :8080")
|
||||||
http.ListenAndServe(":8080", nil)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 user’s 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;
|
||||||
|
}
|
||||||
|
});
|
|
@ -41,6 +41,10 @@ body {
|
||||||
/* overflow: hidden; */
|
/* overflow: hidden; */
|
||||||
/* text-overflow: ellipsis; */
|
/* text-overflow: ellipsis; */
|
||||||
}
|
}
|
||||||
|
/* current-line is for interactive mode */
|
||||||
|
#current-line {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
input {
|
input {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: 14pt;
|
font-size: 14pt;
|
||||||
|
@ -54,6 +58,15 @@ input {
|
||||||
input:focus {
|
input:focus {
|
||||||
outline: none;
|
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 {
|
span.command {
|
||||||
color: var(--command-color);
|
color: var(--command-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
<script type="text/javascript" src="static/keyboard-shortcuts.js"></script>
|
<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/download-command.js"></script>
|
||||||
<script type="text/javascript" src="static/switch-themes.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">
|
<link rel="stylesheet" type="text/css" href="static/stylesheet.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
Loading…
Reference in New Issue