docs: Phase 2 implementation plan — SSH + SFTP with 12 tasks
This commit is contained in:
parent
fe19ee73e2
commit
fad5692c00
487
docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md
Normal file
487
docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md
Normal file
@ -0,0 +1,487 @@
|
||||
# Wraith Desktop — Phase 2: SSH + SFTP Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Connect to remote hosts via SSH with a real terminal (xterm.js), SFTP sidebar with file operations and CWD following, multi-tab sessions, and CodeMirror 6 file editor.
|
||||
|
||||
**Architecture:** Go SSH service wraps `x/crypto/ssh` for connections, PTY requests, and shell I/O. SFTP service wraps `pkg/sftp` riding the same SSH connection. Data flows: xterm.js ↔ Wails bindings ↔ Go SSH pipes. CWD tracking via OSC 7 shell injection.
|
||||
|
||||
**Tech Stack:** `golang.org/x/crypto/ssh`, `github.com/pkg/sftp`, xterm.js 5.x + WebGL addon + fit addon + search addon, CodeMirror 6
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (Sections 6, 10, 11)
|
||||
|
||||
---
|
||||
|
||||
## File Structure (New/Modified)
|
||||
|
||||
```
|
||||
internal/
|
||||
ssh/
|
||||
service.go # SSH dial, PTY, shell, I/O goroutines
|
||||
service_test.go # Connection config tests (unit, no real SSH)
|
||||
hostkey.go # Host key verification + storage
|
||||
hostkey_test.go
|
||||
cwd.go # OSC 7 parser for CWD tracking
|
||||
cwd_test.go
|
||||
sftp/
|
||||
service.go # SFTP operations (list, upload, download, etc.)
|
||||
service_test.go
|
||||
credentials/
|
||||
service.go # Credential CRUD (encrypted passwords + SSH keys)
|
||||
service_test.go
|
||||
app/
|
||||
app.go # Add SSH/SFTP/Credential services
|
||||
frontend/
|
||||
src/
|
||||
components/
|
||||
terminal/
|
||||
TerminalView.vue # xterm.js instance wrapper
|
||||
sftp/
|
||||
FileTree.vue # Remote filesystem tree
|
||||
TransferProgress.vue # Upload/download progress
|
||||
editor/
|
||||
EditorWindow.vue # CodeMirror 6 (placeholder for multi-window)
|
||||
composables/
|
||||
useTerminal.ts # xterm.js lifecycle + Wails binding bridge
|
||||
useSftp.ts # SFTP operations via Wails bindings
|
||||
stores/
|
||||
session.store.ts # Update with real session management
|
||||
assets/
|
||||
css/
|
||||
terminal.css # xterm.js overrides
|
||||
package.json # Add xterm.js, codemirror deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SSH Service — Connect, PTY, Shell I/O
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ssh/service.go`
|
||||
- Create: `internal/ssh/service_test.go`
|
||||
|
||||
The SSH service manages connections and exposes methods to Wails:
|
||||
|
||||
```go
|
||||
// SSHService methods (exposed to frontend via Wails bindings):
|
||||
// Connect(connectionID int64) (string, error) → returns sessionID
|
||||
// Write(sessionID string, data string) error → write to stdin
|
||||
// Resize(sessionID string, cols, rows int) error → window change
|
||||
// Disconnect(sessionID string) error → close session
|
||||
//
|
||||
// Events emitted to frontend via Wails events:
|
||||
// "ssh:data:{sessionID}" → terminal output (stdout)
|
||||
// "ssh:connected:{sessionID}" → connection established
|
||||
// "ssh:disconnected:{sessionID}" → connection closed
|
||||
// "ssh:error:{sessionID}" → error message
|
||||
```
|
||||
|
||||
Key implementation details:
|
||||
- Each SSH session runs two goroutines: one reading stdout→Wails events, one for keepalive
|
||||
- Sessions stored in a `map[string]*SSHSession` with mutex
|
||||
- `SSHSession` holds: `*ssh.Client`, `*ssh.Session`, stdin `io.WriteCloser`, connection metadata
|
||||
- PTY requested as `xterm-256color` with initial size from frontend
|
||||
- Auth method determined by credential type (password, SSH key, keyboard-interactive)
|
||||
- Host key verification delegates to `hostkey.go`
|
||||
|
||||
Tests (unit only — no real SSH server):
|
||||
- TestSSHServiceCreation
|
||||
- TestBuildAuthMethods (password → ssh.Password, key → ssh.PublicKeys)
|
||||
- TestSessionTracking (create, get, remove)
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement service
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: SSH service — connect, PTY, shell I/O with goroutine pipes`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Host Key Verification
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ssh/hostkey.go`
|
||||
- Create: `internal/ssh/hostkey_test.go`
|
||||
|
||||
Host key verification stores/checks fingerprints in the `host_keys` SQLite table:
|
||||
- New host → emit `ssh:hostkey-verify` event to frontend with fingerprint, wait for accept/reject
|
||||
- Known host, matching fingerprint → proceed silently
|
||||
- Known host, CHANGED fingerprint → emit warning event, block connection
|
||||
|
||||
For Phase 2, implement the storage and verification logic. The frontend prompt (accept/reject dialog) will be wired in the frontend task.
|
||||
|
||||
Tests:
|
||||
- TestStoreHostKey
|
||||
- TestVerifyKnownHost (match → ok)
|
||||
- TestVerifyChangedHost (mismatch → error)
|
||||
- TestVerifyNewHost (not found → returns "new")
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement hostkey.go
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: SSH host key verification — store, verify, detect changes`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CWD Tracker (OSC 7 Parser)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ssh/cwd.go`
|
||||
- Create: `internal/ssh/cwd_test.go`
|
||||
|
||||
Parses OSC 7 escape sequences from terminal output to track the remote working directory:
|
||||
|
||||
```
|
||||
Input: "some output\033]7;file://hostname/home/user\033\\more output"
|
||||
Output: stripped="some output more output", cwd="/home/user"
|
||||
```
|
||||
|
||||
The CWD tracker:
|
||||
1. Scans byte stream for `\033]7;` prefix
|
||||
2. Extracts URL between prefix and `\033\\` (or `\007`) terminator
|
||||
3. Parses `file://hostname/path` to extract just the path
|
||||
4. Strips the OSC 7 sequence from the output before forwarding to xterm.js
|
||||
5. Returns the new CWD path when detected
|
||||
|
||||
Shell injection command (injected after PTY is established):
|
||||
```bash
|
||||
# bash
|
||||
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
|
||||
# zsh
|
||||
precmd() { printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD" }
|
||||
# fish
|
||||
function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end
|
||||
```
|
||||
|
||||
Tests:
|
||||
- TestParseOSC7Basic
|
||||
- TestParseOSC7WithBEL (terminated by \007 instead of ST)
|
||||
- TestParseOSC7NoMatch (no OSC 7 in output)
|
||||
- TestParseOSC7MultipleInStream
|
||||
- TestStripOSC7FromOutput
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement cwd.go
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: OSC 7 CWD tracker — parse and strip directory change sequences`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: SFTP Service
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/sftp/service.go`
|
||||
- Create: `internal/sftp/service_test.go`
|
||||
|
||||
SFTP service wraps `pkg/sftp` and exposes file operations to the frontend:
|
||||
|
||||
```go
|
||||
// SFTPService methods (exposed via Wails bindings):
|
||||
// OpenSFTP(sessionID string) error → start SFTP on existing SSH connection
|
||||
// List(sessionID string, path string) ([]FileEntry, error) → directory listing
|
||||
// ReadFile(sessionID string, path string) (string, error) → read file content (max 5MB)
|
||||
// WriteFile(sessionID string, path string, content string) error → write file
|
||||
// Upload(sessionID string, remotePath string, localPath string) error
|
||||
// Download(sessionID string, remotePath string) (string, error) → returns local temp path
|
||||
// Mkdir(sessionID string, path string) error
|
||||
// Delete(sessionID string, path string) error
|
||||
// Rename(sessionID string, oldPath, newPath string) error
|
||||
// Stat(sessionID string, path string) (*FileEntry, error)
|
||||
```
|
||||
|
||||
`FileEntry` type:
|
||||
```go
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Permissions string `json:"permissions"`
|
||||
ModTime string `json:"modTime"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
```
|
||||
|
||||
SFTP client is created from the existing `*ssh.Client` (same connection, separate channel). Stored alongside the SSH session.
|
||||
|
||||
Tests (unit — mock the sftp.Client interface):
|
||||
- TestFileEntryFromFileInfo
|
||||
- TestListSortsDirectoriesFirst
|
||||
- TestReadFileRejectsLargeFiles (>5MB)
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement service
|
||||
- [ ] **Step 3:** Add `pkg/sftp` dependency
|
||||
- [ ] **Step 4:** Run tests, verify pass
|
||||
- [ ] **Step 5:** Commit: `feat: SFTP service — list, read, write, upload, download, mkdir, delete`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Credential Service (Encrypted SSH Keys + Passwords)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/credentials/service.go`
|
||||
- Create: `internal/credentials/service_test.go`
|
||||
|
||||
CRUD for credentials and SSH keys with vault encryption:
|
||||
|
||||
```go
|
||||
// CredentialService methods:
|
||||
// CreatePassword(name, username, password, domain string) (*Credential, error)
|
||||
// CreateSSHKey(name string, privateKey, passphrase []byte) (*SSHKey, error)
|
||||
// GetCredential(id int64) (*Credential, error)
|
||||
// ListCredentials() ([]Credential, error)
|
||||
// DecryptPassword(id int64) (string, error) → decrypt for connection use only
|
||||
// DecryptSSHKey(id int64) ([]byte, string, error) → returns (privateKey, passphrase, error)
|
||||
// DeleteCredential(id int64) error
|
||||
// ImportSSHKeyFile(name, filePath string) (*SSHKey, error) → read .pem file, detect type, store
|
||||
```
|
||||
|
||||
All sensitive data encrypted via VaultService before storage. Decryption only happens at connection time.
|
||||
|
||||
Tests:
|
||||
- TestCreatePasswordCredential
|
||||
- TestCreateSSHKeyCredential
|
||||
- TestDecryptPassword (round-trip through vault)
|
||||
- TestDecryptSSHKey (round-trip)
|
||||
- TestListCredentialsExcludesEncryptedValues
|
||||
- TestDetectKeyType (RSA, Ed25519, ECDSA)
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement service
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: credential service — encrypted password and SSH key storage`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire SSH/SFTP/Credentials into App
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/app/app.go`
|
||||
- Modify: `main.go`
|
||||
|
||||
Add SSHService, SFTPService, and CredentialService to WraithApp. Register as Wails services.
|
||||
|
||||
- [ ] **Step 1:** Update app.go to create and expose new services
|
||||
- [ ] **Step 2:** Update main.go to register them
|
||||
- [ ] **Step 3:** Verify compilation: `go vet ./...`
|
||||
- [ ] **Step 4:** Run all tests: `go test ./... -count=1`
|
||||
- [ ] **Step 5:** Commit: `feat: wire SSH, SFTP, and credential services into Wails app`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — xterm.js Terminal
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json` — add xterm.js + addons
|
||||
- Create: `frontend/src/components/terminal/TerminalView.vue`
|
||||
- Create: `frontend/src/composables/useTerminal.ts`
|
||||
- Create: `frontend/src/assets/css/terminal.css`
|
||||
- Modify: `frontend/src/components/session/SessionContainer.vue`
|
||||
- Modify: `frontend/src/stores/session.store.ts`
|
||||
|
||||
Install xterm.js dependencies:
|
||||
```
|
||||
@xterm/xterm
|
||||
@xterm/addon-fit
|
||||
@xterm/addon-webgl
|
||||
@xterm/addon-search
|
||||
@xterm/addon-web-links
|
||||
```
|
||||
|
||||
`useTerminal` composable:
|
||||
- Creates xterm.js Terminal instance with theme from connection settings
|
||||
- Attaches fit, WebGL, search, web-links addons
|
||||
- Binds `terminal.onData` → Wails `SSHService.Write(sessionId, data)`
|
||||
- Listens for Wails events `ssh:data:{sessionId}` → `terminal.write(data)`
|
||||
- Handles resize via fit addon → Wails `SSHService.Resize(sessionId, cols, rows)`
|
||||
- Cleanup on unmount
|
||||
|
||||
`TerminalView.vue`:
|
||||
- Receives `sessionId` prop
|
||||
- Mounts xterm.js into a div ref
|
||||
- Applies theme colors from the active theme
|
||||
- Handles focus management
|
||||
|
||||
`SessionContainer.vue` update:
|
||||
- Replace placeholder with real TerminalView for SSH sessions
|
||||
- Use `v-show` (not `v-if`) to keep terminals alive across tab switches
|
||||
|
||||
- [ ] **Step 1:** Install xterm.js deps: `cd frontend && npm install @xterm/xterm @xterm/addon-fit @xterm/addon-webgl @xterm/addon-search @xterm/addon-web-links`
|
||||
- [ ] **Step 2:** Create terminal.css (xterm.js container styling)
|
||||
- [ ] **Step 3:** Create useTerminal.ts composable
|
||||
- [ ] **Step 4:** Create TerminalView.vue component
|
||||
- [ ] **Step 5:** Update SessionContainer.vue to render TerminalView
|
||||
- [ ] **Step 6:** Update session.store.ts with real Wails binding calls
|
||||
- [ ] **Step 7:** Build frontend: `npm run build`
|
||||
- [ ] **Step 8:** Commit: `feat: xterm.js terminal with WebGL rendering and Wails binding bridge`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend — SFTP Sidebar
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/sftp/FileTree.vue`
|
||||
- Create: `frontend/src/components/sftp/TransferProgress.vue`
|
||||
- Create: `frontend/src/composables/useSftp.ts`
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue` — SFTP sidebar rendering
|
||||
- Modify: `frontend/src/components/sidebar/SidebarToggle.vue` — enable SFTP tab
|
||||
|
||||
`useSftp` composable:
|
||||
- `listDirectory(sessionId, path)` → calls Wails SFTPService.List
|
||||
- `uploadFile(sessionId, remotePath, file)` → chunked upload with progress
|
||||
- `downloadFile(sessionId, remotePath)` → triggers browser download
|
||||
- `deleteFile(sessionId, path)` → with confirmation
|
||||
- `createDirectory(sessionId, path)`
|
||||
- `renameFile(sessionId, old, new)`
|
||||
- Tracks current path, file list, loading state, transfer progress
|
||||
|
||||
`FileTree.vue`:
|
||||
- Renders file/directory tree (lazy-loaded on expand)
|
||||
- Path bar at top showing current directory
|
||||
- Toolbar: upload, download, new file, new folder, refresh, delete
|
||||
- File entries show: icon (folder/file), name, size, modified date
|
||||
- Double-click file → open in editor (Task 9)
|
||||
- Drag-and-drop upload zone
|
||||
- "Follow terminal folder" toggle at bottom
|
||||
|
||||
`TransferProgress.vue`:
|
||||
- Shows active uploads/downloads with progress bars
|
||||
- File name, percentage, speed, ETA
|
||||
|
||||
- [ ] **Step 1:** Create useSftp.ts composable
|
||||
- [ ] **Step 2:** Create FileTree.vue component
|
||||
- [ ] **Step 3:** Create TransferProgress.vue component
|
||||
- [ ] **Step 4:** Update MainLayout.vue to render SFTP sidebar when toggled
|
||||
- [ ] **Step 5:** Enable SFTP toggle in SidebarToggle.vue
|
||||
- [ ] **Step 6:** Build frontend: `npm run build`
|
||||
- [ ] **Step 7:** Commit: `feat: SFTP sidebar — file tree, upload/download, CWD following`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend — Host Key Dialog + Connection Flow
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/common/HostKeyDialog.vue`
|
||||
- Modify: `frontend/src/components/sidebar/ConnectionTree.vue` — double-click to connect
|
||||
- Modify: `frontend/src/stores/session.store.ts` — real connection flow
|
||||
|
||||
Wire up the full connection flow:
|
||||
1. User double-clicks connection in sidebar
|
||||
2. Session store calls Wails `SSHService.Connect(connectionId)`
|
||||
3. If host key verification needed → show HostKeyDialog
|
||||
4. On success → create tab, mount TerminalView, open SFTP sidebar
|
||||
5. On error → show error toast
|
||||
|
||||
`HostKeyDialog.vue`:
|
||||
- Modal showing: hostname, key type, fingerprint
|
||||
- "New host" vs "CHANGED host key (WARNING)" modes
|
||||
- Accept / Reject buttons
|
||||
- "Always accept for this host" checkbox
|
||||
|
||||
- [ ] **Step 1:** Create HostKeyDialog.vue
|
||||
- [ ] **Step 2:** Update ConnectionTree.vue with double-click handler
|
||||
- [ ] **Step 3:** Update session.store.ts with connection flow
|
||||
- [ ] **Step 4:** Build frontend: `npm run build`
|
||||
- [ ] **Step 5:** Commit: `feat: connection flow — host key dialog, double-click to connect`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — CodeMirror 6 Editor (Placeholder)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json` — add CodeMirror deps
|
||||
- Create: `frontend/src/components/editor/EditorWindow.vue`
|
||||
|
||||
Install CodeMirror 6:
|
||||
```
|
||||
codemirror
|
||||
@codemirror/lang-javascript
|
||||
@codemirror/lang-json
|
||||
@codemirror/lang-html
|
||||
@codemirror/lang-css
|
||||
@codemirror/lang-python
|
||||
@codemirror/lang-markdown
|
||||
@codemirror/theme-one-dark
|
||||
```
|
||||
|
||||
`EditorWindow.vue`:
|
||||
- Renders CodeMirror 6 editor with dark theme
|
||||
- Receives file content, path, and sessionId as props
|
||||
- Syntax highlighting based on file extension
|
||||
- Save button → calls Wails SFTPService.WriteFile
|
||||
- Unsaved changes detection
|
||||
- For Phase 2: renders inline (not separate window — multi-window is Phase 4)
|
||||
|
||||
- [ ] **Step 1:** Install CodeMirror deps
|
||||
- [ ] **Step 2:** Create EditorWindow.vue
|
||||
- [ ] **Step 3:** Wire file click in FileTree.vue to open EditorWindow
|
||||
- [ ] **Step 4:** Build frontend: `npm run build`
|
||||
- [ ] **Step 5:** Commit: `feat: CodeMirror 6 editor — syntax highlighting, dark theme, SFTP save`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Workspace Snapshot Persistence
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/app/workspace.go`
|
||||
- Create: `internal/app/workspace_test.go`
|
||||
|
||||
Implements workspace snapshot saving/restoring per the spec:
|
||||
|
||||
```go
|
||||
// SaveWorkspace() error — serialize current tab layout to settings
|
||||
// LoadWorkspace() (*WorkspaceSnapshot, error) — read last saved layout
|
||||
// Auto-save every 30 seconds via goroutine
|
||||
// Save on clean shutdown
|
||||
```
|
||||
|
||||
WorkspaceSnapshot JSON:
|
||||
```json
|
||||
{
|
||||
"tabs": [
|
||||
{"connectionId": 1, "protocol": "ssh", "position": 0},
|
||||
{"connectionId": 5, "protocol": "rdp", "position": 1}
|
||||
],
|
||||
"sidebarWidth": 240,
|
||||
"sidebarMode": "connections",
|
||||
"activeTab": 0
|
||||
}
|
||||
```
|
||||
|
||||
Tests:
|
||||
- TestSaveAndLoadWorkspace
|
||||
- TestEmptyWorkspace
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement workspace.go
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: workspace snapshot persistence — auto-save layout every 30s`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Integration Test + Final Verification
|
||||
|
||||
- [ ] **Step 1:** Run all Go tests: `go test ./... -count=1`
|
||||
- [ ] **Step 2:** Build frontend: `cd frontend && npm run build`
|
||||
- [ ] **Step 3:** Verify Go compiles with embedded frontend: `go vet ./...`
|
||||
- [ ] **Step 4:** Count tests and lines of code
|
||||
- [ ] **Step 5:** Commit any fixes: `chore: Phase 2 complete — SSH + SFTP with terminal and file operations`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Completion Checklist
|
||||
|
||||
- [ ] SSH service: connect, PTY, shell I/O with goroutine pipes
|
||||
- [ ] Host key verification: store, verify, detect changes
|
||||
- [ ] OSC 7 CWD tracker: parse and strip directory change sequences
|
||||
- [ ] SFTP service: list, read, write, upload, download, mkdir, delete
|
||||
- [ ] Credential service: encrypted password + SSH key storage
|
||||
- [ ] All new services wired into Wails app
|
||||
- [ ] xterm.js terminal with WebGL rendering
|
||||
- [ ] SFTP file tree sidebar with upload/download
|
||||
- [ ] Host key verification dialog
|
||||
- [ ] Double-click connection to connect flow
|
||||
- [ ] CodeMirror 6 inline editor with SFTP save
|
||||
- [ ] Workspace snapshot persistence
|
||||
- [ ] All Go tests passing
|
||||
- [ ] Frontend builds clean
|
||||
Loading…
Reference in New Issue
Block a user