feat: collaboration temps reel - edition simultanee (#62)
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

WebSocket /ws/collab/{vault}/{path} (rooms par fichier), relais Yjs/CRDT, awareness (curseurs colores + presence), persistance serveur debounce 2s, auth WS + check_vault_access, reconnexion automatique. Frontend frontend/js/collab.js, backend backend/collab.py. Tests: 17 backend (5 clients simultanes) + 10 frontend. Docs: CHANGELOG, ROADMAP, fiche features/collaboration.md, README FR/EN.
This commit is contained in:
2026-09-11 23:27:22 -04:00
parent 3f7b7847a5
commit 063b02e996
19 changed files with 1741 additions and 33 deletions
+12 -2
View File
@@ -93,7 +93,8 @@
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/@codemirror/[email protected]"
"@codemirror/state": "https://esm.sh/@codemirror/[email protected]",
"yjs": "https://esm.sh/[email protected]"
}
}
</script>
@@ -111,8 +112,11 @@
rectangularSelection,
crosshairCursor,
highlightActiveLine,
Decoration,
ViewPlugin,
WidgetType,
} from "https://esm.sh/@codemirror/[email protected]?external=@codemirror/state";
import { EditorState } from "@codemirror/state";
import { EditorState, StateEffect } from "@codemirror/state";
import {
defaultHighlightStyle,
syntaxHighlighting,
@@ -196,6 +200,10 @@
rust,
oneDark,
keymap,
Decoration,
ViewPlugin,
WidgetType,
StateEffect,
};
</script>
</head>
@@ -1368,6 +1376,8 @@
title="Renommer le fichier"
/>
<div class="editor-spacer"></div>
<div class="collab-presence" id="collab-presence" style="display: none"
title="Collaboration"></div>
<span class="editor-save-dot ok" id="editor-save-dot"
><span class="dot"></span
><span id="editor-save-label">Saved</span></span
+596
View File
@@ -0,0 +1,596 @@
/* ObsiGate — Collaboration temps réel (ROADMAP #62).
*
* Édition simultanée d'un même document markdown :
* - Yjs (CRDT) côté client : deux personnes qui tapent au même endroit
* fusionnent sans conflit ni perte ;
* - WebSocket `/ws/collab/{vault}/{path}` : une room par fichier, le serveur
* relaie les mises à jour et l'awareness, puis persiste le document
* (debounce 2 s) ;
* - awareness : curseurs distants colorés + indicateur de présence.
*
* Le module n'a aucune dépendance statique externe : Yjs est chargé
* dynamiquement depuis l'import map (`yjs`), CodeMirror via `window.CodeMirror`.
* Cela permet de tester les fonctions pures en Node/JSDOM.
*/
import { state } from './state.js';
import { t } from './i18n.js';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const TEXT_SYNC_MS = 300;
const PING_MS = 25000;
// ── Fonctions pures (testables) ────────────────────────────────────────────
/**
* Calcule un diff minimal à remplacement unique entre deux textes.
* Retourne `null` si les textes sont identiques, sinon `{from, to, insert}`.
*/
export function computeTextDiff(oldText, newText) {
if (oldText === newText) return null;
const oldLen = oldText.length;
const newLen = newText.length;
let start = 0;
const maxPrefix = Math.min(oldLen, newLen);
while (start < maxPrefix && oldText.charCodeAt(start) === newText.charCodeAt(start)) {
start++;
}
let end = 0;
const maxSuffix = Math.min(oldLen - start, newLen - start);
while (
end < maxSuffix &&
oldText.charCodeAt(oldLen - 1 - end) === newText.charCodeAt(newLen - 1 - end)
) {
end++;
}
return { from: start, to: oldLen - end, insert: newText.slice(start, newLen - end) };
}
/** Initiale affichée dans l'avatar d'un pair (1er caractère, majuscule). */
export function initialOf(name) {
const value = String(name || '').trim();
if (!value) return '?';
return value.charAt(0).toUpperCase();
}
/** Construit l'URL WebSocket de collaboration (segments de chemin encodés). */
export function buildCollabUrl(loc, vault, path) {
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const segments = String(path || '')
.split('/')
.filter((s) => s.length > 0)
.map(encodeURIComponent)
.join('/');
return `${proto}//${loc.host}/ws/collab/${encodeURIComponent(vault)}/${segments}`;
}
/** Nombre de pairs affiché dans l'indicateur de présence. */
export function formatPeerCount(count) {
return String(Math.max(0, Number(count) || 0));
}
// ── Encodage base64 binaire ────────────────────────────────────────────────
function toBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
function fromBase64(encoded) {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function currentUserName() {
const el = document.querySelector('.user-display-name');
const name = el && el.textContent ? el.textContent.trim() : '';
return name || 'Utilisateur';
}
// ── Session de collaboration ───────────────────────────────────────────────
let _session = null;
export function isCollabActive() {
return !!_session;
}
export function getCollabSession() {
return _session;
}
/**
* Démarre une session collaborative sur l'éditeur *view*.
* @returns {Promise<object|null>} la session, ou null si Yjs est indisponible.
*/
export async function startCollab({ view, vault, path }) {
await stopCollab();
if (!view || !vault || !path) return null;
let Y;
try {
Y = await import('yjs');
} catch (err) {
console.warn('ObsiGate collab: Yjs indisponible, édition locale seulement', err);
return null;
}
const ydoc = new Y.Doc();
const ytext = ydoc.getText('content');
const session = {
Y,
ydoc,
ytext,
view,
vault,
path,
userName: currentUserName(),
color: null,
connId: null,
ws: null,
status: 'connecting',
stopped: false,
reconnectAttempt: 0,
reconnectTimer: null,
pingTimer: null,
textTimer: null,
applying: false,
peers: new Map(),
notifyAwareness: null,
observer: null,
bindingExt: null,
};
_session = session;
bindYText(session);
renderPresence(session);
connect(session);
return session;
}
/** Arrête la session courante et nettoie tous les listeners. */
export async function stopCollab() {
const session = _session;
if (!session) return;
session.stopped = true;
_session = null;
if (session.reconnectTimer) clearTimeout(session.reconnectTimer);
if (session.pingTimer) clearInterval(session.pingTimer);
if (session.textTimer) clearTimeout(session.textTimer);
try {
if (session.ws && session.ws.readyState === 1) {
// Flush final du texte pour que le serveur persiste le dernier état.
sendText(session);
session.ws.close(1000, 'editor closed');
} else if (session.ws) {
session.ws.close();
}
} catch (e) {
/* ignore */
}
if (session.observer) {
try {
session.ytext.unobserve(session.observer);
} catch (e) {
/* ignore */
}
}
try {
session.ydoc.destroy();
} catch (e) {
/* ignore */
}
removePresence();
}
// ── Liaison Y.Text ↔ CodeMirror ────────────────────────────────────────────
function bindYText(session) {
const CM = window.CodeMirror;
if (!CM) return;
const { EditorView } = CM;
const listener = EditorView.updateListener.of((update) => {
if (session.stopped) return;
if (update.docChanged && !session.applying) {
applyEditorChangesToY(session);
}
if (update.selectionSet || update.docChanged) {
sendAwareness(session);
}
});
const cursorExt = remoteCursors(session);
session.notifyAwareness = cursorExt.notify;
session.bindingExt = [listener, cursorExt.extension];
session.view.dispatch({ effects: CM.StateEffect.appendConfig.of(session.bindingExt) });
session.observer = (event, transaction) => {
if (transaction && transaction.origin === 'local') return;
syncFromY(session);
};
session.ytext.observe(session.observer);
syncFromY(session);
}
function applyEditorChangesToY(session) {
const { view, ydoc, ytext } = session;
const diff = computeTextDiff(ytext.toString(), view.state.doc.toString());
if (!diff) return;
ydoc.transact(() => {
if (diff.to > diff.from) ytext.delete(diff.from, diff.to - diff.from);
if (diff.insert.length) ytext.insert(diff.from, diff.insert);
}, 'local');
}
function syncFromY(session) {
if (session.stopped) return;
const { view, ytext } = session;
const next = ytext.toString();
const current = view.state.doc.toString();
const diff = computeTextDiff(current, next);
if (!diff) return;
session.applying = true;
try {
view.dispatch({ changes: diff });
} finally {
session.applying = false;
}
scheduleTextSync(session);
}
// ── Awareness / présence ───────────────────────────────────────────────────
function sendAwareness(session) {
const { ws } = session;
if (!ws || ws.readyState !== 1 || !session.color) return;
let cursor = null;
try {
const sel = session.view.state.selection.main;
cursor = { anchor: sel.anchor, head: sel.head };
} catch (e) {
cursor = null;
}
try {
ws.send(
JSON.stringify({
type: 'awareness',
clientId: session.ydoc.clientID,
state: { user: { name: session.userName, color: session.color }, cursor },
}),
);
} catch (e) {
/* ignore */
}
}
function scheduleTextSync(session) {
if (session.textTimer) clearTimeout(session.textTimer);
session.textTimer = setTimeout(() => sendText(session), TEXT_SYNC_MS);
}
function sendText(session) {
const { ws } = session;
if (!ws || ws.readyState !== 1) return;
try {
ws.send(JSON.stringify({ type: 'text', text: session.ytext.toString() }));
} catch (e) {
/* ignore */
}
}
// ── Extension CodeMirror : curseurs distants ───────────────────────────────
function remoteCursors(session) {
const CM = window.CodeMirror;
if (!CM) return { extension: [], notify: () => {} };
const { Decoration, ViewPlugin, WidgetType, StateEffect } = CM;
const awarenessEffect = StateEffect.define();
class CursorWidget extends WidgetType {
constructor(name, color) {
super();
this.name = name;
this.color = color;
}
eq(other) {
return other.name === this.name && other.color === this.color;
}
toDOM() {
const caret = document.createElement('span');
caret.className = 'cm-remote-cursor';
caret.style.borderLeftColor = this.color;
const label = document.createElement('span');
label.className = 'cm-remote-cursor-label';
label.style.backgroundColor = this.color;
label.textContent = this.name;
caret.appendChild(label);
return caret;
}
ignoreEvent() {
return true;
}
}
const plugin = ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this._build(view);
}
update(update) {
if (
update.docChanged ||
update.transactions.some((tr) =>
tr.effects.some((e) => e.is(awarenessEffect)),
)
) {
this.decorations = this._build(update.view);
}
}
_build(view) {
const ranges = [];
const docLength = view.state.doc.length;
for (const [clientId, peer] of session.peers) {
if (clientId === session.ydoc.clientID) continue;
if (!peer || !peer.cursor) continue;
const pos = Math.max(0, Math.min(peer.cursor.head, docLength));
const name = (peer.user && peer.user.name) || '?';
const color = (peer.user && peer.user.color) || '#4363d8';
ranges.push(Decoration.widget({ widget: new CursorWidget(name, color), side: 1 }).range(pos));
}
return Decoration.set(ranges, true);
}
},
{ decorations: (v) => v.decorations },
);
return {
extension: plugin,
notify: () => {
if (session.stopped) return;
try {
session.view.dispatch({ effects: awarenessEffect.of(null) });
} catch (e) {
/* ignore */
}
},
};
}
// ── WebSocket ──────────────────────────────────────────────────────────────
function connect(session) {
if (session.stopped) return;
session.status = 'connecting';
renderPresence(session);
let ws;
try {
ws = new WebSocket(buildCollabUrl(window.location, session.vault, session.path));
} catch (e) {
scheduleReconnect(session);
return;
}
session.ws = ws;
ws.onopen = () => {
session.reconnectAttempt = 0;
session.pingTimer = setInterval(() => {
if (ws.readyState === 1) {
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch (e) {
/* ignore */
}
}
}, PING_MS);
};
ws.onmessage = (event) => handleMessage(session, event.data);
ws.onclose = () => {
if (session.pingTimer) clearInterval(session.pingTimer);
if (session.ws === ws && !session.stopped) {
session.status = 'disconnected';
renderPresence(session);
scheduleReconnect(session);
}
};
ws.onerror = () => {
/* onclose follows */
};
}
function scheduleReconnect(session) {
if (session.stopped || session.reconnectTimer) return;
const delay = Math.min(RECONNECT_BASE_MS * 2 ** session.reconnectAttempt, RECONNECT_MAX_MS);
session.reconnectAttempt++;
session.reconnectTimer = setTimeout(() => {
session.reconnectTimer = null;
connect(session);
}, delay);
}
function handleMessage(session, raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch (e) {
return;
}
const { Y } = session;
switch (msg.type) {
case 'init': {
session.connId = msg.connId;
session.color = msg.color || session.color;
session.peers.clear();
for (const peer of msg.peers || []) {
if (peer.clientId == null) continue;
session.peers.set(peer.clientId, {
user: { name: peer.displayName, color: peer.color },
cursor: null,
});
}
for (const entry of msg.awareness || []) {
if (entry.clientId != null) session.peers.set(entry.clientId, entry.state);
}
for (const encoded of msg.updates || []) {
try {
Y.applyUpdate(session.ydoc, fromBase64(encoded), 'remote');
} catch (e) {
/* ignore malformed update */
}
}
if (typeof msg.seed === 'string' && session.ytext.length === 0) {
session.ydoc.transact(() => {
session.ytext.insert(0, msg.seed);
}, 'local');
}
session.status = 'connected';
syncFromY(session);
sendFullState(session);
sendAwareness(session);
renderPresence(session);
break;
}
case 'update': {
if (typeof msg.update !== 'string') return;
try {
Y.applyUpdate(session.ydoc, fromBase64(msg.update), 'remote');
} catch (e) {
/* ignore malformed update */
}
break;
}
case 'awareness': {
if (msg.clientId == null) return;
if (msg.state) session.peers.set(msg.clientId, msg.state);
else session.peers.delete(msg.clientId);
if (session.notifyAwareness) session.notifyAwareness();
renderPresence(session);
break;
}
case 'peer_left': {
if (msg.clientId != null) session.peers.delete(msg.clientId);
else if (msg.peer) {
for (const [id, peer] of session.peers) {
if (peer && peer.user && peer.user.name === msg.peer.displayName) {
session.peers.delete(id);
}
}
}
if (session.notifyAwareness) session.notifyAwareness();
renderPresence(session);
break;
}
case 'peer_joined': {
if (msg.peer && msg.peer.clientId != null && !session.peers.has(msg.peer.clientId)) {
session.peers.set(msg.peer.clientId, {
user: { name: msg.peer.displayName, color: msg.peer.color },
cursor: null,
});
}
renderPresence(session);
break;
}
case 'pong':
default:
break;
}
}
function sendFullState(session) {
const { ws, Y, ydoc } = session;
if (!ws || ws.readyState !== 1) return;
try {
ws.send(JSON.stringify({ type: 'sync', update: toBase64(Y.encodeStateAsUpdate(ydoc)) }));
} catch (e) {
/* ignore */
}
}
// ── Indicateur de présence (DOM) ───────────────────────────────────────────
function presenceContainer() {
return document.getElementById('collab-presence');
}
function removePresence() {
const el = presenceContainer();
if (el) {
el.innerHTML = '';
el.style.display = 'none';
}
}
function renderPresence(session) {
const el = presenceContainer();
if (!el) return;
el.innerHTML = '';
el.style.display = 'flex';
el.title = t('collab.presence_title');
const status = document.createElement('span');
status.className = 'collab-status collab-status-' + session.status;
const statusKey =
session.status === 'connected'
? 'collab.status_connected'
: session.status === 'connecting'
? 'collab.status_connecting'
: 'collab.status_disconnected';
status.title = t(statusKey);
status.setAttribute('aria-label', t(statusKey));
el.appendChild(status);
const peers = [];
peers.push({ name: session.userName, color: session.color || '#4363d8', self: true });
for (const [clientId, peer] of session.peers) {
if (clientId === session.ydoc.clientID) continue;
const user = (peer && peer.user) || {};
peers.push({ name: user.name || '?', color: user.color || '#4363d8', self: false });
}
const avatars = document.createElement('div');
avatars.className = 'collab-avatars';
for (const peer of peers.slice(0, 6)) {
const avatar = document.createElement('span');
avatar.className = 'collab-avatar' + (peer.self ? ' collab-avatar-self' : '');
avatar.style.backgroundColor = peer.color;
avatar.title = peer.name;
avatar.textContent = initialOf(peer.name);
avatars.appendChild(avatar);
}
el.appendChild(avatars);
const count = document.createElement('span');
count.className = 'collab-count';
count.textContent = formatPeerCount(peers.length);
count.title = t('collab.peers_title', { count: peers.length });
el.appendChild(count);
}
+9
View File
@@ -3,6 +3,7 @@ import { api } from './auth.js';
import { openFile, showWelcome } from './viewer.js';
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
import { createAIToolbar } from './ai.js';
import { startCollab, stopCollab } from './collab.js';
// ---------------------------------------------------------------------------
// File extension → Lucide icon mapping
@@ -409,6 +410,13 @@ async function openEditor(vaultName, filePath) {
bodyEl.appendChild(state.fallbackEditorEl);
}
// Real-time collaboration (ROADMAP #62) — bind Yjs to the CodeMirror view.
if (state.editorView) {
startCollab({ view: state.editorView, vault: vaultName, path: filePath }).catch((err) => {
console.warn("Collab start failed:", err);
});
}
// Set up AI toolbar (recreate each time editor opens)
var container = document.getElementById('ai-toolbar-container');
if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) {
@@ -480,6 +488,7 @@ function closeEditor() {
// Restore brand text
var brand = modal.querySelector('.editor-brand');
if (brand) brand.textContent = 'ObsiGate';
stopCollab();
if (state.editorView) {
state.editorView.destroy();
state.editorView = null;
+5
View File
@@ -306,6 +306,11 @@
"common.search": "Search",
"common.unknown": "Unknown",
"common.yes": "Yes",
"collab.peers_title": "{count} person(s) connected",
"collab.presence_title": "Real-time collaboration",
"collab.status_connected": "Collaboration active",
"collab.status_connecting": "Connecting to collaboration…",
"collab.status_disconnected": "Collaboration lost — reconnecting…",
"config.about": "About",
"config.about_error": "Loading error",
"config.about_loading": "Loading...",
+5
View File
@@ -306,6 +306,11 @@
"common.search": "Rechercher",
"common.unknown": "Inconnue",
"common.yes": "Oui",
"collab.peers_title": "{count} personne(s) connectée(s)",
"collab.presence_title": "Collaboration temps réel",
"collab.status_connected": "Collaboration active",
"collab.status_connecting": "Connexion à la collaboration…",
"collab.status_disconnected": "Collaboration interrompue — reconnexion…",
"config.about": "À propos",
"config.about_error": "Erreur de chargement",
"config.about_loading": "Chargement...",
+14
View File
@@ -2772,6 +2772,20 @@ select {
.editor-filename-input:hover { border-color: var(--border); }
.editor-filename-input:focus { border-color: var(--ok); background: var(--bg-input); }
.editor-spacer { flex: 1; }
/* Collaboration temps réel (#62) — indicateur de présence */
.collab-presence { display: flex; align-items: center; gap: 6px; margin-right: 10px; }
.collab-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.collab-status-connected { background: var(--ok); }
.collab-status-connecting { background: var(--warn); animation: editor-pulse 1s infinite; }
.collab-status-disconnected { background: var(--err); animation: editor-pulse 0.8s infinite; }
.collab-avatars { display: flex; align-items: center; }
.collab-avatar { width: 20px; height: 20px; border-radius: 50%; color: #fff; font-size: 11px; font-weight: 600; display: flex; align-items: center; justify-content: center; margin-left: -6px; border: 2px solid var(--bg-secondary); }
.collab-avatar:first-child { margin-left: 0; }
.collab-avatar-self { box-shadow: 0 0 0 2px var(--bg-primary); }
.collab-count { font-size: 11px; color: var(--text-muted); min-width: 10px; }
/* Collaboration temps réel (#62) — curseurs distants CodeMirror */
.cm-remote-cursor { display: inline-block; width: 0; border-left: 2px solid; height: 1.2em; vertical-align: text-bottom; position: relative; margin-left: -1px; }
.cm-remote-cursor-label { position: absolute; top: -1.35em; left: -1px; padding: 1px 4px; border-radius: 3px 3px 3px 0; color: #fff; font-size: 10px; font-weight: 600; white-space: nowrap; pointer-events: none; z-index: 5; }
.editor-save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; margin-right: 8px; }
.editor-btn + .editor-btn { margin-left: 6px; }
.editor-save-dot .dot { width: 6px; height: 6px; border-radius: 50%; }
+3 -2
View File
@@ -1,5 +1,5 @@
/* ObsiGate — Service Worker (PWA offline + push)
* Version: 2 (2026-09-11)
* Version: 3 (2026-09-11)
*
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v2';
const SW_VERSION = 'v3';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -38,6 +38,7 @@ const PRECACHE_URLS = [
'/static/js/offline-db.js',
'/static/js/sync.js',
'/static/js/legacy.js',
'/static/js/collab.js',
];
// ── Install: precache the shell, then activate immediately ──────────────────