diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts
index 64a2e966c9..41a64d600c 100644
--- a/web_src/js/features/repo-projects.ts
+++ b/web_src/js/features/repo-projects.ts
@@ -1,12 +1,37 @@
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
-import {POST, request} from '../modules/fetch.ts';
+import {GET, POST, request} from '../modules/fetch.ts';
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {localUserSettings} from '../modules/user-settings.ts';
+import {UserEventsSharedWorker} from '../modules/worker.ts';
+
+const SESSION_TAG_HEADER = 'X-Session-Tag';
+
+// sessionTag is generated once per page load. It is attached as the
+// X-Session-Tag header on every mutation request and compared against
+// incoming SSE payloads so the originating tab can suppress its own
+// echo (the source-of-truth DOM update already happened locally).
+let sessionTag = '';
+
+function ensureSessionTag(): string {
+ if (sessionTag) return sessionTag;
+ if (globalThis.crypto?.randomUUID) {
+ sessionTag = globalThis.crypto.randomUUID();
+ } else {
+ sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
+ }
+ return sessionTag;
+}
+
+function withSessionTag(headers: HeadersInit | undefined): Headers {
+ const h = new Headers(headers ?? {});
+ h.set(SESSION_TAG_HEADER, ensureSessionTag());
+ return h;
+}
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement!;
@@ -29,6 +54,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise {
try {
await POST(mainBoard.getAttribute('data-url')!, {
data: columnSorting,
+ headers: withSessionTag(undefined),
});
} catch (error) {
console.error(error);
@@ -113,7 +140,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
try {
elForm.classList.add('is-loading');
- await request(formLink, {method: formMethod, data: formData});
+ await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)});
if (!columnId) {
window.location.reload(); // newly added column, need to reload the page
return;
@@ -173,9 +200,263 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
}
}
+// SSE handlers ---------------------------------------------------------------
+
+type EventPayloadBase = {session_tag?: string};
+
+type CardMovedPayload = EventPayloadBase & {
+ project_id: number;
+ issue_id: number;
+ from_column_id: number;
+ to_column_id: number;
+ sorting: number;
+};
+
+type CardLinkedPayload = EventPayloadBase & {
+ project_id: number;
+ issue_id: number;
+ column_id: number;
+};
+
+type CardUnlinkedPayload = EventPayloadBase & {
+ project_id: number;
+ issue_id: number;
+};
+
+type ColumnUpdatedPayload = {
+ project_id: number;
+ column_id: number;
+ title: string;
+ color: string;
+ sorting: number;
+};
+
+type ColumnDeletedPayload = {
+ project_id: number;
+ column_id: number;
+};
+
+type ColumnReorderedPayload = {
+ project_id: number;
+ columns: Array<{column_id: number; sorting: number}>;
+};
+
+type ProjectUpdatedPayload = {
+ project_id: number;
+ title: string;
+ description: string;
+ card_type: string;
+ is_closed: boolean;
+};
+
+// columnIssuesURL builds the appropriate "list issues for column" API
+// path for the current page scope. Server-side these endpoints all
+// return the same JSON shape; the frontend just needs the right base.
+function columnIssuesURL(board: HTMLElement, columnID: number): string | null {
+ const projectID = board.getAttribute('data-project-id');
+ const scope = board.getAttribute('data-project-scope');
+ const owner = board.getAttribute('data-project-owner');
+ const repo = board.getAttribute('data-project-repo');
+ const {appSubUrl} = window.config;
+ if (!projectID || !owner) return null;
+ if (scope === 'repo' && repo) {
+ return `${appSubUrl}/api/v1/repos/${owner}/${repo}/projects/${projectID}/columns/${columnID}/issues`;
+ }
+ if (scope === 'org') {
+ return `${appSubUrl}/api/v1/orgs/${owner}/projects/${projectID}/columns/${columnID}/issues`;
+ }
+ return `${appSubUrl}/api/v1/users/${owner}/projects/${projectID}/columns/${columnID}/issues`;
+}
+
+function updateColumnCount(columnEl: HTMLElement): void {
+ const cards = columnEl.querySelectorAll('.issue-card').length;
+ const badge = columnEl.querySelector('.project-column-issue-count');
+ if (badge) badge.textContent = String(cards);
+}
+
+function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
+ if (payload.session_tag && payload.session_tag === sessionTag) return;
+ const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`);
+ if (!card) {
+ // Card is not currently rendered (filtered out, or new since
+ // page load). A targeted column re-fetch is the safe fallback.
+ refetchColumn(board, payload.to_column_id);
+ return;
+ }
+ const target = board.querySelector(`#board_${payload.to_column_id}`);
+ if (!target) return;
+ const fromColumn = card.parentElement;
+ target.appendChild(card);
+ if (fromColumn instanceof HTMLElement) {
+ const fromColumnEl = fromColumn.closest('.project-column');
+ if (fromColumnEl) updateColumnCount(fromColumnEl);
+ }
+ const toColumnEl = target.closest('.project-column');
+ if (toColumnEl) updateColumnCount(toColumnEl);
+}
+
+async function refetchColumn(board: HTMLElement, columnID: number): Promise {
+ const url = columnIssuesURL(board, columnID);
+ if (!url) return;
+ try {
+ const resp = await GET(url);
+ if (!resp.ok) return;
+ // Response shape: list of API issues; we don't have a templated
+ // card render available client-side, so we just refresh the
+ // column count badge here. The DOM-level reorder/insert is
+ // delivered by the matching CardMoved/CardUnlinked events.
+ const issues = await resp.json();
+ const target = board.querySelector(`#board_${columnID}`);
+ if (!target) return;
+ const colEl = target.closest('.project-column');
+ if (colEl) {
+ const badge = colEl.querySelector('.project-column-issue-count');
+ if (badge) badge.textContent = String(Array.isArray(issues) ? issues.length : 0);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void {
+ if (payload.session_tag && payload.session_tag === sessionTag) return;
+ refetchColumn(board, payload.column_id); // no await
+}
+
+function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void {
+ if (payload.session_tag && payload.session_tag === sessionTag) return;
+ const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`);
+ if (!card) return;
+ const colEl = card.closest('.project-column');
+ card.remove();
+ if (colEl) updateColumnCount(colEl);
+}
+
+function handleColumnCreated(): void {
+ // Rare event; reload is cheap and avoids client-side template duplication.
+ window.location.reload();
+}
+
+function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): void {
+ const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`);
+ if (!colEl) return;
+ const titleEl = colEl.querySelector('.project-column-title-text');
+ if (titleEl) titleEl.textContent = payload.title;
+ if (payload.color) {
+ const textColor = contrastColor(payload.color);
+ colEl.style.setProperty('background', payload.color, 'important');
+ colEl.style.setProperty('color', textColor, 'important');
+ queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
+ } else {
+ colEl.style.removeProperty('background');
+ colEl.style.removeProperty('color');
+ queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
+ }
+}
+
+function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void {
+ const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`);
+ if (colEl) colEl.remove();
+}
+
+function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void {
+ // Sort the columns array by the new sorting value, then re-attach
+ // each column element in that order. appendChild on an existing
+ // node moves it rather than cloning, so the result is an in-place
+ // reorder.
+ const order = [...payload.columns].sort((a, b) => a.sorting - b.sorting);
+ for (const entry of order) {
+ const el = board.querySelector(`.project-column[data-id="${entry.column_id}"]`);
+ if (el) board.appendChild(el);
+ }
+}
+
+function handleProjectUpdated(payload: ProjectUpdatedPayload): void {
+ const header = document.querySelector('.project-header h2');
+ if (header) header.textContent = payload.title;
+ const desc = document.querySelector('.project-description .render-content');
+ if (desc) desc.textContent = payload.description;
+}
+
+function handleProjectDeleted(): void {
+ // Best-effort: navigate up one path segment from the current URL.
+ // The board lives at .../projects/{id}; the listing page is the
+ // parent. Falling back to the homepage on any URL we don't
+ // recognise is acceptable since this is a destructive event.
+ const parts = window.location.pathname.split('/');
+ if (parts.length > 1) {
+ parts.pop();
+ window.location.href = parts.join('/') || '/';
+ } else {
+ window.location.href = '/';
+ }
+}
+
+// dispatchProjectEvent picks the right handler for an SSE payload.
+// The backend uses one event name per project but disambiguates event
+// types by payload shape; we sniff discriminating fields here. Order
+// matters: the more specific shapes are checked first.
+function dispatchProjectEvent(board: HTMLElement, payload: any): void {
+ if ('from_column_id' in payload && 'to_column_id' in payload) {
+ handleCardMoved(board, payload as CardMovedPayload);
+ } else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
+ handleCardLinked(board, payload as CardLinkedPayload);
+ } else if ('issue_id' in payload && !('column_id' in payload)) {
+ handleCardUnlinked(board, payload as CardUnlinkedPayload);
+ } else if ('columns' in payload) {
+ handleColumnReordered(board, payload as ColumnReorderedPayload);
+ } else if ('column_id' in payload && 'title' in payload) {
+ handleColumnUpdated(board, payload as ColumnUpdatedPayload);
+ if ('is_default' in payload) handleColumnCreated();
+ } else if ('column_id' in payload) {
+ handleColumnDeleted(board, payload as ColumnDeletedPayload);
+ } else if ('title' in payload && 'card_type' in payload) {
+ handleProjectUpdated(payload as ProjectUpdatedPayload);
+ } else if ('project_id' in payload && Object.keys(payload).length <= 2) {
+ handleProjectDeleted();
+ }
+}
+
+function initRepoProjectSSE(elProjectsView: HTMLElement): void {
+ const board = elProjectsView.querySelector('#project-board');
+ if (!board) return;
+ const projectID = board.getAttribute('data-project-id');
+ if (!projectID) return;
+ if (!window.EventSource || !window.SharedWorker) return;
+
+ ensureSessionTag();
+
+ const eventName = `project-board.${projectID}`;
+ let worker: UserEventsSharedWorker;
+ try {
+ worker = new UserEventsSharedWorker('project-board-worker');
+ } catch (error) {
+ console.error('project board SSE: failed to start worker', error);
+ return;
+ }
+
+ worker.addMessageEventListener((event: MessageEvent) => {
+ if (!event.data || event.data.type !== eventName) return;
+ let payload: any;
+ try {
+ payload = JSON.parse(event.data.data);
+ } catch (error) {
+ console.error('project board SSE: malformed payload', error, event.data);
+ return;
+ }
+ dispatchProjectEvent(board, payload);
+ });
+ worker.startPort();
+
+ // Subscribe to the per-project event name on top of the worker's
+ // default listener set so the SharedWorker forwards us the events.
+ worker.sharedWorker.port.postMessage({type: 'listen', eventType: eventName});
+}
+
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
initRepoProjectToggleFullScreen(elProjectsView);
+ initRepoProjectSSE(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;