Compare commits
2 Commits
57a4c771a0
...
b9fe3d7131
| Author | SHA1 | Date | |
|---|---|---|---|
| b9fe3d7131 | |||
| 2ec6f73843 |
@@ -0,0 +1,123 @@
|
|||||||
|
# muxplex
|
||||||
|
|
||||||
|
Web-based tmux session dashboard and terminal multiplexer. Pure vanilla JS frontend (no frameworks, no build step), FastAPI backend, xterm.js terminals via ttyd WebSocket proxy.
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
- **Upstream**: https://github.com/bkrabach/muxplex
|
||||||
|
- **Fork (primary working repo)**: git@git.ampbox.io:ken/muxplex.git
|
||||||
|
- **Branch strategy**: Work on `main` directly in the fork. PR upstream when ready.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `muxplex/frontend/app.js` (~4,600 lines) -- dashboard, sidebar, settings, views, mobile UX
|
||||||
|
- `muxplex/frontend/terminal.js` (~700 lines) -- xterm.js terminal, WebSocket, mobile toolbar
|
||||||
|
- `muxplex/frontend/style.css` (~2,600 lines) -- dark theme, responsive breakpoints
|
||||||
|
- `muxplex/frontend/index.html` -- SPA shell
|
||||||
|
- `muxplex/frontend/vendor/` -- vendored xterm.js + addons (fit, search, web-links, image)
|
||||||
|
- `muxplex/main.py` -- FastAPI app, API endpoints, WebSocket proxy, poll loop
|
||||||
|
- `muxplex/sessions.py` -- tmux session enumeration, caching, snapshots
|
||||||
|
- `muxplex/ttyd.py` -- ttyd process lifecycle management
|
||||||
|
- `muxplex/tests/` -- 1300+ tests (Python + frontend structure/behavior tests)
|
||||||
|
|
||||||
|
## Key patterns
|
||||||
|
|
||||||
|
- No build step. JS/CSS served as-is with version-busting query suffixes.
|
||||||
|
- Icons are inline SVGs (replaced unicode entities), stored in `_icons` object in app.js.
|
||||||
|
- Mobile detection: `MOBILE_THRESHOLD = 600`, `isMobile()` function.
|
||||||
|
- Touch devices get a bottom toolbar with Ctrl/Alt modifier toggles.
|
||||||
|
- xterm.js handles touch scrolling natively -- do NOT add custom touch scroll handlers.
|
||||||
|
- Session names can contain spaces -- always use `shlex.quote()` when interpolating into shell commands.
|
||||||
|
- The running service is an editable install from this directory: `systemctl --user restart muxplex` to apply changes.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
|
||||||
|
```
|
||||||
|
|
||||||
|
The CSS test suite enforces that `@media (max-width: 959px)` is the last `@media` block in style.css. Place new `@media` blocks above it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UX Improvement Roadmap
|
||||||
|
|
||||||
|
Prioritized improvements for a power user on desktop + Android phone + Android tablet.
|
||||||
|
|
||||||
|
## P0 -- High Impact (Grid "catches you off guard")
|
||||||
|
|
||||||
|
### Search/filter bar on dashboard grid
|
||||||
|
With 10+ sessions, there's no way to quickly find one. Add a sticky search input at the top of the grid that filters tiles by name as you type. Critical on mobile where the list view requires scrolling through all sessions linearly.
|
||||||
|
|
||||||
|
### Visual hierarchy for tiles
|
||||||
|
All tiles look identical -- no distinction between a session used 30 seconds ago and one idle for 3 days. Add subtle visual cues: accent border on recently active sessions, dimmed styling for idle ones. Give the user's eyes an anchor point.
|
||||||
|
|
||||||
|
### Readable tile previews
|
||||||
|
Terminal snapshot text is 11px in a 300px tile -- it's decoration, not information. Either make it large enough to read (fewer lines, bigger font), or add a readable "last command" / "working directory" summary line beneath the session name.
|
||||||
|
|
||||||
|
### Default session / quick-resume
|
||||||
|
Auto-open the last-used or default session on page load. The `default_session` setting exists but may not be fully wired. At minimum, put a prominent "Resume" action for the most recently used session.
|
||||||
|
|
||||||
|
## P1 -- High Impact (Mobile Phone)
|
||||||
|
|
||||||
|
### Tap-to-open too easy to trigger accidentally
|
||||||
|
On mobile list view, any tap anywhere on the tile opens the terminal. Need to distinguish scroll gestures from taps, and make the tap target the header area rather than the entire tile body.
|
||||||
|
|
||||||
|
### Session pill is too subtle
|
||||||
|
In expanded terminal view on mobile, the floating pill (75% opacity, 13px, bottom-right) is the only session switcher. Make it more prominent -- persistent thin bar at bottom, or a swipe-to-switch gesture.
|
||||||
|
|
||||||
|
### Swipe gestures
|
||||||
|
- Swipe from left edge to open sidebar (Android convention)
|
||||||
|
- Swipe down on bottom sheet to dismiss
|
||||||
|
- Swipe left/right on session pill to switch sessions
|
||||||
|
|
||||||
|
### Scroll-to-bottom indicator
|
||||||
|
When scrolled up in the terminal buffer, there's no visual cue and no quick way to jump back to live output. Add a floating "Jump to bottom" button that appears when scrolled up.
|
||||||
|
|
||||||
|
## P2 -- Medium Impact (Tablet)
|
||||||
|
|
||||||
|
### Tablet grid wastes space
|
||||||
|
At 600-899px (tablet portrait), grid collapses to a single column with 200px tiles. Too conservative. Use a two-column grid with shorter tiles to show more sessions.
|
||||||
|
|
||||||
|
### Compact sidebar for tablet
|
||||||
|
Tablets have room for a mini session list alongside the terminal. Add a compact sidebar mode (just session names, no preview tiles) that works as a side panel rather than a full overlay.
|
||||||
|
|
||||||
|
## P3 -- Medium Impact (General UX)
|
||||||
|
|
||||||
|
### Keyboard shortcut cheat sheet
|
||||||
|
Shortcuts exist (`,` settings, `` ` `` view dropdown, `1-9` views, `Escape` close) but are undiscoverable. Add `?` shortcut to show a cheat sheet overlay.
|
||||||
|
|
||||||
|
### Quick session creation
|
||||||
|
The current flow requires typing a name. Add "Quick new" that auto-generates a name (e.g. `session-4`) and opens immediately. Name prompt becomes optional.
|
||||||
|
|
||||||
|
### Inline session rename
|
||||||
|
No rename exists today -- typo means kill and recreate. Add long-press (mobile) / double-click (desktop) on tile header to rename. Backend: `tmux rename-session`.
|
||||||
|
|
||||||
|
### Pull-to-refresh on mobile dashboard
|
||||||
|
The 2s poll handles updates, but users expect pull-to-refresh on mobile. Even a triggered immediate poll gives a sense of control.
|
||||||
|
|
||||||
|
### Confirmation before killing sessions
|
||||||
|
Verify the delete confirmation step is present in the mobile bottom sheet (not just desktop flyout). Accidentally killing a session with running work is catastrophic.
|
||||||
|
|
||||||
|
## P4 -- Polish
|
||||||
|
|
||||||
|
### Fix tile metadata fade on hover
|
||||||
|
Timestamp and device badge fade to transparent on hover -- exactly when you're looking at it. Invert: show more detail on hover, not less.
|
||||||
|
|
||||||
|
### Reduce grid flicker on poll
|
||||||
|
The 2s poll rebuilds DOM for all tiles. Diff the DOM instead (only update changed tiles) to eliminate subtle flicker.
|
||||||
|
|
||||||
|
### Long-press context menu on mobile tiles
|
||||||
|
The kebab button is small on mobile. Long-press on the tile itself should open the same action menu -- standard Android convention.
|
||||||
|
|
||||||
|
### Better toast positioning on mobile
|
||||||
|
Toast at `bottom: 80px` may be occluded by the mobile toolbar or FAB. Position above any floating elements.
|
||||||
|
|
||||||
|
## Future Ideas
|
||||||
|
|
||||||
|
- **Session pinning** -- pin 1-3 sessions to always show at top of grid
|
||||||
|
- **Session tags/colors** -- color-code sessions by project
|
||||||
|
- **Command palette** -- `Ctrl+K` / `Cmd+K` to search sessions, switch views, open settings
|
||||||
|
- **Notification badges on tiles** -- "3 new lines" counter on idle tiles
|
||||||
|
- **Multi-select on grid** -- select multiple sessions for bulk kill or bulk-add to view
|
||||||
|
- **Dim vs deep dark toggle** -- for outdoor tablet use in bright light
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Cloudflare Tunnel Setup for Incus Host
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Single routing point for all services across Incus instances.
|
||||||
|
Services self-declare by dropping a file into a shared directory.
|
||||||
|
No port forwarding, no OPNsense reverse proxy config, no DDNS.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Domain: ampbox.io (registrar: Namecheap)
|
||||||
|
- Cloudflare account (free tier) with ampbox.io DNS delegated
|
||||||
|
- At Namecheap: set nameservers to the ones Cloudflare assigns
|
||||||
|
- Verify domain is active in Cloudflare dashboard
|
||||||
|
|
||||||
|
## 1. Install cloudflared on the Incus host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||||
|
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
|
||||||
|
https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||||
|
sudo apt update && sudo apt install -y cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Authenticate and create the tunnel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudflared tunnel login # opens browser, authorize ampbox.io
|
||||||
|
cloudflared tunnel create ampbox # creates tunnel, saves credentials JSON
|
||||||
|
cloudflared tunnel route dns ampbox "*.ampbox.io" # wildcard DNS route
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the credentials file path printed (typically
|
||||||
|
`~/.cloudflared/<TUNNEL_ID>.json`). You need it for step 4.
|
||||||
|
|
||||||
|
## 3. Create the service declaration directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/cloudflared/services.d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Write the main tunnel config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace <TUNNEL_ID> with your actual tunnel ID from step 2
|
||||||
|
sudo tee /etc/cloudflared/config.yml << 'EOF'
|
||||||
|
tunnel: ampbox
|
||||||
|
credentials-file: /root/.cloudflared/<TUNNEL_ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- service: http_status:404
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Share the declaration directory into all instances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
incus profile device add default svc-declare disk \
|
||||||
|
source=/etc/cloudflared/services.d \
|
||||||
|
path=/mnt/services
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Helper scripts (on the host)
|
||||||
|
|
||||||
|
### /usr/local/bin/svc-rebuild
|
||||||
|
|
||||||
|
Reads all service declarations and rebuilds the cloudflared ingress config.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG="/etc/cloudflared/config.yml"
|
||||||
|
SERVICES_DIR="/etc/cloudflared/services.d"
|
||||||
|
TUNNEL_CREDS="/root/.cloudflared/<TUNNEL_ID>.json" # fix this
|
||||||
|
|
||||||
|
cat > "$CONFIG" <<HEADER
|
||||||
|
tunnel: ampbox
|
||||||
|
credentials-file: $TUNNEL_CREDS
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
for f in "$SERVICES_DIR"/*.yml; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo " $line" >> "$CONFIG"
|
||||||
|
done < "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " - service: http_status:404" >> "$CONFIG"
|
||||||
|
sudo systemctl restart cloudflared
|
||||||
|
echo "Rebuilt with $(ls "$SERVICES_DIR"/*.yml 2>/dev/null | wc -l) service(s)"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 755 /dev/stdin /usr/local/bin/svc-rebuild < svc-rebuild.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### /usr/local/bin/svc-watch
|
||||||
|
|
||||||
|
Auto-rebuilds on file changes (optional but recommended).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICES_DIR="/etc/cloudflared/services.d"
|
||||||
|
echo "Watching $SERVICES_DIR for changes..."
|
||||||
|
while inotifywait -q -e create,delete,modify "$SERVICES_DIR"; do
|
||||||
|
sleep 1 # debounce
|
||||||
|
/usr/local/bin/svc-rebuild
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y inotify-tools
|
||||||
|
sudo install -m 755 /dev/stdin /usr/local/bin/svc-watch < svc-watch.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Systemd services (on the host)
|
||||||
|
|
||||||
|
### /etc/systemd/system/cloudflared.service
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Cloudflare Tunnel
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/cloudflared tunnel run ampbox
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### /etc/systemd/system/svc-watch.service
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Service declaration watcher
|
||||||
|
After=cloudflared.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/svc-watch
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now cloudflared svc-watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Declaring services FROM INSIDE an instance
|
||||||
|
|
||||||
|
### Expose an HTTP service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /mnt/services/myapp.yml << EOF
|
||||||
|
- hostname: myapp.ampbox.io
|
||||||
|
service: http://$(hostname -I | awk '{print $1}'):8080
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expose SSH
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /mnt/services/ssh-$(hostname).yml << EOF
|
||||||
|
- hostname: ssh-$(hostname).ampbox.io
|
||||||
|
service: ssh://$(hostname -I | awk '{print $1}'):22
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove a service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm /mnt/services/myapp.yml
|
||||||
|
# svc-watch picks it up and rebuilds automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Client-side SSH config (on laptops / remote machines)
|
||||||
|
|
||||||
|
```
|
||||||
|
# ~/.ssh/config
|
||||||
|
Host *.ampbox.io
|
||||||
|
ProxyCommand cloudflared access ssh --hostname %h
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: `ssh user@ssh-myinstance.ampbox.io` just works.
|
||||||
|
|
||||||
|
## 10. OPNsense cleanup
|
||||||
|
|
||||||
|
Once the tunnel is verified working, remove from OPNsense:
|
||||||
|
- All per-service reverse proxy rules in os-caddy
|
||||||
|
- Port forward / NAT rules for 80 and 443
|
||||||
|
- DDNS configuration (no longer needed)
|
||||||
|
|
||||||
|
OPNsense goes back to just being your firewall.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Action | From inside instance |
|
||||||
|
|-------------------------|----------------------------------------------------------|
|
||||||
|
| Expose HTTP | `echo "- hostname: X.ampbox.io\n service: http://IP:PORT" > /mnt/services/X.yml` |
|
||||||
|
| Expose SSH | Same pattern with `service: ssh://IP:22` |
|
||||||
|
| Expose raw TCP | Same pattern with `service: tcp://IP:PORT` |
|
||||||
|
| Remove service | `rm /mnt/services/X.yml` |
|
||||||
|
| List registered | `ls /mnt/services/` |
|
||||||
|
| Check tunnel status | `sudo systemctl status cloudflared` (from host) |
|
||||||
+42
-23
@@ -1,5 +1,20 @@
|
|||||||
// Phase 2b implementation — app.js
|
// Phase 2b implementation — app.js
|
||||||
|
|
||||||
|
/** Inline SVG icon strings used throughout the UI. */
|
||||||
|
var _icons = {
|
||||||
|
statusOk: '<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>',
|
||||||
|
statusWarn: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>',
|
||||||
|
statusErr: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||||||
|
kebab: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>',
|
||||||
|
chevronLeft: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>',
|
||||||
|
chevronRight: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>',
|
||||||
|
chevronUp: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg>',
|
||||||
|
chevronDown: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>',
|
||||||
|
check: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 8l4 4 6-7"/></svg>',
|
||||||
|
bell: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg>',
|
||||||
|
close: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a Unix timestamp (seconds) into a relative time string.
|
* Format a Unix timestamp (seconds) into a relative time string.
|
||||||
* @param {number|null|undefined} ts - Unix timestamp in seconds
|
* @param {number|null|undefined} ts - Unix timestamp in seconds
|
||||||
@@ -324,13 +339,13 @@ function setConnectionStatus(level) {
|
|||||||
const el = $('connection-status');
|
const el = $('connection-status');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const map = {
|
const map = {
|
||||||
ok: { text: '●', cls: 'connection-status--ok' },
|
ok: { text: _icons.statusOk, cls: 'connection-status--ok' },
|
||||||
warn: { text: '◌ slow', cls: 'connection-status--warn' },
|
warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' },
|
||||||
err: { text: '✕ offline', cls: 'connection-status--err' },
|
err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' },
|
||||||
};
|
};
|
||||||
const s = map[level];
|
const s = map[level];
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
el.textContent = s.text;
|
el.innerHTML = s.text;
|
||||||
el.className = s.cls;
|
el.className = s.cls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +558,7 @@ function buildTileHTML(session, index, mobile) {
|
|||||||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||||
`${badgeHtml}` +
|
`${badgeHtml}` +
|
||||||
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
|
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
|
||||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||||
`</article>`
|
`</article>`
|
||||||
@@ -601,7 +616,7 @@ function buildSidebarHTML(session, currentSession, currentRemoteId) {
|
|||||||
`<div class="sidebar-item-header">` +
|
`<div class="sidebar-item-header">` +
|
||||||
`<span class="sidebar-item-name">${escapedName}</span>` +
|
`<span class="sidebar-item-name">${escapedName}</span>` +
|
||||||
badgeHtml +
|
badgeHtml +
|
||||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||||
`</article>`
|
`</article>`
|
||||||
@@ -959,7 +974,7 @@ function toggleSidebar() {
|
|||||||
patchServerSetting('sidebarOpen', isOpen);
|
patchServerSetting('sidebarOpen', isOpen);
|
||||||
|
|
||||||
var collapseBtn = $('sidebar-collapse-btn');
|
var collapseBtn = $('sidebar-collapse-btn');
|
||||||
if (collapseBtn) collapseBtn.textContent = isOpen ? '\u2039' : '\u203a';
|
if (collapseBtn) collapseBtn.innerHTML = isOpen ? _icons.chevronLeft : _icons.chevronRight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1437,7 +1452,7 @@ function renderViewsSettingsTab() {
|
|||||||
// Up button
|
// Up button
|
||||||
var upBtn = document.createElement('button');
|
var upBtn = document.createElement('button');
|
||||||
upBtn.className = 'views-settings-btn';
|
upBtn.className = 'views-settings-btn';
|
||||||
upBtn.textContent = '\u25b2';
|
upBtn.innerHTML = _icons.chevronUp;
|
||||||
upBtn.title = 'Move up';
|
upBtn.title = 'Move up';
|
||||||
upBtn.setAttribute('data-action', 'move-up');
|
upBtn.setAttribute('data-action', 'move-up');
|
||||||
upBtn.setAttribute('data-idx', String(idx));
|
upBtn.setAttribute('data-idx', String(idx));
|
||||||
@@ -1446,7 +1461,7 @@ function renderViewsSettingsTab() {
|
|||||||
// Down button
|
// Down button
|
||||||
var downBtn = document.createElement('button');
|
var downBtn = document.createElement('button');
|
||||||
downBtn.className = 'views-settings-btn';
|
downBtn.className = 'views-settings-btn';
|
||||||
downBtn.textContent = '\u25bc';
|
downBtn.innerHTML = _icons.chevronDown;
|
||||||
downBtn.title = 'Move down';
|
downBtn.title = 'Move down';
|
||||||
downBtn.setAttribute('data-action', 'move-down');
|
downBtn.setAttribute('data-action', 'move-down');
|
||||||
downBtn.setAttribute('data-idx', String(idx));
|
downBtn.setAttribute('data-idx', String(idx));
|
||||||
@@ -2038,7 +2053,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
|
|||||||
var v = views[i];
|
var v = views[i];
|
||||||
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
||||||
html += '<button class="flyout-sheet__item" role="menuitem" data-view-index="' + i + '">';
|
html += '<button class="flyout-sheet__item" role="menuitem" data-view-index="' + i + '">';
|
||||||
html += '<span style="margin-right:8px">' + (isIn ? '\u2713' : '\u00a0\u00a0') + '</span>';
|
html += '<span style="margin-right:8px">' + (isIn ? _icons.check : '\u00a0\u00a0') + '</span>';
|
||||||
html += escapeHtml(v.name);
|
html += escapeHtml(v.name);
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
}
|
}
|
||||||
@@ -2082,7 +2097,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
|
|||||||
|
|
||||||
// Update checkmark immediately for responsiveness
|
// Update checkmark immediately for responsiveness
|
||||||
var checkEl = viewBtn.querySelector('span');
|
var checkEl = viewBtn.querySelector('span');
|
||||||
if (checkEl) checkEl.textContent = nowIn ? '\u2713' : '\u00a0\u00a0';
|
if (checkEl) checkEl.innerHTML = nowIn ? _icons.check : '\u00a0\u00a0';
|
||||||
|
|
||||||
api('PATCH', '/api/settings', patch)
|
api('PATCH', '/api/settings', patch)
|
||||||
.then(function() {
|
.then(function() {
|
||||||
@@ -2095,7 +2110,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
|
|||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
showToast('Couldn\u2019t save \u2014 try again');
|
showToast('Couldn\u2019t save \u2014 try again');
|
||||||
// Revert checkmark
|
// Revert checkmark
|
||||||
if (checkEl) checkEl.textContent = nowIn ? '\u00a0\u00a0' : '\u2713';
|
if (checkEl) checkEl.innerHTML = nowIn ? '\u00a0\u00a0' : _icons.check;
|
||||||
console.warn('[_openMobileViewPicker] PATCH failed:', err);
|
console.warn('[_openMobileViewPicker] PATCH failed:', err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2170,7 +2185,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
|||||||
var v = views[i];
|
var v = views[i];
|
||||||
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
||||||
html += '<button class="flyout-submenu__item" role="menuitem" data-view-index="' + i + '">';
|
html += '<button class="flyout-submenu__item" role="menuitem" data-view-index="' + i + '">';
|
||||||
html += '<span class="flyout-submenu__check">' + (isIn ? '\u2713' : '') + '</span>';
|
html += '<span class="flyout-submenu__check">' + (isIn ? _icons.check : '') + '</span>';
|
||||||
html += escapeHtml(v.name);
|
html += escapeHtml(v.name);
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
}
|
}
|
||||||
@@ -2283,7 +2298,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
|||||||
var checkEl = checkItems[ci].querySelector('.flyout-submenu__check');
|
var checkEl = checkItems[ci].querySelector('.flyout-submenu__check');
|
||||||
var updViews = (_serverSettings && _serverSettings.views) || [];
|
var updViews = (_serverSettings && _serverSettings.views) || [];
|
||||||
if (checkEl && updViews[vi]) {
|
if (checkEl && updViews[vi]) {
|
||||||
checkEl.textContent = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : '';
|
checkEl.innerHTML = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? _icons.check : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2474,7 +2489,7 @@ function openManageViewPanel() {
|
|||||||
nameRow.innerHTML =
|
nameRow.innerHTML =
|
||||||
'<h2 id="manage-view-name" class="manage-view-panel__name">' + escapeHtml(_activeView) + '</h2>' +
|
'<h2 id="manage-view-name" class="manage-view-panel__name">' + escapeHtml(_activeView) + '</h2>' +
|
||||||
'<button id="manage-view-delete-btn" class="manage-view-panel__delete-btn" ' +
|
'<button id="manage-view-delete-btn" class="manage-view-panel__delete-btn" ' +
|
||||||
'title="Delete this view" aria-label="Delete view">\u2715</button>';
|
'title="Delete this view" aria-label="Delete view">' + _icons.close + '</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
renderManageViewList();
|
renderManageViewList();
|
||||||
@@ -3193,7 +3208,7 @@ function _buildRemoteInstanceRow(url, name, key) {
|
|||||||
keyInput.setAttribute('aria-label', 'Federation key for remote instance');
|
keyInput.setAttribute('aria-label', 'Federation key for remote instance');
|
||||||
var removeBtn = document.createElement('button');
|
var removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'settings-remote-remove';
|
removeBtn.className = 'settings-remote-remove';
|
||||||
removeBtn.textContent = '\u00d7';
|
removeBtn.innerHTML = _icons.close;
|
||||||
removeBtn.setAttribute('aria-label', 'Remove remote instance');
|
removeBtn.setAttribute('aria-label', 'Remove remote instance');
|
||||||
row.appendChild(urlInput);
|
row.appendChild(urlInput);
|
||||||
row.appendChild(nameInput);
|
row.appendChild(nameInput);
|
||||||
@@ -3731,7 +3746,7 @@ function renderSheetList() {
|
|||||||
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
||||||
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
|
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
|
||||||
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
||||||
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
|
(hasBell ? '<span class="sheet-item__bell">' + _icons.bell + '</span>' : '') +
|
||||||
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
||||||
'</li>';
|
'</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -4013,26 +4028,30 @@ async function createNewSession(name, remoteId) {
|
|||||||
// Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format)
|
// Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format)
|
||||||
var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName;
|
var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName;
|
||||||
|
|
||||||
// Poll until the session appears in _currentSessions (max 30s, every 2s)
|
// Poll until the session appears in _currentSessions.
|
||||||
|
// Check immediately first (session may already be in cache), then retry
|
||||||
|
// at short intervals. This eliminates the old 2-second dead-wait.
|
||||||
var attempts = 0;
|
var attempts = 0;
|
||||||
var maxAttempts = 15;
|
var maxAttempts = 30;
|
||||||
var pollForSession = setInterval(async function() {
|
async function checkForSession() {
|
||||||
attempts++;
|
attempts++;
|
||||||
await pollSessions();
|
await pollSessions();
|
||||||
var found = _currentSessions && _currentSessions.find(function(s) {
|
var found = _currentSessions && _currentSessions.find(function(s) {
|
||||||
return (s.sessionKey || s.name) === expectedKey;
|
return (s.sessionKey || s.name) === expectedKey;
|
||||||
});
|
});
|
||||||
if (found) {
|
if (found) {
|
||||||
clearInterval(pollForSession);
|
|
||||||
removeLoadingTile();
|
removeLoadingTile();
|
||||||
showToast('Session \'' + sessionName + '\' ready');
|
showToast('Session \'' + sessionName + '\' ready');
|
||||||
openSession(sessionName, { remoteId: deviceId });
|
openSession(sessionName, { remoteId: deviceId });
|
||||||
} else if (attempts >= maxAttempts) {
|
} else if (attempts >= maxAttempts) {
|
||||||
clearInterval(pollForSession);
|
|
||||||
removeLoadingTile();
|
removeLoadingTile();
|
||||||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||||||
|
} else {
|
||||||
|
// Short retry: 500ms for first few attempts, then 2s
|
||||||
|
setTimeout(checkForSession, attempts <= 5 ? 500 : 2000);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}
|
||||||
|
checkForSession();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Failed to create session');
|
showToast(err.message || 'Failed to create session');
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-15
@@ -22,14 +22,14 @@
|
|||||||
<div class="view-dropdown" id="view-dropdown">
|
<div class="view-dropdown" id="view-dropdown">
|
||||||
<button id="view-dropdown-trigger" class="view-dropdown__trigger" aria-haspopup="true" aria-expanded="false" aria-controls="view-dropdown-menu">
|
<button id="view-dropdown-trigger" class="view-dropdown__trigger" aria-haspopup="true" aria-expanded="false" aria-controls="view-dropdown-menu">
|
||||||
<span id="view-dropdown-label">All Sessions</span>
|
<span id="view-dropdown-label">All Sessions</span>
|
||||||
<span class="view-dropdown__caret" aria-hidden="true">▾</span>
|
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
<button id="new-session-btn" class="header-btn" aria-label="New session"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></button>
|
||||||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
<button id="settings-btn" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
|
||||||
<span id="connection-status"></span>
|
<span id="connection-status"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
<!-- ── Expanded (terminal) view ──────────────────────────────────────── -->
|
<!-- ── Expanded (terminal) view ──────────────────────────────────────── -->
|
||||||
<div id="view-expanded" class="view hidden">
|
<div id="view-expanded" class="view hidden">
|
||||||
<header class="expanded-header">
|
<header class="expanded-header">
|
||||||
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
|
<button id="back-btn" class="back-btn" aria-label="Back"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
|
||||||
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">☰</button>
|
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 4h12M2 8h12M2 12h12"/></svg></button>
|
||||||
<span id="expanded-session-name" class="expanded-session-name"></span>
|
<span id="expanded-session-name" class="expanded-session-name"></span>
|
||||||
<button id="settings-btn-expanded" class="header-btn" aria-label="Settings">⚙</button>
|
<button id="settings-btn-expanded" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="view-body">
|
<div class="view-body">
|
||||||
<div id="session-sidebar" class="session-sidebar">
|
<div id="session-sidebar" class="session-sidebar">
|
||||||
@@ -52,11 +52,11 @@
|
|||||||
<div class="sidebar-view-dropdown" id="sidebar-view-dropdown">
|
<div class="sidebar-view-dropdown" id="sidebar-view-dropdown">
|
||||||
<button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu">
|
<button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu">
|
||||||
<span id="sidebar-view-label">All Sessions</span>
|
<span id="sidebar-view-label">All Sessions</span>
|
||||||
<span class="view-dropdown__caret" aria-hidden="true">▾</span>
|
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
<div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||||
</div>
|
</div>
|
||||||
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">‹</button>
|
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sidebar-list" class="sidebar-list"></div>
|
<div id="sidebar-list" class="sidebar-list"></div>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -67,11 +67,28 @@
|
|||||||
<div id="terminal-search-bar" class="terminal-search-bar hidden">
|
<div id="terminal-search-bar" class="terminal-search-bar hidden">
|
||||||
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
|
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
|
||||||
<span id="terminal-search-count" class="terminal-search-count"></span>
|
<span id="terminal-search-count" class="terminal-search-count"></span>
|
||||||
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">▲</button>
|
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg></button>
|
||||||
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">▼</button>
|
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||||
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">×</button>
|
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="terminal-container" class="terminal-container"></div>
|
<div id="terminal-container" class="terminal-container"></div>
|
||||||
|
<div id="mobile-toolbar" class="mobile-toolbar hidden">
|
||||||
|
<div class="mobile-toolbar__scroll">
|
||||||
|
<button class="mobile-toolbar__key" data-key="Escape">Esc</button>
|
||||||
|
<button class="mobile-toolbar__key" data-key="Tab">Tab</button>
|
||||||
|
<button class="mobile-toolbar__key mobile-toolbar__key--modifier" data-modifier="ctrl">Ctrl</button>
|
||||||
|
<button class="mobile-toolbar__key mobile-toolbar__key--modifier" data-modifier="alt">Alt</button>
|
||||||
|
<button class="mobile-toolbar__key" data-key="ArrowUp"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 12V4M4 7l4-4 4 4"/></svg></button>
|
||||||
|
<button class="mobile-toolbar__key" data-key="ArrowDown"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 4v8M4 9l4 4 4-4"/></svg></button>
|
||||||
|
<button class="mobile-toolbar__key" data-key="ArrowLeft"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8H4M7 4L3 8l4 4"/></svg></button>
|
||||||
|
<button class="mobile-toolbar__key" data-key="ArrowRight"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 8h8M9 4l4 4-4 4"/></svg></button>
|
||||||
|
<button class="mobile-toolbar__key" data-input="|">|</button>
|
||||||
|
<button class="mobile-toolbar__key" data-input="~">~</button>
|
||||||
|
<button class="mobile-toolbar__key" data-input="/">/</button>
|
||||||
|
<button class="mobile-toolbar__key" data-input="-">-</button>
|
||||||
|
<button class="mobile-toolbar__key" data-input="`">`</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||||
@@ -106,11 +123,11 @@
|
|||||||
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
|
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
|
||||||
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
||||||
<span id="session-pill-label" class="session-pill__label"></span>
|
<span id="session-pill-label" class="session-pill__label"></span>
|
||||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
|
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
|
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
|
||||||
<button id="new-session-fab" class="new-session-fab" aria-label="New session">+</button>
|
<button id="new-session-fab" class="new-session-fab" aria-label="New session"><svg viewBox="0 0 16 16" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||||
|
|
||||||
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
@@ -120,7 +137,7 @@
|
|||||||
<dialog id="settings-dialog" class="settings-dialog">
|
<dialog id="settings-dialog" class="settings-dialog">
|
||||||
<div class="settings-dialog-header">
|
<div class="settings-dialog-header">
|
||||||
<h2 class="settings-dialog-title">Settings</h2>
|
<h2 class="settings-dialog-title">Settings</h2>
|
||||||
<button id="settings-close-btn" class="settings-close-btn" aria-label="Close settings">×</button>
|
<button id="settings-close-btn" class="settings-close-btn" aria-label="Close settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<nav class="settings-tabs">
|
<nav class="settings-tabs">
|
||||||
|
|||||||
+102
-2
@@ -53,6 +53,23 @@
|
|||||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline SVG icons — ensure consistent vertical alignment */
|
||||||
|
.header-btn svg,
|
||||||
|
.back-btn svg,
|
||||||
|
.sidebar-toggle-btn svg,
|
||||||
|
.sidebar-collapse-btn svg,
|
||||||
|
.terminal-search-btn svg,
|
||||||
|
.settings-close-btn svg,
|
||||||
|
.tile-options-btn svg,
|
||||||
|
.new-session-fab svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-dropdown__caret {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
/* Box-sizing reset */
|
/* Box-sizing reset */
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -752,9 +769,13 @@ body {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
/* xterm.js manages touch scrolling natively via its Viewport — let it work.
|
||||||
|
Hide the visual scrollbar on touch devices but keep overflow-y functional. */
|
||||||
.xterm .xterm-viewport {
|
.xterm .xterm-viewport {
|
||||||
overflow-y: hidden !important;
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reconnect-overlay {
|
.reconnect-overlay {
|
||||||
@@ -2056,6 +2077,13 @@ body {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure mobile toolbar is hidden on non-touch desktop */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.mobile-toolbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Responsive overlay sidebar at <960px
|
Responsive overlay sidebar at <960px
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -2539,3 +2567,75 @@ body {
|
|||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
background: var(--border-subtle);
|
background: var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Mobile terminal toolbar — virtual keys for touch devices
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.mobile-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar__scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
padding-bottom: 2px; /* prevent clipping of active state */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar__scroll::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar__key {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar__key:active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar__key svg {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modifier keys (Ctrl, Alt) — slightly different base style */
|
||||||
|
.mobile-toolbar__key--modifier {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active (toggled on) state for modifier keys */
|
||||||
|
.mobile-toolbar__key--active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|||||||
+129
-56
@@ -11,6 +11,8 @@ let _vpHandler = null;
|
|||||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
||||||
let _searchAddon = null;
|
let _searchAddon = null;
|
||||||
let _resizeObserver = null;
|
let _resizeObserver = null;
|
||||||
|
let _ctrlActive = false; // mobile toolbar: Ctrl modifier toggle (one-shot)
|
||||||
|
let _altActive = false; // mobile toolbar: Alt modifier toggle (one-shot)
|
||||||
|
|
||||||
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
||||||
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
||||||
@@ -76,8 +78,24 @@ function connectWebSocket(name, remoteId) {
|
|||||||
if (_term) {
|
if (_term) {
|
||||||
_term.onData(function(data) {
|
_term.onData(function(data) {
|
||||||
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
||||||
|
var outData = data;
|
||||||
|
// Mobile toolbar modifier keys (one-shot: auto-deactivate after use)
|
||||||
|
if (_ctrlActive && data.length === 1) {
|
||||||
|
var code = data.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 65 && code <= 90) { // A-Z → Ctrl+letter
|
||||||
|
outData = String.fromCharCode(code - 64);
|
||||||
|
}
|
||||||
|
_ctrlActive = false;
|
||||||
|
var cb = document.querySelector('[data-modifier="ctrl"]');
|
||||||
|
if (cb) cb.classList.remove('mobile-toolbar__key--active');
|
||||||
|
} else if (_altActive && data.length === 1) {
|
||||||
|
outData = '\x1b' + data; // Alt = ESC prefix
|
||||||
|
_altActive = false;
|
||||||
|
var ab = document.querySelector('[data-modifier="alt"]');
|
||||||
|
if (ab) ab.classList.remove('mobile-toolbar__key--active');
|
||||||
|
}
|
||||||
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
|
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
|
||||||
_ws.send(encodePayload(0x30, data));
|
_ws.send(encodePayload(0x30, outData));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_term.onResize(function(size) {
|
_term.onResize(function(size) {
|
||||||
@@ -206,12 +224,19 @@ function initVisualViewport() {
|
|||||||
var container = document.getElementById('terminal-container');
|
var container = document.getElementById('terminal-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Resize container to fill visual viewport above keyboard
|
// Resize container to fill visual viewport above keyboard.
|
||||||
|
// Account for header + mobile toolbar (if visible).
|
||||||
var headerHeight = 44; // matches --header-height CSS custom property
|
var headerHeight = 44; // matches --header-height CSS custom property
|
||||||
|
var toolbar = document.getElementById('mobile-toolbar');
|
||||||
|
var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
|
||||||
var vvh = window.visualViewport.height;
|
var vvh = window.visualViewport.height;
|
||||||
var termHeight = Math.max(100, vvh - headerHeight);
|
var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
|
||||||
container.style.height = termHeight + 'px';
|
container.style.height = termHeight + 'px';
|
||||||
|
|
||||||
|
// Ensure terminal viewport stays pinned — prevent the page from
|
||||||
|
// shifting upward when the soft keyboard opens on mobile.
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
// Refit xterm.js to new container size
|
// Refit xterm.js to new container size
|
||||||
try { _fitAddon.fit(); } catch (_) {}
|
try { _fitAddon.fit(); } catch (_) {}
|
||||||
};
|
};
|
||||||
@@ -501,6 +526,10 @@ function openTerminal(sessionName, remoteId, fontSize) {
|
|||||||
|
|
||||||
connectWebSocket(sessionName, remoteId);
|
connectWebSocket(sessionName, remoteId);
|
||||||
initVisualViewport(); /* defined in Task 14 */
|
initVisualViewport(); /* defined in Task 14 */
|
||||||
|
|
||||||
|
// --- Mobile enhancements ---
|
||||||
|
_initAndroidIMEFix(container);
|
||||||
|
_initMobileToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -524,6 +553,12 @@ function closeTerminal() {
|
|||||||
|
|
||||||
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
|
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
|
||||||
|
|
||||||
|
// Clean up mobile enhancements
|
||||||
|
_ctrlActive = false;
|
||||||
|
_altActive = false;
|
||||||
|
var mobileToolbar = document.getElementById('mobile-toolbar');
|
||||||
|
if (mobileToolbar) mobileToolbar.classList.add('hidden');
|
||||||
|
|
||||||
if (_term) {
|
if (_term) {
|
||||||
_term.dispose();
|
_term.dispose();
|
||||||
_term = null;
|
_term = null;
|
||||||
@@ -563,71 +598,109 @@ function setTerminalFontSize(size) {
|
|||||||
window._setTerminalFontSize = setTerminalFontSize;
|
window._setTerminalFontSize = setTerminalFontSize;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mobile touch scroll — rAF-batched WheelEvent dispatch
|
// Android IME fix — prevent double-space-to-period from duplicating input
|
||||||
// Mobile devices batch touchmove events irregularly; dispatching one WheelEvent
|
// Android keyboards use insertReplacementText beforeinput events to perform
|
||||||
// per frame (via requestAnimationFrame) smooths over burst delivery.
|
// auto-corrections (e.g. " " → ". "). xterm.js's hidden textarea gets confused
|
||||||
// Applies to Android, iOS, and iPadOS touch devices.
|
// by these replacements, causing the entire composition buffer to replay.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
;(function initMobileTerminalScroll() {
|
|
||||||
var isTouchDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
function _initAndroidIMEFix(container) {
|
||||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
if (!/Android/i.test(navigator.userAgent)) return;
|
||||||
|
|
||||||
|
// Wait a tick for xterm.js to create its hidden textarea
|
||||||
|
setTimeout(function() {
|
||||||
|
var ta = container.querySelector('.xterm-helper-textarea');
|
||||||
|
if (!ta) return;
|
||||||
|
|
||||||
|
ta.addEventListener('beforeinput', function(e) {
|
||||||
|
if (e.inputType === 'insertReplacementText') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
var data = e.data || '';
|
||||||
|
if (data && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||||
|
// Send backspace (delete the char being replaced) + replacement text
|
||||||
|
_ws.send(_encodePayload(0x30, '\x08' + data));
|
||||||
|
}
|
||||||
|
// Clear textarea to prevent stale IME state
|
||||||
|
ta.value = '';
|
||||||
|
}
|
||||||
|
}, true); // capture phase — run before xterm.js handlers
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile toolbar — virtual keys for Esc, Tab, Ctrl, Alt, arrows, specials
|
||||||
|
// Ctrl and Alt are one-shot modifier toggles: tap Ctrl, then type a letter
|
||||||
|
// on the soft keyboard → sends Ctrl+letter, modifier auto-deactivates.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _initMobileToolbar() {
|
||||||
|
var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
if (!isTouchDevice) return;
|
if (!isTouchDevice) return;
|
||||||
|
|
||||||
var container = document.getElementById('terminal-container');
|
var toolbar = document.getElementById('mobile-toolbar');
|
||||||
if (!container) return;
|
if (!toolbar) return;
|
||||||
|
|
||||||
var _lastY = 0;
|
toolbar.classList.remove('hidden');
|
||||||
var _accumulated = 0; // pixel debt between rAF ticks
|
_ctrlActive = false;
|
||||||
var _rafId = null;
|
_altActive = false;
|
||||||
var SCROLL_PX = 20; // pixels of touch movement = one WheelEvent dispatch
|
|
||||||
|
|
||||||
function flushScroll() {
|
var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
|
||||||
_rafId = null;
|
var altBtn = toolbar.querySelector('[data-modifier="alt"]');
|
||||||
if (!_term || Math.abs(_accumulated) < SCROLL_PX) return;
|
|
||||||
|
|
||||||
var viewport = container.querySelector('.xterm-viewport');
|
// Single delegated handler for all toolbar keys.
|
||||||
if (!viewport) { _accumulated = 0; return; }
|
// Use 'pointerdown' + preventDefault to avoid focusing xterm's hidden textarea
|
||||||
|
// (which brings up the soft keyboard). Modifier toggles (Ctrl/Alt) DO focus
|
||||||
|
// intentionally so the user can type the next character on the soft keyboard.
|
||||||
|
toolbar.addEventListener('pointerdown', function(e) {
|
||||||
|
var btn = e.target.closest('.mobile-toolbar__key');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
// One WheelEvent per frame — dir * 120 = one standard scroll click
|
// --- Modifier toggles: focus terminal to bring up keyboard ---
|
||||||
var dir = _accumulated > 0 ? 1 : -1;
|
var modifier = btn.dataset.modifier;
|
||||||
viewport.dispatchEvent(new WheelEvent('wheel', {
|
if (modifier === 'ctrl') {
|
||||||
deltaY: dir * 120,
|
e.preventDefault();
|
||||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
_ctrlActive = !_ctrlActive;
|
||||||
bubbles: true,
|
btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
|
||||||
cancelable: true,
|
if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
|
||||||
}));
|
// Focus terminal so soft keyboard appears for the next character
|
||||||
_accumulated -= dir * SCROLL_PX;
|
if (_ctrlActive && _term) _term.focus();
|
||||||
|
return;
|
||||||
// Self-schedule until remainder is consumed
|
|
||||||
if (Math.abs(_accumulated) >= SCROLL_PX) {
|
|
||||||
_rafId = requestAnimationFrame(flushScroll);
|
|
||||||
}
|
}
|
||||||
|
if (modifier === 'alt') {
|
||||||
|
e.preventDefault();
|
||||||
|
_altActive = !_altActive;
|
||||||
|
btn.classList.toggle('mobile-toolbar__key--active', _altActive);
|
||||||
|
if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
|
||||||
|
// Focus terminal so soft keyboard appears for the next character
|
||||||
|
if (_altActive && _term) _term.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.addEventListener('touchstart', function (e) {
|
// --- Direct key presses: send sequence WITHOUT focusing (no keyboard popup) ---
|
||||||
_lastY = e.touches[0].clientY;
|
e.preventDefault(); // prevent focus transfer to xterm textarea
|
||||||
_accumulated = 0;
|
var key = btn.dataset.key;
|
||||||
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
var input = btn.dataset.input;
|
||||||
}, { passive: true });
|
var seq = '';
|
||||||
|
|
||||||
container.addEventListener('touchmove', function (e) {
|
if (key) {
|
||||||
if (!_term) return;
|
switch (key) {
|
||||||
e.preventDefault(); // block outer-container scroll
|
case 'Escape': seq = '\x1b'; break;
|
||||||
|
case 'Tab': seq = '\t'; break;
|
||||||
var y = e.touches[0].clientY;
|
case 'ArrowUp': seq = '\x1b[A'; break;
|
||||||
_accumulated += _lastY - y; // positive = swipe up = newer content
|
case 'ArrowDown': seq = '\x1b[B'; break;
|
||||||
_lastY = y;
|
case 'ArrowRight': seq = '\x1b[C'; break;
|
||||||
|
case 'ArrowLeft': seq = '\x1b[D'; break;
|
||||||
if (!_rafId) {
|
}
|
||||||
_rafId = requestAnimationFrame(flushScroll);
|
} else if (input) {
|
||||||
|
seq = input;
|
||||||
}
|
}
|
||||||
}, { passive: false }); // passive:false required for preventDefault
|
|
||||||
|
|
||||||
container.addEventListener('touchend', function () {
|
if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||||
_lastY = 0;
|
_ws.send(_encodePayload(0x30, seq));
|
||||||
_accumulated = 0;
|
}
|
||||||
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
// Do NOT focus terminal — keeps soft keyboard hidden
|
||||||
}, { passive: true });
|
});
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -20,6 +20,7 @@ import pwd
|
|||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -374,7 +375,9 @@ async def lifespan(app: FastAPI):
|
|||||||
"set-hook",
|
"set-hook",
|
||||||
"-g",
|
"-g",
|
||||||
"alert-bell",
|
"alert-bell",
|
||||||
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'",
|
"run-shell '"
|
||||||
|
f'name=$(printf "%s" "#{{session_name}}" | sed "s/ /%20/g"); '
|
||||||
|
f"curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/$name/bell || true'",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # tmux not running at startup is OK; hook will be set on first poll
|
pass # tmux not running at startup is OK; hook will be set on first poll
|
||||||
@@ -635,7 +638,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
|||||||
"Ensure it is installed and in the server's PATH.",
|
"Ensure it is installed and in the server's PATH.",
|
||||||
)
|
)
|
||||||
|
|
||||||
command = template.replace("{name}", name)
|
command = template.replace("{name}", shlex.quote(name))
|
||||||
_log.info("Creating session '%s' with command: %s", name, command)
|
_log.info("Creating session '%s' with command: %s", name, command)
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_shell(
|
proc = await asyncio.create_subprocess_shell(
|
||||||
@@ -692,6 +695,16 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to launch command: {exc}",
|
detail=f"Failed to launch command: {exc}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Eagerly refresh the session cache so the next GET /api/sessions
|
||||||
|
# reflects the newly-created session without waiting for the poll loop.
|
||||||
|
try:
|
||||||
|
fresh_names = await enumerate_sessions()
|
||||||
|
fresh_snapshots = await snapshot_all(fresh_names)
|
||||||
|
update_session_cache(fresh_names, fresh_snapshots)
|
||||||
|
except Exception:
|
||||||
|
pass # non-fatal; poll loop will catch up
|
||||||
|
|
||||||
return {"name": name, "ok": True}
|
return {"name": name, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -713,6 +726,14 @@ async def connect_session(name: str) -> dict:
|
|||||||
await kill_ttyd()
|
await kill_ttyd()
|
||||||
await spawn_ttyd(name)
|
await spawn_ttyd(name)
|
||||||
|
|
||||||
|
# Wait for ttyd to actually bind its port before returning.
|
||||||
|
# This eliminates the 0.8s blind sleep in the WebSocket proxy path —
|
||||||
|
# the client can connect immediately when this endpoint responds.
|
||||||
|
for _attempt in range(20): # up to ~1s (20 × 50ms)
|
||||||
|
if _ttyd_is_listening():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
async with state_lock:
|
async with state_lock:
|
||||||
state = load_state()
|
state = load_state()
|
||||||
state["active_session"] = name
|
state["active_session"] = name
|
||||||
@@ -758,7 +779,7 @@ async def delete_session(name: str) -> dict:
|
|||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
command = settings.get(
|
command = settings.get(
|
||||||
"delete_session_template", "tmux kill-session -t {name}"
|
"delete_session_template", "tmux kill-session -t {name}"
|
||||||
).replace("{name}", name)
|
).replace("{name}", shlex.quote(name))
|
||||||
|
|
||||||
_log.info("Deleting session '%s' with command: %s", name, command)
|
_log.info("Deleting session '%s' with command: %s", name, command)
|
||||||
try:
|
try:
|
||||||
@@ -858,7 +879,9 @@ async def setup_hooks() -> dict:
|
|||||||
"set-hook",
|
"set-hook",
|
||||||
"-g",
|
"-g",
|
||||||
"alert-bell",
|
"alert-bell",
|
||||||
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'",
|
"run-shell '"
|
||||||
|
f'name=$(printf "%s" "#{{session_name}}" | sed "s/ /%20/g"); '
|
||||||
|
f"curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/$name/bell || true'",
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -963,8 +963,9 @@ def test_html_fab_exists() -> None:
|
|||||||
assert fab.get("aria-label") == "New session", (
|
assert fab.get("aria-label") == "New session", (
|
||||||
f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}"
|
f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}"
|
||||||
)
|
)
|
||||||
text = fab.get_text(strip=True)
|
# FAB uses an inline SVG icon instead of text
|
||||||
assert text == "+", f"#new-session-fab text must be '+', got: {text!r}"
|
svg = fab.find("svg")
|
||||||
|
assert svg is not None, "#new-session-fab must contain an SVG icon"
|
||||||
|
|
||||||
|
|
||||||
def test_html_fab_before_toast() -> None:
|
def test_html_fab_before_toast() -> None:
|
||||||
|
|||||||
@@ -1734,9 +1734,9 @@ def test_create_new_session_polls_before_open() -> None:
|
|||||||
assert "setTimeout(() => openSession" not in body, (
|
assert "setTimeout(() => openSession" not in body, (
|
||||||
"createNewSession must not use immediate setTimeout(() => openSession) — should poll instead"
|
"createNewSession must not use immediate setTimeout(() => openSession) — should poll instead"
|
||||||
)
|
)
|
||||||
# New polling pattern must be present
|
# Polling pattern must be present (recursive setTimeout or setInterval)
|
||||||
assert "setInterval" in body, (
|
assert "setTimeout" in body or "setInterval" in body, (
|
||||||
"createNewSession must use setInterval to poll for session readiness"
|
"createNewSession must poll for session readiness (setTimeout or setInterval)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+122
-2
@@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
|
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
|
||||||
All 11 acceptance-criteria tests are defined here.
|
All 11 acceptance-criteria tests are defined here, plus 5 tests for the
|
||||||
|
_pids_on_port() lsof→fuser→ss fallback chain (the root-cause guard for systems
|
||||||
|
where lsof is not installed).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import signal
|
import signal
|
||||||
@@ -9,7 +11,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import muxplex.ttyd as ttyd_mod
|
import muxplex.ttyd as ttyd_mod
|
||||||
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd
|
from muxplex.ttyd import (
|
||||||
|
_kill_pids_on_port,
|
||||||
|
_pids_on_port,
|
||||||
|
kill_orphan_ttyd,
|
||||||
|
kill_ttyd,
|
||||||
|
spawn_ttyd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -311,6 +319,118 @@ async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _pids_on_port fallback chain tests
|
||||||
|
#
|
||||||
|
# These cover the exact production failure: lsof not installed → all kill
|
||||||
|
# strategies silently returned False → stale ttyd survived restarts.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_subprocess_result(returncode: int, stdout: str) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.returncode = returncode
|
||||||
|
r.stdout = stdout
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def test_pids_on_port_uses_lsof_when_available():
|
||||||
|
"""_pids_on_port() returns PID list from lsof output when lsof is available."""
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||||
|
if cmd[0] == "lsof":
|
||||||
|
return _make_subprocess_result(0, "3555095\n")
|
||||||
|
return _make_subprocess_result(1, "")
|
||||||
|
|
||||||
|
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||||
|
result = _pids_on_port(7682)
|
||||||
|
|
||||||
|
assert result == [3555095], "Should return PID from lsof"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pids_on_port_falls_back_to_fuser_when_lsof_missing():
|
||||||
|
"""_pids_on_port() uses fuser when lsof is not installed (FileNotFoundError).
|
||||||
|
|
||||||
|
This is the exact failure mode that caused the yazi-stuck-ttyd bug:
|
||||||
|
lsof was absent, _kill_pids_on_port silently returned False, and the
|
||||||
|
stale ttyd was never killed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||||
|
if cmd[0] == "lsof":
|
||||||
|
raise FileNotFoundError("lsof not found")
|
||||||
|
if cmd[0] == "fuser":
|
||||||
|
return _make_subprocess_result(0, " 3555095")
|
||||||
|
return _make_subprocess_result(1, "")
|
||||||
|
|
||||||
|
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||||
|
result = _pids_on_port(7682)
|
||||||
|
|
||||||
|
assert result == [3555095], "Should fall back to fuser when lsof is missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pids_on_port_falls_back_to_ss_when_lsof_and_fuser_missing():
|
||||||
|
"""_pids_on_port() uses ss when both lsof and fuser are unavailable."""
|
||||||
|
ss_output = (
|
||||||
|
'LISTEN 0 128 0.0.0.0:7682 0.0.0.0:* users:(("ttyd",pid=3555095,fd=12))\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||||
|
if cmd[0] in ("lsof", "fuser"):
|
||||||
|
raise FileNotFoundError(f"{cmd[0]} not found")
|
||||||
|
if cmd[0] == "ss":
|
||||||
|
return _make_subprocess_result(0, ss_output)
|
||||||
|
return _make_subprocess_result(1, "")
|
||||||
|
|
||||||
|
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||||
|
result = _pids_on_port(7682)
|
||||||
|
|
||||||
|
assert result == [3555095], "Should fall back to ss when lsof and fuser missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pids_on_port_returns_empty_when_all_tools_fail():
|
||||||
|
"""_pids_on_port() returns [] when no discovery tool is available."""
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||||
|
raise FileNotFoundError(f"{cmd[0]} not found")
|
||||||
|
|
||||||
|
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||||
|
result = _pids_on_port(7682)
|
||||||
|
|
||||||
|
assert result == [], "Should return empty list when all tools fail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_pids_on_port_sends_signal_via_fuser_fallback():
|
||||||
|
"""_kill_pids_on_port() sends the correct signal using the fuser fallback.
|
||||||
|
|
||||||
|
End-to-end regression guard: verifies that the full chain from
|
||||||
|
_kill_pids_on_port → _pids_on_port → fuser → os.kill works correctly
|
||||||
|
so that stale ttyd processes are killed even when lsof is absent.
|
||||||
|
"""
|
||||||
|
killed: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||||
|
if cmd[0] == "lsof":
|
||||||
|
raise FileNotFoundError("lsof not found")
|
||||||
|
if cmd[0] == "fuser":
|
||||||
|
return _make_subprocess_result(0, " 3555095")
|
||||||
|
return _make_subprocess_result(1, "")
|
||||||
|
|
||||||
|
def mock_kill(pid: int, sig: int) -> None:
|
||||||
|
killed.append((pid, sig))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("muxplex.ttyd._subprocess.run", side_effect=mock_run),
|
||||||
|
patch("os.kill", side_effect=mock_kill),
|
||||||
|
):
|
||||||
|
result = _kill_pids_on_port(7682, signal.SIGKILL)
|
||||||
|
|
||||||
|
assert result is True, "_kill_pids_on_port must return True when fuser finds a PID"
|
||||||
|
assert (3555095, signal.SIGKILL) in killed, (
|
||||||
|
"Must send SIGKILL to PID 3555095 discovered via fuser fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
|
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
|
||||||
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
|
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
|
||||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
|||||||
+85
-18
@@ -17,6 +17,7 @@ Public API:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import re as _re
|
||||||
import signal
|
import signal
|
||||||
import subprocess as _subprocess
|
import subprocess as _subprocess
|
||||||
import time
|
import time
|
||||||
@@ -43,12 +44,21 @@ _active_process: asyncio.subprocess.Process | None = None
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _kill_pids_on_port(port: int, sig: int) -> bool:
|
def _pids_on_port(port: int) -> list[int]:
|
||||||
"""Find and signal all processes listening on *port* via lsof.
|
"""Return PIDs of all processes listening on *port*.
|
||||||
|
|
||||||
Returns True if at least one PID was found and signalled.
|
Tries three tools in order, stopping at the first that returns results:
|
||||||
Silently ignores lsof unavailability and already-dead processes.
|
|
||||||
|
1. ``lsof -ti :<port>`` — one PID per line, most widely available.
|
||||||
|
2. ``fuser <port>/tcp`` — space-separated PIDs on stdout (psmisc).
|
||||||
|
3. ``ss -Hnltp`` — parses ``pid=N`` from users field (iproute2).
|
||||||
|
|
||||||
|
Returns an empty list if none of the tools are available or find anything.
|
||||||
|
Silently swallows all errors so callers always get a list.
|
||||||
"""
|
"""
|
||||||
|
pids: list[int] = []
|
||||||
|
|
||||||
|
# --- Tool 1: lsof ---
|
||||||
try:
|
try:
|
||||||
result = _subprocess.run(
|
result = _subprocess.run(
|
||||||
["lsof", "-ti", f":{port}"],
|
["lsof", "-ti", f":{port}"],
|
||||||
@@ -56,23 +66,79 @@ def _kill_pids_on_port(port: int, sig: int) -> bool:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
for tok in result.stdout.split():
|
||||||
|
try:
|
||||||
|
pids.append(int(tok))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
if pids:
|
||||||
|
return pids
|
||||||
|
|
||||||
|
# --- Tool 2: fuser (psmisc) ---
|
||||||
|
try:
|
||||||
|
result = _subprocess.run(
|
||||||
|
["fuser", f"{port}/tcp"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
# fuser writes the port label to stderr and PIDs to stdout.
|
||||||
|
if result.stdout.strip():
|
||||||
|
for tok in result.stdout.split():
|
||||||
|
try:
|
||||||
|
pids.append(int(tok))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
if pids:
|
||||||
|
return pids
|
||||||
|
|
||||||
|
# --- Tool 3: ss (iproute2) ---
|
||||||
|
try:
|
||||||
|
result = _subprocess.run(
|
||||||
|
["ss", "-Hnltp", f"sport = :{port}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
for match in _re.finditer(r"pid=(\d+)", result.stdout):
|
||||||
|
try:
|
||||||
|
pids.append(int(match.group(1)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_pids_on_port(port: int, sig: int) -> bool:
|
||||||
|
"""Find and signal all processes listening on *port*.
|
||||||
|
|
||||||
|
Uses :func:`_pids_on_port` (lsof → fuser → ss) to locate PIDs, then
|
||||||
|
signals each with *sig*.
|
||||||
|
|
||||||
|
Returns True if at least one PID was found and signalled.
|
||||||
|
Silently ignores unavailable tools and already-dead processes.
|
||||||
|
"""
|
||||||
|
pids = _pids_on_port(port)
|
||||||
|
if not pids:
|
||||||
return False
|
return False
|
||||||
sent = False
|
sent = False
|
||||||
for pid_str in result.stdout.strip().split("\n"):
|
for pid in pids:
|
||||||
pid_str = pid_str.strip()
|
|
||||||
if not pid_str:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
orphan_pid = int(pid_str)
|
os.kill(pid, sig)
|
||||||
os.kill(orphan_pid, sig)
|
|
||||||
sent = True
|
sent = True
|
||||||
except (ValueError, ProcessLookupError, PermissionError):
|
except (ProcessLookupError, PermissionError):
|
||||||
pass
|
pass
|
||||||
return sent
|
return sent
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
# lsof not available, timed out, or other unexpected failure
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -94,9 +160,10 @@ async def kill_ttyd() -> bool:
|
|||||||
|
|
||||||
Strategy 2 — port-based fallback:
|
Strategy 2 — port-based fallback:
|
||||||
After the PID-file kill, finds and kills any process still listening on
|
After the PID-file kill, finds and kills any process still listening on
|
||||||
TTYD_PORT via ``lsof -ti :<port>``. This catches orphaned ttyd processes
|
TTYD_PORT via ``_pids_on_port()`` (lsof → fuser → ss). This catches
|
||||||
whose PID was never recorded in the file (e.g. after a coordinator crash).
|
orphaned ttyd processes whose PID was never recorded in the file (e.g.
|
||||||
A brief 0.3 s wait is added to let the OS release the port.
|
after a coordinator crash). A brief 0.3 s wait is added to let the OS
|
||||||
|
release the port.
|
||||||
|
|
||||||
The PID file and ``_active_process`` are cleared in all cases before
|
The PID file and ``_active_process`` are cleared in all cases before
|
||||||
returning.
|
returning.
|
||||||
|
|||||||
Reference in New Issue
Block a user