changeledger 0.5.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) {
@@ -666,8 +669,316 @@ export function restoreInitialViewerShell(root = document, getStorage = () => wi
666
669
 
667
670
  let managedProject = null;
668
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 }
669
675
 
670
- export function projectsViewTemplate(projects, selected, config, localOnly) {
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
+ }
770
+
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
+ ) {
671
982
  const project = projects.find((item) => item.id === selected);
672
983
  return html`<div class="projects-shell">
673
984
  <div class="projects-list">
@@ -710,12 +1021,7 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
710
1021
  project.alive
711
1022
  ? config?.loading
712
1023
  ? html`<p class="empty">Loading configuration…</p>`
713
- : html`<form class="config-form">
714
- <div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
715
- <textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
716
- <p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
717
- <div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
718
- </form>`
1024
+ : configSectionTemplate(config, configMode, preview)
719
1025
  : html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
720
1026
  }
721
1027
  ${
@@ -730,9 +1036,11 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
730
1036
 
731
1037
  async function openManagedProject(id, { reload = false } = {}) {
732
1038
  managedProject = id;
1039
+ configDirty = false;
733
1040
  const project = state.projectsList.find((item) => item.id === id);
734
1041
  if (!project?.alive) {
735
1042
  managedConfig = null;
1043
+ migrationPreview = null;
736
1044
  renderProjects();
737
1045
  return;
738
1046
  }
@@ -741,9 +1049,17 @@ async function openManagedProject(id, { reload = false } = {}) {
741
1049
  return;
742
1050
  }
743
1051
  managedConfig = { id, loading: true };
1052
+ migrationPreview = null;
744
1053
  renderProjects();
745
1054
  try {
746
- 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
+ }
747
1063
  } catch (error) {
748
1064
  managedConfig = { id, content: '', revision: '', error: error.message };
749
1065
  }
@@ -770,15 +1086,20 @@ export async function projectMutation(root, request, onSuccess) {
770
1086
  if (error) {
771
1087
  error.textContent = failure.message;
772
1088
  error.hidden = false;
773
- } else alert(failure.message);
1089
+ } else showToast(failure.message);
774
1090
  } finally {
775
1091
  setProjectFormPending(root, false);
776
1092
  }
777
1093
  }
778
1094
 
779
- export function requestUnregisterConfirmation(project, ask = prompt) {
780
- 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(
781
1101
  `Type "${project.name}" to unregister this project. No repository files will be deleted.`,
1102
+ { placeholder: project.name },
782
1103
  );
783
1104
  }
784
1105
 
@@ -798,6 +1119,92 @@ async function refreshProjectRegistry() {
798
1119
  select.style.display = projects.length > 1 ? '' : 'none';
799
1120
  }
800
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
+
801
1208
  function renderProjects() {
802
1209
  const root = $('#projects');
803
1210
  litRender(
@@ -805,18 +1212,77 @@ function renderProjects() {
805
1212
  root,
806
1213
  );
807
1214
  bindProjectViewActions(root, {
808
- select: (id) => openManagedProject(id),
809
- reload: () => openManagedProject(managedProject, { reload: true }),
810
- 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) =>
811
1242
  projectMutation(
812
1243
  configForm,
813
1244
  () => postProjectConfig(managedProject, content, managedConfig.revision),
814
1245
  async (body) => {
815
- managedConfig = { id: managedProject, content, revision: body.revision };
1246
+ configDirty = false;
1247
+ managedConfig = { ...managedConfig, content, revision: body.revision };
816
1248
  await refreshProjectRegistry();
817
1249
  renderProjects();
818
1250
  },
819
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
+ },
820
1286
  repair: (projectPath, pathForm) =>
821
1287
  projectMutation(
822
1288
  pathForm,
@@ -826,13 +1292,13 @@ function renderProjects() {
826
1292
  await openManagedProject(managedProject, { reload: true });
827
1293
  },
828
1294
  ),
829
- unregister: (editor) => {
1295
+ unregister: async (editor) => {
830
1296
  const project = state.projectsList.find((item) => item.id === managedProject);
831
- const confirm = requestUnregisterConfirmation(project);
832
- if (confirm === null) return;
1297
+ const answer = await requestUnregisterConfirmation(project);
1298
+ if (answer === null) return;
833
1299
  projectMutation(
834
1300
  editor,
835
- () => postProjectRemove(managedProject, confirm),
1301
+ () => postProjectRemove(managedProject, answer),
836
1302
  async () => {
837
1303
  managedProject = null;
838
1304
  managedConfig = null;
@@ -849,14 +1315,39 @@ export function bindProjectViewActions(root, handlers) {
849
1315
  root.querySelectorAll('[data-manage-project]').forEach((button) => {
850
1316
  button.onclick = () => handlers.select(button.dataset.manageProject);
851
1317
  });
1318
+
1319
+ root.querySelectorAll('[data-config-mode]').forEach((button) => {
1320
+ button.onclick = () => handlers.switchMode(button.dataset.configMode);
1321
+ });
1322
+
852
1323
  const reload = root.querySelector('[data-reload-config]');
853
1324
  if (reload) reload.onclick = () => handlers.reload();
854
- const configForm = root.querySelector('.config-form');
855
- if (configForm)
856
- configForm.onsubmit = (event) => {
1325
+
1326
+ const rawForm = root.querySelector('.config-form:not([data-config-form])');
1327
+ if (rawForm) {
1328
+ rawForm.onsubmit = (event) => {
1329
+ event.preventDefault();
1330
+ handlers.saveRaw(rawForm.querySelector('textarea').value, rawForm);
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) => {
857
1338
  event.preventDefault();
858
- handlers.save(configForm.querySelector('textarea').value, configForm);
1339
+ handlers.saveForm(formEditor, formEditor);
859
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
+
860
1351
  const pathForm = root.querySelector('.project-path-form');
861
1352
  if (pathForm)
862
1353
  pathForm.onsubmit = (event) => {
@@ -980,8 +1471,17 @@ function bootstrap() {
980
1471
  $('#view-specs').onclick = () => activateView('specs');
981
1472
  $('#view-metrics').onclick = () => activateView('metrics');
982
1473
  $('#view-projects').onclick = () => activateView('projects');
983
- $('#project').onchange = (e) => {
984
- 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);
985
1485
  load();
986
1486
  };
987
1487
  document.onkeydown = (e) => {
@@ -61,6 +61,8 @@
61
61
  </div>
62
62
  </div>
63
63
 
64
+ <div id="toast-container" class="toast-container" aria-live="assertive" aria-atomic="true"></div>
65
+
64
66
  <script type="module" src="/app.js"></script>
65
67
  </body>
66
68
  </html>