changeledger 0.4.0 → 0.6.0

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.
@@ -1,8 +1,11 @@
1
1
  import {
2
+ getConfigMigrationPreview,
2
3
  getGitRefs,
3
- getProjectConfig,
4
+ getProjectConfigStructured,
4
5
  getProjects,
5
6
  getRepo,
7
+ patchProjectConfigApi,
8
+ postConfigMigrationApply,
6
9
  postProjectConfig,
7
10
  postProjectPath,
8
11
  postProjectRemove,
@@ -255,11 +258,11 @@ async function moveStatus(id, status, reason) {
255
258
  const res = await postStatus(state.currentProject, id, status, reason);
256
259
  const out = await res.json();
257
260
  if (!res.ok) {
258
- alert(out.error || 'status change failed');
261
+ showToast(out.error || 'status change failed');
259
262
  return;
260
263
  }
261
264
  } catch (e) {
262
- alert(e.message);
265
+ showToast(e.message);
263
266
  return;
264
267
  }
265
268
  invalidateCache();
@@ -485,7 +488,7 @@ export function createDiagramLightbox({ overlay, canvas, closeButton }) {
485
488
  async function gotoChange(proj, changeId) {
486
489
  const match = state.projectsList.find((p) => p.id === proj || p.name === proj);
487
490
  if (!match?.alive) {
488
- alert(`Project "${proj}" is not registered or its path is gone.`);
491
+ showToast(`Project "${proj}" is not registered or its path is gone.`);
489
492
  return;
490
493
  }
491
494
  if (match.id !== state.currentProject) {
@@ -595,11 +598,41 @@ function openSpec(s) {
595
598
  );
596
599
  const overlay = $('#overlay');
597
600
  overlay.classList.remove('hidden');
598
- $('#detail').querySelector('.close').onclick = closeDetail;
601
+ const detail = $('#detail');
602
+ detail.querySelector('.close').onclick = closeDetail;
599
603
  overlay.onclick = (e) => {
600
604
  if (e.target === overlay) closeDetail();
601
605
  };
602
- renderExpandableMermaid($('#detail'));
606
+ detail.onclick = (e) => handleSpecBodyClick(e, (href) => openSpecByName(href, state, openSpec));
607
+ renderExpandableMermaid(detail);
608
+ }
609
+
610
+ /**
611
+ * Normalizes a spec href and opens the matching spec.
612
+ * Exported for testing: accepts `repoState` and `_openSpec` to avoid DOM coupling.
613
+ */
614
+ export function openSpecByName(href, repoState = state, _openSpec = openSpec) {
615
+ const name = href.replace(/^\.\//, '').replace(/\.md$/, '');
616
+ const found = (repoState.repo?.specs ?? []).find((s) => s.name.replace(/\.md$/, '') === name);
617
+ if (found) _openSpec(found);
618
+ }
619
+
620
+ /**
621
+ * Click handler for the spec body container.
622
+ * Exported for testing: accepts `_openSpecByName` callback to avoid DOM coupling.
623
+ * Intercepts relative *.md links only; lets external links through unchanged.
624
+ */
625
+ export function handleSpecBodyClick(event, _openSpecByName) {
626
+ const anchor = event.target.closest('a');
627
+ if (!anchor) return;
628
+ const href = anchor.getAttribute('href');
629
+ if (!href) return;
630
+ // Let external links (with scheme or absolute path) through unchanged.
631
+ if (/^[a-z][a-z\d+\-.]*:/i.test(href) || href.startsWith('/')) return;
632
+ // Only intercept relative *.md links.
633
+ if (!href.endsWith('.md')) return;
634
+ event.preventDefault();
635
+ _openSpecByName(href);
603
636
  }
604
637
 
605
638
  const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
@@ -636,8 +669,316 @@ export function restoreInitialViewerShell(root = document, getStorage = () => wi
636
669
 
637
670
  let managedProject = null;
638
671
  let managedConfig = null;
672
+ let configMode = 'form'; // 'form' | 'raw'
673
+ let configDirty = false; // true when form/raw has unsaved edits
674
+ let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
675
+
676
+ const SUPPORTED_SCHEMA_VERSION = 1;
677
+ // Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
678
+ // _confirmImpl is replaceable in tests (JSDOM lacks showModal).
679
+ let _confirmImpl = null;
680
+ let dialogSequence = 0;
681
+
682
+ export function showToast(message, { type = 'error', duration = 4000 } = {}) {
683
+ const container = document.getElementById('toast-container');
684
+ if (!container) return;
685
+ const el = document.createElement('div');
686
+ el.className = `toast toast-${type}`;
687
+ el.textContent = message;
688
+ el.setAttribute('role', 'alert');
689
+ container.appendChild(el);
690
+ setTimeout(() => el.remove(), duration);
691
+ }
692
+
693
+ export function setConfirmImpl(impl) {
694
+ _confirmImpl = impl;
695
+ }
696
+
697
+ // Prompt dialog — returns the entered string or null (cancel). Mockable via _promptImpl.
698
+ let _promptImpl = null;
699
+ export function setPromptImpl(impl) {
700
+ _promptImpl = impl;
701
+ }
702
+
703
+ export function showPrompt(message, { placeholder = '' } = {}) {
704
+ if (_promptImpl !== null) return Promise.resolve(_promptImpl(message));
705
+ return new Promise((resolve) => {
706
+ const id = `cl-prompt-${++dialogSequence}`;
707
+ const dialog = document.createElement('dialog');
708
+ dialog.className = 'cl-confirm-dialog';
709
+ dialog.setAttribute('aria-labelledby', `${id}-title`);
710
+ dialog.innerHTML = `
711
+ <p id="${id}-title" class="cl-confirm-message"></p>
712
+ <label for="${id}-input" class="cl-prompt-label">Confirmation value</label>
713
+ <input id="${id}-input" class="cl-prompt-input" type="text" autocomplete="off" />
714
+ <div class="cl-confirm-actions">
715
+ <button type="button" class="button cl-confirm-yes">Confirm</button>
716
+ <button type="button" class="button secondary cl-confirm-no">Cancel</button>
717
+ </div>`;
718
+ dialog.querySelector('.cl-confirm-message').textContent = message;
719
+ const input = dialog.querySelector('.cl-prompt-input');
720
+ if (placeholder) input.placeholder = placeholder;
721
+ document.body.appendChild(dialog);
722
+ const done = (result) => {
723
+ dialog.close();
724
+ dialog.remove();
725
+ resolve(result);
726
+ };
727
+ dialog.querySelector('.cl-confirm-yes').onclick = () => done(input.value);
728
+ dialog.querySelector('.cl-confirm-no').onclick = () => done(null);
729
+ dialog.addEventListener('cancel', () => done(null));
730
+ dialog.addEventListener('click', (e) => {
731
+ if (e.target === dialog) done(null);
732
+ });
733
+ input.addEventListener('keydown', (e) => {
734
+ if (e.key === 'Enter') done(input.value);
735
+ });
736
+ dialog.showModal();
737
+ input.focus();
738
+ });
739
+ }
740
+
741
+ export function showConfirm(message) {
742
+ if (_confirmImpl) return Promise.resolve(_confirmImpl(message));
743
+ return new Promise((resolve) => {
744
+ const id = `cl-confirm-${++dialogSequence}`;
745
+ const dialog = document.createElement('dialog');
746
+ dialog.className = 'cl-confirm-dialog';
747
+ dialog.setAttribute('aria-labelledby', `${id}-title`);
748
+ dialog.innerHTML = `
749
+ <p id="${id}-title" class="cl-confirm-message"></p>
750
+ <div class="cl-confirm-actions">
751
+ <button type="button" class="button cl-confirm-yes">Confirm</button>
752
+ <button type="button" class="button secondary cl-confirm-no">Cancel</button>
753
+ </div>`;
754
+ dialog.querySelector('.cl-confirm-message').textContent = message;
755
+ document.body.appendChild(dialog);
756
+ const done = (result) => {
757
+ dialog.close();
758
+ dialog.remove();
759
+ resolve(result);
760
+ };
761
+ dialog.querySelector('.cl-confirm-yes').onclick = () => done(true);
762
+ dialog.querySelector('.cl-confirm-no').onclick = () => done(false);
763
+ dialog.addEventListener('cancel', () => done(false));
764
+ dialog.addEventListener('click', (e) => {
765
+ if (e.target === dialog) done(false);
766
+ });
767
+ dialog.showModal();
768
+ });
769
+ }
639
770
 
640
- export function projectsViewTemplate(projects, selected, config, localOnly) {
771
+ function configSectionTemplate(config, mode, preview) {
772
+ if (!config) return nothing;
773
+ if (config.error) {
774
+ return html`<div class="config-section">
775
+ <p class="project-error" role="alert" aria-live="assertive">${config.error}</p>
776
+ </div>`;
777
+ }
778
+
779
+ const schema = config.schemaVersion ?? 0;
780
+ const futureSch = schema > SUPPORTED_SCHEMA_VERSION;
781
+ const outdated = schema < SUPPORTED_SCHEMA_VERSION;
782
+
783
+ return html`<div class="config-section">
784
+ ${
785
+ !futureSch
786
+ ? html`<div class="config-tabs" role="tablist" aria-label="Config editor mode">
787
+ <button
788
+ type="button"
789
+ role="tab"
790
+ class=${`config-tab${mode === 'form' ? ' active' : ''}`}
791
+ aria-selected=${mode === 'form'}
792
+ data-config-mode="form"
793
+ >Form</button>
794
+ <button
795
+ type="button"
796
+ role="tab"
797
+ class=${`config-tab${mode === 'raw' ? ' active' : ''}`}
798
+ aria-selected=${mode === 'raw'}
799
+ data-config-mode="raw"
800
+ >Raw YAML</button>
801
+ </div>`
802
+ : nothing
803
+ }
804
+
805
+ ${
806
+ futureSch
807
+ ? html`<div class="config-future-schema">
808
+ <p class="config-schema-badge">Schema ${schema}</p>
809
+ <p>Update ChangeLedger to edit config schema ${schema}.</p>
810
+ </div>
811
+ ${rawReadonlyTemplate(config)}`
812
+ : outdated
813
+ ? html`<div class="config-migration-card">
814
+ <h3>Migration required</h3>
815
+ <p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${SUPPORTED_SCHEMA_VERSION} to enable the Form editor.</p>
816
+ ${
817
+ preview?.already_current
818
+ ? html`<p class="config-migration-ok">Migration already applied.</p>`
819
+ : preview?.error
820
+ ? html`<p class="project-error" role="alert" aria-live="assertive">${preview.error}</p>
821
+ <div class="project-actions">
822
+ <button class="button secondary" type="button" data-preview-migration>Retry preview</button>
823
+ </div>`
824
+ : preview
825
+ ? html`<div class="config-migration-preview">
826
+ <p class="config-migration-summary">${preview.summary}</p>
827
+ <p><strong>Changes:</strong></p>
828
+ <ul>${preview.changes?.map((c) => html`<li>${c}</li>`)}</ul>
829
+ <pre class="config-migration-yaml">${preview.yaml}</pre>
830
+ <div class="project-actions">
831
+ <button class="button" type="button" data-apply-migration>Apply migration</button>
832
+ </div>
833
+ </div>`
834
+ : html`<div class="project-actions">
835
+ <button class="button secondary" type="button" data-preview-migration>Preview migration</button>
836
+ </div>`
837
+ }
838
+ <p class="config-section-note">You can still inspect the current config in Raw YAML.</p>
839
+ ${rawEditorTemplate(config)}
840
+ </div>`
841
+ : mode === 'form'
842
+ ? formEditorTemplate(config)
843
+ : rawEditorTemplate(config)
844
+ }
845
+ </div>`;
846
+ }
847
+
848
+ // Raw editor with Save button (for editable schemas)
849
+ function rawEditorTemplate(config) {
850
+ return html`<form class="config-form">
851
+ <div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
852
+ <textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
853
+ <p class="project-error" role="alert" aria-live="assertive" ?hidden=${!config?.rawError}>${config?.rawError ?? ''}</p>
854
+ <div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
855
+ </form>`;
856
+ }
857
+
858
+ // Raw viewer without Save (future schema — strictly read-only)
859
+ function rawReadonlyTemplate(config) {
860
+ return html`<div class="config-form config-form-readonly">
861
+ <div class="config-label"><label for="project-config-ro">.changeledger/config.yml</label></div>
862
+ <textarea id="project-config-ro" spellcheck="false" readonly .value=${config?.content ?? ''}></textarea>
863
+ <p class="config-note">Editing disabled for schema ${config.schemaVersion ?? '?'}. Update ChangeLedger to enable editing.</p>
864
+ </div>`;
865
+ }
866
+
867
+ function formEditorTemplate(config) {
868
+ const cfg = config.config ?? {};
869
+ const types = cfg.types ?? {};
870
+ const impacts = cfg.release?.impacts ?? {};
871
+ const readiness = cfg.readiness ?? {};
872
+ const allStatuses = cfg.statuses ?? [];
873
+ const stages = cfg.stages ?? [];
874
+
875
+ return html`<form class="config-form config-form-structured" data-config-form>
876
+ <div class="config-label">
877
+ <label>.changeledger/config.yml (Form)</label>
878
+ <button type="button" class="text-button" data-reload-config>Reload</button>
879
+ </div>
880
+
881
+ <fieldset class="config-group">
882
+ <legend>General</legend>
883
+ <label>Project name
884
+ <input name="project_name" .value=${cfg.project_name ?? ''} />
885
+ </label>
886
+ <label>Language
887
+ <input name="language" .value=${cfg.language ?? 'en'} />
888
+ </label>
889
+ <label class="config-checkbox">
890
+ <input type="checkbox" name="tdd" ?checked=${cfg.tdd !== false} />
891
+ TDD mode (require test-grade criteria)
892
+ </label>
893
+ </fieldset>
894
+
895
+ <fieldset class="config-group">
896
+ <legend>Paths</legend>
897
+ <p class="config-note">Changing paths only updates the config — existing files are not moved.</p>
898
+ <label>Changes directory
899
+ <input name="changes_dir" .value=${cfg.changes_dir ?? '.changeledger/changes'} />
900
+ </label>
901
+ <label>Specs directory
902
+ <input name="specs_dir" .value=${cfg.specs_dir ?? '.changeledger/specs'} />
903
+ </label>
904
+ </fieldset>
905
+
906
+ <fieldset class="config-group">
907
+ <legend>Lifecycle statuses</legend>
908
+ <label>Status order (one per line)
909
+ <textarea name="statuses" rows="8">${allStatuses.join('\n')}</textarea>
910
+ </label>
911
+ <p class="config-note">Canonical statuses are required. Custom statuses may be added and reordered.</p>
912
+ </fieldset>
913
+
914
+ <fieldset class="config-group">
915
+ <legend>Lifecycle stages</legend>
916
+ <label>Canonical stage order (one per line)
917
+ <textarea name="stages" rows="6">${stages.join('\n')}</textarea>
918
+ </label>
919
+ <p class="config-note">Stages used by a change type cannot be removed until that type is updated.</p>
920
+ </fieldset>
921
+
922
+ <fieldset class="config-group">
923
+ <legend>Change types</legend>
924
+ ${Object.entries(types).map(
925
+ ([typeName, typeDef]) => html`
926
+ <fieldset class="config-type-row">
927
+ <legend>${typeName}</legend>
928
+ <label>Active stages (one per line)
929
+ <textarea name=${`stages_${typeName}`} rows="3">${(typeDef?.stages ?? []).join('\n')}</textarea>
930
+ </label>
931
+ <label>Review policy
932
+ <select name=${`review_required_${typeName}`}>
933
+ <option value="" ?selected=${!Object.hasOwn(typeDef ?? {}, 'review_required')}>Not configured</option>
934
+ <option value="true" ?selected=${typeDef?.review_required === true}>Required</option>
935
+ <option value="false" ?selected=${typeDef?.review_required === false}>Not required</option>
936
+ </select>
937
+ </label>
938
+ <label>SemVer impact
939
+ <select name=${`impact_${typeName}`}>
940
+ <option value="" ?selected=${!Object.hasOwn(impacts, typeName)}>Not configured</option>
941
+ ${['none', 'patch', 'minor', 'major'].map(
942
+ (v) =>
943
+ html`<option value=${v} ?selected=${impacts[typeName] === v}>${v}</option>`,
944
+ )}
945
+ </select>
946
+ </label>
947
+ </fieldset>
948
+ `,
949
+ )}
950
+ </fieldset>
951
+
952
+ <fieldset class="config-group">
953
+ <legend>Definition of Ready</legend>
954
+ <label>Target patterns (one per line)
955
+ <textarea name="target_patterns" rows="3">${(readiness.target_patterns ?? []).join('\n')}</textarea>
956
+ </label>
957
+ <label>Verification patterns (one per line)
958
+ <textarea name="verification_patterns" rows="3">${(readiness.verification_patterns ?? []).join('\n')}</textarea>
959
+ </label>
960
+ </fieldset>
961
+
962
+ <fieldset class="config-group config-group-internal">
963
+ <legend>Internal</legend>
964
+ <p><span class="config-readonly-label">schema_version</span><span class="config-readonly-value">${cfg.schema_version ?? 0}</span></p>
965
+ <p><span class="config-readonly-label">project_id</span><span class="config-readonly-value mono">${cfg.project_id ?? ''}</span></p>
966
+ </fieldset>
967
+
968
+ <p class="project-error" role="alert" aria-live="assertive" ?hidden=${!config?.formError}>${config?.formError ?? ''}</p>
969
+ <div class="project-actions">
970
+ <button class="button" type="submit">Save configuration</button>
971
+ </div>
972
+ </form>`;
973
+ }
974
+
975
+ export function projectsViewTemplate(
976
+ projects,
977
+ selected,
978
+ config,
979
+ localOnly,
980
+ preview = migrationPreview,
981
+ ) {
641
982
  const project = projects.find((item) => item.id === selected);
642
983
  return html`<div class="projects-shell">
643
984
  <div class="projects-list">
@@ -680,12 +1021,7 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
680
1021
  project.alive
681
1022
  ? config?.loading
682
1023
  ? html`<p class="empty">Loading configuration…</p>`
683
- : html`<form class="config-form">
684
- <div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
685
- <textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
686
- <p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
687
- <div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
688
- </form>`
1024
+ : configSectionTemplate(config, configMode, preview)
689
1025
  : html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
690
1026
  }
691
1027
  ${
@@ -700,9 +1036,11 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
700
1036
 
701
1037
  async function openManagedProject(id, { reload = false } = {}) {
702
1038
  managedProject = id;
1039
+ configDirty = false;
703
1040
  const project = state.projectsList.find((item) => item.id === id);
704
1041
  if (!project?.alive) {
705
1042
  managedConfig = null;
1043
+ migrationPreview = null;
706
1044
  renderProjects();
707
1045
  return;
708
1046
  }
@@ -711,9 +1049,17 @@ async function openManagedProject(id, { reload = false } = {}) {
711
1049
  return;
712
1050
  }
713
1051
  managedConfig = { id, loading: true };
1052
+ migrationPreview = null;
714
1053
  renderProjects();
715
1054
  try {
716
- managedConfig = { id, ...(await getProjectConfig(id)) };
1055
+ const structured = await getProjectConfigStructured(id);
1056
+ managedConfig = { id, ...structured };
1057
+ // Default to form for current schema, raw for future schema
1058
+ if (structured.schemaVersion > SUPPORTED_SCHEMA_VERSION) {
1059
+ configMode = 'raw';
1060
+ } else {
1061
+ configMode = 'form';
1062
+ }
717
1063
  } catch (error) {
718
1064
  managedConfig = { id, content: '', revision: '', error: error.message };
719
1065
  }
@@ -740,15 +1086,20 @@ export async function projectMutation(root, request, onSuccess) {
740
1086
  if (error) {
741
1087
  error.textContent = failure.message;
742
1088
  error.hidden = false;
743
- } else alert(failure.message);
1089
+ } else showToast(failure.message);
744
1090
  } finally {
745
1091
  setProjectFormPending(root, false);
746
1092
  }
747
1093
  }
748
1094
 
749
- export function requestUnregisterConfirmation(project, ask = prompt) {
750
- return ask(
1095
+ export function requestUnregisterConfirmation(project, ask = null) {
1096
+ if (ask !== null)
1097
+ return ask(
1098
+ `Type "${project.name}" to unregister this project. No repository files will be deleted.`,
1099
+ );
1100
+ return showPrompt(
751
1101
  `Type "${project.name}" to unregister this project. No repository files will be deleted.`,
1102
+ { placeholder: project.name },
752
1103
  );
753
1104
  }
754
1105
 
@@ -768,6 +1119,92 @@ async function refreshProjectRegistry() {
768
1119
  select.style.display = projects.length > 1 ? '' : 'none';
769
1120
  }
770
1121
 
1122
+ const listFromControl = (control) =>
1123
+ (control?.value ?? '')
1124
+ .split('\n')
1125
+ .map((value) => value.trim())
1126
+ .filter(Boolean);
1127
+
1128
+ const sameList = (left = [], right = []) =>
1129
+ left.length === right.length && left.every((value, index) => value === right[index]);
1130
+
1131
+ export function collectFormPatch(formEl, currentConfig) {
1132
+ const patch = {};
1133
+ const els = formEl.elements;
1134
+
1135
+ if (els.project_name && els.project_name.value !== (currentConfig.project_name ?? '')) {
1136
+ patch.project_name = els.project_name.value;
1137
+ }
1138
+ if (els.language && els.language.value !== (currentConfig.language ?? 'en')) {
1139
+ patch.language = els.language.value;
1140
+ }
1141
+ if (els.tdd && els.tdd.checked !== (currentConfig.tdd !== false)) patch.tdd = els.tdd.checked;
1142
+ if (
1143
+ els.changes_dir &&
1144
+ els.changes_dir.value !== (currentConfig.changes_dir ?? '.changeledger/changes')
1145
+ ) {
1146
+ patch.changes_dir = els.changes_dir.value;
1147
+ }
1148
+ if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
1149
+ patch.specs_dir = els.specs_dir.value;
1150
+ }
1151
+
1152
+ const statuses = listFromControl(els.statuses);
1153
+ if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
1154
+ const stages = listFromControl(els.stages);
1155
+ if (els.stages && !sameList(stages, currentConfig.stages ?? [])) patch.stages = stages;
1156
+
1157
+ // Type fields are tri-state: an empty select means the key is not configured.
1158
+ const types = currentConfig.types ?? {};
1159
+ const existingImpacts = currentConfig.release?.impacts ?? {};
1160
+ const typePatch = {};
1161
+ const impacts = {};
1162
+ for (const typeName of Object.keys(types)) {
1163
+ const currentType = types[typeName] ?? {};
1164
+ const typeStages = listFromControl(els[`stages_${typeName}`]);
1165
+ if (els[`stages_${typeName}`] && !sameList(typeStages, currentType.stages ?? [])) {
1166
+ typePatch[typeName] = { ...(typePatch[typeName] ?? {}), stages: typeStages };
1167
+ }
1168
+
1169
+ const rrEl = els[`review_required_${typeName}`];
1170
+ const currentReview = Object.hasOwn(currentType, 'review_required')
1171
+ ? String(currentType.review_required)
1172
+ : '';
1173
+ if (rrEl && rrEl.value !== currentReview) {
1174
+ typePatch[typeName] = {
1175
+ ...(typePatch[typeName] ?? {}),
1176
+ review_required: rrEl.value === '' ? null : rrEl.value === 'true',
1177
+ };
1178
+ }
1179
+
1180
+ const impactEl = els[`impact_${typeName}`];
1181
+ const currentImpact = Object.hasOwn(existingImpacts, typeName) ? existingImpacts[typeName] : '';
1182
+ if (impactEl && impactEl.value !== currentImpact) {
1183
+ impacts[typeName] = impactEl.value === '' ? null : impactEl.value;
1184
+ }
1185
+ }
1186
+ if (Object.keys(typePatch).length) patch.types = typePatch;
1187
+ if (Object.keys(impacts).length) patch.release = { impacts };
1188
+
1189
+ // readiness patterns
1190
+ if (els.target_patterns !== undefined) {
1191
+ const targetPatterns = listFromControl(els.target_patterns);
1192
+ const verifyPatterns = listFromControl(els.verification_patterns);
1193
+ const currentReadiness = currentConfig.readiness ?? {};
1194
+ if (
1195
+ !sameList(targetPatterns, currentReadiness.target_patterns ?? []) ||
1196
+ !sameList(verifyPatterns, currentReadiness.verification_patterns ?? [])
1197
+ ) {
1198
+ patch.readiness = {
1199
+ target_patterns: targetPatterns,
1200
+ verification_patterns: verifyPatterns,
1201
+ };
1202
+ }
1203
+ }
1204
+
1205
+ return patch;
1206
+ }
1207
+
771
1208
  function renderProjects() {
772
1209
  const root = $('#projects');
773
1210
  litRender(
@@ -775,18 +1212,77 @@ function renderProjects() {
775
1212
  root,
776
1213
  );
777
1214
  bindProjectViewActions(root, {
778
- select: (id) => openManagedProject(id),
779
- reload: () => openManagedProject(managedProject, { reload: true }),
780
- save: (content, configForm) =>
1215
+ select: async (id) => {
1216
+ if (configDirty) {
1217
+ const ok = await showConfirm('You have unsaved changes. Switch project and discard them?');
1218
+ if (!ok) return;
1219
+ }
1220
+ openManagedProject(id);
1221
+ },
1222
+ reload: async () => {
1223
+ if (configDirty) {
1224
+ const ok = await showConfirm('Reload will discard your unsaved changes. Continue?');
1225
+ if (!ok) return;
1226
+ }
1227
+ openManagedProject(managedProject, { reload: true });
1228
+ },
1229
+ markDirty: () => {
1230
+ configDirty = true;
1231
+ },
1232
+ switchMode: async (mode) => {
1233
+ if (configDirty && mode !== configMode) {
1234
+ const ok = await showConfirm('You have unsaved changes. Switch mode and discard them?');
1235
+ if (!ok) return;
1236
+ }
1237
+ configMode = mode;
1238
+ configDirty = false;
1239
+ renderProjects();
1240
+ },
1241
+ saveRaw: (content, configForm) =>
781
1242
  projectMutation(
782
1243
  configForm,
783
1244
  () => postProjectConfig(managedProject, content, managedConfig.revision),
784
1245
  async (body) => {
785
- managedConfig = { id: managedProject, content, revision: body.revision };
1246
+ configDirty = false;
1247
+ managedConfig = { ...managedConfig, content, revision: body.revision };
786
1248
  await refreshProjectRegistry();
787
1249
  renderProjects();
788
1250
  },
789
1251
  ),
1252
+ saveForm: (formEl, configForm) =>
1253
+ projectMutation(
1254
+ configForm,
1255
+ () => {
1256
+ const patch = collectFormPatch(formEl, managedConfig.config ?? {});
1257
+ return patchProjectConfigApi(managedProject, patch, managedConfig.revision);
1258
+ },
1259
+ async (_body) => {
1260
+ configDirty = false;
1261
+ await openManagedProject(managedProject, { reload: true });
1262
+ },
1263
+ ),
1264
+ previewMigration: async () => {
1265
+ try {
1266
+ const result = await getConfigMigrationPreview(managedProject, managedConfig.revision);
1267
+ migrationPreview = result;
1268
+ } catch (e) {
1269
+ migrationPreview = { error: e.message };
1270
+ }
1271
+ renderProjects();
1272
+ },
1273
+ applyMigration: async () => {
1274
+ const ok = await showConfirm(
1275
+ 'Apply the config migration? This will update .changeledger/config.yml.',
1276
+ );
1277
+ if (!ok) return;
1278
+ try {
1279
+ await postConfigMigrationApply(managedProject, managedConfig.revision);
1280
+ await openManagedProject(managedProject, { reload: true });
1281
+ } catch (e) {
1282
+ migrationPreview = { error: e.message };
1283
+ renderProjects();
1284
+ }
1285
+ },
790
1286
  repair: (projectPath, pathForm) =>
791
1287
  projectMutation(
792
1288
  pathForm,
@@ -796,13 +1292,13 @@ function renderProjects() {
796
1292
  await openManagedProject(managedProject, { reload: true });
797
1293
  },
798
1294
  ),
799
- unregister: (editor) => {
1295
+ unregister: async (editor) => {
800
1296
  const project = state.projectsList.find((item) => item.id === managedProject);
801
- const confirm = requestUnregisterConfirmation(project);
802
- if (confirm === null) return;
1297
+ const answer = await requestUnregisterConfirmation(project);
1298
+ if (answer === null) return;
803
1299
  projectMutation(
804
1300
  editor,
805
- () => postProjectRemove(managedProject, confirm),
1301
+ () => postProjectRemove(managedProject, answer),
806
1302
  async () => {
807
1303
  managedProject = null;
808
1304
  managedConfig = null;
@@ -819,14 +1315,39 @@ export function bindProjectViewActions(root, handlers) {
819
1315
  root.querySelectorAll('[data-manage-project]').forEach((button) => {
820
1316
  button.onclick = () => handlers.select(button.dataset.manageProject);
821
1317
  });
1318
+
1319
+ root.querySelectorAll('[data-config-mode]').forEach((button) => {
1320
+ button.onclick = () => handlers.switchMode(button.dataset.configMode);
1321
+ });
1322
+
822
1323
  const reload = root.querySelector('[data-reload-config]');
823
1324
  if (reload) reload.onclick = () => handlers.reload();
824
- const configForm = root.querySelector('.config-form');
825
- if (configForm)
826
- configForm.onsubmit = (event) => {
1325
+
1326
+ const rawForm = root.querySelector('.config-form:not([data-config-form])');
1327
+ if (rawForm) {
1328
+ rawForm.onsubmit = (event) => {
827
1329
  event.preventDefault();
828
- handlers.save(configForm.querySelector('textarea').value, configForm);
1330
+ handlers.saveRaw(rawForm.querySelector('textarea').value, rawForm);
829
1331
  };
1332
+ rawForm.querySelector('textarea')?.addEventListener('input', () => handlers.markDirty?.());
1333
+ }
1334
+
1335
+ const formEditor = root.querySelector('[data-config-form]');
1336
+ if (formEditor) {
1337
+ formEditor.onsubmit = (event) => {
1338
+ event.preventDefault();
1339
+ handlers.saveForm(formEditor, formEditor);
1340
+ };
1341
+ formEditor.addEventListener('input', () => handlers.markDirty?.());
1342
+ formEditor.addEventListener('change', () => handlers.markDirty?.());
1343
+ }
1344
+
1345
+ const previewBtn = root.querySelector('[data-preview-migration]');
1346
+ if (previewBtn) previewBtn.onclick = () => handlers.previewMigration();
1347
+
1348
+ const applyBtn = root.querySelector('[data-apply-migration]');
1349
+ if (applyBtn) applyBtn.onclick = () => handlers.applyMigration();
1350
+
830
1351
  const pathForm = root.querySelector('.project-path-form');
831
1352
  if (pathForm)
832
1353
  pathForm.onsubmit = (event) => {
@@ -950,8 +1471,17 @@ function bootstrap() {
950
1471
  $('#view-specs').onclick = () => activateView('specs');
951
1472
  $('#view-metrics').onclick = () => activateView('metrics');
952
1473
  $('#view-projects').onclick = () => activateView('projects');
953
- $('#project').onchange = (e) => {
954
- selectProject(e.target.value);
1474
+ $('#project').onchange = async (e) => {
1475
+ const nextProject = e.target.value;
1476
+ if (state.currentView === 'projects' && configDirty && nextProject !== state.currentProject) {
1477
+ const ok = await showConfirm('You have unsaved changes. Switch project and discard them?');
1478
+ if (!ok) {
1479
+ e.target.value = state.currentProject;
1480
+ return;
1481
+ }
1482
+ configDirty = false;
1483
+ }
1484
+ selectProject(nextProject);
955
1485
  load();
956
1486
  };
957
1487
  document.onkeydown = (e) => {