filegrc 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/web.js CHANGED
@@ -94,6 +94,7 @@ let repositorySyncPollTimer = null;
94
94
  let repositorySyncPollInFlight = false;
95
95
  let mutationStateRefreshInFlight = false;
96
96
  let mutationStateRefreshTimer = null;
97
+ let mutationStateRefreshRequested = false;
97
98
  let programSelectionGeneration = 0;
98
99
  let expiredStateRefresh = null;
99
100
 
@@ -132,7 +133,8 @@ function render() {
132
133
  if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
133
134
  const main = root.querySelector("main");
134
135
  const waitingFor = blockingStateSections(route).filter((section) => state.sections?.[section] !== "complete");
135
- if (waitingFor.length) renderStateLoading(main, route, waitingFor);
136
+ if (state.refreshingAfterMutation) renderStateLoading(main, route, []);
137
+ else if (waitingFor.length) renderStateLoading(main, route, waitingFor);
136
138
  else if (route.name === "home") renderHome(main);
137
139
  else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
138
140
  else if (route.name === "obligations") renderObligations(main, route.params);
@@ -660,6 +662,7 @@ function openDocumentActivationDialog(auditId = null) {
660
662
  '<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
661
663
  '<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Activate selected content</button></div></form>';
662
664
  document.body.append(dialog);
665
+ const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
663
666
  const close = () => dialog.close();
664
667
  dialog.querySelector(".icon-button").addEventListener("click", close);
665
668
  dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
@@ -674,6 +677,8 @@ function openDocumentActivationDialog(auditId = null) {
674
677
  }
675
678
  setMutationBusy(dialog, true, "Activating…", "Activate selected content");
676
679
  try {
680
+ const prefetch = await repositoryPrefetch;
681
+ if (prefetch?.error) throw prefetch.error;
677
682
  const response = await localFetch(auditId ? "/api/document-activations" : "/api/governed-content-activations", {
678
683
  method: "POST",
679
684
  headers: { "content-type": "application/json" },
@@ -683,6 +688,7 @@ function openDocumentActivationDialog(auditId = null) {
683
688
  activatedByIds: [event.currentTarget.elements.activatedById.value],
684
689
  activatedOn: event.currentTarget.elements.activatedOn.value,
685
690
  effectiveOn: event.currentTarget.elements.effectiveOn.value,
691
+ prefetchToken: prefetch?.token,
686
692
  expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, entryById.get(resourceId).revision]))
687
693
  })
688
694
  });
