filegrc 0.12.4 → 0.13.1

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);
@@ -174,7 +176,7 @@ function desiredStateSections(route) {
174
176
  const sections = new Set(["repository", ...blockingStateSections(route)]);
175
177
  if (route.name === "list" && (["requirement", "requirement-mapping"].includes(route.type) || state.model.collectionReviews?.[route.type])) sections.add("program");
176
178
  if (route.name === "detail" && ["policy", "document", "training", "control", "component", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("program");
177
- if (route.name === "detail" && ["policy", "document", "framework", "requirement", "commitment", "system", "component", "vendor", "information-type", "source-coverage", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("workflow");
179
+ if (route.name === "detail") sections.add("workflow");
178
180
  if (route.name === "detail" && ["obligation", "action-item", "obligation-event"].includes(route.type)) sections.add("obligations");
179
181
  if (route.name === "detail" && route.type === "audit") sections.add("audits");
180
182
  return [...sections];
@@ -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
  });
@@ -1001,10 +1011,12 @@ function workflowItemHref(item) {
1001
1011
  if (source && ["assigned-work", "obligation-occurrence"].includes(item.kind)) {
1002
1012
  return "#/stage/run?work=" + encodeURIComponent(source.type + ":" + source.id);
1003
1013
  }
1004
- const commands = [
1005
- ...(item.actions || []).map((action) => action.command),
1006
- item.nextAction?.command
1007
- ].filter(Boolean);
1014
+ const actions = [...(item.actions || []), item.nextAction].filter(Boolean);
1015
+ const reconciliationAction = actions.find((action) => action.kind === "reconcile-transition");
1016
+ if (reconciliationAction?.candidateId) {
1017
+ return "#/stage/run?reconcile=" + encodeURIComponent(reconciliationAction.candidateId);
1018
+ }
1019
+ const commands = actions.map((action) => action.command).filter(Boolean);
1008
1020
  if (item.createResourceType && state.model.resources[item.createResourceType]) {
1009
1021
  const params = new URLSearchParams({ new: "1" });
1010
1022
  if (item.title) params.set("title", item.createResourceType === "commitment"
@@ -1612,6 +1624,16 @@ function renderObligations(main, params = new URLSearchParams()) {
1612
1624
  if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
1613
1625
  });
1614
1626
  }
1627
+ const requestedReconciliation = params.get("reconcile");
1628
+ if (requestedReconciliation) {
1629
+ queueMicrotask(() => {
1630
+ const candidate = state.reconciliation?.candidates?.find((item) => (
1631
+ item.transitionFingerprint === requestedReconciliation
1632
+ || item.id === requestedReconciliation
1633
+ ));
1634
+ if (candidate) openReconciliationConfirmation(candidate);
1635
+ });
1636
+ }
1615
1637
  const requestedWork = params.get("work");
1616
1638
  if (requestedWork) {
1617
1639
  queueMicrotask(() => {
@@ -2674,10 +2696,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2674
2696
  const definition = state.model.resources[type];
2675
2697
  if (!entry || !definition) return renderNotFound(main);
2676
2698
  if (entry.detailsLoaded === false) {
2677
- 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>';
2699
+ 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>';
2678
2700
  loadResourceDetail(type, id);
2679
2701
  return;
2680
2702
  }
2703
+ if (entry.historyLoaded === false) loadResourceHistory(type, id);
2681
2704
  const fields = { ...state.model.commonFields, ...definition.fields };
2682
2705
  const recordContent = recordContentDefinition(type);
2683
2706
  const narrative = recordNarrative(entry.record, fields);
@@ -2744,13 +2767,16 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2744
2767
  const historyPanel = entry.history?.length
2745
2768
  ? '<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>'
2746
2769
  : "";
2770
+ const participationPanel = entry.historyLoaded === false
2771
+ ? '<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>'
2772
+ : personParticipation(entry);
2747
2773
  const supportPanels = renderDetailSupport({
2748
2774
  hasRecordBody,
2749
2775
  workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
2750
2776
  reviewPanel: resourceReviewCriteria(type),
2751
2777
  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>',
2752
2778
  attachmentPanel,
2753
- participationPanel: personParticipation(entry),
2779
+ participationPanel,
2754
2780
  connectionsPanel: resourceConnections(entry),
2755
2781
  historyPanel
2756
2782
  });
@@ -2867,6 +2893,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2867
2893
  main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
2868
2894
  main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
2869
2895
  main.querySelector("#delete-resource")?.addEventListener("click", async () => {
2896
+ const repositoryPrefetch = prefetchRepositoryForReview();
2870
2897
  if (!await confirmAction({
2871
2898
  kicker: "Delete record",
2872
2899
  title: entry.record.title,
@@ -2875,7 +2902,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2875
2902
  danger: true
2876
2903
  })) return;
2877
2904
  try {
2878
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
2905
+ const prefetch = await repositoryPrefetch;
2906
+ if (prefetch?.error) throw prefetch.error;
2907
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision) + (prefetch?.token ? "&prefetchToken=" + encodeURIComponent(prefetch.token) : ""), { method: "DELETE" });
2879
2908
  if (!response.ok) return showError(await responseMessage(response));
2880
2909
  applyMutationState(await response.json());
2881
2910
  location.hash = "#/resources/" + encodeURIComponent(type);
@@ -2925,7 +2954,7 @@ async function loadResourceDetail(type, id) {
2925
2954
  let token = state.stateToken;
2926
2955
  let detail = null;
2927
2956
  for (let attempt = 0; attempt < 3; attempt += 1) {
2928
- const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
2957
+ const tokenQuery = token ? "?token=" + encodeURIComponent(token) + "&history=false" : "?history=false";
2929
2958
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
2930
2959
  if (response.status === 409 && token) {
2931
2960
  await refreshExpiredAppState(token);
@@ -2963,6 +2992,44 @@ async function loadResourceDetail(type, id) {
2963
2992
  return request;
2964
2993
  }
2965
2994
 
2995
+ async function loadResourceHistory(type, id) {
2996
+ const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
2997
+ if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
2998
+ const request = (async () => {
2999
+ try {
3000
+ const token = state.stateToken;
3001
+ const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
3002
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
3003
+ if (response.status === 409 && token) {
3004
+ await refreshExpiredAppState(token);
3005
+ const route = parseRoute();
3006
+ if (route.name === "detail" && route.type === type && route.id === id) {
3007
+ render();
3008
+ loadStateForRoute();
3009
+ }
3010
+ return;
3011
+ }
3012
+ if (!response.ok) throw new Error(await responseMessage(response));
3013
+ const history = await response.json();
3014
+ if (token && (history.stateToken !== token || state.stateToken !== token)) return;
3015
+ const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
3016
+ if (!entry) return;
3017
+ entry.history = history.history || [];
3018
+ entry.historyLoaded = true;
3019
+ const route = parseRoute();
3020
+ if (route.name === "detail" && route.type === type && route.id === id) render();
3021
+ } catch {
3022
+ // History and participation are supplemental. The record stays usable.
3023
+ } finally {
3024
+ for (const [requestKey, pending] of resourceDetailRequests) {
3025
+ if (pending === request) resourceDetailRequests.delete(requestKey);
3026
+ }
3027
+ }
3028
+ })();
3029
+ resourceDetailRequests.set(key, request);
3030
+ return request;
3031
+ }
3032
+
2966
3033
  function refreshExpiredAppState(expectedToken) {
2967
3034
  if (expiredStateRefresh) {
2968
3035
  if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
@@ -3274,6 +3341,67 @@ function openReconciliationDismissal(candidate) {
3274
3341
  });
3275
3342
  }
3276
3343
 
3344
+ function openReconciliationConfirmation(candidate) {
3345
+ if (document.querySelector('[data-reconciliation-confirmation="' + CSS.escape(candidate.transitionFingerprint) + '"]')) return;
3346
+ const writeDisabled = state.readOnly
3347
+ ? ' disabled title="Writes are not available in this repository state"'
3348
+ : "";
3349
+ const needsTimestamp = (candidate.requiredFacts || []).includes("occurredAt");
3350
+ const eventField = needsTimestamp
3351
+ ? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
3352
+ : '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
3353
+ const riskField = (candidate.requiredFacts || []).includes("riskLevel")
3354
+ ? '<label><span>Departure risk</span><select name="riskLevel" required><option value="normal">Normal</option><option value="high">High or involuntary</option></select></label>'
3355
+ : "";
3356
+ const dialog = document.createElement("dialog");
3357
+ dialog.className = "commit-dialog event-dialog";
3358
+ dialog.dataset.reconciliationConfirmation = candidate.transitionFingerprint;
3359
+ dialog.setAttribute("aria-labelledby", "reconciliation-confirmation-title");
3360
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git transition review</p><h2 id="reconciliation-confirmation-title">' + esc(policyEventName(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(candidate.message) + '</p><section class="event-dialog-steps"><div><strong>' + esc(candidate.subject.title || candidate.subject.id) + '</strong><small>' + esc(candidate.sourcePath) + '</small></div></section>' + eventField + riskField + '<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(candidate.eventType)) + '"' + writeDisabled + '></label>' + (state.readOnly ? '<p class="dialog-note">Open this workspace in the local writable renderer or use the CLI to confirm or dismiss this transition.</p>' : "") + '<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-dismiss-candidate' + writeDisabled + '>Dismiss false positive</button><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary"' + writeDisabled + '>Confirm and add work</button></div></form>';
3361
+ document.body.append(dialog);
3362
+ dialog.showModal();
3363
+ dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
3364
+ dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
3365
+ dialog.querySelector("[data-dismiss-candidate]").addEventListener("click", () => {
3366
+ dialog.close();
3367
+ openReconciliationDismissal(candidate);
3368
+ });
3369
+ dialog.addEventListener("close", () => dialog.remove());
3370
+ dialog.querySelector("form").addEventListener("submit", async (event) => {
3371
+ event.preventDefault();
3372
+ const form = event.currentTarget;
3373
+ if (!form.reportValidity()) return;
3374
+ try {
3375
+ setMutationBusy(dialog, true, "Confirming…", "Confirm and add work");
3376
+ const response = await localFetch("/api/reconciliation", {
3377
+ method: "POST",
3378
+ headers: { "content-type": "application/json" },
3379
+ body: JSON.stringify({
3380
+ candidateId: candidate.transitionFingerprint,
3381
+ occurredOn: form.elements.occurredOn?.value || undefined,
3382
+ occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
3383
+ riskLevel: form.elements.riskLevel?.value || undefined,
3384
+ title: form.elements.title.value,
3385
+ confirmed: true
3386
+ })
3387
+ });
3388
+ if (!response.ok) throw new Error(await responseMessage(response));
3389
+ const created = await response.json();
3390
+ policyEventFeedback = {
3391
+ name: policyEventName(candidate.eventType),
3392
+ taskCount: created.actions?.length || 0
3393
+ };
3394
+ applyMutationState(created);
3395
+ dialog.close();
3396
+ history.replaceState(null, "", "#/stage/run");
3397
+ render();
3398
+ } catch (error) {
3399
+ setMutationBusy(dialog, false, "", "Confirm and add work");
3400
+ dialog.querySelector(".dialog-error").textContent = error.message;
3401
+ }
3402
+ });
3403
+ }
3404
+
3277
3405
  async function runRepositoryGitAction(action) {
3278
3406
  const buttons = [...document.querySelectorAll("[data-git-action]")];
3279
3407
  const disabled = buttons.map((button) => button.disabled);
@@ -4109,6 +4237,14 @@ function openEditor(type, entry = null, options = {}) {
4109
4237
  '<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>';
4110
4238
  document.body.append(dialog);
4111
4239
  dialog.showModal();
4240
+ const saveStatus = dialog.querySelector(".save-status");
4241
+ const fastResourceSave = !options.occurrenceReconciliation
4242
+ && !options.auditPopulationCorrection
4243
+ && !options.actionCompletion
4244
+ && !options.obligationCompletion;
4245
+ const repositoryPrefetch = fastResourceSave
4246
+ ? prefetchRepositoryForReview(saveStatus)
4247
+ : Promise.resolve(null);
4112
4248
  dialog.addEventListener("close", () => dialog.remove());
4113
4249
  dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
4114
4250
  wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
@@ -4181,9 +4317,14 @@ function openEditor(type, entry = null, options = {}) {
4181
4317
  [recordContentItem?.path, recordContentItem?.revision]
4182
4318
  ].filter(([path, revision]) => path && revision));
4183
4319
  setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
4320
+ const prefetch = await repositoryPrefetch;
4321
+ if (prefetch?.error) throw prefetch.error;
4184
4322
  const response = await localFetch(url, {
4185
4323
  method: options.occurrenceReconciliation ? "POST" : entry ? "PUT" : "POST",
4186
- headers: { "content-type": "application/json" },
4324
+ headers: {
4325
+ "content-type": "application/json",
4326
+ ...(fastResourceSave ? { prefer: "respond-async" } : {})
4327
+ },
4187
4328
  body: JSON.stringify({
4188
4329
  record: updated,
4189
4330
  content,
@@ -4191,7 +4332,8 @@ function openEditor(type, entry = null, options = {}) {
4191
4332
  contentRevisions,
4192
4333
  obligationId: options.obligationCompletion?.obligationId,
4193
4334
  actionItemId: options.actionCompletion?.actionItemId,
4194
- completedOn: options.actionCompletion?.completedOn
4335
+ completedOn: options.actionCompletion?.completedOn,
4336
+ prefetchToken: prefetch?.token
4195
4337
  })
4196
4338
  });
4197
4339
  if (!response.ok) throw new Error(await responseMessage(response));
@@ -4952,12 +5094,15 @@ function openContentEditor(entry, name) {
4952
5094
  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>';
4953
5095
  document.body.append(dialog);
4954
5096
  dialog.showModal();
5097
+ const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
4955
5098
  dialog.addEventListener("close", () => dialog.remove());
4956
5099
  dialog.querySelector("#save-content").addEventListener("click", async () => {
4957
5100
  if (dialog.dataset.mutationBusy === "true") return;
4958
5101
  try {
4959
5102
  setMutationBusy(dialog, true, "Saving…", "Save Markdown");
4960
- 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 }) });
5103
+ const prefetch = await repositoryPrefetch;
5104
+ if (prefetch?.error) throw prefetch.error;
5105
+ 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 }) });
4961
5106
  if (!response.ok) throw new Error(await responseMessage(response));
4962
5107
  applyMutationState(await response.json());
4963
5108
  dialog.close();
@@ -5430,7 +5575,16 @@ function applyMutationState(result) {
5430
5575
  state = normalizeAppState(result.state);
5431
5576
  } else if (result?.stateRefresh) {
5432
5577
  applyFastMutationPatch(result);
5578
+ state.refreshingAfterMutation = true;
5579
+ state.readOnly = true;
5580
+ state.resources = state.resources.map((entry) => ({
5581
+ ...entry,
5582
+ content: {},
5583
+ history: undefined,
5584
+ detailsLoaded: false
5585
+ }));
5433
5586
  scheduleMutationStateRefresh();
5587
+ render();
5434
5588
  } else {
5435
5589
  throw new Error("The save response did not include the current workspace state.");
5436
5590
  }
@@ -5439,6 +5593,19 @@ function applyMutationState(result) {
5439
5593
 
5440
5594
  function applyFastMutationPatch(result) {
5441
5595
  state.stateToken = null;
5596
+ if (result.deleted && result.type && result.id) {
5597
+ state.resources = state.resources.filter(({ record }) => record.type !== result.type || record.id !== result.id);
5598
+ }
5599
+ if (result.dataRelativePath && typeof result.source === "string") {
5600
+ for (const entry of state.resources) {
5601
+ for (const item of Object.values(entry.content || {})) {
5602
+ if (item.path !== result.dataRelativePath) continue;
5603
+ item.source = result.source;
5604
+ item.revision = result.revision;
5605
+ if (typeof result.html === "string") item.html = result.html;
5606
+ }
5607
+ }
5608
+ }
5442
5609
  if (result.operation === "collection-review" && result.assessment?.resourceType) {
5443
5610
  state.collectionReviews[result.assessment.resourceType] = result.assessment;
5444
5611
  }
@@ -5451,7 +5618,24 @@ function applyFastMutationPatch(result) {
5451
5618
  state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
5452
5619
  }
5453
5620
  }
5454
- for (const record of [result.workspace, result.program, result.system, result.renderer, result.commitment].filter(Boolean)) {
5621
+ const immediateRecords = [
5622
+ result.record,
5623
+ result.workspace,
5624
+ result.program,
5625
+ result.system,
5626
+ result.renderer,
5627
+ result.commitment,
5628
+ result.audit,
5629
+ result.event,
5630
+ result.created,
5631
+ result.linked,
5632
+ result.dismissal,
5633
+ result.result?.record,
5634
+ result.result?.created,
5635
+ result.result?.linked,
5636
+ ...(result.actions || [])
5637
+ ].filter((record) => record?.id && record?.type);
5638
+ for (const record of immediateRecords) {
5455
5639
  const entry = state.resources.find(({ record: current }) => current.id === record.id);
5456
5640
  if (entry) entry.record = record;
5457
5641
  else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
@@ -5471,6 +5655,7 @@ function applyFastMutationPatch(result) {
5471
5655
  }
5472
5656
 
5473
5657
  function scheduleMutationStateRefresh(delay = 0) {
5658
+ mutationStateRefreshRequested = true;
5474
5659
  if (mutationStateRefreshTimer || mutationStateRefreshInFlight) return;
5475
5660
  mutationStateRefreshTimer = setTimeout(refreshMutationState, delay);
5476
5661
  }
@@ -5479,6 +5664,7 @@ async function refreshMutationState() {
5479
5664
  mutationStateRefreshTimer = null;
5480
5665
  if (mutationStateRefreshInFlight) return;
5481
5666
  mutationStateRefreshInFlight = true;
5667
+ mutationStateRefreshRequested = false;
5482
5668
  let retry = false;
5483
5669
  try {
5484
5670
  const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
@@ -5494,24 +5680,24 @@ async function refreshMutationState() {
5494
5680
  } finally {
5495
5681
  mutationStateRefreshInFlight = false;
5496
5682
  }
5497
- if (retry) scheduleMutationStateRefresh(1_000);
5683
+ if (retry || mutationStateRefreshRequested) scheduleMutationStateRefresh(retry ? 1_000 : 0);
5498
5684
  }
5499
5685
 
5500
- function prefetchRepositoryForReview(status) {
5686
+ function prefetchRepositoryForReview(status = null) {
5501
5687
  if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
5502
5688
  return Promise.resolve(null);
5503
5689
  }
5504
- status.textContent = "Checking repository…";
5690
+ if (status) status.textContent = "Checking repository…";
5505
5691
  return fetch("/api/git/prefetch", { method: "POST" })
5506
5692
  .then(async (response) => {
5507
5693
  if (!response.ok) throw new Error(await responseMessage(response));
5508
5694
  const result = await response.json();
5509
- status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
5695
+ if (status) status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
5510
5696
  return result;
5511
5697
  })
5512
5698
  .catch((error) => {
5513
- status.textContent = "Repository check failed";
5514
- return { error };
5699
+ if (status) status.textContent = "Repository check failed";
5700
+ return null;
5515
5701
  });
5516
5702
  }
5517
5703
 
@@ -5624,15 +5810,25 @@ async function localFetch(url, options) {
5624
5810
  }
5625
5811
  const scopedUrl = requestUrl.pathname + requestUrl.search + requestUrl.hash;
5626
5812
  const method = String(options?.method || "GET").toUpperCase();
5813
+ const mutation = ["POST", "PUT", "DELETE"].includes(method);
5814
+ const requestOptions = mutation
5815
+ ? {
5816
+ ...options,
5817
+ headers: {
5818
+ ...Object.fromEntries(new Headers(options?.headers || {}).entries()),
5819
+ prefer: "respond-async"
5820
+ }
5821
+ }
5822
+ : options;
5627
5823
  const synchronizing = state?.repository?.mode === "trunk"
5628
- && ["POST", "PUT", "DELETE"].includes(method)
5824
+ && mutation
5629
5825
  && !["/api/evidence-packet", "/api/git/prefetch"].includes(requestUrl.pathname);
5630
5826
  const chip = synchronizing ? document.querySelector(".repo-chip") : null;
5631
5827
  const previousChip = chip?.innerHTML;
5632
5828
  let repositoryRefreshed = false;
5633
5829
  if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
5634
5830
  try {
5635
- const response = await fetch(scopedUrl, options);
5831
+ const response = await fetch(scopedUrl, requestOptions);
5636
5832
  if (synchronizing && !response.ok) {
5637
5833
  try {
5638
5834
  const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));