@ibgib/space-gib 0.0.34 → 0.0.36

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.
Files changed (54) hide show
  1. package/dist/client/bootstrap.mjs +1 -1
  2. package/dist/client/{chunk-25PXC4HP.mjs → chunk-5DTAUY3E.mjs} +1586 -464
  3. package/dist/client/chunk-J7X3XTAI.mjs +53 -0
  4. package/dist/client/css/activity-bar.css +21 -3
  5. package/dist/client/css/layout.css +126 -5
  6. package/dist/client/css/tutorial.css +327 -0
  7. package/dist/client/index.html +6 -0
  8. package/dist/client/index.mjs +22 -45
  9. package/dist/client/privacy.html +22 -22
  10. package/dist/client/script.mjs +1 -1
  11. package/dist/client/style.css +1 -0
  12. package/dist/server/server.mjs +3 -3
  13. package/package.json +6 -6
  14. package/src/client/AUTO-GENERATED-version.mts +1 -1
  15. package/src/client/cosmos/seed-cosmos-workspace.mts +20 -6
  16. package/src/client/css/activity-bar.css +21 -3
  17. package/src/client/css/layout.css +126 -5
  18. package/src/client/css/tutorial.css +327 -0
  19. package/src/client/dev-tools/vcs-workspace.mts +4 -4
  20. package/src/client/index.html +6 -0
  21. package/src/client/privacy.html +22 -22
  22. package/src/client/style.css +1 -0
  23. package/src/client/ui/component/changes/changes.css +413 -0
  24. package/src/client/ui/component/changes/changes.html +37 -0
  25. package/src/client/ui/component/changes/changes.mts +766 -0
  26. package/src/client/ui/component/changes/index.mts +9 -0
  27. package/src/client/ui/component/chat-view/chat-view.html +6 -58
  28. package/src/client/ui/component/chat-view/chat-view.mts +13 -26
  29. package/src/client/ui/component/clone/clone.mts +67 -39
  30. package/src/client/ui/component/code-editor/code-editor.css +91 -9
  31. package/src/client/ui/component/code-editor/code-editor.html +58 -45
  32. package/src/client/ui/component/code-editor/code-editor.mts +265 -49
  33. package/src/client/ui/component/cosmos-navigator/cosmos-navigator.css +1 -1
  34. package/src/client/ui/component/cosmos-navigator/cosmos-navigator.html +5 -10
  35. package/src/client/ui/component/cosmos-navigator/cosmos-navigator.mts +328 -64
  36. package/src/client/ui/component/file-explorer/file-explorer.css +475 -11
  37. package/src/client/ui/component/file-explorer/file-explorer.html +45 -65
  38. package/src/client/ui/component/file-explorer/file-explorer.mts +1164 -98
  39. package/src/client/ui/component/nested-chat/chat/chat.mts +5 -90
  40. package/src/client/ui/component/nested-chat/nested-chat.mts +10 -8
  41. package/src/client/ui/component/timeline/timeline.css +16 -1
  42. package/src/client/ui/component/timeline/timeline.mts +37 -93
  43. package/src/client/ui/component/tutorial/index.mts +9 -0
  44. package/src/client/ui/component/tutorial/scripts/pitch-tour-script.mts +265 -0
  45. package/src/client/ui/component/tutorial/tutorial-controller.mts +622 -0
  46. package/src/client/ui/component/tutorial/tutorial-types.mts +45 -0
  47. package/src/client/ui/component/tutorial/tutorial.css +260 -0
  48. package/src/client/ui/router/space-gib-router-helpers.mts +33 -233
  49. package/src/client/ui/router/space-gib-router-helpers.respec.mts +298 -122
  50. package/src/client/ui/router/space-gib-router-service.mts +28 -151
  51. package/src/client/ui/router/space-gib-router-types.mts +32 -76
  52. package/src/client/ui/shell/cosmic-big-bang.mts +12 -3
  53. package/src/client/ui/shell/space-gib-shell-service.mts +715 -143
  54. package/dist/client/chunk-RXWLL2AI.mjs +0 -30
