Terminal: Ctrl+C copies a selection (else ^C), Ctrl+V pastes, over http too; drop Log out everywhere

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 10:44:17 -07:00
co-authored by Claude Fable 5.1
parent d7ed4d8549
commit d3f2a83249
6 changed files with 28 additions and 28 deletions
+8 -2
View File
@@ -204,5 +204,11 @@ and `every=` values on the sensor API; phone drawer, key bar and picker.
10. ✅ **Terminal tabs reorder** — drag on a desktop, ‹ › on a touch screen, remembered per
machine in the browser — and **Alt+Shift+←/→** switches tabs, **Alt+Shift+T** opens one,
with the keyboard left in the terminal each time.
11. ✅ **"Log out everywhere"** in Settings ends every session on every device (tokens are
not sessions and keep working).
11. ➖ **"Log out everywhere"** — built, then removed the same day at Mitchell's word: no need
for it on a single-user console.
12. ✅ **Real clipboard shortcuts in the terminal**, from a physical keyboard: **Ctrl+C copies
when text is selected** (and is still ^C when nothing is — the selection clears after a
copy, so a second Ctrl+C interrupts), **Ctrl+V pastes**, Ctrl+Shift+C / Ctrl+Shift+V do
the same unconditionally, Ctrl+Insert / Shift+Insert too. It rides the browser's own
copy and paste events, which xterm already answers, so it works over plain http on the
LAN where `navigator.clipboard` does not exist; bracketed paste is honoured.
-10
View File
@@ -107,7 +107,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
m.HandleFunc("POST /api/logout", s.auth(s.logout))
m.HandleFunc("POST /api/logout/all", s.auth(s.logoutAll))
m.HandleFunc("GET /api/me", s.auth(s.me))
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
@@ -709,15 +708,6 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// logoutAll ends every session, this one included: a lost phone, a borrowed laptop.
// Tokens are not sessions and are untouched; revoke those by name.
func (s *Server) logoutAll(w http.ResponseWriter, r *http.Request) {
n := s.sessions.DeleteAll()
log.Printf("all %d session(s) ended (from %s)", n, clientIP(r))
s.setCookie(w, r, "", -1)
jsonOK(w, 200, map[string]int{"ended": n})
}
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
// reverse proxy set it, else the socket peer.
func clientKey(r *http.Request) string {
-9
View File
@@ -146,15 +146,6 @@ func (s *Sessions) Valid(tok string) bool {
return ok
}
// DeleteAll ends every session on every device and says how many there were.
func (s *Sessions) DeleteAll() (n int) {
_ = s.st.Update(func(state *store.State) {
n = len(state.Sessions)
state.Sessions = map[string]time.Time{}
})
return n
}
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
-6
View File
@@ -124,12 +124,6 @@ export default function Settings({ me }: { me: Me }) {
</div>
</section>
<div className="dash-bar"><h2 className="section">Sessions</h2></div>
<section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>Signs out every browser and phone that is logged in to this Ham Lab, this one included. Access tokens are not sessions and keep working.</div>
<Btn className="btn danger" confirm="Log out everywhere? Every device, including this one, will need the password again." onClick={async () => { try { await api.logoutAll() } finally { window.dispatchEvent(new Event('hamlab:unauthenticated')) } }}>Log out everywhere</Btn>
</section>
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
<div className="dash-bar"><h2 className="section">About</h2></div>
+20
View File
@@ -84,6 +84,26 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
}
connect()
// Ctrl+C / Ctrl+V as on any desktop. Returning false tells xterm to leave the key alone,
// and *not* preventing the default lets the browser fire its own copy / paste event —
// which xterm answers with its selection, or pastes (bracketed, if the shell asked). That
// path needs no navigator.clipboard, so it works over plain http on the LAN as well.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
const k = e.key.toLowerCase(), ctrl = e.ctrlKey && !e.altKey && !e.metaKey
const copy = (ctrl && k === 'c' && (e.shiftKey || term.hasSelection())) || (ctrl && k === 'insert')
const paste = (ctrl && k === 'v') || (e.shiftKey && !ctrl && k === 'insert')
if (copy) {
if (!term.hasSelection()) { e.preventDefault(); return false } // Ctrl+Shift+C with nothing selected: not devtools, not ^C
if (e.shiftKey) { e.preventDefault(); document.execCommand('copy') } // Chrome keeps Ctrl+Shift+C for itself; copy by hand
if (navigator.clipboard) navigator.clipboard.writeText(term.getSelection()).catch(() => {})
window.setTimeout(() => term.clearSelection(), 50) // so the next Ctrl+C is an interrupt again
return false
}
if (paste) return false
return true
})
term.onData((d) => {
if (ctrlArmed && d.length === 1) {
ctrlArmed = false
-1
View File
@@ -182,7 +182,6 @@ export const api = {
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
logoutAll: () => req<{ ended: number }>('POST', '/api/logout/all', undefined, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),