@ibgib/space-gib 0.0.34 → 0.0.35

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 (45) hide show
  1. package/dist/client/bootstrap.mjs +1 -1
  2. package/dist/client/{chunk-25PXC4HP.mjs → chunk-FIAGRVBY.mjs} +1487 -468
  3. package/dist/client/chunk-XLQ3UCQY.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/index.html +6 -0
  7. package/dist/client/index.mjs +22 -45
  8. package/dist/client/privacy.html +22 -22
  9. package/dist/client/script.mjs +1 -1
  10. package/dist/server/server.mjs +3 -3
  11. package/package.json +6 -6
  12. package/src/client/AUTO-GENERATED-version.mts +1 -1
  13. package/src/client/cosmos/seed-cosmos-workspace.mts +20 -6
  14. package/src/client/css/activity-bar.css +21 -3
  15. package/src/client/css/layout.css +126 -5
  16. package/src/client/dev-tools/vcs-workspace.mts +4 -4
  17. package/src/client/index.html +6 -0
  18. package/src/client/privacy.html +22 -22
  19. package/src/client/ui/component/changes/changes.css +413 -0
  20. package/src/client/ui/component/changes/changes.html +37 -0
  21. package/src/client/ui/component/changes/changes.mts +766 -0
  22. package/src/client/ui/component/changes/index.mts +9 -0
  23. package/src/client/ui/component/chat-view/chat-view.html +6 -58
  24. package/src/client/ui/component/chat-view/chat-view.mts +13 -26
  25. package/src/client/ui/component/clone/clone.mts +67 -39
  26. package/src/client/ui/component/code-editor/code-editor.css +91 -9
  27. package/src/client/ui/component/code-editor/code-editor.html +58 -45
  28. package/src/client/ui/component/code-editor/code-editor.mts +265 -49
  29. package/src/client/ui/component/cosmos-navigator/cosmos-navigator.css +1 -1
  30. package/src/client/ui/component/cosmos-navigator/cosmos-navigator.html +5 -10
  31. package/src/client/ui/component/cosmos-navigator/cosmos-navigator.mts +297 -64
  32. package/src/client/ui/component/file-explorer/file-explorer.css +475 -11
  33. package/src/client/ui/component/file-explorer/file-explorer.html +45 -65
  34. package/src/client/ui/component/file-explorer/file-explorer.mts +1164 -98
  35. package/src/client/ui/component/nested-chat/chat/chat.mts +5 -90
  36. package/src/client/ui/component/nested-chat/nested-chat.mts +10 -8
  37. package/src/client/ui/component/timeline/timeline.css +16 -1
  38. package/src/client/ui/component/timeline/timeline.mts +37 -93
  39. package/src/client/ui/router/space-gib-router-helpers.mts +33 -233
  40. package/src/client/ui/router/space-gib-router-helpers.respec.mts +298 -122
  41. package/src/client/ui/router/space-gib-router-service.mts +28 -151
  42. package/src/client/ui/router/space-gib-router-types.mts +32 -76
  43. package/src/client/ui/shell/cosmic-big-bang.mts +12 -3
  44. package/src/client/ui/shell/space-gib-shell-service.mts +687 -119
  45. package/dist/client/chunk-RXWLL2AI.mjs +0 -30
@@ -1,11 +1,12 @@
1
1
  import thisCss from "./file-explorer.css";
2
2
  import thisHtml from "./file-explorer.html";
3
+ import vcsStylesCss from "@ibgib/web-gib/dist/ui/component/vcs-styles.css";
3
4
 
4
5
  import { ROOT_ADDR } from "@ibgib/ts-gib/dist/V1/constants.mjs";
5
6
  import { IbGibAddr } from "@ibgib/ts-gib/dist/types.mjs";
6
7
  import { IbGib_V1 } from "@ibgib/ts-gib/dist/V1/types.mjs";
7
8
  import { extractErrorMsg } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs";
8
- import { promptForText } from "@ibgib/web-gib/dist/helpers.web.mjs";
9
+ import { promptForText, promptForConfirm } from "@ibgib/web-gib/dist/helpers.web.mjs";
9
10
  import {
10
11
  IbGibDynamicComponentMetaBase, IbGibDynamicComponentInstanceBase,
11
12
  } from "@ibgib/web-gib/dist/ui/component/ibgib-dynamic-component-bases.mjs";
@@ -13,6 +14,24 @@ import {
13
14
  ElementsBase, IbGibDynamicComponentInstance,
14
15
  } from "@ibgib/web-gib/dist/ui/component/component-types.mjs";
15
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
+ createFileInGalaxyBranch,
21
+ getFolderChildren,
22
+ getFileItemFromPath,
23
+ } from "@ibgib/core-gib/dist/cosmology/branching/item/item-helpers.mjs";
24
+ import { GalaxyIbGib_V1 } from "@ibgib/core-gib/dist/cosmology/galaxy/galaxy-types.mjs";
25
+ import { B2tFSGalaxyIbGib_V1 } from "@ibgib/core-gib/dist/cosmology/branching/repo/b2tfs-repo-types.mjs";
26
+ import { getIbGibFromSpace, getTjpAddr } from "@ibgib/core-gib/dist/common/other/ibgib-helper.mjs";
27
+ import { getSpaceGibRouterSvc } from "../../router/index.mjs";
28
+ import {
29
+ getAllBranchInfosFromGalaxy, getCompositeBranchTreeFromGalaxy,
30
+ validateBranchName,
31
+ } from "@ibgib/core-gib/dist/cosmology/branching/branch/branch-helpers.mjs";
32
+ import { CompositeBranchNode } from "@ibgib/core-gib/dist/cosmology/branching/branch/branch-types.mjs";
33
+ import { getDeterministicColorInfo } from "@ibgib/web-gib/dist/helpers.mjs";
34
+ import { GibColorInfo } from "@ibgib/web-gib/dist/types.mjs";
16
35
 
17
36
  export const FILE_EXPLORER_COMPONENT_NAME = 'space-gib-file-explorer';
18
37
 
@@ -44,26 +63,43 @@ export class FileExplorerComponentMeta extends IbGibDynamicComponentMetaBase {
44
63
  ibGibAddr: ibGibAddr || ROOT_ADDR,
45
64
  meta: this,
46
65
  html: thisHtml,
47
- css: [thisCss],
66
+ css: [vcsStylesCss, thisCss],
48
67
  });
49
68
  return component;
50
69
  }
51
70
  }
52
71
 
72
+ /**
73
+ * A single command item in the file explorer popup command menu.
74
+ */
75
+ export interface CommandMenuItem {
76
+ id: string;
77
+ icon: string;
78
+ label: string;
79
+ action: () => void | Promise<void>;
80
+ danger?: boolean;
81
+ }
82
+
53
83
  /**
54
84
  * References to DOM elements within the component's Shadow Root.
55
85
  */
56
86
  export interface FileExplorerElements extends ElementsBase {
57
87
  rootEl: HTMLDivElement;
88
+ modeToolbarEl?: HTMLDivElement;
89
+ btnModeSingleEl?: HTMLButtonElement;
90
+ btnModeCompositeEl?: HTMLButtonElement;
91
+ btnRefreshExplorerEl?: HTMLButtonElement;
92
+ singleBranchViewEl?: HTMLDivElement;
93
+ compositeTreeContainerEl?: HTMLDivElement;
58
94
  branchHeaderEl: HTMLDivElement;
59
95
  btnBranchToggleEl: HTMLButtonElement;
96
+ btnBranchSelectEl: HTMLButtonElement;
60
97
  branchTitleEl: HTMLSpanElement;
98
+ branchDirtyBadgeEl?: HTMLSpanElement;
99
+ branchDropdownMenuEl: HTMLDivElement;
61
100
  fileTreeContainerEl: HTMLDivElement;
62
- btnNewFileEl: HTMLButtonElement;
63
- btnNewFolderEl: HTMLButtonElement;
64
- btnCollapseAllEl: HTMLButtonElement;
65
- btnBranchBranchEl?: HTMLButtonElement;
66
- btnCloneBranchEl?: HTMLButtonElement;
101
+ btnBranchMenuEl?: HTMLButtonElement;
102
+ commandMenuPopupEl?: HTMLDivElement;
67
103
  }
68
104
 