@@ -759,6 +765,7 @@ function openPolicyActivationDialog() {
759
765
  '<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
760
766
  '<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button id="activate-policy" type="submit" class="button primary">Activate selected Policies</button></div></form>';
761
767
  document.body.append(dialog);
768
+ const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
762
769
  const close = () => dialog.close();
763
770
  dialog.querySelector(".icon-button").addEventListener("click", close);
764
771
  dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
@@ -773,12 +780,15 @@ function openPolicyActivationDialog() {
773
780
  }
774
781
  setMutationBusy(dialog, true, "Activating…", "Activate selected Policies");
775
782
  try {
783
+ const prefetch = await repositoryPrefetch;
784
+ if (prefetch?.error) throw prefetch.error;
776
785
  const response = await localFetch("/api/policy-activations", {
777
786
  method: "POST",
778
787
  headers: { "content-type": "application/json" },
779
788
  body: JSON.stringify({
780
789
  policyIds,
781
790
  effectiveOn: event.currentTarget.elements.effectiveOn.value,
791
+ prefetchToken: prefetch?.token,
782
792
  expectedRevisions: Object.fromEntries(policyIds.map((policyId) => [policyId, entryById.get(policyId).revision]))
783
793
  })
784
794
  });
@@ -928,15 +938,26 @@ function openCollectionReviewDialog(type) {
928
938
  const sourceType = v4 ? "component" : "system";
929
939
  const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
930
940
  const allowedDecisions = configuration.decisions || ["complete"];
931
- const defaultDecision = assessment.review?.decision
932
- || (!assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0]);
941
+ const priorDecision = assessment.review?.decision;
942
+ const priorAuthoritativeSourceId = assessment.review?.authoritativeComponentId || assessment.review?.authoritativeSystemId || null;
943
+ const priorAuthoritativeSourceActive = priorDecision !== "externally-managed"
944
+ || systems.some(({ record }) => record.id === priorAuthoritativeSourceId);
945
+ const priorDecisionStillValid = allowedDecisions.includes(priorDecision)
946
+ && priorAuthoritativeSourceActive
947
+ && (assessment.recordCount ? priorDecision !== "zero-population" : priorDecision !== "complete");
948
+ const preservedAuthoritativeSourceId = priorDecisionStillValid && priorDecision === "externally-managed"
949
+ ? priorAuthoritativeSourceId
950
+ : null;
951
+ const defaultDecision = priorDecisionStillValid
952
+ ? priorDecision
953
+ : !assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0];
933
954
  const decisions = allowedDecisions.map((decision) => (
934
955
  '<option value="' + esc(decision) + '" ' + (defaultDecision === decision ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>'
935
956
  )).join("");
936
957
  const dialog = document.createElement("dialog");
937
958
  dialog.className = "commit-dialog event-dialog collection-review-dialog";
938
959
  dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
939
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(assessment.review?.reviewedOn || currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.authoritativeComponentId || assessment.review?.authoritativeSystemId) === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>Confirm ' + assessment.recordCount + ' current ' + esc(pluralize("record", assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Confirm and save</button></div></form>';
960
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>Confirm ' + assessment.recordCount + ' current ' + esc(pluralize("record", assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Confirm and save</button></div></form>';
940
961
  document.body.append(dialog);
941
962
  dialog.showModal();
942
963
  const form = dialog.querySelector("form");
@@ -2686,10 +2707,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2686
2707
  const definition = state.model.resources[type];
2687
2708
  if (!entry || !definition) return renderNotFound(main);
2688
2709
  if (entry.detailsLoaded === false) {
2689
- main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record content and file history…</section></div>';
2710
+ main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record…</section></div>';
2690
2711
  loadResourceDetail(type, id);
2691
2712
  return;
2692
2713
  }
2714
+ if (entry.historyLoaded === false) loadResourceHistory(type, id);
2693
2715
  const fields = { ...state.model.commonFields, ...definition.fields };
2694
2716
  const recordContent = recordContentDefinition(type);
2695
2717
  const narrative = recordNarrative(entry.record, fields);
@@ -2756,13 +2778,16 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2756
2778
  const historyPanel = entry.history?.length
2757
2779
  ? '<section class="panel detail-history-panel"><div class="panel-head"><h3>File History</h3></div><div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div></section>'
2758
2780
  : "";
2781
+ const participationPanel = entry.historyLoaded === false
2782
+ ? '<section class="panel detail-support-panel" role="status"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Loading participation and file history…</p></section>'
2783
+ : personParticipation(entry);
2759
2784
  const supportPanels = renderDetailSupport({
2760
2785
  hasRecordBody,
2761
2786
  workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
2762
2787
  reviewPanel: resourceReviewCriteria(type),
2763
2788
  metadataPanel: '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>',
2764
2789
  attachmentPanel,
2765
- participationPanel: personParticipation(entry),
2790
+ participationPanel,
2766
2791
  connectionsPanel: resourceConnections(entry),
2767
2792
  historyPanel
2768
2793
  });
@@ -2879,6 +2904,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2879
2904
  main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
2880
2905
  main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
2881
2906
  main.querySelector("#delete-resource")?.addEventListener("click", async () => {
2907
+ const repositoryPrefetch = prefetchRepositoryForReview();
2882
2908
  if (!await confirmAction({
2883
2909
  kicker: "Delete record",
2884
2910
  title: entry.record.title,
@@ -2887,7 +2913,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2887
2913
  danger: true
2888
2914
  })) return;
2889
2915
  try {
2890
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
2916
+ const prefetch = await repositoryPrefetch;
2917
+ if (prefetch?.error) throw prefetch.error;
2918
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision) + (prefetch?.token ? "&prefetchToken=" + encodeURIComponent(prefetch.token) : ""), { method: "DELETE" });
2891
2919
  if (!response.ok) return showError(await responseMessage(response));
2892
2920
  applyMutationState(await response.json());
2893
2921
  location.hash = "#/resources/" + encodeURIComponent(type);
@@ -2937,7 +2965,7 @@ async function loadResourceDetail(type, id) {
2937
2965
  let token = state.stateToken;
2938
2966
  let detail = null;
2939
2967
  for (let attempt = 0; attempt < 3; attempt += 1) {
2940
- const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
2968
+ const tokenQuery = token ? "?token=" + encodeURIComponent(token) + "&history=false" : "?history=false";
2941
2969
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
2942
2970
  if (response.status === 409 && token) {
2943
2971
  await refreshExpiredAppState(token);
@@ -2975,6 +3003,44 @@ async function loadResourceDetail(type, id) {
2975
3003
  return request;
2976
3004
  }
2977
3005
 
3006
+ async function loadResourceHistory(type, id) {
3007
+ const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
3008
+ if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
3009
+ const request = (async () => {
3010
+ try {
3011
+ const token = state.stateToken;
3012
+ const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
3013
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
3014
+ if (response.status === 409 && token) {
3015
+ await refreshExpiredAppState(token);
3016
+ const route = parseRoute();
3017
+ if (route.name === "detail" && route.type === type && route.id === id) {
3018
+ render();
3019
+ loadStateForRoute();
3020
+ }
3021
+ return;
3022
+ }
3023
+ if (!response.ok) throw new Error(await responseMessage(response));
3024
+ const history = await response.json();
3025
+ if (token && (history.stateToken !== token || state.stateToken !== token)) return;
3026
+ const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
3027
+ if (!entry) return;
3028
+ entry.history = history.history || [];
3029
+ entry.historyLoaded = true;
3030
+ const route = parseRoute();
3031
+ if (route.name === "detail" && route.type === type && route.id === id) render();
3032
+ } catch {
3033
+ // History and participation are supplemental. The record stays usable.
3034
+ } finally {
3035
+ for (const [requestKey, pending] of resourceDetailRequests) {
3036
+ if (pending === request) resourceDetailRequests.delete(requestKey);
3037
+ }
3038
+ }
3039
+ })();
3040
+ resourceDetailRequests.set(key, request);
3041
+ return request;
3042
+ }
3043
+
2978
3044
  function refreshExpiredAppState(expectedToken) {
2979
3045
  if (expiredStateRefresh) {
2980
3046
  if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
@@ -4182,6 +4248,14 @@ function openEditor(type, entry = null, options = {}) {
4182
4248
  '<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
4183
4249
  document.body.append(dialog);
4184
4250
  dialog.showModal();
4251
+ const saveStatus = dialog.querySelector(".save-status");
4252
+ const fastResourceSave = !options.occurrenceReconciliation
4253
+ && !options.auditPopulationCorrection
4254
+ && !options.actionCompletion
4255
+ && !options.obligationCompletion;
4256
+ const repositoryPrefetch = fastResourceSave
4257
+ ? prefetchRepositoryForReview(saveStatus)
4258
+ : Promise.resolve(null);
4185
4259
  dialog.addEventListener("close", () => dialog.remove());
4186
4260
  dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
4187
4261
  wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
@@ -4254,9 +4328,14 @@ function openEditor(type, entry = null, options = {}) {
4254
4328
  [recordContentItem?.path, recordContentItem?.revision]
4255
4329
  ].filter(([path, revision]) => path && revision));
4256
4330
  setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
4331
+ const prefetch = await repositoryPrefetch;
4332
+ if (prefetch?.error) throw prefetch.error;
4257
4333
  const response = await localFetch(url, {
4258
4334
  method: options.occurrenceReconciliation ? "POST" : entry ? "PUT" : "POST",
4259
- headers: { "content-type": "application/json" },
4335
+ headers: {
4336
+ "content-type": "application/json",
4337
+ ...(fastResourceSave ? { prefer: "respond-async" } : {})
4338
+ },
4260
4339
  body: JSON.stringify({
4261
4340
  record: updated,
4262
4341
  content,
@@ -4264,7 +4343,8 @@ function openEditor(type, entry = null, options = {}) {
4264
4343
  contentRevisions,
4265
4344
  obligationId: options.obligationCompletion?.obligationId,
4266
4345
  actionItemId: options.actionCompletion?.actionItemId,
4267
- completedOn: options.actionCompletion?.completedOn
4346
+ completedOn: options.actionCompletion?.completedOn,
4347
+ prefetchToken: prefetch?.token
4268
4348
  })
4269
4349
  });
4270
4350
  if (!response.ok) throw new Error(await responseMessage(response));
@@ -5025,12 +5105,15 @@ function openContentEditor(entry, name) {
5025
5105
  dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
5026
5106
  document.body.append(dialog);
5027
5107
  dialog.showModal();
5108
+ const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
5028
5109
  dialog.addEventListener("close", () => dialog.remove());
5029
5110
  dialog.querySelector("#save-content").addEventListener("click", async () => {
5030
5111
  if (dialog.dataset.mutationBusy === "true") return;
5031
5112
  try {
5032
5113
  setMutationBusy(dialog, true, "Saving…", "Save Markdown");
5033
- const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision }) });
5114
+ const prefetch = await repositoryPrefetch;
5115
+ if (prefetch?.error) throw prefetch.error;
5116
+ const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision, prefetchToken: prefetch?.token }) });
5034
5117
  if (!response.ok) throw new Error(await responseMessage(response));
5035
5118
  applyMutationState(await response.json());
5036
5119
  dialog.close();
@@ -5503,7 +5586,16 @@ function applyMutationState(result) {
5503
5586
  state = normalizeAppState(result.state);
5504
5587
  } else if (result?.stateRefresh) {
5505
5588
  applyFastMutationPatch(result);
5589
+ state.refreshingAfterMutation = true;
5590
+ state.readOnly = true;
5591
+ state.resources = state.resources.map((entry) => ({
5592
+ ...entry,
5593
+ content: {},
5594
+ history: undefined,
5595
+ detailsLoaded: false
5596
+ }));
5506
5597
  scheduleMutationStateRefresh();
5598
+ render();
5507
5599
  } else {
5508
5600
  throw new Error("The save response did not include the current workspace state.");
5509
5601
  }
@@ -5512,6 +5604,19 @@ function applyMutationState(result) {
5512
5604
 
5513
5605
  function applyFastMutationPatch(result) {
5514
5606
  state.stateToken = null;
5607
+ if (result.deleted && result.type && result.id) {
5608
+ state.resources = state.resources.filter(({ record }) => record.type !== result.type || record.id !== result.id);
5609
+ }
5610
+ if (result.dataRelativePath && typeof result.source === "string") {
5611
+ for (const entry of state.resources) {
5612
+ for (const item of Object.values(entry.content || {})) {
5613
+ if (item.path !== result.dataRelativePath) continue;
5614
+ item.source = result.source;
5615
+ item.revision = result.revision;
5616
+ if (typeof result.html === "string") item.html = result.html;
5617
+ }
5618
+ }
5619
+ }
5515
5620
  if (result.operation === "collection-review" && result.assessment?.resourceType) {
5516
5621
  state.collectionReviews[result.assessment.resourceType] = result.assessment;
5517
5622
  }
@@ -5524,7 +5629,24 @@ function applyFastMutationPatch(result) {
5524
5629
  state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
5525
5630
  }
5526
5631
  }
5527
- for (const record of [result.workspace, result.program, result.system, result.renderer, result.commitment].filter(Boolean)) {
5632
+ const immediateRecords = [
5633
+ result.record,
5634
+ result.workspace,
5635
+ result.program,
5636
+ result.system,
5637
+ result.renderer,
5638
+ result.commitment,
5639
+ result.audit,
5640
+ result.event,
5641
+ result.created,
5642
+ result.linked,
5643
+ result.dismissal,
5644
+ result.result?.record,
5645
+ result.result?.created,
5646
+ result.result?.linked,
5647
+ ...(result.actions || [])
5648
+ ].filter((record) => record?.id && record?.type);
5649
+ for (const record of immediateRecords) {
5528
5650
  const entry = state.resources.find(({ record: current }) => current.id === record.id);
5529
5651
  if (entry) entry.record = record;
5530
5652
  else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
@@ -5544,6 +5666,7 @@ function applyFastMutationPatch(result) {
5544
5666
  }
5545
5667
 
5546
5668
  function scheduleMutationStateRefresh(delay = 0) {
5669
+ mutationStateRefreshRequested = true;
5547
5670
  if (mutationStateRefreshTimer || mutationStateRefreshInFlight) return;
5548
5671
  mutationStateRefreshTimer = setTimeout(refreshMutationState, delay);
5549
5672
  }
@@ -5552,6 +5675,7 @@ async function refreshMutationState() {
5552
5675
  mutationStateRefreshTimer = null;
5553
5676
  if (mutationStateRefreshInFlight) return;
5554
5677
  mutationStateRefreshInFlight = true;
5678
+ mutationStateRefreshRequested = false;
5555
5679
  let retry = false;
5556
5680
  try {
5557
5681
  const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
@@ -5567,24 +5691,24 @@ async function refreshMutationState() {
5567
5691
  } finally {
5568
5692
  mutationStateRefreshInFlight = false;
5569
5693
  }
5570
- if (retry) scheduleMutationStateRefresh(1_000);
5694
+ if (retry || mutationStateRefreshRequested) scheduleMutationStateRefresh(retry ? 1_000 : 0);
5571
5695
  }
5572
5696
 
5573
- function prefetchRepositoryForReview(status) {
5697
+ function prefetchRepositoryForReview(status = null) {
5574
5698
  if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
5575
5699
  return Promise.resolve(null);
5576
5700
  }
5577
- status.textContent = "Checking repository…";
5701
+ if (status) status.textContent = "Checking repository…";
5578
5702
  return fetch("/api/git/prefetch", { method: "POST" })
5579
5703
  .then(async (response) => {
5580
5704
  if (!response.ok) throw new Error(await responseMessage(response));
5581
5705
  const result = await response.json();
5582
- status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
5706
+ if (status) status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
5583
5707
  return result;
5584
5708
  })
5585
5709
  .catch((error) => {
5586
- status.textContent = "Repository check failed";
5587
- return { error };
5710
+ if (status) status.textContent = "Repository check failed";
5711
+ return null;
5588
5712
  });
5589
5713
  }
5590
5714
 
@@ -5697,15 +5821,25 @@ async function localFetch(url, options) {
5697
5821
  }
5698
5822
  const scopedUrl = requestUrl.pathname + requestUrl.search + requestUrl.hash;
5699
5823
  const method = String(options?.method || "GET").toUpperCase();
5824
+ const mutation = ["POST", "PUT", "DELETE"].includes(method);
5825
+ const requestOptions = mutation
5826
+ ? {
5827
+ ...options,
5828
+ headers: {
5829
+ ...Object.fromEntries(new Headers(options?.headers || {}).entries()),
5830
+ prefer: "respond-async"
5831
+ }
5832
+ }
5833
+ : options;
5700
5834
  const synchronizing = state?.repository?.mode === "trunk"
5701
- && ["POST", "PUT", "DELETE"].includes(method)
5835
+ && mutation
5702
5836
  && !["/api/evidence-packet", "/api/git/prefetch"].includes(requestUrl.pathname);
5703
5837
  const chip = synchronizing ? document.querySelector(".repo-chip") : null;
5704
5838
  const previousChip = chip?.innerHTML;
5705
5839
  let repositoryRefreshed = false;
5706
5840
  if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
5707
5841
  try {
5708
- const response = await fetch(scopedUrl, options);
5842
+ const response = await fetch(scopedUrl, requestOptions);
5709
5843
  if (synchronizing && !response.ok) {
5710
5844
  try {
5711
5845
  const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));