feat: presença por sessão, eventos SSE em tempo real e autosave

- presence.go: hub SSE + registro de presença (TTL 30s por heartbeat)
- GET /api/events (EventSource, auth cookie/Bearer) transmite saved/presence;
  POST /api/presence publica {session, notes[]}; create/update/restore/delete
  publicam savedEvent com a session de origem (eco ignorado pelo autor)
- PUT/POST/restore aceitam campo opcional session
- UI: id de sessão em sessionStorage; selo 👥 na aba, na lista de notas e no
  topo do editor quando outra sessão tem a mesma nota aberta; aba LIMPA
  recarrega sozinha ao receber saved; aba SUJA abre o diálogo de conflito
  automaticamente (conflictPending evita diálogos empilhados)
- Autosave: debounce de 1,5s em qualquer alteração (rascunho vira nota),
  mensagem de revisão 'autosave', status 'salvando…', toggle nas Preferências
- Testes: SSE hello/presence/saved + presença 400/401 via httptest
This commit is contained in:
lgcosta committed 2026-11-04 13:35:33 -03:00
1 parent 3b91c1a509
commit a55591d208
9 files changed
+543 -15

No files matched your search

+14
View File
@@ -919,3 +919,17 @@ html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450;
}
#wysiwyg:empty::before { content: 'Comece a escrever…'; color: var(--muted); }
#wysiwyg [contenteditable="true"]:focus { outline: none; }
/* ------------------------------------------------- presença e autosave */
.presence-badge {
align-self: center;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--warn);
white-space: nowrap;
flex: none;
}
.tab .tab-pres { color: var(--warn); font-size: 10px; flex: none; }
#st-saved.pending { color: var(--accent); font-weight: 600; }
+2
View File
@@ -34,6 +34,7 @@
<label class="row"><input type="checkbox" id="pf-colmark"> Marcar coluna
<input type="number" id="pf-col" min="20" max="320" step="1" class="num"> (padrão 80)</label>
<label class="row"><input type="checkbox" id="pf-hl"> Colorir sintaxe (Markdown e HTML)</label>
<label class="row"><input type="checkbox" id="pf-autosave"> Autosave — salva automaticamente ~1,5 s após parar de digitar</label>
<label class="row">Tecla Tab
<select id="pf-tabmode" class="compact">
<option value="spaces">insere espaços</option>
@@ -222,6 +223,7 @@
</div>
<div id="note-meta">
<input id="note-title" type="text" placeholder="Título da nota" maxlength="200" autocomplete="off">
<span id="presence-badge" class="presence-badge hidden" title="Outras sessões com esta nota aberta"></span>
<button type="button" class="btn" id="btn-cats" title="Atribuir categorias">🗀 Sem categoria</button>
<div id="cat-picker" class="cat-picker hidden"></div>
</div>
+154 -13
View File
@@ -17,6 +17,7 @@
notesList: $('#notes-list'), notesCount: $('#notes-count'),
tabs: $('#tabs'), btnTabNew: $('#btn-tab-new'),
noteTitle: $('#note-title'), btnCats: $('#btn-cats'), catPicker: $('#cat-picker'),
presenceBadge: $('#presence-badge'),
editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'),
gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'),
preview: $('#preview'), previewFrame: $('#preview-frame'), wysiwyg: $('#wysiwyg'),
@@ -33,6 +34,16 @@
const EXPAND_KEY = 'notas_expanded';
const DRAFT_MAX = 200 * 1024;
// id de sessão por aba do navegador (sessionStorage): presença + eventos
const SID = (() => {
let v = sessionStorage.getItem('notas_sid');
if (!v) {
v = (crypto.randomUUID ? crypto.randomUUID() : 's' + Date.now() + '-' + Math.random().toString(36).slice(2));
sessionStorage.setItem('notas_sid', v);
}
return v;
})();
const state = {
tabs: [], // abas abertas (nota ou rascunho)
activeKey: null,
@@ -42,6 +53,8 @@
expanded: new Set(JSON.parse(localStorage.getItem(EXPAND_KEY) || '[]')),
selectedRev: null,
ctxCat: null,
presence: {}, // noteID → [session,...] (exclui a própria sessão)
booted: false,
};
let tabSeq = 0;
@@ -183,10 +196,91 @@
}
});
/* --------------------------------------------- presença + SSE (multi-sessão) */
const openNoteIds = () => state.tabs.filter((t) => t.id).map((t) => t.id);
function sendPresence() {
fetch('/api/presence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: SID, notes: openNoteIds() }),
}).catch(() => { /* offline: heartbeat tenta de novo */ });
}
let es = null;
function connectEvents() {
if (es) es.close();
es = new EventSource('/api/events?session=' + encodeURIComponent(SID));
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
handleServerEvent(ev);
};
// onerror: EventSource reconecta sozinho; o checkFreshness no focus é a rede de segurança
}
function handleServerEvent(ev) {
if (ev.type === 'hello' || ev.type === 'presence') { applyPresence(ev.sessions || []); return; }
if (ev.type !== 'saved') return;
const t = state.tabs.find((x) => x.id === ev.note_id);
if (!t || ev.session === SID) return; // nota não aberta aqui, ou fui eu que salvei
if (ev.deleted) {
if (!t.dirty) {
closeTab(t.key, true);
toast('“' + (ev.title || 'Sem título') + '” foi excluída por outra sessão.');
} else {
toast('Cuidado: outra sessão excluiu esta nota; o texto local não salvo não poderá ser gravado.');
}
return;
}
if (t.hash === ev.hash) return;
t.remoteHash = ev.hash;
if (!t.dirty) {
// aba limpa: acompanha o servidor em tempo real
api('GET', '/api/notes/' + t.id)
.then((d) => {
applyNoteToTab(t, d);
if (t.key === state.activeKey) {
toast('“' + (d.title || 'Sem título') + '” atualizada por outra sessão — recarregada');
}
})
.catch(() => {});
} else if (t.key === state.activeKey) {
resolveConflict(t, null);
} else {
renderTabs();
}
}
function applyPresence(sessions) {
const map = {};
for (const s of sessions) {
if (s.session === SID) continue;
for (const n of s.notes || []) (map[n] = map[n] || []).push(s.session);
}
state.presence = map;
renderTabs();
renderNotesList();
updatePresenceBadge();
}
function updatePresenceBadge() {
const t = activeTab();
const others = t && t.id ? (state.presence[t.id] || []) : [];
E.presenceBadge.classList.toggle('hidden', !others.length);
if (others.length) {
E.presenceBadge.textContent = others.length === 1
? '👥 outra sessão aberta aqui'
: `👥 ${others.length} outras sessões abertas aqui`;
}
}
/* -------------------------------------------------------------- abas */
function addTab(tab, activate = true) {
state.tabs.push(tab);
sendPresence();
if (activate) activateTab(tab.key);
else renderTabs();
}
@@ -223,11 +317,12 @@
closeDrawer();
}
async function closeTab(key) {
async function closeTab(key, force) {
const idx = state.tabs.findIndex((t) => t.key === key);
if (idx < 0) return;
if (!(await confirmDiscardTab(state.tabs[idx]))) return;
if (!force && !(await confirmDiscardTab(state.tabs[idx]))) return;
state.tabs.splice(idx, 1);
sendPresence();
if (!state.tabs.length) { addTab(newTab()); return; }
if (state.activeKey === key) {
const next = state.tabs[Math.min(idx, state.tabs.length - 1)];
@@ -256,10 +351,17 @@
const label = document.createElement('span');
label.className = 'tab-label';
label.textContent = (t.title || 'Sem título') + EXT[t.format];
el.appendChild(label);
if (t.id && state.presence[t.id] && state.presence[t.id].length) {
const pres = document.createElement('span');
pres.className = 'tab-pres';
pres.textContent = '👥';
pres.title = 'outra(s) sessão(ões) com esta nota aberta';
el.appendChild(pres);
}
const close = document.createElement('span');
close.className = 'tab-close';
close.textContent = '✕';
el.appendChild(label);
el.appendChild(close);
el.addEventListener('click', (e) => {
if (e.target === close) { e.stopPropagation(); closeTab(t.key); }
@@ -381,6 +483,7 @@
const bits = [relTime(n.updated_at)];
if (n.categories && n.categories.length) bits.push(n.categories.map(catName).join(' · '));
if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' '));
if (state.presence[n.id] && state.presence[n.id].length) bits.push('👥 aberta por outra sessão');
sub.textContent = bits.join(' · ');
li.appendChild(title);
li.appendChild(sub);
@@ -648,6 +751,7 @@
updateCatButton();
updateToolbar();
updateStatus();
updatePresenceBadge();
renderEditorVisual();
if (modeState.mode === 'wysiwyg') enterWysiwyg();
else refreshPreviewIfOpen();
@@ -665,6 +769,17 @@
if (t) t.dirty = true;
updateStatus();
persistTabs();
scheduleAutosave();
}
let autosaveTimer = 0;
function scheduleAutosave() {
if (!prefs.autosave) return;
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => {
const t = activeTab();
if (t && t.dirty && !t.conflictPending) save(true);
}, 1500);
}
E.editor.addEventListener('input', () => {
@@ -832,7 +947,7 @@
/* ------------------------------------------------------- preferências */
const PREFS_KEY = 'notas_prefs';
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, autosave: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
const THEMES = ['claro', 'escuro', 'solar-claro', 'solar-escuro'];
const FONT_STACKS = {
mono: '"DejaVu Sans Mono", "Ubuntu Mono", "Cascadia Code", Consolas, monospace',
@@ -1081,6 +1196,7 @@
const PF = {
overlay: $('#prefs-overlay'), form: $('#prefs-form'), gutter: $('#pf-gutter'),
wrap: $('#pf-wrap'), colmark: $('#pf-colmark'), col: $('#pf-col'), hl: $('#pf-hl'),
autosave: $('#pf-autosave'),
tabmode: $('#pf-tabmode'), tabsize: $('#pf-tabsize'),
font: $('#pf-font'), size: $('#pf-size'), theme: $('#pf-theme'),
reset: $('#pf-reset'), cancel: $('#pf-cancel'),
@@ -1093,6 +1209,7 @@
PF.colmark.checked = prefs.colmark;
PF.col.value = prefs.col;
PF.hl.checked = prefs.hl;
PF.autosave.checked = prefs.autosave;
PF.tabmode.value = prefs.tabSpaces ? 'spaces' : 'tab';
PF.tabsize.value = prefs.tabSize;
PF.font.value = prefs.font;
@@ -1106,6 +1223,7 @@
prefs.colmark = PF.colmark.checked;
prefs.col = Math.min(320, Math.max(20, parseInt(PF.col.value, 10) || 80));
prefs.hl = PF.hl.checked;
prefs.autosave = PF.autosave.checked;
prefs.tabSpaces = PF.tabmode.value !== 'tab';
prefs.tabSize = Math.min(16, Math.max(1, parseInt(PF.tabsize.value, 10) || 4));
prefs.font = FONT_STACKS[PF.font.value] ? PF.font.value : 'mono';
@@ -1475,25 +1593,26 @@
/* ------------------------------------------------------------ salvar */
async function save() {
async function save(auto) {
const t = activeTab();
if (!t) return;
if (!t || t.conflictPending) return;
clearTimeout(autosaveTimer);
const body = {
title: currentTitle(),
format: currentFormat(),
categories: t.categories,
content: t.content,
author: E.authorInput.value.trim(),
message: E.revMessage.value.trim(),
message: E.revMessage.value.trim() || (auto ? 'autosave' : ''),
base_hash: t.hash || '',
session: SID,
};
E.stSaved.textContent = 'salvando…';
E.stSaved.className = 'st-item pending';
let data;
try {
if (t.id) {
body.base_hash = t.hash;
data = await api('PUT', '/api/notes/' + t.id, body);
} else {
data = await api('POST', '/api/notes', body);
}
if (t.id) data = await api('PUT', '/api/notes/' + t.id, body);
else data = await api('POST', '/api/notes', body);
} catch (err) {
if (err.status === 409 && err.data && err.data.server_hash) {
t.remoteHash = err.data.server_hash;
@@ -1501,10 +1620,13 @@
return;
}
toast('Falha ao salvar: ' + err.message);
updateStatus();
return;
}
const wasDraft = !t.id;
applyNoteToTab(t, data);
E.revMessage.value = '';
if (wasDraft) sendPresence(); // rascunho virou nota: presença passa a reportá-la
loadNotes().catch(() => {});
loadAggregates().catch(() => {});
if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {});
@@ -1513,6 +1635,8 @@
// Conflito: o servidor mudou desde a base da aba. Recarregar ou sobrescrever.
async function resolveConflict(t, body) {
if (t.conflictPending) return;
t.conflictPending = true;
if (!body) {
body = {
title: t.title || currentTitle(),
@@ -1521,6 +1645,7 @@
content: t.content,
author: E.authorInput.value.trim(),
message: E.revMessage.value.trim(),
session: SID,
};
}
const reload = await modal({
@@ -1546,6 +1671,8 @@
}
} catch (err) {
toast('Conflito: ' + err.message);
} finally {
t.conflictPending = false;
}
}
@@ -1560,6 +1687,7 @@
toast('Nota excluída.');
const idx = state.tabs.indexOf(t);
state.tabs.splice(idx, 1);
sendPresence();
if (!state.tabs.length) addTab(newTab());
else activateTab(state.tabs[Math.min(idx, state.tabs.length - 1)].key);
loadNotes().catch(() => {});
@@ -1798,8 +1926,21 @@
await restoreTabs();
updateStatus();
state.booted = true;
connectEvents();
sendPresence();
setInterval(sendPresence, 10000); // heartbeat de presença
}
window.addEventListener('pagehide', () => {
// remove a sessão do registro assim que a aba fecha (keepalive sobrevive ao unload)
fetch('/api/presence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: SID, notes: [] }),
keepalive: true,
}).catch(() => {});
});
window.addEventListener('unhandledrejection', (e) => {
if (e.reason instanceof ApiError && e.reason.status === 401) return; // login já exibido
console.error(e.reason);