From 45e18b5423a3598056b5a8dc5dfacaa6ae1f89bd Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sun, 24 May 2026 06:37:59 +0000 Subject: [PATCH] fix session with split screens --- .gitignore | 3 +- internals/config.go | 11 +- internals/handlers.go | 16 +- internals/server.go | 19 +- internals/web/app.css | 64 +++- internals/web/app.js | 706 ++++++++++++++++++++++++++++----------- internals/web/shell.html | 44 ++- internals/workspace.go | 118 +++++++ test-resume.sh | 3 - 9 files changed, 760 insertions(+), 224 deletions(-) create mode 100644 internals/workspace.go delete mode 100644 test-resume.sh diff --git a/.gitignore b/.gitignore index a0c96da..225f26c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ test/ upload/ gotermix *.json -*.key \ No newline at end of file +*.key +test* \ No newline at end of file diff --git a/internals/config.go b/internals/config.go index 81207bf..820983a 100644 --- a/internals/config.go +++ b/internals/config.go @@ -24,11 +24,12 @@ const ( // custom TLS cert paths. The password is salted + iterated-SHA256 hashed // (never stored plaintext); the whole struct is AES-256-GCM encrypted on disk. type storedCreds struct { - Username string `json:"username"` - Salt string `json:"salt"` - Hash string `json:"hash"` - CertFile string `json:"cert_file,omitempty"` - KeyFile string `json:"key_file,omitempty"` + Username string `json:"username"` + Salt string `json:"salt"` + Hash string `json:"hash"` + CertFile string `json:"cert_file,omitempty"` + KeyFile string `json:"key_file,omitempty"` + Workspaces map[string]*WorkspaceLayout `json:"workspaces,omitempty"` } type client struct { diff --git a/internals/handlers.go b/internals/handlers.go index c54b925..e637c77 100644 --- a/internals/handlers.go +++ b/internals/handlers.go @@ -57,19 +57,17 @@ func handleStaticJS(w http.ResponseWriter, r *http.Request) { w.Write(data) } -// handleIndex: clean URL, always creates a fresh session. -// The session-specific /s/ URL is available in the toolbar for sharing. +// handleIndex: always creates a fresh workspace and redirects to its stable URL. +// PTY sessions are started lazily by the frontend via WebSocket connections. func handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - id := randHex(16) - getOrCreate(id) - serveTerminalPage(w, id, isAuthed(r)) + http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound) } -// handleShell: reconnects to a specific session by ID. +// handleShell: serves the terminal page for an existing (or new) workspace ID. func handleShell(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/s/") if !validID(id) { @@ -79,14 +77,14 @@ func handleShell(w http.ResponseWriter, r *http.Request) { serveTerminalPage(w, id, isAuthed(r)) } -func serveTerminalPage(w http.ResponseWriter, id string, authed bool) { +func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) { authedStr := "false" if authed { authedStr = "true" } html := strings.NewReplacer( - "[[SESSION_ID]]", id, - "[[AUTHED]]", authedStr, + "[[WORKSPACE_ID]]", workspaceID, + "[[AUTHED]]", authedStr, ).Replace(shellPageHTML) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) diff --git a/internals/server.go b/internals/server.go index eacb405..07c2f9e 100644 --- a/internals/server.go +++ b/internals/server.go @@ -136,15 +136,16 @@ func Run() { } mux := http.NewServeMux() - mux.HandleFunc("/", handleIndex) - mux.HandleFunc("/s/", handleShell) - mux.HandleFunc("/ws/", handleWS) - mux.HandleFunc("/auth", handleAuth) - mux.HandleFunc("/upload", handleUpload) - mux.HandleFunc("/download", handleDownload) - mux.HandleFunc("/favicon.svg", handleFavicon) - mux.HandleFunc("/static/app.css", handleStaticCSS) - mux.HandleFunc("/static/app.js", handleStaticJS) + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/s/", handleShell) + mux.HandleFunc("/ws/", handleWS) + mux.HandleFunc("/auth", handleAuth) + mux.HandleFunc("/upload", handleUpload) + mux.HandleFunc("/download", handleDownload) + mux.HandleFunc("/favicon.svg", handleFavicon) + mux.HandleFunc("/static/app.css", handleStaticCSS) + mux.HandleFunc("/static/app.js", handleStaticJS) + mux.HandleFunc("/api/workspace/", handleWorkspaceAPI) ln, _ := net.Listen("tcp", *addr) fmt.Printf("Go Web Shell https://%s\n", *addr) diff --git a/internals/web/app.css b/internals/web/app.css index 1047503..180e6a8 100644 --- a/internals/web/app.css +++ b/internals/web/app.css @@ -33,6 +33,12 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0; border-color: rgba(255,255,255,0.08); } .tab-label { cursor: pointer; } +.tab-rename-input { + background: #0d0f14; border: 1px solid rgba(110,231,183,0.45); + border-radius: 3px; color: #e2e8f0; font-size: 11px; + font-family: inherit; padding: 1px 5px; outline: none; + width: 90px; min-width: 40px; +} .tab-x { width: 16px; height: 16px; border: none; border-radius: 3px; background: transparent; color: inherit; cursor: pointer; @@ -54,7 +60,53 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0; #termContainer { position: fixed; top: 36px; left: 0; right: 0; bottom: 48px; } -.term-pane { width: 100%; height: 100%; } + +/* One per tab — holds the entire pane tree for that tab */ +.tab-pane { width: 100%; height: 100%; } + +/* Leaf node — wraps an xterm canvas */ +.term-pane { width: 100%; height: 100%; overflow: hidden; } + +/* Active pane highlight (shown only when 2+ panes exist in a tab) */ +.pane-active { box-shadow: inset 0 0 0 1px rgba(110,231,183,0.45); } + +/* ── Split containers ── */ +/* dir 'h': side-by-side with a vertical divider */ +.split-h { + display: flex; flex-direction: row; + width: 100%; height: 100%; overflow: hidden; +} +/* dir 'v': stacked with a horizontal divider */ +.split-v { + display: flex; flex-direction: column; + width: 100%; height: 100%; overflow: hidden; +} + +/* ── Draggable dividers ── */ +.split-div-h { + flex: 0 0 4px; height: 100%; + background: rgba(255,255,255,0.07); + cursor: col-resize; + transition: background 0.15s; + position: relative; +} +.split-div-h::after { + content: ''; position: absolute; inset: 0 -2px; + /* wider hit target without affecting layout */ +} +.split-div-h:hover { background: rgba(110,231,183,0.35); } + +.split-div-v { + flex: 0 0 4px; width: 100%; + background: rgba(255,255,255,0.07); + cursor: row-resize; + transition: background 0.15s; + position: relative; +} +.split-div-v::after { + content: ''; position: absolute; inset: -2px 0; +} +.split-div-v:hover { background: rgba(110,231,183,0.35); } /* ── Compact toolbar ── */ .toolbar { @@ -91,9 +143,13 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0; } .tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; } .tb-btn svg { flex-shrink: 0; } -.tb-btn.accent { background: rgba(110,231,183,0.1); color: #6ee7b7; - border: 1px solid rgba(110,231,183,0.15); } -.tb-btn.accent:hover { background: rgba(110,231,183,0.18); } +.tb-btn.accent { background: rgba(110,231,183,0.1); color: #6ee7b7; + border: 1px solid rgba(110,231,183,0.15); } +.tb-btn.accent:hover { background: rgba(110,231,183,0.18); } +.tb-btn.tb-danger { background: rgba(239,68,68,0.08); color: #f87171; + border: 1px solid rgba(239,68,68,0.15); } +.tb-btn.tb-danger:hover { background: rgba(239,68,68,0.16); color: #fca5a5; } +.tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; } /* ── Toast ── */ .toast { diff --git a/internals/web/app.js b/internals/web/app.js index f80e2be..0717d00 100644 --- a/internals/web/app.js +++ b/internals/web/app.js @@ -1,23 +1,20 @@ -// ── Session context (injected as inline diff --git a/internals/workspace.go b/internals/workspace.go new file mode 100644 index 0000000..d1bbacf --- /dev/null +++ b/internals/workspace.go @@ -0,0 +1,118 @@ +package internals + +import ( + "encoding/json" + "net/http" + "strings" + "sync" +) + +// credsMu guards concurrent reads/writes of appCreds.Workspaces from HTTP +// handlers. Credential fields (Username/Hash/etc.) are only written at +// startup via CLI flags that call os.Exit, so they don't need the lock. +var credsMu sync.Mutex + +// WorkspacePaneLayout is a recursive pane tree. +// Leaf nodes own a PTY session; split nodes contain two children. +type WorkspacePaneLayout struct { + Type string `json:"type"` // "leaf" | "split" + ID string `json:"id,omitempty"` // PTY session hex ID (leaf only) + Dir string `json:"dir,omitempty"` // "h" | "v" (split only) + Ratio float64 `json:"ratio,omitempty"` // 0.1–0.9 (split only) + A *WorkspacePaneLayout `json:"a,omitempty"` + B *WorkspacePaneLayout `json:"b,omitempty"` +} + +// WorkspaceTabLayout stores one tab's label and pane tree. +type WorkspaceTabLayout struct { + TabID string `json:"tab_id"` + Label string `json:"label"` + Root WorkspacePaneLayout `json:"root"` +} + +// WorkspaceLayout is the full layout stored per workspace ID in gws-creds.json. +type WorkspaceLayout struct { + ID string `json:"id"` + Tabs []WorkspaceTabLayout `json:"tabs"` +} + +// handleWorkspaceAPI dispatches GET / PUT / DELETE for /api/workspace/. +// All methods require auth. +func handleWorkspaceAPI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !isAuthed(r) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/workspace/") + if !validID(id) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid id"}`)) + return + } + switch r.Method { + case http.MethodGet: + workspaceGet(w, id) + case http.MethodPut: + workspacePut(w, r, id) + case http.MethodDelete: + workspaceDelete(w, id) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte(`{"error":"method not allowed"}`)) + } +} + +func workspaceGet(w http.ResponseWriter, id string) { + credsMu.Lock() + ws := appCreds.Workspaces[id] + credsMu.Unlock() + if ws == nil { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"not found"}`)) + return + } + json.NewEncoder(w).Encode(ws) //nolint:errcheck +} + +func workspacePut(w http.ResponseWriter, r *http.Request, id string) { + var ws WorkspaceLayout + if err := json.NewDecoder(r.Body).Decode(&ws); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"bad json"}`)) + return + } + ws.ID = id + + credsMu.Lock() + if appCreds.Workspaces == nil { + appCreds.Workspaces = make(map[string]*WorkspaceLayout) + } + appCreds.Workspaces[id] = &ws + err := saveCreds(appCreds) + credsMu.Unlock() + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"save failed"}`)) + return + } + w.Write([]byte(`{"ok":true}`)) +} + +func workspaceDelete(w http.ResponseWriter, id string) { + credsMu.Lock() + if appCreds.Workspaces != nil { + delete(appCreds.Workspaces, id) + } + err := saveCreds(appCreds) + credsMu.Unlock() + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"save failed"}`)) + return + } + w.Write([]byte(`{"ok":true}`)) +} diff --git a/test-resume.sh b/test-resume.sh deleted file mode 100644 index a53ad99..0000000 --- a/test-resume.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -export CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 -CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 claude --resume dd1f4084-d53b-4d1a-9401-cb605b5c7718