diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index 03cadfd295..4092a2196a 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -27,7 +27,7 @@ {{end}}
- +
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}} @@ -46,7 +46,7 @@ {{end}} {{end}}
-
{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}
+
{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}
{{if .TotalTrackedTime}}
{{svg "octicon-clock"}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index 90e2a8ae3b..d427a8ffe1 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -15,16 +15,16 @@ {{template "repo/issue/filters" .}} -
+
{{range .Milestones}} -
  • +
  • {{svg "octicon-milestone" 16}} {{.Name}}

    - {{.Completeness}}% + {{.Completeness}}%
    @@ -32,11 +32,11 @@
    {{svg "octicon-issue-opened" 14}} - {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}} + {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
    {{svg "octicon-check" 14}} - {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}} + {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
    {{if .TotalTrackedTime}}
    diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl index d5dd64b1a3..95ea4e01ca 100644 --- a/templates/user/dashboard/milestones.tmpl +++ b/templates/user/dashboard/milestones.tmpl @@ -73,7 +73,7 @@
    {{range .Milestones}} -
  • +
  • @@ -83,7 +83,7 @@ {{.Name}}

    - {{.Completeness}}% + {{.Completeness}}%
    @@ -91,11 +91,11 @@
    {{svg "octicon-issue-opened" 14}} - {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}} + {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
    {{svg "octicon-check" 14}} - {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}} + {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
    {{if .TotalTrackedTime}}
    diff --git a/web_src/js/features/dashboard.ts b/web_src/js/features/dashboard.ts index 71a0df3a64..3c1e29519a 100644 --- a/web_src/js/features/dashboard.ts +++ b/web_src/js/features/dashboard.ts @@ -1,9 +1,16 @@ import {createApp} from 'vue'; import DashboardRepoList from '../components/DashboardRepoList.vue'; +import {initRepoMilestoneListSSE} from './repo-milestone-sse.ts'; export function initDashboardRepoList() { const el = document.querySelector('#dashboard-repo-list'); if (el) { createApp(DashboardRepoList).mount(el); } + // The dashboard milestones page lists milestones across many repos; + // subscribe to live progress for each. subscribeRepos is guarded so + // this is a no-op if repo-legacy already wired it on the same page. + if (document.querySelector('.page-content.dashboard.milestones li.milestone-card[data-repo-id]')) { + initRepoMilestoneListSSE(); + } } diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index db91529535..77e6552650 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -14,6 +14,7 @@ import {initRepoSettings} from './repo-settings.ts'; import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts'; import {initRepoIssueCommentEdit} from './repo-issue-edit.ts'; import {initRepoMilestone} from './repo-milestone.ts'; +import {initRepoMilestoneListSSE, initRepoMilestoneSingleSSE} from './repo-milestone-sse.ts'; import {initRepoNew} from './repo-new.ts'; import {createApp} from 'vue'; import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue'; @@ -50,6 +51,12 @@ export function initRepository() { // Labels initCompLabelEdit('.page-content.repository.labels'); initRepoMilestone(); + if (pageContent.matches('.page-content.repository.milestones')) { + initRepoMilestoneListSSE(); + } + if (pageContent.matches('.page-content.repository.milestone-issue-list')) { + initRepoMilestoneSingleSSE(); + } initRepoNew(); initRepoCloneButtons(); diff --git a/web_src/js/features/repo-milestone-sse.ts b/web_src/js/features/repo-milestone-sse.ts new file mode 100644 index 0000000000..10e4379495 --- /dev/null +++ b/web_src/js/features/repo-milestone-sse.ts @@ -0,0 +1,173 @@ +import {UserEventsSharedWorker} from '../modules/worker.ts'; + +// sessionTag is generated once per page load. The mutation requests on +// milestone pages (close/open/delete/edit) flow through the existing +// fetch helpers which attach the X-Session-Tag header; the backend +// echoes it back inside SSE payloads so the originating tab can suppress +// its own echo. We only need the read side here: skip any event whose +// session_tag matches ours. +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; +} + +type MilestoneProgressPayload = { + repo_id: number; + milestone_id: number; + open_issues: number; + closed_issues: number; + completeness: number; + session_tag?: string; +}; + +type MilestoneDeletedPayload = { + repo_id: number; + milestone_id: number; +}; + +function isProgressPayload(p: any): p is MilestoneProgressPayload { + return p && typeof p.completeness === 'number' && 'open_issues' in p; +} + +// patchMilestoneCard updates every progress-bar / counter site for a +// single milestone id, covering both the list-card layout (milestones +// list, dashboard) and the single-milestone big progress bar. +function patchMilestoneCard(payload: MilestoneProgressPayload): void { + const selector = `[data-milestone-id="${payload.milestone_id}"]`; + for (const el of document.querySelectorAll(selector)) { + // The element itself may be a (single view) or a card + //
  • containing a (list views). + const progressEls = el instanceof HTMLProgressElement + ? [el] + : Array.from(el.querySelectorAll('progress')); + for (const pe of progressEls) { + pe.value = payload.completeness; + } + + const scope: ParentNode = el instanceof HTMLProgressElement ? document : el; + + for (const pct of scope.querySelectorAll('.milestone-completeness-pct')) { + // The list cards render just the number; the single-milestone + // view renders an i18n HTML fragment ("N% + // Completed"). Detect which by whether the node already holds a + // child. + const strong = pct.querySelector('strong'); + if (strong) { + strong.textContent = `${payload.completeness}%`; + } else { + pct.textContent = String(payload.completeness); + } + } + for (const oc of scope.querySelectorAll('.milestone-open-count')) { + oc.textContent = String(payload.open_issues); + } + for (const cc of scope.querySelectorAll('.milestone-closed-count')) { + cc.textContent = String(payload.closed_issues); + } + } +} + +function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void { + const card = document.querySelector(`li.milestone-card[data-milestone-id="${payload.milestone_id}"]`); + if (card) { + card.remove(); + return; + } + // Single-milestone view: the milestone we are looking at is gone. + const single = document.querySelector(`progress[data-milestone-id="${payload.milestone_id}"]`); + if (single) { + const parts = window.location.pathname.split('/'); + // .../milestone/{id} -> go up to the milestones listing. + const idx = parts.lastIndexOf('milestone'); + if (idx > 0) { + window.location.href = `${parts.slice(0, idx).join('/')}/milestones`; + } else { + window.location.href = '/'; + } + } +} + +function dispatchMilestoneEvent(payload: any): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + if (isProgressPayload(payload)) { + patchMilestoneCard(payload); + } else if ('milestone_id' in payload && 'repo_id' in payload) { + handleMilestoneDeleted(payload as MilestoneDeletedPayload); + } +} + +// subscribed guards against a double subscription if more than one init +// entry point matches the same page (e.g. the dashboard milestones page +// is wired both from repo-legacy and dashboard). +let subscribed = false; + +// subscribeRepos opens one SharedWorker subscription per distinct repo +// id and dispatches every "repo-milestones.{repoID}" event by payload. +function subscribeRepos(repoIDs: Set): void { + if (subscribed) return; + if (!repoIDs.size) return; + if (!window.EventSource || !window.SharedWorker) return; + subscribed = true; + + ensureSessionTag(); + + let worker: UserEventsSharedWorker; + try { + worker = new UserEventsSharedWorker('repo-milestone-worker'); + } catch (error) { + console.error('milestone SSE: failed to start worker', error); + return; + } + + const eventNames = new Set(); + for (const repoID of repoIDs) { + eventNames.add(`repo-milestones.${repoID}`); + } + + worker.addMessageEventListener((event: MessageEvent) => { + if (!event.data || !eventNames.has(event.data.type)) return; + let payload: any; + try { + payload = JSON.parse(event.data.data); + } catch (error) { + console.error('milestone SSE: malformed payload', error, event.data); + return; + } + dispatchMilestoneEvent(payload); + }); + worker.startPort(); + + for (const name of eventNames) { + worker.sharedWorker.port.postMessage({type: 'listen', eventType: name}); + } +} + +// initRepoMilestoneListSSE wires the milestone list page and the +// dashboard milestones page: collect every distinct data-repo-id present +// on the cards (the dashboard mixes many repos) and subscribe to each. +export function initRepoMilestoneListSSE(): void { + const cards = document.querySelectorAll('li.milestone-card[data-repo-id]'); + if (!cards.length) return; + const repoIDs = new Set(); + for (const card of cards) { + const id = card.getAttribute('data-repo-id'); + if (id) repoIDs.add(id); + } + subscribeRepos(repoIDs); +} + +// initRepoMilestoneSingleSSE wires the single-milestone issue list view. +export function initRepoMilestoneSingleSSE(): void { + const progress = document.querySelector('progress[data-milestone-id][data-repo-id]'); + if (!progress) return; + const repoID = progress.getAttribute('data-repo-id'); + if (!repoID) return; + subscribeRepos(new Set([repoID])); +}