69
105
  /**
@@ -75,9 +111,43 @@ export class FileExplorerComponentInstance
75
111
 
76
112
  protected lc: string = `[${FileExplorerComponentInstance.name}]`;
77
113
 
114
+ private galaxyName: string = '';
78
115
  private branchName: string = 'main';
79
- private isBranchExpanded: boolean = true;
80
- private selectedFilePath: string = 'src/index.ts';
116
+ private viewMode: 'single' | 'composite' = 'single';
117
+ private isSingleExpanded: boolean = true;
118
+ private isCompositeExpanded: boolean = true;
119
+ private isDropdownOpen: boolean = false;
120
+ private selectedFilePath: string = '';
121
+ private branchFileStatusMap: Map<string, Map<string, 'untracked' | 'modified'>> = new Map();
122
+ private branchColorCache: Map<string, GibColorInfo> = new Map();
123
+ private loadTreeSeq: number = 0;
124
+ private activeCommandMenuAnchor: HTMLElement | null = null;
125
+
126
+ private getBranchKey(galaxyName: string = this.galaxyName, branchName: string = this.branchName): string {
127
+ return `${galaxyName || 'code-1'}~${branchName || 'main'}`;
128
+ }
129
+
130
+ public getFileStatusMap(galaxyName: string = this.galaxyName, branchName: string = this.branchName): Map<string, 'untracked' | 'modified'> {
131
+ const key = this.getBranchKey(galaxyName, branchName);
132
+ let map = this.branchFileStatusMap.get(key);
133
+ if (!map) {
134
+ map = new Map();
135
+ this.branchFileStatusMap.set(key, map);
136
+ }
137
+ return map;
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
+ }
81
151
 
82
152
  get el(): FileExplorerElements {
83
153
  if (!this.elements) {
@@ -86,25 +156,83 @@ export class FileExplorerComponentInstance
86
156
  return this.elements;
87
157
  }
88
158
 
159
+ private onWindowClick = (e: MouseEvent) => {
160
+ const path = e.composedPath();
161
+ if (this.isDropdownOpen) {
162
+ const insideSelector = path.some(
163
+ (target: any) => target.id === 'branch-selector-container' || (target.classList && target.classList.contains('branch-dropdown-menu'))
164
+ );
165
+ if (!insideSelector) {
166
+ this.closeBranchDropdown();
167
+ }
168
+ }
169
+
170
+ if (this.el?.commandMenuPopupEl && this.el.commandMenuPopupEl.style.display !== 'none') {
171
+ const insideMenu = path.some(
172
+ (target: any) => target.id === 'command-menu-popup' || (target.classList && target.classList.contains('branch-menu-btn'))
173
+ );
174
+ if (!insideMenu) {
175
+ this.closeCommandMenu();
176
+ }
177
+ }
178
+ };
179
+
180
+ private onWindowKeyDown = (e: KeyboardEvent) => {
181
+ if (e.key === 'Escape') {
182
+ if (this.isDropdownOpen) {
183
+ this.closeBranchDropdown();
184
+ }
185
+ this.closeCommandMenu();
186
+ }
187
+ };
188
+
189
+ private onCodeChanged = (e: any) => {
190
+ const { filepath, branch } = e.detail || {};
191
+ if (filepath) {
192
+ const statusMap = this.getFileStatusMap(this.galaxyName, branch || this.branchName);
193
+ if (!statusMap.has(filepath)) {
194
+ this.setFileStatus(filepath, 'modified', branch || this.branchName);
195
+ }
196
+ }
197
+ };
198
+
89
199
  override async created(): Promise<void> {
90
200
  const lc = `${this.lc}[${this.created.name}]`;
91
201
  try {
92
202
  const shadow = this.shadowRoot!;
93
203
  this.elements = {
94
204
  rootEl: shadow.getElementById('file-explorer-root') as HTMLDivElement,
205
+ modeToolbarEl: shadow.getElementById('explorer-mode-toolbar') as HTMLDivElement,
206
+ btnModeSingleEl: shadow.getElementById('btn-mode-single') as HTMLButtonElement,
207
+ btnModeCompositeEl: shadow.getElementById('btn-mode-composite') as HTMLButtonElement,
208
+ btnRefreshExplorerEl: shadow.getElementById('btn-refresh-explorer') as HTMLButtonElement,
209
+ singleBranchViewEl: shadow.getElementById('single-branch-view') as HTMLDivElement,
210
+ compositeTreeContainerEl: shadow.getElementById('composite-tree-container') as HTMLDivElement,
95
211
  branchHeaderEl: shadow.getElementById('branch-header') as HTMLDivElement,
96
212
  btnBranchToggleEl: shadow.getElementById('btn-branch-toggle') as HTMLButtonElement,
213
+ btnBranchSelectEl: shadow.getElementById('btn-branch-select') as HTMLButtonElement,
97
214
  branchTitleEl: shadow.getElementById('branch-title') as HTMLSpanElement,
215
+ branchDirtyBadgeEl: shadow.getElementById('branch-dirty-badge') as HTMLSpanElement,
216
+ branchDropdownMenuEl: shadow.getElementById('branch-dropdown-menu') as HTMLDivElement,
98
217
  fileTreeContainerEl: shadow.getElementById('file-tree-container') as HTMLDivElement,
99
- btnNewFileEl: shadow.getElementById('btn-new-file') as HTMLButtonElement,
100
- btnNewFolderEl: shadow.getElementById('btn-new-folder') as HTMLButtonElement,
101
- btnCollapseAllEl: shadow.getElementById('btn-collapse-all') as HTMLButtonElement,
102
- btnBranchBranchEl: shadow.getElementById('btn-branch-branch') as HTMLButtonElement,
103
- btnCloneBranchEl: shadow.getElementById('btn-clone-branch') as HTMLButtonElement,
218
+ btnBranchMenuEl: shadow.getElementById('btn-branch-menu') as HTMLButtonElement,
219
+ commandMenuPopupEl: shadow.getElementById('command-menu-popup') as HTMLDivElement,
104
220
  contentEl: shadow.getElementById('file-explorer-root') as HTMLDivElement,
105
221
  };
106
222
 
223
+ const isExpanded = this.viewMode === 'single' ? this.isSingleExpanded : this.isCompositeExpanded;
224
+ this.toggleAttribute('collapsed', !isExpanded);
107
225
  this.bindHandlers();
226
+ if (this.galaxyName) {
227
+ this.updateBranchColorStyling();
228
+ }
229
+ if (this.el?.fileTreeContainerEl) {
230
+ this.el.fileTreeContainerEl.innerHTML = `<div class="empty-tree-msg">No galaxy selected</div>`;
231
+ }
232
+
233
+ window.addEventListener('click', this.onWindowClick);
234
+ window.addEventListener('keydown', this.onWindowKeyDown);
235
+ window.addEventListener('space-gib-code-changed', this.onCodeChanged);
108
236
  } catch (error) {
109
237
  console.error(`${lc} ${extractErrorMsg(error)}`);
110
238
  }
@@ -113,73 +241,39 @@ export class FileExplorerComponentInstance
113
241
  private bindHandlers(): void {
114
242
  const shadow = this.shadowRoot!;
115
243
 
244
+ // View Mode Switcher
245
+ this.el.btnModeSingleEl?.addEventListener('click', () => {
246
+ this.setViewMode('single');
247
+ });
248
+ this.el.btnModeCompositeEl?.addEventListener('click', () => {
249
+ this.setViewMode('composite');
250
+ });
251
+ this.el.btnRefreshExplorerEl?.addEventListener('click', () => {
252
+ if (this.viewMode === 'single') {
253
+ this.loadBranchTree(this.galaxyName, this.branchName);
254
+ } else {
255
+ this.loadCompositeBranchTree();
256
+ }
257
+ });
258
+
116
259
  // Branch header toggle
117
260
  this.el.branchHeaderEl.addEventListener('click', (e) => {
118
261
  const target = e.target as HTMLElement;
119
262
  if (target.closest('.action-btn')) return; // ignore action buttons
263
+ if (target.closest('#branch-selector-container')) return; // ignore branch selector
120
264
  this.toggleBranchAccordion();
121
265
  });
122
266
 
123
- // Action buttons
124
- this.el.btnNewFileEl.addEventListener('click', (e) => {
267
+ // Branch dropdown toggle
268
+ this.el.btnBranchSelectEl.addEventListener('click', (e) => {
125
269
  e.stopPropagation();
126
- this.dispatchEvent(new CustomEvent('space-gib-new-file-requested', {
127
- detail: { branch: this.branchName },
128
- bubbles: true,
129
- composed: true,
130
- }));
270
+ this.toggleBranchDropdown();
131
271
  });
132
272
 
133
- this.el.btnNewFolderEl.addEventListener('click', (e) => {
273
+ // Single Branch Command Menu (⋮)
274
+ this.el.btnBranchMenuEl?.addEventListener('click', (e) => {
134
275
  e.stopPropagation();
135
- this.dispatchEvent(new CustomEvent('space-gib-new-folder-requested', {
136
- detail: { branch: this.branchName },
137
- bubbles: true,
138
- composed: true,
139
- }));
140
- });
141
-
142
- this.el.btnCollapseAllEl.addEventListener('click', (e) => {
143
- e.stopPropagation();
144
- this.collapseAllFolders();
145
- });
146
-
147
- // Worktree Branch action
148
- this.el.btnBranchBranchEl?.addEventListener('click', async (e) => {
149
- e.stopPropagation();
150
- const newBranchName = await promptForText({
151
- title: 'Branch from Worktree',
152
- msg: `Create new branch diverging from [${this.branchName}]:`,
153
- defaultValue: `feature/branch-from-${this.branchName}`,
154
- cancelable: true,
155
- });
156
- if (newBranchName) {
157
- console.log(`${this.lc} New branch created from worktree [${this.branchName}]: ${newBranchName}`);
158
- this.setBranch(newBranchName);
159
- this.dispatchEvent(new CustomEvent('space-gib-branch-requested', {
160
- detail: {
161
- galaxyName: 'Code Galaxy',
162
- sourceBranch: this.branchName,
163
- newBranchName,
164
- scope: 'branch',
165
- },
166
- bubbles: true,
167
- composed: true,
168
- }));
169
- }
170
- });
171
-
172
- // Branch Clone action
173
- this.el.btnCloneBranchEl?.addEventListener('click', (e) => {
174
- e.stopPropagation();
175
- this.dispatchEvent(new CustomEvent('space-gib-clone-requested', {
176
- detail: {
177
- galaxyName: 'Code Galaxy',
178
- branchName: this.branchName,
179
- },
180
- bubbles: true,
181
- composed: true,
182
- }));
276
+ this.showCommandMenu(this.el.btnBranchMenuEl!, this.getBranchCommandMenuItems(this.branchName));
183
277
  });
184
278
 
185
279
  // Single Item Branch actions
@@ -190,31 +284,41 @@ export class FileExplorerComponentInstance
190
284
  const path = treeItem?.getAttribute('data-path') || treeItem?.getAttribute('data-folder') || '';
191
285
  const isDir = treeItem?.classList.contains('folder') || false;
192
286
  const filename = path.split('/').pop() || path;
193
- const defaultBranch = `feature/branch-${filename.replace(/[^a-zA-Z0-9]/g, '-')}`;
194
-
195
- const newBranchName = await promptForText({
196
- title: `Branch from ${isDir ? 'Directory' : 'File'} Item`,
197
- msg: `Create new branch isolating and diverging from item [${path}]:`,
198
- defaultValue: defaultBranch,
199
- cancelable: true,
200
- });
287
+ let candidateName = `feature-branch-${filename.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
288
+ let promptMsg = `Create new branch isolating and diverging from item [${path}]:`;
289
+ let cleanBranchName = '';
201
290
 
202
- if (newBranchName) {
203
- console.log(`${this.lc} New branch created from item [${path}]: ${newBranchName}`);
204
- this.setBranch(newBranchName);
205
- this.dispatchEvent(new CustomEvent('space-gib-branch-requested', {
206
- detail: {
207
- galaxyName: 'Code Galaxy',
208
- sourceBranch: this.branchName,
209
- itemPath: path,
210
- isDir,
211
- newBranchName,
212
- scope: 'item',
213
- },
214
- bubbles: true,
215
- composed: true,
216
- }));
291
+ while (true) {
292
+ const newBranchName = await promptForText({
293
+ title: `Branch from ${isDir ? 'Directory' : 'File'} Item`,
294
+ msg: promptMsg,
295
+ defaultValue: candidateName,
296
+ cancelable: true,
297
+ });
298
+ if (!newBranchName || !newBranchName.trim()) return;
299
+ cleanBranchName = newBranchName.trim();
300
+ const validation = validateBranchName(cleanBranchName);
301
+ if (!validation.isValid) {
302
+ candidateName = cleanBranchName;
303
+ promptMsg = `Invalid branch name: ${validation.error}\n\nPlease enter a valid branch name:`;
304
+ continue;
305
+ }
306
+ break;
217
307
  }
308
+
309
+ console.log(`${this.lc} New branch requested from item [${path}]: ${cleanBranchName}`);
310
+ this.dispatchEvent(new CustomEvent('space-gib-branch-requested', {
311
+ detail: {
312
+ galaxyName: this.galaxyName,
313
+ sourceBranch: this.branchName,
314
+ itemPath: path,
315
+ isDir,
316
+ newBranchName: cleanBranchName,
317
+ scope: 'item',
318
+ },
319
+ bubbles: true,
320
+ composed: true,
321
+ }));
218
322
  });
219
323
  });
220
324
 
@@ -281,9 +385,161 @@ export class FileExplorerComponentInstance
281
385
  });
282
386
  }
283
387
 
388
+ public toggleBranchDropdown(): void {
389
+ if (this.isDropdownOpen) {
390
+ this.closeBranchDropdown();
391
+ } else {
392
+ this.openBranchDropdown();
393
+ }
394
+ }
395
+
396
+ public async openBranchDropdown(): Promise<void> {
397
+ if (!this.el?.branchDropdownMenuEl) return;
398
+ this.isDropdownOpen = true;
399
+ this.el.branchDropdownMenuEl.style.display = 'flex';
400
+ await this.populateBranchDropdown();
401
+ }
402
+
403
+ public closeBranchDropdown(): void {
404
+ if (!this.el?.branchDropdownMenuEl) return;
405
+ this.isDropdownOpen = false;
406
+ this.el.branchDropdownMenuEl.style.display = 'none';
407
+ }
408
+
409
+ public async populateBranchDropdown(): Promise<void> {
410
+ const menu = this.el?.branchDropdownMenuEl;
411
+ if (!menu) return;
412
+ menu.innerHTML = '<div class="branch-dropdown-item" style="color: #64748b; cursor: default;">Loading branches...</div>';
413
+
414
+ try {
415
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
416
+ const galaxyIbGib = await this.resolveGalaxyIbGib(this.galaxyName, space);
417
+ if (!galaxyIbGib) {
418
+ menu.innerHTML = '<div class="branch-dropdown-item" style="color: #ef4444; cursor: default;">No galaxy found</div>';
419
+ return;
420
+ }
421
+
422
+ const branchInfos = await getAllBranchInfosFromGalaxy({ galaxyIbGib, metaspace }).catch(() => []);
423
+ if (branchInfos.length === 0) {
424
+ menu.innerHTML = '<div class="branch-dropdown-item" style="color: #64748b; cursor: default;">No branches found</div>';
425
+ return;
426
+ }
427
+
428
+ // Sort: 'main' first, then alphabetical
429
+ branchInfos.sort((a, b) => {
430
+ if (a.branchName === 'main') return -1;
431
+ if (b.branchName === 'main') return 1;
432
+ return a.branchName.localeCompare(b.branchName);
433
+ });
434
+
435
+ menu.innerHTML = '';
436
+ for (const info of branchInfos) {
437
+ const itemEl = document.createElement('div');
438
+ const isActive = info.branchName === this.branchName;
439
+ itemEl.className = `branch-dropdown-item ${isActive ? 'active' : ''}`;
440
+ itemEl.setAttribute('data-branch', info.branchName);
441
+
442
+ // Dot color derived from branch TJP
443
+ const colorInfo = await this.getBranchColor(info.branchName);
444
+ const dotEl = document.createElement('span');
445
+ dotEl.className = 'branch-color-dot';
446
+ dotEl.style.backgroundColor = colorInfo.punctiliarColor;
447
+
448
+ const nameEl = document.createElement('span');
449
+ nameEl.className = 'branch-item-name';
450
+ nameEl.textContent = info.branchName;
451
+
452
+ itemEl.appendChild(dotEl);
453
+ itemEl.appendChild(nameEl);
454
+
455
+ if (isActive) {
456
+ const checkEl = document.createElement('span');
457
+ checkEl.className = 'branch-dropdown-check';
458
+ checkEl.textContent = '✓';
459
+ itemEl.appendChild(checkEl);
460
+ }
461
+
462
+ itemEl.addEventListener('click', (e) => {
463
+ e.stopPropagation();
464
+ this.closeBranchDropdown();
465
+ if (info.branchName !== this.branchName) {
466
+ this.dispatchEvent(new CustomEvent('space-gib-branch-switched', {
467
+ detail: {
468
+ galaxy: this.galaxyName,
469
+ branch: info.branchName,
470
+ },
471
+ bubbles: true,
472
+ composed: true,
473
+ }));
474
+ }
475
+ });
476
+
477
+ menu.appendChild(itemEl);
478
+ }
479
+ } catch (err) {
480
+ menu.innerHTML = `<div class="branch-dropdown-item" style="color: #ef4444; cursor: default;">Error loading branches</div>`;
481
+ console.error(`${this.lc} Error populating branch dropdown: ${extractErrorMsg(err)}`);
482
+ }
483
+ }
484
+
485
+ public clearBranchColorCache(): void {
486
+ this.branchColorCache.clear();
487
+ }
488
+
489
+ public async getBranchColor(branchName: string, galaxyName: string = this.galaxyName): Promise<GibColorInfo> {
490
+ const cacheKey = `${galaxyName || 'unknown'}~${branchName}`;
491
+ let colorInfo = this.branchColorCache.get(cacheKey);
492
+ if (colorInfo) return colorInfo;
493
+
494
+ let branchAddrOrTjp: string | undefined;
495
+ try {
496
+ if (galaxyName) {
497
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
498
+ const galaxyIbGib = await this.resolveGalaxyIbGib(galaxyName, space);
499
+ if (galaxyIbGib) {
500
+ const branchAndRoot = await getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib({
501
+ galaxyIbGib,
502
+ branchName,
503
+ metaspace,
504
+ }).catch(() => undefined);
505
+ if (branchAndRoot?.branchIbGib) {
506
+ branchAddrOrTjp = getTjpAddr({ ibGib: branchAndRoot.branchIbGib }) || branchAndRoot.branchInfo?.branchAddr;
507
+ }
508
+ }
509
+ }
510
+ } catch (err) {
511
+ // ignore and fallback
512
+ }
513
+
514
+ colorInfo = getDeterministicColorInfo({
515
+ ibGibAddr: branchAddrOrTjp,
516
+ gib: branchAddrOrTjp ? undefined : branchName,
517
+ translucentAlpha: 18,
518
+ });
519
+ if (galaxyName && branchAddrOrTjp) {
520
+ this.branchColorCache.set(cacheKey, colorInfo);
521
+ }
522
+ return colorInfo;
523
+ }
524
+
525
+ public async updateBranchColorStyling(): Promise<void> {
526
+ try {
527
+ if (!this.galaxyName) return;
528
+ const colorInfo = await this.getBranchColor(this.branchName, this.galaxyName);
529
+ if (colorInfo) {
530
+ this.style.setProperty('--branch-accent-color', colorInfo.punctiliarColor);
531
+ this.style.setProperty('--branch-accent-translucent', colorInfo.punctiliarColorTranslucent);
532
+ this.style.setProperty('--branch-accent-contrast', colorInfo.punctiliarColorContrast);
533
+ }
534
+ } catch (err) {
535
+ console.warn(`${this.lc} Error updating branch color styling: ${extractErrorMsg(err)}`);
536
+ }
537
+ }
538
+
284
539
  public toggleBranchAccordion(): void {
285
- this.isBranchExpanded = !this.isBranchExpanded;
286
- if (this.isBranchExpanded) {
540
+ this.isSingleExpanded = !this.isSingleExpanded;
541
+ this.toggleAttribute('collapsed', !this.isSingleExpanded);
542
+ if (this.isSingleExpanded) {
287
543
  this.el.branchHeaderEl.classList.remove('collapsed');
288
544
  this.el.branchHeaderEl.classList.add('expanded');
289
545
  this.el.fileTreeContainerEl.classList.remove('collapsed');
@@ -292,6 +548,30 @@ export class FileExplorerComponentInstance
292
548
  this.el.branchHeaderEl.classList.remove('expanded');
293
549
  this.el.fileTreeContainerEl.classList.add('collapsed');
294
550
  }
551
+ this.dispatchEvent(new CustomEvent('space-gib-accordion-toggled', {
552
+ detail: { isExpanded: this.isSingleExpanded },
553
+ bubbles: true,
554
+ composed: true,
555
+ }));
556
+ }
557
+
558
+ public toggleCompositeRootAccordion(headerEl: HTMLElement, bodyEl: HTMLElement): void {
559
+ this.isCompositeExpanded = !this.isCompositeExpanded;
560
+ this.toggleAttribute('collapsed', !this.isCompositeExpanded);
561
+ if (this.isCompositeExpanded) {
562
+ headerEl.classList.remove('collapsed');
563
+ headerEl.classList.add('expanded');
564
+ bodyEl.style.display = 'flex';
565
+ } else {
566
+ headerEl.classList.add('collapsed');
567
+ headerEl.classList.remove('expanded');
568
+ bodyEl.style.display = 'none';
569
+ }
570
+ this.dispatchEvent(new CustomEvent('space-gib-accordion-toggled', {
571
+ detail: { isExpanded: this.isCompositeExpanded },
572
+ bubbles: true,
573
+ composed: true,
574
+ }));
295
575
  }
296
576
 
297
577
  public collapseAllFolders(): void {
@@ -300,7 +580,6 @@ export class FileExplorerComponentInstance
300
580
  const folderName = folderEl.getAttribute('data-folder');
301
581
  folderEl.classList.remove('expanded');
302
582
  const chevron = folderEl.querySelector('.tree-chevron');
303
- // if (chevron) chevron.textContent = '▶';
304
583
  if (chevron) { chevron.textContent = '▼'; }
305
584
  if (folderName) {
306
585
  shadow.querySelectorAll(`.child-of-${folderName}`).forEach(child => child.classList.add('hidden'));
@@ -320,25 +599,812 @@ export class FileExplorerComponentInstance
320
599
  });
321
600
  }
322
601
 
323
- public setBranch(branchName: string): void {
602
+ public setGalaxyAndBranch(galaxyName: string, branchName: string = 'main', forceReload: boolean = false): void {
603
+ const changed = this.galaxyName !== galaxyName || this.branchName !== branchName;
604
+ this.galaxyName = galaxyName;
605
+ this.branchName = branchName;
606
+ if (this.el?.branchTitleEl) {
607
+ this.el.branchTitleEl.textContent = branchName;
608
+ }
609
+ this.updateBranchColorStyling();
610
+ this.updateDirtyBadges();
611
+ if (changed || forceReload) {
612
+ if (this.viewMode === 'composite') {
613
+ this.loadCompositeBranchTree();
614
+ } else {
615
+ this.loadBranchTree(galaxyName, branchName);
616
+ }
617
+ }
618
+ }
619
+
620
+ public async loadBranchTree(galaxyName: string = this.galaxyName, branchName: string = this.branchName): Promise<void> {
621
+ const seq = ++this.loadTreeSeq;
622
+ this.galaxyName = galaxyName;
324
623
  this.branchName = branchName;
325
624
  if (this.el?.branchTitleEl) {
326
625
  this.el.branchTitleEl.textContent = branchName;
327
626
  }
627
+
628
+ const container = this.el?.fileTreeContainerEl;
629
+ if (!container) return;
630
+
631
+ if (!galaxyName) {
632
+ container.innerHTML = '<div class="empty-tree-msg">No galaxy selected</div>';
633
+ return;
634
+ }
635
+
636
+ try {
637
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
638
+ if (seq !== this.loadTreeSeq) return;
639
+
640
+ const galaxyIbGib = await this.resolveGalaxyIbGib(galaxyName, space);
641
+ if (seq !== this.loadTreeSeq) return;
642
+
643
+ if (!galaxyIbGib) {
644
+ container.innerHTML = `<div class="empty-tree-msg">Galaxy [${galaxyName}] not found in cosmos manifest</div>`;
645
+ return;
646
+ }
647
+
648
+ const branchAndRoot = await getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib({
649
+ galaxyIbGib,
650
+ branchName,
651
+ metaspace,
652
+ });
653
+ if (seq !== this.loadTreeSeq) return;
654
+
655
+ const branchSpace = branchAndRoot.branchSpace;
656
+ const rootItem = branchAndRoot.rootItemIbGib_latest || branchAndRoot.rootItemIbGib;
657
+
658
+ const children = await getFolderChildren({ branchSpace, folderItem: rootItem });
659
+ if (seq !== this.loadTreeSeq) return;
660
+
661
+ const committedNames = new Set(children.map(c => c.data.name));
662
+
663
+ // Merge untracked working-tree files not yet in CAS for this specific branch
664
+ const statusMap = this.getFileStatusMap(galaxyName, branchName);
665
+ const untrackedNames: string[] = [];
666
+ for (const [filePath, status] of statusMap) {
667
+ if (status === 'untracked' && !committedNames.has(filePath)) {
668
+ untrackedNames.push(filePath);
669
+ }
670
+ }
671
+
672
+ if (children.length === 0 && untrackedNames.length === 0) {
673
+ const emptyMsg = document.createElement('div');
674
+ emptyMsg.className = 'empty-tree-msg';
675
+ emptyMsg.innerHTML = `No files in worktree yet.<br/>Click <span class="highlight">📄+</span> to create a file.`;
676
+ if (seq !== this.loadTreeSeq) return;
677
+ container.replaceChildren(emptyMsg);
678
+ return;
679
+ }
680
+
681
+ const createRow = (name: string, isDir: boolean): HTMLElement => {
682
+ const itemEl = document.createElement('div');
683
+ itemEl.className = `tree-item ${isDir ? 'folder' : 'file'} ${name === this.selectedFilePath ? 'selected' : ''}`;
684
+ itemEl.setAttribute(isDir ? 'data-folder' : 'data-path', name);
685
+ itemEl.setAttribute('data-name', name);
686
+ itemEl.tabIndex = 0;
687
+
688
+ const icon = isDir ? '📁' : (name.endsWith('.md') ? '📝' : (name.endsWith('.json') ? '⚙️' : '📄'));
689
+ const status = !isDir ? statusMap.get(name) : undefined;
690
+ const statusClass = status ? (status === 'untracked' ? 'vcs-untracked' : 'vcs-modified') : '';
691
+ const badgeHtml = status ? `<span class="vcs-status-badge ${status}" title="${status === 'untracked' ? 'Untracked' : 'Modified'}">${status === 'untracked' ? 'U' : 'M'}</span>` : '';
692
+
693
+ itemEl.innerHTML = `
694
+ <span class="tree-indent tree-indent-2"></span>
695
+ <span class="tree-icon">${icon}</span>
696
+ <span class="tree-label ${statusClass}">${name}</span>
697
+ ${badgeHtml}
698
+ `;
699
+
700
+ if (!isDir) {
701
+ itemEl.addEventListener('click', () => {
702
+ this.selectFile(name);
703
+ this.dispatchEvent(new CustomEvent('space-gib-file-selected', {
704
+ detail: {
705
+ galaxy: this.galaxyName,
706
+ branch: this.branchName,
707
+ path: name,
708
+ name: name,
709
+ },
710
+ bubbles: true,
711
+ composed: true,
712
+ }));
713
+ });
714
+ }
715
+
716
+ return itemEl;
717
+ };
718
+
719
+ interface ExplorerItem {
720
+ name: string;
721
+ isDir: boolean;
722
+ }
723
+ const allItems: ExplorerItem[] = [
724
+ ...children.map(c => ({ name: c.data.name, isDir: c.data.fsType === 'folder' })),
725
+ ...untrackedNames.map(name => ({ name, isDir: false })),
726
+ ];
727
+
728
+ if (this.selectedFilePath && !allItems.some(i => i.name === this.selectedFilePath)) {
729
+ this.selectedFilePath = '';
730
+ }
731
+
732
+ // Sort: folders first, then alphabetically by name (case-insensitive, natural numeric sort)
733
+ allItems.sort((a, b) => {
734
+ if (a.isDir !== b.isDir) {
735
+ return a.isDir ? -1 : 1;
736
+ }
737
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true });
738
+ });
739
+
740
+ const fragment = document.createDocumentFragment();
741
+ for (const item of allItems) {
742
+ fragment.appendChild(createRow(item.name, item.isDir));
743
+ }
744
+
745
+ if (seq !== this.loadTreeSeq) return;
746
+ container.replaceChildren(fragment);
747
+ this.updateDirtyBadges();
748
+ } catch (err) {
749
+ console.error(`${this.lc} Failed to load branch tree: ${extractErrorMsg(err)}`);
750
+ }
751
+ }
752
+
753
+ public isUntracked(filepath: string, branchName: string = this.branchName, galaxyName: string = this.galaxyName): boolean {
754
+ return this.getFileStatusMap(galaxyName, branchName).get(filepath) === 'untracked';
755
+ }
756
+
757
+ public removeFile(filepath: string, branchName: string = this.branchName, galaxyName: string = this.galaxyName): void {
758
+ this.getFileStatusMap(galaxyName, branchName).delete(filepath);
759
+ if (this.selectedFilePath === filepath && branchName === this.branchName) {
760
+ this.selectedFilePath = '';
761
+ }
762
+ if (branchName === this.branchName && galaxyName === this.galaxyName) {
763
+ this.loadBranchTree(this.galaxyName, this.branchName);
764
+ }
765
+ this.updateDirtyBadges();
766
+ }
767
+
768
+ public setFileStatus(filepath: string, status?: 'untracked' | 'modified', branchName: string = this.branchName, galaxyName: string = this.galaxyName): void {
769
+ const statusMap = this.getFileStatusMap(galaxyName, branchName);
770
+ if (status) {
771
+ statusMap.set(filepath, status);
772
+ } else {
773
+ statusMap.delete(filepath);
774
+ }
775
+
776
+ this.updateDirtyBadges();
777
+
778
+ // Only update DOM badges if this is the currently viewed branch
779
+ if (branchName !== this.branchName || galaxyName !== this.galaxyName) {
780
+ return;
781
+ }
782
+
783
+ const shadow = this.shadowRoot;
784
+ if (!shadow) return;
785
+ const fileEl = shadow.querySelector(`.tree-item.file[data-path="${filepath}"]`);
786
+ if (!fileEl) return;
787
+ const labelEl = fileEl.querySelector('.tree-label');
788
+ let badgeEl = fileEl.querySelector('.vcs-status-badge');
789
+ if (!status) {
790
+ labelEl?.classList.remove('vcs-untracked', 'vcs-modified');
791
+ badgeEl?.remove();
792
+ return;
793
+ }
794
+ labelEl?.classList.remove('vcs-untracked', 'vcs-modified');
795
+ labelEl?.classList.add(status === 'untracked' ? 'vcs-untracked' : 'vcs-modified');
796
+ if (!badgeEl) {
797
+ badgeEl = document.createElement('span');
798
+ fileEl.appendChild(badgeEl);
799
+ }
800
+ badgeEl.className = `vcs-status-badge ${status}`;
801
+ badgeEl.textContent = status === 'untracked' ? 'U' : 'M';
802
+ badgeEl.setAttribute('title', status === 'untracked' ? 'Untracked' : 'Modified');
803
+ }
804
+
805
+ public clearAllStatusBadges(branchName: string = this.branchName, galaxyName: string = this.galaxyName): void {
806
+ this.getFileStatusMap(galaxyName, branchName).clear();
807
+ this.updateDirtyBadges();
808
+ if (branchName === this.branchName && galaxyName === this.galaxyName) {
809
+ this.loadBranchTree(this.galaxyName, this.branchName);
810
+ }
811
+ }
812
+
813
+ public setBranch(branchName: string): void {
814
+ this.setGalaxyAndBranch(this.galaxyName, branchName);
328
815
  }
329
816
 
330
817
  public getBranch(): string {
331
818
  return this.branchName;
332
819
  }
333
820
 
821
+ public getBranchName(): string {
822
+ return this.branchName;
823
+ }
824
+
825
+ public getGalaxyName(): string {
826
+ return this.galaxyName;
827
+ }
828
+
334
829
  public getSelectedFilePath(): string {
335
830
  return this.selectedFilePath;
336
831
  }
337
832
 
833
+ public setViewMode(mode: 'single' | 'composite'): void {
834
+ this.viewMode = mode;
835
+ this.closeCommandMenu();
836
+ if (this.el.btnModeSingleEl) {
837
+ this.el.btnModeSingleEl.classList.toggle('active', mode === 'single');
838
+ }
839
+ if (this.el.btnModeCompositeEl) {
840
+ this.el.btnModeCompositeEl.classList.toggle('active', mode === 'composite');
841
+ }
842
+ if (this.el.singleBranchViewEl) {
843
+ this.el.singleBranchViewEl.style.display = mode === 'single' ? 'flex' : 'none';
844
+ this.el.singleBranchViewEl.style.flexDirection = 'column';
845
+ }
846
+ if (this.el.compositeTreeContainerEl) {
847
+ this.el.compositeTreeContainerEl.style.display = mode === 'composite' ? 'flex' : 'none';
848
+ this.el.compositeTreeContainerEl.style.flexDirection = 'column';
849
+ }
850
+
851
+ const isExpanded = mode === 'single' ? this.isSingleExpanded : this.isCompositeExpanded;
852
+ this.toggleAttribute('collapsed', !isExpanded);
853
+
854
+ this.dispatchEvent(new CustomEvent('space-gib-view-mode-changed', {
855
+ detail: { mode },
856
+ bubbles: true,
857
+ composed: true,
858
+ }));
859
+
860
+ this.dispatchEvent(new CustomEvent('space-gib-accordion-toggled', {
861
+ detail: { isExpanded },
862
+ bubbles: true,
863
+ composed: true,
864
+ }));
865
+
866
+ if (mode === 'composite') {
867
+ this.loadCompositeBranchTree();
868
+ } else {
869
+ this.loadBranchTree(this.galaxyName, this.branchName);
870
+ }
871
+ }
872
+
873
+ public getViewMode(): 'single' | 'composite' {
874
+ return this.viewMode;
875
+ }
876
+
877
+ public getBranchCommandMenuItems(branchName: string): CommandMenuItem[] {
878
+ const items: CommandMenuItem[] = [
879
+ {
880
+ id: 'new-sub-branch',
881
+ icon: '🌿+',
882
+ label: `New Sub-Branch...`,
883
+ action: () => this.handleNewSubBranch(branchName),
884
+ },
885
+ {
886
+ id: 'new-file',
887
+ icon: '📄+',
888
+ label: `New File...`,
889
+ action: () => this.handleNewFile(branchName),
890
+ },
891
+ {
892
+ id: 'new-folder',
893
+ icon: '📁+',
894
+ label: `New Folder...`,
895
+ action: () => this.handleNewFolder(branchName),
896
+ },
897
+ {
898
+ id: 'clone-branch',
899
+ icon: '⤓',
900
+ label: `Clone / Export Worktree...`,
901
+ action: () => this.handleCloneBranch(branchName),
902
+ },
903
+ ];
904
+
905
+ if (this.viewMode === 'single' || branchName === this.branchName) {
906
+ items.push({
907
+ id: 'collapse-all',
908
+ icon: '⇱',
909
+ label: 'Collapse Folders',
910
+ action: () => this.collapseAllFolders(),
911
+ });
912
+ }
913
+
914
+ return items;
915
+ }
916
+
917
+ public showCommandMenu(anchorEl: HTMLElement, items: CommandMenuItem[]): void {
918
+ const menuEl = this.el?.commandMenuPopupEl;
919
+ if (!menuEl) return;
920
+
921
+ if (this.activeCommandMenuAnchor === anchorEl && menuEl.style.display !== 'none') {
922
+ this.closeCommandMenu();
923
+ return;
924
+ }
925
+
926
+ this.closeCommandMenu();
927
+ this.activeCommandMenuAnchor = anchorEl;
928
+ anchorEl.classList.add('active');
929
+ menuEl.innerHTML = '';
930
+
931
+ for (const item of items) {
932
+ const row = document.createElement('div');
933
+ row.className = `command-menu-item ${item.danger ? 'danger' : ''}`;
934
+ row.innerHTML = `
935
+ <span class="cmd-icon">${item.icon}</span>
936
+ <span class="cmd-label">${item.label}</span>
937
+ `;
938
+ row.addEventListener('click', async (e) => {
939
+ e.stopPropagation();
940
+ this.closeCommandMenu();
941
+ await item.action();
942
+ });
943
+ menuEl.appendChild(row);
944
+ }
945
+
946
+ // Calculate absolute position inside file-explorer-root
947
+ const rootRect = this.el.rootEl.getBoundingClientRect();
948
+ const anchorRect = anchorEl.getBoundingClientRect();
949
+
950
+ const top = anchorRect.bottom - rootRect.top + 4;
951
+ const right = rootRect.right - anchorRect.right;
952
+
953
+ menuEl.style.top = `${Math.max(0, top)}px`;
954
+ menuEl.style.right = `${Math.max(4, right)}px`;
955
+ menuEl.style.left = 'auto';
956
+ menuEl.style.display = 'flex';
957
+ }
958
+
959
+ public closeCommandMenu(): void {
960
+ if (!this.el?.commandMenuPopupEl) return;
961
+ this.el.commandMenuPopupEl.style.display = 'none';
962
+ if (this.activeCommandMenuAnchor) {
963
+ this.activeCommandMenuAnchor.classList.remove('active');
964
+ this.activeCommandMenuAnchor = null;
965
+ }
966
+ }
967
+
968
+ public async handleNewSubBranch(sourceBranchName: string): Promise<void> {
969
+ let candidateName = `feature-from-${sourceBranchName}`;
970
+ let promptMsg = `Create new branch diverging from [${sourceBranchName}]:`;
971
+ let cleanBranchName = '';
972
+
973
+ while (true) {
974
+ const newBranchName = await promptForText({
975
+ title: 'Branch from Worktree',
976
+ msg: promptMsg,
977
+ defaultValue: candidateName,
978
+ cancelable: true,
979
+ });
980
+ if (!newBranchName || !newBranchName.trim()) return;
981
+ cleanBranchName = newBranchName.trim();
982
+ const validation = validateBranchName(cleanBranchName);
983
+ if (!validation.isValid) {
984
+ candidateName = cleanBranchName;
985
+ promptMsg = `Invalid branch name: ${validation.error}\n\nPlease enter a valid branch name:`;
986
+ continue;
987
+ }
988
+ break;
989
+ }
990
+
991
+ console.log(`${this.lc} New branch requested from worktree [${sourceBranchName}]: ${cleanBranchName}`);
992
+ this.dispatchEvent(new CustomEvent('space-gib-branch-requested', {
993
+ detail: {
994
+ galaxyName: this.galaxyName,
995
+ sourceBranch: sourceBranchName,
996
+ newBranchName: cleanBranchName,
997
+ scope: 'branch',
998
+ },
999
+ bubbles: true,
1000
+ composed: true,
1001
+ }));
1002
+ }
1003
+
1004
+ public async handleNewFile(targetBranchName: string): Promise<void> {
1005
+ const filename = await promptForText({
1006
+ title: 'New File',
1007
+ msg: `Create new file on branch [${targetBranchName}]:`,
1008
+ defaultValue: '',
1009
+ cancelable: true,
1010
+ });
1011
+
1012
+ if (!filename || !filename.trim()) return;
1013
+ const cleanFilename = filename.trim();
1014
+
1015
+ try {
1016
+ const statusMap = this.getFileStatusMap(this.galaxyName, targetBranchName);
1017
+ if (statusMap.has(cleanFilename)) {
1018
+ alert(`A file named "${cleanFilename}" already exists in your working tree on branch [${targetBranchName}].`);
1019
+ return;
1020
+ }
1021
+
1022
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
1023
+ const galaxyIbGib = await this.resolveGalaxyIbGib(this.galaxyName, space);
1024
+ if (galaxyIbGib) {
1025
+ const branchAndRoot = await getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib({
1026
+ galaxyIbGib,
1027
+ branchName: targetBranchName,
1028
+ metaspace,
1029
+ });
1030
+ const existing = await getFileItemFromPath({
1031
+ branchSpace: branchAndRoot.branchSpace,
1032
+ rootItem: branchAndRoot.rootItemIbGib_latest || branchAndRoot.rootItemIbGib,
1033
+ filePath: cleanFilename,
1034
+ });
1035
+ if (existing) {
1036
+ alert(`A file or folder named "${cleanFilename}" already exists on branch [${targetBranchName}].`);
1037
+ return;
1038
+ }
1039
+ }
1040
+
1041
+ // Add to working tree as untracked for this branch
1042
+ statusMap.set(cleanFilename, 'untracked');
1043
+
1044
+ if (this.viewMode === 'composite') {
1045
+ await this.loadCompositeBranchTree();
1046
+ } else {
1047
+ if (targetBranchName !== this.branchName) {
1048
+ this.setGalaxyAndBranch(this.galaxyName, targetBranchName);
1049
+ } else {
1050
+ await this.loadBranchTree(this.galaxyName, targetBranchName);
1051
+ }
1052
+ this.selectFile(cleanFilename);
1053
+ }
1054
+ this.updateDirtyBadges();
1055
+
1056
+ this.dispatchEvent(new CustomEvent('space-gib-file-created', {
1057
+ detail: {
1058
+ galaxy: this.galaxyName,
1059
+ branch: targetBranchName,
1060
+ path: cleanFilename,
1061
+ content: '',
1062
+ },
1063
+ bubbles: true,
1064
+ composed: true,
1065
+ }));
1066
+
1067
+ this.dispatchEvent(new CustomEvent('space-gib-file-selected', {
1068
+ detail: {
1069
+ galaxy: this.galaxyName,
1070
+ branch: targetBranchName,
1071
+ path: cleanFilename,
1072
+ name: cleanFilename,
1073
+ },
1074
+ bubbles: true,
1075
+ composed: true,
1076
+ }));
1077
+ } catch (err) {
1078
+ console.error(`${this.lc} Failed to create file: ${extractErrorMsg(err)}`);
1079
+ alert(`Failed to create file: ${extractErrorMsg(err)}`);
1080
+ }
1081
+ }
1082
+
1083
+ public handleNewFolder(targetBranchName: string): void {
1084
+ this.dispatchEvent(new CustomEvent('space-gib-new-folder-requested', {
1085
+ detail: { branch: targetBranchName, galaxy: this.galaxyName },
1086
+ bubbles: true,
1087
+ composed: true,
1088
+ }));
1089
+ }
1090
+
1091
+ public handleCloneBranch(targetBranchName: string): void {
1092
+ this.dispatchEvent(new CustomEvent('space-gib-clone-requested', {
1093
+ detail: {
1094
+ galaxyName: this.galaxyName,
1095
+ branchName: targetBranchName,
1096
+ },
1097
+ bubbles: true,
1098
+ composed: true,
1099
+ }));
1100
+ }
1101
+
1102
+ public getDirtyCount(galaxyName: string = this.galaxyName, branchName: string = this.branchName): number {
1103
+ const map = this.getFileStatusMap(galaxyName, branchName);
1104
+ let count = 0;
1105
+ for (const [_, status] of map) {
1106
+ if (status === 'modified' || status === 'untracked') {
1107
+ count++;
1108
+ }
1109
+ }
1110
+ return count;
1111
+ }
1112
+
1113
+ public updateDirtyBadges(): void {
1114
+ const shadow = this.shadowRoot;
1115
+ if (!shadow) return;
1116
+
1117
+ // 1. Single branch header dirty badge
1118
+ const singleCount = this.getDirtyCount(this.galaxyName, this.branchName);
1119
+ if (this.el.branchDirtyBadgeEl) {
1120
+ if (singleCount > 0) {
1121
+ this.el.branchDirtyBadgeEl.style.display = 'inline-flex';
1122
+ this.el.branchDirtyBadgeEl.textContent = `● ${singleCount}`;
1123
+ } else {
1124
+ this.el.branchDirtyBadgeEl.style.display = 'none';
1125
+ }
1126
+ }
1127
+
1128
+ // 2. Composite tree branch nodes dirty badges
1129
+ const badges = shadow.querySelectorAll('.composite-tree-container .branch-dirty-badge');
1130
+ badges.forEach((badge) => {
1131
+ const bName = badge.getAttribute('data-branch');
1132
+ if (!bName) return;
1133
+ const count = this.getDirtyCount(this.galaxyName, bName);
1134
+ const htmlBadge = badge as HTMLElement;
1135
+ if (count > 0) {
1136
+ htmlBadge.style.display = 'inline-flex';
1137
+ htmlBadge.textContent = `● ${count}`;
1138
+ } else {
1139
+ htmlBadge.style.display = 'none';
1140
+ }
1141
+ });
1142
+ }
1143
+
1144
+ public async loadCompositeBranchTree(): Promise<void> {
1145
+ const container = this.el?.compositeTreeContainerEl;
1146
+ if (!container) return;
1147
+
1148
+ if (!this.galaxyName) {
1149
+ container.innerHTML = '<div class="empty-tree-msg">No galaxy selected</div>';
1150
+ return;
1151
+ }
1152
+
1153
+ try {
1154
+ const { metaspace, space } = await getPlaygroundMetaspaceAndSpace();
1155
+ const galaxyIbGib = await this.resolveGalaxyIbGib(this.galaxyName, space);
1156
+ if (!galaxyIbGib) {
1157
+ container.innerHTML = `<div class="empty-tree-msg">Galaxy [${this.galaxyName}] not found</div>`;
1158
+ return;
1159
+ }
1160
+
1161
+ const rootNode = await getCompositeBranchTreeFromGalaxy({
1162
+ galaxyIbGib,
1163
+ metaspace,
1164
+ });
1165
+
1166
+ if (!rootNode) {
1167
+ container.innerHTML = '<div class="empty-tree-msg">No branches in composite tree</div>';
1168
+ return;
1169
+ }
1170
+
1171
+ container.innerHTML = '';
1172
+ await this.renderCompositeBranchNode(rootNode, container, metaspace, space, galaxyIbGib);
1173
+ this.updateDirtyBadges();
1174
+ } catch (err) {
1175
+ container.innerHTML = `<div class="empty-tree-msg" style="color: #ef4444;">Error loading composite branch tree: ${extractErrorMsg(err)}</div>`;
1176
+ console.error(`${this.lc} Error loading composite branch tree: ${extractErrorMsg(err)}`);
1177
+ }
1178
+ }
1179
+
1180
+ private async renderCompositeBranchNode(
1181
+ node: CompositeBranchNode,
1182
+ containerEl: HTMLElement,
1183
+ metaspace: any,
1184
+ space: any,
1185
+ galaxyIbGib: B2tFSGalaxyIbGib_V1
1186
+ ): Promise<void> {
1187
+ const branchName = node.branchInfo.branchName;
1188
+ const colorInfo = await this.getBranchColor(branchName, this.galaxyName);
1189
+
1190
+ const nodeEl = document.createElement('div');
1191
+ nodeEl.className = 'composite-branch-node';
1192
+ nodeEl.setAttribute('data-branch', branchName);
1193
+
1194
+ // Header
1195
+ const headerEl = document.createElement('div');
1196
+ headerEl.className = `composite-branch-header ${branchName === this.branchName ? 'active' : ''}`;
1197
+ const branchColor = colorInfo?.punctiliarColor || '#818cf8';
1198
+ const branchTranslucent = colorInfo?.punctiliarColorTranslucent || 'rgba(129, 140, 248, 0.15)';
1199
+ headerEl.style.setProperty('--node-accent-color', branchColor);
1200
+ headerEl.style.setProperty('--node-accent-translucent', branchTranslucent);
1201
+ headerEl.style.setProperty('--branch-accent-color', branchColor);
1202
+ headerEl.style.setProperty('--branch-accent-translucent', branchTranslucent);
1203
+ headerEl.style.paddingLeft = `${node.depth * 14 + 6}px`;
1204
+
1205
+ const toggleBtn = document.createElement('button');
1206
+ toggleBtn.className = 'branch-toggle-btn';
1207
+ toggleBtn.type = 'button';
1208
+ toggleBtn.setAttribute('aria-label', `Toggle Branch ${branchName}`);
1209
+ const chevronEl = document.createElement('span');
1210
+ chevronEl.className = 'chevron-icon';
1211
+ chevronEl.textContent = '▼';
1212
+ toggleBtn.appendChild(chevronEl);
1213
+
1214
+ const iconEl = document.createElement('span');
1215
+ iconEl.className = 'branch-icon composite-branch-icon';
1216
+ iconEl.textContent = '🌿';
1217
+ iconEl.style.color = branchColor;
1218
+
1219
+ const titleEl = document.createElement('span');
1220
+ titleEl.className = 'branch-title composite-branch-title';
1221
+ titleEl.textContent = branchName;
1222
+ titleEl.style.color = branchColor;
1223
+
1224
+ headerEl.appendChild(toggleBtn);
1225
+ headerEl.appendChild(iconEl);
1226
+ headerEl.appendChild(titleEl);
1227
+
1228
+ if (node.scopedItemPath) {
1229
+ const scopedEl = document.createElement('span');
1230
+ scopedEl.className = 'composite-scoped-badge';
1231
+ scopedEl.textContent = `↳ ${node.scopedItemPath}`;
1232
+ scopedEl.title = `Scoped to item: ${node.scopedItemPath}`;
1233
+ headerEl.appendChild(scopedEl);
1234
+ }
1235
+
1236
+ const dirtyBadge = document.createElement('span');
1237
+ dirtyBadge.className = 'branch-dirty-badge';
1238
+ dirtyBadge.setAttribute('data-branch', branchName);
1239
+ dirtyBadge.style.display = 'none';
1240
+ dirtyBadge.style.marginLeft = 'auto';
1241
+ dirtyBadge.style.marginRight = '0.25rem';
1242
+ dirtyBadge.style.setProperty('--branch-accent-color', colorInfo?.punctiliarColor || '#fbbf24');
1243
+ dirtyBadge.style.setProperty('--branch-accent-translucent', colorInfo?.punctiliarColorTranslucent || 'rgba(234, 179, 8, 0.18)');
1244
+ headerEl.appendChild(dirtyBadge);
1245
+
1246
+ // DRY Branch actions menu (⋮)
1247
+ const actionsEl = document.createElement('div');
1248
+ actionsEl.className = 'branch-actions';
1249
+
1250
+ const menuBtn = document.createElement('button');
1251
+ menuBtn.className = 'action-btn branch-menu-btn';
1252
+ menuBtn.type = 'button';
1253
+ menuBtn.title = `Branch actions for [${branchName}] (⋮)`;
1254
+ menuBtn.textContent = '⋮';
1255
+ menuBtn.addEventListener('click', (e) => {
1256
+ e.stopPropagation();
1257
+ this.showCommandMenu(menuBtn, this.getBranchCommandMenuItems(branchName));
1258
+ });
1259
+ actionsEl.appendChild(menuBtn);
1260
+ headerEl.appendChild(actionsEl);
1261
+
1262
+ // Body (Files + Children)
1263
+ const bodyEl = document.createElement('div');
1264
+ bodyEl.className = 'composite-branch-body';
1265
+
1266
+ if (node.depth === 0) {
1267
+ if (this.isCompositeExpanded) {
1268
+ headerEl.classList.remove('collapsed');
1269
+ headerEl.classList.add('expanded');
1270
+ bodyEl.style.display = 'flex';
1271
+ } else {
1272
+ headerEl.classList.add('collapsed');
1273
+ headerEl.classList.remove('expanded');
1274
+ bodyEl.style.display = 'none';
1275
+ }
1276
+
1277
+ headerEl.addEventListener('click', (e) => {
1278
+ const target = e.target as HTMLElement;
1279
+ if (target.closest('.action-btn') || target.closest('.branch-menu-btn')) return;
1280
+ this.toggleCompositeRootAccordion(headerEl, bodyEl);
1281
+ });
1282
+ } else {
1283
+ headerEl.addEventListener('click', (e) => {
1284
+ const target = e.target as HTMLElement;
1285
+ if (target.closest('.action-btn') || target.closest('.branch-menu-btn')) return;
1286
+ const isCollapsed = headerEl.classList.toggle('collapsed');
1287
+ headerEl.classList.toggle('expanded', !isCollapsed);
1288
+ bodyEl.style.display = isCollapsed ? 'none' : 'flex';
1289
+ });
1290
+ }
1291
+
1292
+ // Files container for this branch
1293
+ const filesEl = document.createElement('div');
1294
+ filesEl.className = 'composite-branch-files';
1295
+
1296
+ // Load files for this branch
1297
+ try {
1298
+ const branchAndRoot = await getBranchAndRootItemInfosAndIbGibsAndSpaceFromGalaxyIbGib({
1299
+ galaxyIbGib,
1300
+ branchName,
1301
+ metaspace,
1302
+ });
1303
+ const rootItem = branchAndRoot.rootItemIbGib_latest || branchAndRoot.rootItemIbGib;
1304
+ const children = await getFolderChildren({ branchSpace: branchAndRoot.branchSpace, folderItem: rootItem });
1305
+ const committedNames = new Set(children.map(c => c.data.name));
1306
+
1307
+ const statusMap = this.getFileStatusMap(this.galaxyName, branchName);
1308
+ const untrackedNames: string[] = [];
1309
+ for (const [filePath, status] of statusMap) {
1310
+ if (status === 'untracked' && !committedNames.has(filePath)) {
1311
+ untrackedNames.push(filePath);
1312
+ }
1313
+ }
1314
+
1315
+ if (children.length === 0 && untrackedNames.length === 0) {
1316
+ const emptyFileEl = document.createElement('div');
1317
+ emptyFileEl.className = 'file-item-empty';
1318
+ emptyFileEl.textContent = '(no files)';
1319
+ emptyFileEl.style.cssText = 'color: #64748b; font-size: 0.72rem; padding: 2px 6px; font-style: italic;';
1320
+ filesEl.appendChild(emptyFileEl);
1321
+ } else {
1322
+ for (const child of children) {
1323
+ const fileRow = this.createCompositeFileRow(child.data.name, branchName, false);
1324
+ filesEl.appendChild(fileRow);
1325
+ }
1326
+ for (const untrackedPath of untrackedNames) {
1327
+ const fileRow = this.createCompositeFileRow(untrackedPath, branchName, true);
1328
+ filesEl.appendChild(fileRow);
1329
+ }
1330
+ }
1331
+ } catch (err) {
1332
+ console.warn(`${this.lc} Could not load files for branch [${branchName}]: ${extractErrorMsg(err)}`);
1333
+ }
1334
+
1335
+ bodyEl.appendChild(filesEl);
1336
+
1337
+ // Nested sub-branches
1338
+ if (node.children && node.children.length > 0) {
1339
+ const subBranchesEl = document.createElement('div');
1340
+ subBranchesEl.className = 'composite-branch-children';
1341
+ for (const childNode of node.children) {
1342
+ await this.renderCompositeBranchNode(childNode, subBranchesEl, metaspace, space, galaxyIbGib);
1343
+ }
1344
+ bodyEl.appendChild(subBranchesEl);
1345
+ }
1346
+
1347
+ nodeEl.appendChild(headerEl);
1348
+ nodeEl.appendChild(bodyEl);
1349
+ containerEl.appendChild(nodeEl);
1350
+ }
1351
+
1352
+ private createCompositeFileRow(filePath: string, branchName: string, isUntracked: boolean): HTMLElement {
1353
+ const fileEl = document.createElement('div');
1354
+ fileEl.className = `tree-item file ${filePath === this.selectedFilePath && branchName === this.branchName ? 'selected' : ''}`;
1355
+ fileEl.setAttribute('data-path', filePath);
1356
+ fileEl.setAttribute('data-branch', branchName);
1357
+
1358
+ const statusMap = this.getFileStatusMap(this.galaxyName, branchName);
1359
+ const status = isUntracked ? 'untracked' : statusMap.get(filePath);
1360
+
1361
+ const iconEl = document.createElement('span');
1362
+ iconEl.className = 'tree-icon';
1363
+ iconEl.textContent = filePath.endsWith('.md') ? '📝' : (filePath.endsWith('.json') ? '⚙️' : '📄');
1364
+
1365
+ const labelEl = document.createElement('span');
1366
+ labelEl.className = `tree-label ${status === 'untracked' ? 'vcs-untracked' : (status === 'modified' ? 'vcs-modified' : '')}`;
1367
+ labelEl.textContent = filePath;
1368
+ labelEl.title = `${filePath} [${branchName}]`;
1369
+
1370
+ fileEl.appendChild(iconEl);
1371
+ fileEl.appendChild(labelEl);
1372
+
1373
+ if (status) {
1374
+ const badgeEl = document.createElement('span');
1375
+ badgeEl.className = `vcs-status-badge ${status}`;
1376
+ badgeEl.textContent = status === 'untracked' ? 'U' : 'M';
1377
+ badgeEl.title = status === 'untracked' ? 'Untracked' : 'Modified';
1378
+ fileEl.appendChild(badgeEl);
1379
+ }
1380
+
1381
+ fileEl.addEventListener('click', () => {
1382
+ this.selectedFilePath = filePath;
1383
+ const shadow = this.shadowRoot;
1384
+ shadow?.querySelectorAll('.tree-item.file').forEach(el => el.classList.remove('selected'));
1385
+ fileEl.classList.add('selected');
1386
+
1387
+ this.dispatchEvent(new CustomEvent('space-gib-file-selected', {
1388
+ detail: {
1389
+ galaxy: this.galaxyName,
1390
+ branch: branchName,
1391
+ path: filePath,
1392
+ name: filePath,
1393
+ },
1394
+ bubbles: true,
1395
+ composed: true,
1396
+ }));
1397
+ });
1398
+
1399
+ return fileEl;
1400
+ }
1401
+
338
1402
  override async disconnected(): Promise<void> {
339
1403
  const lc = `${this.lc}[${this.disconnected.name}]`;
340
1404
  try {
341
- // cleanup if needed
1405
+ window.removeEventListener('click', this.onWindowClick);
1406
+ window.removeEventListener('keydown', this.onWindowKeyDown);
1407
+ window.removeEventListener('space-gib-code-changed', this.onCodeChanged);
342
1408
  } catch (error) {
343
1409
  console.error(`${lc} ${extractErrorMsg(error)}`);
344
1410
  }