@@ -0,0 +1,766 @@
1
+ import thisCss from "./changes.css";
2
+ import thisHtml from "./changes.html";
3
+ import vcsStylesCss from "@ibgib/web-gib/dist/ui/component/vcs-styles.css";
4
+
5
+ import { ROOT_ADDR } from "@ibgib/ts-gib/dist/V1/constants.mjs";
6
+ import { IbGibAddr } from "@ibgib/ts-gib/dist/types.mjs";
7
+ import { IbGib_V1 } from "@ibgib/ts-gib/dist/V1/types.mjs";
8
+ import { getIbGibAddr } from "@ibgib/ts-gib/dist/helper.mjs";
9
+ import { extractErrorMsg } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs";
10
+ import {
11
+ IbGibDynamicComponentMetaBase, IbGibDynamicComponentInstanceBase,
12
+ } from "@ibgib/web-gib/dist/ui/component/ibgib-dynamic-component-bases.mjs";
13
+ import {
14
+ ElementsBase, IbGibDynamicComponentInstance,
15
+ } from "@ibgib/web-gib/dist/ui/component/component-types.mjs";
16
+ import { getComponentCtorArg } from "@ibgib/web-gib/dist/app-bootstrap/init-orchestration.mjs";
17
+ import { getPlaygroundMetaspaceAndSpace } from "../../../cosmos/seed-cosmos-workspace.mjs";
18
+ import {
19
+ getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib,
20
+ commitChangesToGalaxyBranch,
21
+ ChangedFileInfo,
22
+ } from "@ibgib/core-gib/dist/cosmology/branching/item/item-helpers.mjs";
23
+ import { B2tFSGalaxyIbGib_V1 } from "@ibgib/core-gib/dist/cosmology/branching/repo/b2tfs-repo-types.mjs";
24
+ import { getIbGibFromSpace, getTjpAddr } from "@ibgib/core-gib/dist/common/other/ibgib-helper.mjs";
25
+ import { getSpaceGibRouterSvc } from "../../router/index.mjs";
26
+ import { promptForConfirm } from "@ibgib/web-gib/dist/helpers.web.mjs";
27
+ import { getDeterministicColorInfo } from "@ibgib/web-gib/dist/helpers.mjs";
28
+ import { GibColorInfo } from "@ibgib/web-gib/dist/types.mjs";
29
+
30
+ export const CHANGES_COMPONENT_NAME = 'space-gib-changes';
31
+
32
+ export interface WorkingTreeChange {
33
+ path: string;
34
+ status: 'untracked' | 'modified' | 'deleted';
35
+ content: string;
36
+ originalContent?: string;
37
+ }
38
+
39
+ /**
40
+ * Metadata factory for space-gib-changes Web Component.
41
+ */
42
+ export class ChangesComponentMeta extends IbGibDynamicComponentMetaBase {
43
+ protected lc: string = `[${ChangesComponentMeta.name}]`;
44
+
45
+ routeRegExp?: RegExp = new RegExp(`^${CHANGES_COMPONENT_NAME}$`);
46
+ componentName = CHANGES_COMPONENT_NAME;
47
+
48
+ constructor() {
49
+ super(getComponentCtorArg());
50
+ if (!customElements.get(this.componentName)) {
51
+ customElements.define(this.componentName, ChangesComponentInstance);
52
+ }
53
+ }
54
+
55
+ async createInstance({
56
+ path,
57
+ ibGibAddr
58
+ }: {
59
+ path: string;
60
+ ibGibAddr: IbGibAddr;
61
+ }): Promise<IbGibDynamicComponentInstance> {
62
+ const component = document.createElement(this.componentName) as ChangesComponentInstance;
63
+ await component.initialize({
64
+ ibGibAddr: ibGibAddr || ROOT_ADDR,
65
+ meta: this,
66
+ html: thisHtml,
67
+ css: [vcsStylesCss, thisCss],
68
+ });
69
+ return component;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * References to DOM elements within the component's Shadow Root.
75
+ */
76
+ export interface ChangesElements extends ElementsBase {
77
+ rootEl: HTMLDivElement;
78
+ headerEl: HTMLDivElement;
79
+ btnToggleEl: HTMLButtonElement;
80
+ titleEl: HTMLSpanElement;
81
+ counterEl: HTMLSpanElement;
82
+ badgesContainerEl?: HTMLDivElement;
83
+ btnDiscardAllEl: HTMLButtonElement;
84
+ bodyContainerEl: HTMLDivElement;
85
+ emptyMsgEl: HTMLDivElement;
86
+ changesListEl: HTMLDivElement;
87
+ commitBoxEl: HTMLDivElement;
88
+ commitInputEl: HTMLTextAreaElement;
89
+ btnCommitEl: HTMLButtonElement;
90
+ commitBtnTextEl: HTMLSpanElement;
91
+ }
92
+
93
+ /**
94
+ * Concrete implementation of the Changes/Staging Accordion component.
95
+ */
96
+ export class ChangesComponentInstance
97
+ extends IbGibDynamicComponentInstanceBase<IbGib_V1, ChangesElements>
98
+ implements IbGibDynamicComponentInstance<IbGib_V1, ChangesElements> {
99
+
100
+ protected lc: string = `[${ChangesComponentInstance.name}]`;
101
+
102
+ private galaxyName: string = '';
103
+ private branchName: string = 'main';
104
+ private viewMode: 'single' | 'composite' = 'single';
105
+ private isExpanded: boolean = true;
106
+ private branchChangesMap: Map<string, Map<string, WorkingTreeChange>> = new Map();
107
+ private branchColorCache: Map<string, GibColorInfo> = new Map();
108
+
109
+ public setViewMode(mode: 'single' | 'composite'): void {
110
+ this.viewMode = mode;
111
+ this.updateBranchColorStyling();
112
+ this.renderChanges();
113
+ }
114
+
115
+ public getViewMode(): 'single' | 'composite' {
116
+ return this.viewMode;
117
+ }
118
+
119
+ private getBranchKey(galaxyName: string = this.galaxyName, branchName: string = this.branchName): string {
120
+ return `${galaxyName || 'code-1'}~${branchName || 'main'}`;
121
+ }
122
+
123
+ public getWorkingTreeChangesMap(galaxyName: string = this.galaxyName, branchName: string = this.branchName): Map<string, WorkingTreeChange> {
124
+ const key = this.getBranchKey(galaxyName, branchName);
125
+ let map = this.branchChangesMap.get(key);
126
+ if (!map) {
127
+ map = new Map();
128
+ this.branchChangesMap.set(key, map);
129
+ }
130
+ return map;
131
+ }
132
+
133
+ get el(): ChangesElements {
134
+ if (!this.elements) {
135
+ throw new Error(`elements is not defined in ${this.lc}`);
136
+ }
137
+ return this.elements;
138
+ }
139
+
140
+ private async resolveGalaxyIbGib(galaxyName: string, space: any): Promise<B2tFSGalaxyIbGib_V1 | undefined> {
141
+ const router = getSpaceGibRouterSvc();
142
+ for (const manifest of router.cosmosManifestCache?.values() || []) {
143
+ const entry = manifest.data?.galaxies?.[galaxyName];
144
+ if (entry?.galaxyAddr) {
145
+ const gib = await getIbGibFromSpace<B2tFSGalaxyIbGib_V1>({ addr: entry.galaxyAddr, space });
146
+ if (gib) return gib;
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ override async created(): Promise<void> {
153
+ const lc = `${this.lc}[${this.created.name}]`;
154
+ try {
155
+ const shadow = this.shadowRoot!;
156
+ this.elements = {
157
+ rootEl: shadow.getElementById('changes-root') as HTMLDivElement,
158
+ headerEl: shadow.getElementById('changes-header') as HTMLDivElement,
159
+ btnToggleEl: shadow.getElementById('btn-changes-toggle') as HTMLButtonElement,
160
+ titleEl: shadow.getElementById('changes-title') as HTMLSpanElement,
161
+ counterEl: shadow.getElementById('changes-counter') as HTMLSpanElement,
162
+ badgesContainerEl: shadow.getElementById('changes-badges-container') as HTMLDivElement,
163
+ btnDiscardAllEl: shadow.getElementById('btn-discard-all') as HTMLButtonElement,
164
+ bodyContainerEl: shadow.getElementById('changes-body-container') as HTMLDivElement,
165
+ emptyMsgEl: shadow.getElementById('empty-changes-msg') as HTMLDivElement,
166
+ changesListEl: shadow.getElementById('changes-list') as HTMLDivElement,
167
+ commitBoxEl: shadow.getElementById('changes-commit-box') as HTMLDivElement,
168
+ commitInputEl: shadow.getElementById('commit-message-input') as HTMLTextAreaElement,
169
+ btnCommitEl: shadow.getElementById('btn-commit-changes') as HTMLButtonElement,
170
+ commitBtnTextEl: shadow.getElementById('commit-btn-text') as HTMLSpanElement,
171
+ contentEl: shadow.getElementById('changes-root') as HTMLDivElement,
172
+ };
173
+
174
+ this.toggleAttribute('collapsed', !this.isExpanded);
175
+ this.bindHandlers();
176
+ if (this.galaxyName) {
177
+ this.updateBranchColorStyling();
178
+ }
179
+ this.renderChanges();
180
+ } catch (error) {
181
+ console.error(`${lc} ${extractErrorMsg(error)}`);
182
+ }
183
+ }
184
+
185
+ private bindHandlers(): void {
186
+ this.el.headerEl.addEventListener('click', (e) => {
187
+ const target = e.target as HTMLElement;
188
+ if (target.closest('.action-btn')) return;
189
+ this.toggleAccordion();
190
+ });
191
+
192
+ this.el.btnDiscardAllEl.addEventListener('click', async (e) => {
193
+ e.stopPropagation();
194
+ if (this.viewMode === 'single') {
195
+ const map = this.getWorkingTreeChangesMap();
196
+ if (map.size === 0) return;
197
+ const count = map.size;
198
+ const confirmed = await promptForConfirm({
199
+ msg: `Discard all ${count} uncommitted change(s) on branch [${this.branchName}]? These changes cannot be recovered.`,
200
+ yesLabel: 'Discard All',
201
+ noLabel: 'Cancel',
202
+ });
203
+ if (confirmed) {
204
+ this.discardAll(this.branchName);
205
+ }
206
+ } else {
207
+ const dirtyBranches = this.getDirtyBranchNames();
208
+ if (dirtyBranches.length === 0) return;
209
+ const confirmed = await promptForConfirm({
210
+ msg: `Discard all uncommitted changes across all branches in this workspace? These changes cannot be recovered.`,
211
+ yesLabel: 'Discard All',
212
+ noLabel: 'Cancel',
213
+ });
214
+ if (confirmed) {
215
+ this.discardAll();
216
+ }
217
+ }
218
+ });
219
+
220
+ this.el.btnCommitEl.addEventListener('click', () => {
221
+ this.commitChanges();
222
+ });
223
+
224
+ this.el.commitInputEl.addEventListener('keydown', (e: KeyboardEvent) => {
225
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
226
+ e.preventDefault();
227
+ this.commitChanges();
228
+ }
229
+ });
230
+ }
231
+
232
+ public toggleAccordion(): void {
233
+ this.isExpanded = !this.isExpanded;
234
+ this.toggleAttribute('collapsed', !this.isExpanded);
235
+ const chevron = this.el.headerEl.querySelector('.chevron-icon');
236
+ if (this.isExpanded) {
237
+ this.el.headerEl.classList.remove('collapsed');
238
+ this.el.bodyContainerEl.classList.remove('hidden');
239
+ if (chevron) { chevron.textContent = '▼'; }
240
+ } else {
241
+ this.el.headerEl.classList.add('collapsed');
242
+ this.el.bodyContainerEl.classList.add('hidden');
243
+ if (chevron) { chevron.textContent = '▼'; }
244
+ }
245
+ this.dispatchEvent(new CustomEvent('space-gib-accordion-toggled', {
246
+ detail: { isExpanded: this.isExpanded },
247
+ bubbles: true,
248
+ composed: true,
249
+ }));
250
+ }
251
+
252
+ public setGalaxyAndBranch(galaxyName: string, branchName: string = 'main', opts?: { carryOverChanges?: boolean; preserveChanges?: boolean }): void {
253
+ const oldKey = this.getBranchKey();
254
+ this.galaxyName = galaxyName;
255
+ this.branchName = branchName;
256
+ const newKey = this.getBranchKey();
257
+
258
+ // Carry-over is only triggered when explicitly requested (e.g. creating a new branch diverging from existing branch)
259
+ if (opts?.carryOverChanges && oldKey !== newKey) {
260
+ const oldMap = this.branchChangesMap.get(oldKey);
261
+ if (oldMap && oldMap.size > 0) {
262
+ const newMap = this.getWorkingTreeChangesMap(galaxyName, branchName);
263
+ for (const [p, change] of oldMap) {
264
+ newMap.set(p, { ...change });
265
+ }
266
+ oldMap.clear();
267
+ }
268
+ }
269
+
270
+ if (this.el?.commitBtnTextEl) {
271
+ this.el.commitBtnTextEl.textContent = `Commit to [${branchName}]`;
272
+ }
273
+ if (this.el?.titleEl) {
274
+ this.el.titleEl.textContent = `Changes (${branchName})`;
275
+ }
276
+ this.updateBranchColorStyling();
277
+ this.renderChanges();
278
+ }
279
+
280
+ public clearBranchColorCache(): void {
281
+ this.branchColorCache.clear();
282
+ }
283
+
284
+ public async getBranchColor(branchName: string, galaxyName: string = this.galaxyName): Promise<GibColorInfo> {
285
+ const cacheKey = `${galaxyName || 'unknown'}~${branchName}`;
286
+ let colorInfo = this.branchColorCache.get(cacheKey);
287
+ if (colorInfo) return colorInfo;
288
+
289
+ let branchAddrOrTjp: string | undefined;
290
+ try {
291
+ if (galaxyName) {
292
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
293
+ const galaxyIbGib = await this.resolveGalaxyIbGib(galaxyName, space);
294
+ if (galaxyIbGib) {
295
+ const branchAndRoot = await getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib({
296
+ galaxyIbGib,
297
+ branchName,
298
+ metaspace,
299
+ }).catch(() => undefined);
300
+ if (branchAndRoot?.branchIbGib) {
301
+ branchAddrOrTjp = getTjpAddr({ ibGib: branchAndRoot.branchIbGib }) || branchAndRoot.branchInfo?.branchAddr;
302
+ }
303
+ }
304
+ }
305
+ } catch (err) {
306
+ // ignore
307
+ }
308
+
309
+ colorInfo = getDeterministicColorInfo({
310
+ ibGibAddr: branchAddrOrTjp,
311
+ gib: branchAddrOrTjp ? undefined : branchName,
312
+ translucentAlpha: 18,
313
+ });
314
+ if (galaxyName && branchAddrOrTjp) {
315
+ this.branchColorCache.set(cacheKey, colorInfo);
316
+ }
317
+ return colorInfo;
318
+ }
319
+
320
+ public async updateBranchColorStyling(): Promise<void> {
321
+ try {
322
+ if (!this.galaxyName) return;
323
+ const colorInfo = await this.getBranchColor(this.branchName, this.galaxyName);
324
+ if (colorInfo) {
325
+ this.style.setProperty('--branch-accent-color', colorInfo.punctiliarColor);
326
+ this.style.setProperty('--branch-accent-translucent', colorInfo.punctiliarColorTranslucent);
327
+ this.style.setProperty('--branch-accent-contrast', colorInfo.punctiliarColorContrast);
328
+ }
329
+ } catch (err) {
330
+ console.warn(`${this.lc} Error updating branch color styling: ${extractErrorMsg(err)}`);
331
+ }
332
+ }
333
+
334
+ public getGalaxyName(): string {
335
+ return this.galaxyName;
336
+ }
337
+
338
+ public getBranchName(): string {
339
+ return this.branchName;
340
+ }
341
+
342
+ public hasChanges(branchName?: string): boolean {
343
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName || this.branchName);
344
+ return map.size > 0;
345
+ }
346
+
347
+ public getChangesCount(branchName?: string): number {
348
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName || this.branchName);
349
+ return map.size;
350
+ }
351
+
352
+ public getChange(path: string, branchName?: string): WorkingTreeChange | undefined {
353
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName || this.branchName);
354
+ return map.get(path);
355
+ }
356
+
357
+ public isUntracked(path: string, branchName?: string): boolean {
358
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName || this.branchName);
359
+ return map.get(path)?.status === 'untracked';
360
+ }
361
+
362
+ public recordChange(change: { path: string; status: 'untracked' | 'modified' | 'deleted'; content?: string; originalContent?: string }, branchName?: string): void {
363
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName || this.branchName);
364
+ const existing = map.get(change.path);
365
+ const status = existing?.status === 'untracked' ? 'untracked' : change.status;
366
+ map.set(change.path, {
367
+ path: change.path,
368
+ status,
369
+ content: change.content ?? existing?.content ?? '',
370
+ originalContent: existing?.originalContent ?? change.originalContent,
371
+ });
372
+ this.renderChanges();
373
+ }
374
+
375
+ public removeChange(path: string, branchName?: string): void {
376
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName || this.branchName);
377
+ if (map.has(path)) {
378
+ map.delete(path);
379
+ this.renderChanges();
380
+ }
381
+ }
382
+
383
+ public getDirtyBranchNames(): string[] {
384
+ const branches: string[] = [];
385
+ for (const [key, map] of this.branchChangesMap) {
386
+ if (key.startsWith(`${this.galaxyName}~`) && map.size > 0) {
387
+ const b = key.split('~')[1];
388
+ if (b && !branches.includes(b)) {
389
+ branches.push(b);
390
+ }
391
+ }
392
+ }
393
+ return branches;
394
+ }
395
+
396
+ public discardChange(path: string, branchName: string = this.branchName): void {
397
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, branchName);
398
+ const change = map.get(path);
399
+ if (!change) return;
400
+
401
+ map.delete(path);
402
+ this.renderChanges();
403
+
404
+ this.dispatchEvent(new CustomEvent('space-gib-change-discarded', {
405
+ detail: {
406
+ path,
407
+ galaxy: this.galaxyName,
408
+ branch: branchName,
409
+ originalContent: change.originalContent,
410
+ },
411
+ bubbles: true,
412
+ composed: true,
413
+ }));
414
+ }
415
+
416
+ public discardAll(branchName?: string): void {
417
+ const branchesToDiscard = branchName ? [branchName] : this.getDirtyBranchNames();
418
+ for (const b of branchesToDiscard) {
419
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, b);
420
+ if (map.size === 0) continue;
421
+ const paths = Array.from(map.keys());
422
+ map.clear();
423
+ for (const path of paths) {
424
+ this.dispatchEvent(new CustomEvent('space-gib-change-discarded', {
425
+ detail: {
426
+ path,
427
+ galaxy: this.galaxyName,
428
+ branch: b,
429
+ },
430
+ bubbles: true,
431
+ composed: true,
432
+ }));
433
+ }
434
+ }
435
+ this.renderChanges();
436
+ }
437
+
438
+ public async commitChanges(targetBranchName: string = this.branchName, message?: string, btnEl?: HTMLButtonElement): Promise<void> {
439
+ const lc = `${this.lc}[${this.commitChanges.name}]`;
440
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, targetBranchName);
441
+ if (map.size === 0) {
442
+ alert(`No changes to commit for branch [${targetBranchName}].`);
443
+ return;
444
+ }
445
+
446
+ const commitMessage = (message !== undefined ? message : this.el?.commitInputEl?.value)?.trim();
447
+ if (!commitMessage) {
448
+ alert(`Please enter a commit message for branch [${targetBranchName}].`);
449
+ return;
450
+ }
451
+
452
+ try {
453
+ if (btnEl) {
454
+ btnEl.disabled = true;
455
+ btnEl.textContent = 'Committing...';
456
+ } else if (this.el?.btnCommitEl) {
457
+ this.el.btnCommitEl.disabled = true;
458
+ this.el.btnCommitEl.textContent = 'Committing...';
459
+ }
460
+
461
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
462
+ const galaxyIbGib = await this.resolveGalaxyIbGib(this.galaxyName, space);
463
+ if (!galaxyIbGib) {
464
+ throw new Error(`Galaxy [${this.galaxyName}] not found in cosmos manifest`);
465
+ }
466
+
467
+ const branchAndRoot = await getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib({
468
+ galaxyIbGib,
469
+ branchName: targetBranchName,
470
+ metaspace,
471
+ });
472
+
473
+ const changesToCommit: ChangedFileInfo[] = Array.from(map.values()).map(c => ({
474
+ path: c.path,
475
+ status: c.status,
476
+ content: c.content,
477
+ }));
478
+
479
+ const commitRes = await commitChangesToGalaxyBranch({
480
+ metaspace,
481
+ branchSpace: branchAndRoot.branchSpace,
482
+ branchIbGib: branchAndRoot.branchIbGib,
483
+ rootFolderItem: branchAndRoot.rootItemIbGib_latest || branchAndRoot.rootItemIbGib,
484
+ changes: changesToCommit,
485
+ message: commitMessage,
486
+ author: 'You',
487
+ galaxyIbGib,
488
+ localSpace: space,
489
+ });
490
+
491
+ if (commitRes.updatedGalaxy) {
492
+ const newGalaxyAddr = getIbGibAddr({ ibGib: commitRes.updatedGalaxy });
493
+ const router = getSpaceGibRouterSvc();
494
+ for (const manifest of router.cosmosManifestCache?.values() || []) {
495
+ const entry = manifest.data?.galaxies?.[this.galaxyName];
496
+ if (entry) {
497
+ entry.galaxyAddr = newGalaxyAddr;
498
+ }
499
+ }
500
+ }
501
+
502
+ const shortHash = commitRes.newTipAddr.slice(commitRes.newTipAddr.lastIndexOf('^') + 1, commitRes.newTipAddr.lastIndexOf('^') + 8);
503
+
504
+ // Clear working tree changes for this branch
505
+ map.clear();
506
+ if (this.el?.commitInputEl && targetBranchName === this.branchName) {
507
+ this.el.commitInputEl.value = '';
508
+ }
509
+ await this.renderChanges();
510
+
511
+ // Dispatch commit event
512
+ this.dispatchEvent(new CustomEvent('space-gib-commit-created', {
513
+ detail: {
514
+ galaxy: this.galaxyName,
515
+ branch: targetBranchName,
516
+ message: commitMessage,
517
+ tipAddr: commitRes.newTipAddr,
518
+ shortHash,
519
+ committedFiles: changesToCommit,
520
+ },
521
+ bubbles: true,
522
+ composed: true,
523
+ }));
524
+
525
+ } catch (err) {
526
+ console.error(`${lc} Commit failed: ${extractErrorMsg(err)}`);
527
+ alert(`Commit failed: ${extractErrorMsg(err)}`);
528
+ } finally {
529
+ if (btnEl) {
530
+ btnEl.disabled = false;
531
+ btnEl.innerHTML = `
532
+ <span class="commit-btn-icon">✓</span>
533
+ <span class="commit-btn-text">Commit to [${targetBranchName}]</span>
534
+ `;
535
+ } else if (this.el?.btnCommitEl) {
536
+ this.el.btnCommitEl.disabled = false;
537
+ this.el.btnCommitEl.innerHTML = `
538
+ <span class="commit-btn-icon">✓</span>
539
+ <span id="commit-btn-text" class="commit-btn-text">Commit to [${targetBranchName}]</span>
540
+ `;
541
+ this.el.commitBtnTextEl = this.el.btnCommitEl.querySelector('#commit-btn-text') as HTMLSpanElement;
542
+ }
543
+ }
544
+ }
545
+
546
+ public async renderChanges(): Promise<void> {
547
+ const dirtyBranches: { branchName: string; changesMap: Map<string, WorkingTreeChange> }[] = [];
548
+ let totalCount = 0;
549
+
550
+ if (this.viewMode === 'single') {
551
+ const map = this.getWorkingTreeChangesMap(this.galaxyName, this.branchName);
552
+ if (map.size > 0) {
553
+ dirtyBranches.push({ branchName: this.branchName, changesMap: map });
554
+ totalCount = map.size;
555
+ }
556
+ } else {
557
+ for (const [key, map] of this.branchChangesMap) {
558
+ if (key.startsWith(`${this.galaxyName}~`) && map.size > 0) {
559
+ const b = key.split('~')[1];
560
+ dirtyBranches.push({ branchName: b, changesMap: map });
561
+ totalCount += map.size;
562
+ }
563
+ }
564
+ }
565
+
566
+ if (this.el?.counterEl) {
567
+ this.el.counterEl.style.display = 'none';
568
+ }
569
+
570
+ const badgesContainer = this.el?.badgesContainerEl;
571
+ if (badgesContainer) {
572
+ badgesContainer.innerHTML = '';
573
+ const allDirtyBranchInfos: { branchName: string; count: number }[] = [];
574
+ for (const [key, map] of this.branchChangesMap) {
575
+ if (key.startsWith(`${this.galaxyName}~`) && map.size > 0) {
576
+ const b = key.split('~')[1];
577
+ if (b && !allDirtyBranchInfos.some(x => x.branchName === b)) {
578
+ allDirtyBranchInfos.push({ branchName: b, count: map.size });
579
+ }
580
+ }
581
+ }
582
+
583
+ for (const info of allDirtyBranchInfos) {
584
+ const badge = document.createElement('span');
585
+ badge.className = 'changes-branch-badge';
586
+ badge.textContent = String(info.count);
587
+ badge.title = `Branch [${info.branchName}]: ${info.count} uncommitted change(s). Click to switch.`;
588
+
589
+ const bColor = await this.getBranchColor(info.branchName, this.galaxyName);
590
+ if (bColor) {
591
+ badge.style.setProperty('--badge-accent-color', bColor.punctiliarColor);
592
+ badge.style.setProperty('--badge-accent-translucent', bColor.punctiliarColorTranslucent);
593
+ }
594
+
595
+ badge.addEventListener('click', (e) => {
596
+ e.stopPropagation();
597
+ if (this.viewMode === 'single' && info.branchName !== this.branchName) {
598
+ this.dispatchEvent(new CustomEvent('space-gib-branch-selected', {
599
+ detail: {
600
+ galaxyName: this.galaxyName,
601
+ branchName: info.branchName,
602
+ },
603
+ bubbles: true,
604
+ composed: true,
605
+ }));
606
+ } else if (this.viewMode === 'composite') {
607
+ const group = this.shadowRoot?.querySelector(`.branch-changes-group[data-branch="${info.branchName}"]`);
608
+ if (group) {
609
+ group.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
610
+ }
611
+ }
612
+ });
613
+
614
+ badgesContainer.appendChild(badge);
615
+ }
616
+ }
617
+
618
+ if (this.el?.titleEl) {
619
+ this.el.titleEl.textContent = this.viewMode === 'single'
620
+ ? `Changes (${this.branchName})`
621
+ : 'Changes';
622
+ }
623
+
624
+ if (totalCount === 0) {
625
+ if (this.el?.emptyMsgEl) {
626
+ this.el.emptyMsgEl.style.display = 'block';
627
+ this.el.emptyMsgEl.textContent = this.viewMode === 'single'
628
+ ? `No uncommitted changes on [${this.branchName}]`
629
+ : 'No uncommitted changes in workspace';
630
+ }
631
+ if (this.el?.changesListEl) this.el.changesListEl.style.display = 'none';
632
+ if (this.el?.commitBoxEl) this.el.commitBoxEl.style.display = 'none';
633
+ return;
634
+ }
635
+
636
+ if (this.el?.emptyMsgEl) this.el.emptyMsgEl.style.display = 'none';
637
+ if (this.el?.changesListEl) this.el.changesListEl.style.display = 'flex';
638
+ if (this.el?.commitBoxEl) this.el.commitBoxEl.style.display = 'none'; // Branch groups handle their own commit boxes
639
+
640
+ const listEl = this.el.changesListEl;
641
+ listEl.innerHTML = '';
642
+
643
+ for (const { branchName, changesMap } of dirtyBranches) {
644
+ const groupEl = document.createElement('div');
645
+ groupEl.className = 'branch-changes-group';
646
+ groupEl.setAttribute('data-branch', branchName);
647
+
648
+ const colorInfo = await this.getBranchColor(branchName, this.galaxyName);
649
+ groupEl.style.setProperty('--group-accent-color', colorInfo?.punctiliarColor || '#78f87e');
650
+ groupEl.style.setProperty('--group-accent-translucent', colorInfo?.punctiliarColorTranslucent || 'rgba(120, 248, 126, 0.15)');
651
+
652
+ // Group Header
653
+ const headerEl = document.createElement('div');
654
+ headerEl.className = 'branch-group-header';
655
+ headerEl.innerHTML = `
656
+ <span class="branch-group-icon">🌿</span>
657
+ <span class="branch-group-title">${branchName}</span>
658
+ <span class="branch-group-badge">${changesMap.size}</span>
659
+ <button class="action-btn branch-group-discard-btn" type="button" title="Discard all on [${branchName}]">↺</button>
660
+ `;
661
+
662
+ const discardAllBtn = headerEl.querySelector('.branch-group-discard-btn');
663
+ discardAllBtn?.addEventListener('click', async (e) => {
664
+ e.stopPropagation();
665
+ const confirmed = await promptForConfirm({
666
+ msg: `Discard all ${changesMap.size} changes on branch [${branchName}]? These changes cannot be recovered.`,
667
+ yesLabel: 'Discard All',
668
+ noLabel: 'Cancel',
669
+ });
670
+ if (confirmed) {
671
+ this.discardAll(branchName);
672
+ }
673
+ });
674
+
675
+ // Group Files List
676
+ const filesContainer = document.createElement('div');
677
+ filesContainer.className = 'branch-group-files';
678
+
679
+ for (const [path, change] of changesMap) {
680
+ const row = document.createElement('div');
681
+ row.className = 'change-item';
682
+ row.setAttribute('data-path', path);
683
+
684
+ const badgeChar = change.status === 'untracked' ? 'U' : (change.status === 'modified' ? 'M' : 'D');
685
+ const badgeTitle = change.status === 'untracked' ? 'Untracked' : (change.status === 'modified' ? 'Modified' : 'Deleted');
686
+ const statusClass = change.status === 'untracked' ? 'vcs-untracked' : (change.status === 'modified' ? 'vcs-modified' : 'vcs-deleted');
687
+
688
+ row.innerHTML = `
689
+ <span class="change-icon">📄</span>
690
+ <span class="change-label ${statusClass}" title="${path}">${path}</span>
691
+ <span class="vcs-status-badge ${change.status}" title="${badgeTitle}">${badgeChar}</span>
692
+ <button class="change-item-discard-btn" type="button" title="Discard change">↺</button>
693
+ `;
694
+
695
+ row.addEventListener('click', (e) => {
696
+ const target = e.target as HTMLElement;
697
+ if (target.closest('.change-item-discard-btn')) return;
698
+ this.dispatchEvent(new CustomEvent('space-gib-file-selected', {
699
+ detail: {
700
+ galaxy: this.galaxyName,
701
+ branch: branchName,
702
+ path: change.path,
703
+ name: change.path,
704
+ },
705
+ bubbles: true,
706
+ composed: true,
707
+ }));
708
+ });
709
+
710
+ const discardBtn = row.querySelector('.change-item-discard-btn');
711
+ discardBtn?.addEventListener('click', async (e) => {
712
+ e.stopPropagation();
713
+ const confirmed = await promptForConfirm({
714
+ msg: `Discard changes to "${path}" on branch [${branchName}]? These changes cannot be recovered.`,
715
+ yesLabel: 'Discard',
716
+ noLabel: 'Cancel',
717
+ });
718
+ if (confirmed) {
719
+ this.discardChange(path, branchName);
720
+ }
721
+ });
722
+
723
+ filesContainer.appendChild(row);
724
+ }
725
+
726
+ // Group Commit Box
727
+ const commitBoxEl = document.createElement('div');
728
+ commitBoxEl.className = 'branch-group-commit-box';
729
+ commitBoxEl.innerHTML = `
730
+ <textarea class="commit-message-input" placeholder="Commit message for [${branchName}] (Ctrl+Enter)..." rows="2"></textarea>
731
+ <button class="btn-commit" type="button">
732
+ <span class="commit-btn-icon">✓</span>
733
+ <span class="commit-btn-text">Commit to [${branchName}]</span>
734
+ </button>
735
+ `;
736
+
737
+ const commitInput = commitBoxEl.querySelector('.commit-message-input') as HTMLTextAreaElement;
738
+ const commitBtn = commitBoxEl.querySelector('.btn-commit') as HTMLButtonElement;
739
+
740
+ commitBtn.addEventListener('click', () => {
741
+ this.commitChanges(branchName, commitInput.value.trim(), commitBtn);
742
+ });
743
+
744
+ commitInput.addEventListener('keydown', (e: KeyboardEvent) => {
745
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
746
+ e.preventDefault();
747
+ this.commitChanges(branchName, commitInput.value.trim(), commitBtn);
748
+ }
749
+ });
750
+
751
+ groupEl.appendChild(headerEl);
752
+ groupEl.appendChild(filesContainer);
753
+ groupEl.appendChild(commitBoxEl);
754
+ listEl.appendChild(groupEl);
755
+ }
756
+ }
757
+
758
+ override async disconnected(): Promise<void> {
759
+ const lc = `${this.lc}[${this.disconnected.name}]`;
760
+ try {
761
+ // cleanup if needed
762
+ } catch (error) {
763
+ console.error(`${lc} ${extractErrorMsg(error)}`);
764
+ }
765
+ }
766
+ }