terminal: rename sessions + drag-and-drop reorder

Backend: POST /sessions/<name>/rename in tmux-api runs tmux
rename-session as the mapped OS user. 400 on bad name, 404 on missing
source, 409 on duplicate target, 401 on missing auth header.

Frontend:
- Rename button per card → prompt() dialog, validates against the
  shared regex. Updates currentActive + hash + iframe.src if the
  renamed session was active.
- Session order is now user-driven, persisted in localStorage
  keyed per osUser. New sessions append at the bottom. The previous
  sort-by-lastActivity is gone.
- HTML5 drag-and-drop reorders cards live during dragover; dragend
  captures the DOM order into localStorage.
- Polling renderLobby is suppressed while a drag is in flight so the
  5s tick doesn't yank the list out from under the user.
This commit is contained in:
Viktor Barzin 2026-05-13 20:18:15 +00:00 committed by Viktor Barzin
parent 04fd241679
commit cbe83597c0
2 changed files with 180 additions and 8 deletions

View file

@ -124,10 +124,14 @@
background: #1f1f33; color: #eee; font-family: inherit;
font-size: 11px; cursor: pointer;
}
.session-actions button.rename {
color: #a29bfe; border-color: rgba(162, 155, 254, 0.4);
}
.session-actions button.kill {
color: #e74c3c; border-color: rgba(231, 76, 60, 0.4);
}
.session-actions button:hover { filter: brightness(1.3); }
.session-card.dragging { opacity: 0.4; cursor: grabbing; }
.empty { color: #888; font-style: italic; padding: 12px 0; font-size: 12px; }
@media (max-width: 720px) {
@ -259,6 +263,9 @@
const emptyEl = document.getElementById('lobby-empty');
let currentActive = null;
let dragSrcCard = null;
let isDragging = false;
const ORDER_KEY = 'tmux-session-order-' + whoami.osUser;
function relativeTime(epochSec) {
if (!epochSec) return '';
@ -269,6 +276,41 @@
return Math.floor(diff / 86400) + 'd ago';
}
function getSavedOrder() {
try {
const raw = localStorage.getItem(ORDER_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter(n => typeof n === 'string') : [];
} catch (e) { return []; }
}
function saveOrder(names) {
try { localStorage.setItem(ORDER_KEY, JSON.stringify(names)); } catch (e) {}
}
function captureOrderFromDom() {
return Array.from(listEl.querySelectorAll('.session-card')).map(c => c.dataset.name);
}
function applySavedOrder(sessions) {
const saved = getSavedOrder();
const byName = new Map(sessions.map(s => [s.name, s]));
const out = [];
for (const name of saved) {
const s = byName.get(name);
if (s) { out.push(s); byName.delete(name); }
}
for (const s of sessions) {
if (byName.has(s.name)) out.push(s);
}
return out;
}
function renameInOrder(oldName, newName) {
const order = getSavedOrder();
const i = order.indexOf(oldName);
if (i < 0) return;
order[i] = newName;
saveOrder(order);
}
function updateActiveCard() {
listEl.querySelectorAll('.session-card').forEach(c => {
if (c.dataset.name === currentActive) c.classList.add('active');
@ -324,6 +366,47 @@
}
}
async function renameSession(oldName) {
if (!NAME_RE.test(oldName)) { showToast('Invalid name', 'error'); return; }
const input = prompt('Rename "' + oldName + '" to:', oldName);
if (input === null) return;
const newName = input.trim();
if (!NAME_RE.test(newName)) {
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
return;
}
if (newName === oldName) return;
try {
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(oldName) + '/rename', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (resp.status === 204) {
renameInOrder(oldName, newName);
showToast('Renamed to ' + newName, 'success');
if (currentActive === oldName) {
currentActive = newName;
frameEl.src = '/?arg=' + encodeURIComponent(newName);
history.replaceState(null, '', '#' + newName);
document.title = 'tmux: ' + whoami.osUser + '/' + newName;
}
renderLobby();
} else if (resp.status === 404) {
showToast('Session not found', 'error');
} else if (resp.status === 409) {
showToast('Name "' + newName + '" already taken', 'error', 4000);
} else if (resp.status === 400) {
showToast('Invalid name', 'error');
} else {
showToast('Rename failed: HTTP ' + resp.status, 'error');
}
} catch (err) {
showToast('Rename error: ' + err.message, 'error');
}
}
function renderCard(s) {
const card = document.createElement('div');
card.className = 'session-card';
@ -331,6 +414,7 @@
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', 'Activate session ' + s.name);
card.draggable = true;
if (s.name === currentActive) card.classList.add('active');
const meta = document.createElement('div');
@ -348,9 +432,19 @@
const actions = document.createElement('div');
actions.className = 'session-actions';
const renameBtn = document.createElement('button');
renameBtn.className = 'rename';
renameBtn.textContent = 'Rename';
renameBtn.draggable = false;
renameBtn.addEventListener('click', (e) => {
e.stopPropagation();
renameSession(s.name);
});
actions.appendChild(renameBtn);
const killBtn = document.createElement('button');
killBtn.className = 'kill';
killBtn.textContent = 'Kill';
killBtn.draggable = false;
killBtn.addEventListener('click', (e) => {
e.stopPropagation();
killSession(s.name);
@ -366,10 +460,34 @@
}
});
card.addEventListener('dragstart', (e) => {
dragSrcCard = card;
isDragging = true;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.name);
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
if (dragSrcCard === card) saveOrder(captureOrderFromDom());
dragSrcCard = null;
isDragging = false;
});
card.addEventListener('dragover', (e) => {
if (!dragSrcCard || dragSrcCard === card) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
if (after) card.after(dragSrcCard);
else card.before(dragSrcCard);
});
return card;
}
async function renderLobby() {
if (isDragging) return;
try {
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
if (!resp.ok) {
@ -386,8 +504,8 @@
listEl.appendChild(emptyState('No sessions yet.'));
return;
}
sessions.sort((a, b) => b.lastActivity - a.lastActivity);
for (const s of sessions) listEl.appendChild(renderCard(s));
const ordered = applySavedOrder(sessions);
for (const s of ordered) listEl.appendChild(renderCard(s));
} catch (err) {
clearChildren(listEl);
listEl.appendChild(emptyState('Error: ' + err.message));

View file

@ -181,21 +181,39 @@ func handleSessions(w http.ResponseWriter, r *http.Request) {
}
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/sessions/")
name = strings.TrimSuffix(name, "/")
path := strings.TrimPrefix(r.URL.Path, "/sessions/")
path = strings.TrimSuffix(path, "/")
parts := strings.Split(path, "/")
name := parts[0]
if !sessionNameRe.MatchString(name) {
http.Error(w, "invalid session name", http.StatusBadRequest)
return
}
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
if len(parts) == 1 {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
killSession(w, osUser, name)
return
}
if len(parts) == 2 && parts[1] == "rename" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
renameSession(w, r, osUser, name)
return
}
http.Error(w, "not found", http.StatusNotFound)
}
func killSession(w http.ResponseWriter, osUser, name string) {
out, err := tmuxCmd(osUser, "kill-session", "-t", name).CombinedOutput()
if err != nil {
msg := string(out)
@ -209,3 +227,39 @@ func handleSessionByName(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
func renameSession(w http.ResponseWriter, r *http.Request, osUser, oldName string) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
newName := strings.TrimSpace(body.Name)
if !sessionNameRe.MatchString(newName) {
http.Error(w, "invalid new name", http.StatusBadRequest)
return
}
if newName == oldName {
w.WriteHeader(http.StatusNoContent)
return
}
out, err := tmuxCmd(osUser, "rename-session", "-t", oldName, newName).CombinedOutput()
if err != nil {
msg := string(out)
if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if strings.Contains(msg, "duplicate session") || strings.Contains(msg, "session already exists") {
http.Error(w, "target name already exists", http.StatusConflict)
return
}
log.Printf("rename-session %s→%s as %s failed: %v: %s", oldName, newName, osUser, err, msg)
http.Error(w, "rename-session failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}