diff --git a/go.mod b/go.mod
index dbdbdbc..4096831 100644
--- a/go.mod
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a15fd45
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/main.go b/main.go
index d7a3e43..0680fbe 100644
--- a/main.go
+++ b/main.go
@@ -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
+ }
+ }
+}
diff --git a/static/start-interactive.js b/static/start-interactive.js
new file mode 100644
index 0000000..024de9b
--- /dev/null
+++ b/static/start-interactive.js
@@ -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', '█');
+ }
+
+ // 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;
+ }
+});
diff --git a/static/stylesheet.css b/static/stylesheet.css
index 0b91b15..7908782 100644
--- a/static/stylesheet.css
+++ b/static/stylesheet.css
@@ -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);
}
diff --git a/templates/index.html b/templates/index.html
index 0543aae..6a50930 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -7,6 +7,8 @@
+
+