diff --git a/docs/superpowers/plans/2026-10-15-workspace.md b/docs/superpowers/plans/2026-10-15-workspace.md new file mode 100644 index 0000000..aa37b77 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-workspace.md @@ -0,0 +1,1073 @@ +# LPRA 0.2.0 Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One-page panel shell where terminals and file managers (on machines and SSH hosts) live as panes in browser-session tabs that survive moving between Машины, Терминал and Настройки, with drag and drop of files between panes. + +**Architecture:** The server keeps rendering the existing templates, but in a fragment mode (`?frag=1`) without the page chrome; one shell page (`app.html`) holds the header and three views, and a small router (`app.js`) swaps views with `history.pushState`, loads fragments, intercepts their forms and mounts their scripts. The terminal view is created once and only hidden, so its sessions stay open. The terminal page's code splits into a workspace (tabs, layouts, menu) and pane types: terminal (`termpane.js`) and files (`filepane.js`, pulled out of `files.html`). Files on SSH hosts are done by the agent through shell commands over SSH, as the existing tar transfer does. + +**Tech Stack:** Go 1.26 (server, agent; `golang.org/x/crypto/ssh` already in go.mod), plain browser JavaScript without a build step (`internal/server/web/static/*.js`), Go `html/template`, node (`node --test`) for the pure JS helpers, Chrome DevTools (MCP) on the local stand for UI checks. + +**Spec:** `docs/superpowers/specs/2026-10-15-workspace-design.md` + +## Global Constraints + +- Version `0.2.0` in `VERSION.txt` (set in the last task). +- No new Go or JS dependencies. Files over SSH use shell commands over the existing `golang.org/x/crypto/ssh` client; no SFTP library. +- No build step for the front end: plain scripts under `internal/server/web/static/`, served with `{{asset "name.js"}}` (content-hashed URLs). +- All UI text in Russian, in the tone of the existing panel; code comments in English, in the style of the surrounding code. +- Workspace state lives only in the page's memory: one per browser tab, gone on reload, kept across Машины ↔ Терминал ↔ Настройки. +- `beforeunload` asks only while a terminal or SSH terminal session is alive. +- Old URLs `/agents/{id}/terminal` and `/agents/{id}/files` keep working (they open the shell with a tab of that kind). +- Roles: a non-admin sees and opens only machines given to them; «Настройки» is shown to admins only. +- Commits go straight to `main`, no attribution lines in commit messages, `dist.zip` and `deploy/nginx.conf.example` are never staged; stage files by name. +- Local stand for checks: scratchpad `r/` (`./server -addr 127.0.0.1:28491 -db lpra.db -tunnel "" -insecure-cookies`, agent `HOST=http://127.0.0.1:28491 STATE_PATH=$PWD/state.json TUNNEL_ADDR=off ./agent`, admin `adm`/`Passw0rd12345`, agent id `75108f8d676069f9`, test sshd on `127.0.0.1:28622`, user `t`, password `secret`). Start the agent with `env -u TERM_SESSION_ID -u TERM_PROGRAM HISTFILE=/dev/null`, and type ` unset HISTFILE` first in any test shell. + +## Review Focus + +1. A form inside a fragment that the server answers with a redirect to `/login` (session expired) — expected: the whole page goes to `/login`, not a login form inside the settings area. Test: Task 2, step "session expiry". +2. Paths with spaces, quotes and `$` dropped into a terminal or used by SSH file operations — expected: quoted so the shell sees one literal path. Tests: Task 1 (`shellQuote`), Task 6 (`shq` round trip). +3. Two file panes on the same machine in one tab — expected: each keeps its own folder, selection and buttons. Test: Task 5, step "two panes". +4. Closing a tab with an SSH file pane — expected: the agent closes that SSH connection at once, not after the idle timeout. Test: Task 7, step "close releases". +5. A machine dialog (Статус) closed while its live graph runs — expected: its timers stop (no requests after closing). Test: Task 9, step "unmount stops timers". + +--- + +## File Structure + +| file | responsibility | +|---|---| +| `internal/server/web/templates/layout.html` (new) | `{{define "head"}}`, `{{define "topbar"}}`, `{{define "assets"}}` shared by pages | +| `internal/server/web/templates/app.html` (new) | the shell: header, three views, the machine dialog, the scripts | +| `internal/server/frag.go` (new) | fragment mode: `isFrag(r)`, `renderPage(w, r, name, data)` | +| `internal/server/frag_test.go` (new) | tests of the fragment mode and shell routes | +| `internal/server/web/static/app.js` (new) | router, fragment loader, form interception, module registry, `beforeunload` | +| `internal/server/web/static/wsutil.js` (new) | pure helpers: `shellQuote`, `dropAction`, `fmtSize`; exported for node tests | +| `internal/server/web/static/wsutil.test.js` (new) | `node --test` for `wsutil.js` | +| `internal/server/web/static/workspace.js` (new, from `terminal.js`) | tabs, layouts, splits, pane and tab dragging, «+» menu | +| `internal/server/web/static/termpane.js` (new, from `terminal.js`) | the `Session` class (terminal, SSH terminal), clipboard keys | +| `internal/server/web/static/filepane.js` (new, from `files.html`) | `FilePane` class, its data sources (machine, SSH) | +| `internal/server/web/static/fileviews.js` (new, from `files.html`) | shared file dialogs: viewer/editor, hex, properties, move/copy, uploads | +| `internal/server/web/static/dnd.js` (new) | dragging files between panes and into terminals, the drop dialog | +| `internal/server/web/static/machine.js` (new) | the machine dialog (Порты, Статус, Изменить fragments) | +| `internal/server/web/static/pages.js` (new) | page modules: machines list, settings copy buttons and install code | +| `internal/agent/sshfs.go` (new) | SSH file sessions and `sshfs.*` tasks | +| `internal/agent/sshfs_test.go` (new) | tests of stat parsing and command building | +| `internal/server/sshfs.go` (new) | server side of `sshfs.*`: open, ops, download, upload, audit | +| `internal/proto/proto.go` | new task names and argument types | +| removed at the end | `templates/terminal.html`, `templates/files.html` (their markup moves into `app.html` and the JS files above), `static/terminal.js` | + +--- + +### Task 1: Pure helpers with node tests + +**Files:** +- Create: `internal/server/web/static/wsutil.js` +- Create: `internal/server/web/static/wsutil.test.js` + +**Interfaces:** +- Produces: `window.WSUtil = {shellQuote(path: string): string, shellQuoteAll(paths: string[]): string, dropAction(item, target): {kind: 'transfer'|'paste'|'deny'|'none', reason?: string}, fmtSize(n: number): string}`. + `item = {source: {kind: 'agent'|'ssh', id: string, label: string}, paths: string[], dir: string}`; `target = {kind: 'files'|'folder'|'term', source: {kind, id, label}, dir?: string}`. Two sources are «the same place» when `kind` and `id` are equal (for SSH, `id` is `"|user@host:port"`). + +- [ ] **Step 1: Write the failing tests** + +`internal/server/web/static/wsutil.test.js`: +```js +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const U = require('./wsutil.js'); + +test('shellQuote keeps a path one literal word', () => { + assert.strictEqual(U.shellQuote('/srv/a b'), "'/srv/a b'"); + assert.strictEqual(U.shellQuote("/x/it's"), "'/x/it'\\''s'"); + assert.strictEqual(U.shellQuote('/x/$HOME;rm'), "'/x/$HOME;rm'"); + assert.strictEqual(U.shellQuoteAll(['/a', '/b c']), "'/a' '/b c'"); +}); + +const m1 = {kind: 'agent', id: 'm1', label: 'web'}; +const m2 = {kind: 'agent', id: 'm2', label: 'db'}; +const s1 = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'}; +const item = {source: m1, paths: ['/etc/x'], dir: '/etc'}; + +test('dropAction: files into a file pane is a transfer', () => { + assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m2, dir: '/tmp'}), {kind: 'transfer'}); + assert.deepStrictEqual(U.dropAction(item, {kind: 'folder', source: m1, dir: '/var'}), {kind: 'transfer'}); +}); +test('dropAction: into its own folder does nothing', () => { + assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m1, dir: '/etc'}), {kind: 'none'}); +}); +test('dropAction: terminal of the same place pastes, of another is denied', () => { + assert.deepStrictEqual(U.dropAction(item, {kind: 'term', source: m1}), {kind: 'paste'}); + const d = U.dropAction(item, {kind: 'term', source: m2}); + assert.strictEqual(d.kind, 'deny'); + assert.strictEqual(d.reason, 'Нельзя: файл на web, а терминал на db'); + assert.strictEqual(U.dropAction({source: s1, paths: ['/a'], dir: '/'}, {kind: 'term', source: s1}).kind, 'paste'); +}); +test('fmtSize', () => { + assert.strictEqual(U.fmtSize(10), '10 B'); + assert.strictEqual(U.fmtSize(2048), '2.0 K'); +}); +``` + +- [ ] **Step 2: Run them to see them fail** + +Run: `cd internal/server/web/static && node --test wsutil.test.js` +Expected: FAIL, `Cannot find module './wsutil.js'`. + +- [ ] **Step 3: Write `wsutil.js`** + +```js +// Pure helpers of the workspace: quoting paths for a shell, what a dropped +// file does where it lands, sizes. No DOM here, so node can test them. +(function (root) { + 'use strict'; + function shellQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'"; } + function shellQuoteAll(ps) { return ps.map(shellQuote).join(' '); } + const same = (a, b) => a.kind === b.kind && a.id === b.id; + function dropAction(item, target) { + if (target.kind === 'term') { + return same(item.source, target.source) ? {kind: 'paste'} + : {kind: 'deny', reason: 'Нельзя: файл на ' + item.source.label + ', а терминал на ' + target.source.label}; + } + if (same(item.source, target.source) && target.dir === item.dir) return {kind: 'none'}; + return {kind: 'transfer'}; + } + function fmtSize(n) { + if (n < 1024) return n + ' B'; + if (n < 1048576) return (n / 1024).toFixed(1) + ' K'; + if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M'; + return (n / 1073741824).toFixed(1) + ' G'; + } + const api = {shellQuote, shellQuoteAll, dropAction, fmtSize}; + if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api; +})(this); +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd internal/server/web/static && node --test wsutil.test.js` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Keep the test file out of the served assets** + +`staticHandler` serves everything under `web/static`. In `internal/server/server.go` find `staticHandler` and make it answer 404 for names ending in `.test.js`: +```go +if strings.HasSuffix(r.URL.Path, ".test.js") { + http.NotFound(w, r) + return +} +``` +Run: `go build ./... && go vet ./internal/server` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/web/static/wsutil.js internal/server/web/static/wsutil.test.js internal/server/server.go +git commit -m "feat: workspace helpers - quoting paths, what a dropped file does, with node tests" +``` + +--- + +### Task 2: Shell page, fragment mode, router + +**Files:** +- Create: `internal/server/web/templates/layout.html`, `internal/server/web/templates/app.html`, `internal/server/frag.go`, `internal/server/frag_test.go`, `internal/server/web/static/app.js`, `internal/server/web/static/pages.js` +- Modify: `internal/server/web/templates/dashboard.html`, `settings.html`, `edit.html`, `forwards.html`, `status.html` (use the `layout` blocks, wrap content in `{{define "-body"}}`), `internal/server/handlers_panel.go` (handlers call `renderPage`), `internal/server/server.go` (routes `/`, `/terminal`, `/settings`) + +**Interfaces:** +- Produces (Go): `func isFrag(r *http.Request) bool`; `func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any)` — with `?frag=1` executes template `name+"-body"`, otherwise executes `app.html` with `data["View"] = name` and the body pre-rendered into `data["Body"] template.HTML`. +- Produces (JS): `window.App = {go(url: string), load(area: Element, url: string): Promise, register(name: string, mod: {mount(root: Element), unmount?()})}`; each fragment root has `data-module=""`; `App.load` calls the module's `mount(root)` after inserting and the previous module's `unmount()` before replacing. + +- [ ] **Step 1: Write the failing Go test** + +`internal/server/frag_test.go`: +```go +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFragmentHasNoChrome(t *testing.T) { + s, cookie := newTestServer(t) // helper below + req := httptest.NewRequest(http.MethodGet, "/settings?frag=1", nil) + req.AddCookie(cookie) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + body := w.Body.String() + if w.Code != 200 || strings.Contains(body, " + + + +{{end}} + +{{define "topbar"}}
+
LPRA · Удалённый доступ
+ +
{{end}} +``` + +- [ ] **Step 4: Turn each page into a body block** + +For `dashboard.html`, `settings.html`, `edit.html`, `forwards.html`, `status.html`: +1. Cut everything between `` (after the `
…
`) and the page scripts into `{{define "-body"}}
…
{{end}}` (`` = `dashboard`, `settings`, `edit`, `forwards`, `status`). +2. Move the page `