@jxsuite/studio 0.19.0 → 0.20.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.
Files changed (93) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +12577 -12183
  3. package/dist/studio.js.map +146 -144
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +17 -6
  8. package/src/browse/browse.js +25 -19
  9. package/src/canvas/canvas-diff.js +24 -16
  10. package/src/canvas/canvas-helpers.js +24 -19
  11. package/src/canvas/canvas-live-render.js +94 -39
  12. package/src/canvas/canvas-render.js +119 -73
  13. package/src/canvas/canvas-utils.js +38 -15
  14. package/src/canvas/nested-site-style.js +50 -0
  15. package/src/editor/component-inline-edit.js +14 -13
  16. package/src/editor/content-inline-edit.js +22 -10
  17. package/src/editor/context-menu.js +1 -1
  18. package/src/editor/convert-to-component.js +12 -10
  19. package/src/editor/inline-edit.js +66 -28
  20. package/src/editor/insertion-helper.js +16 -12
  21. package/src/editor/shortcuts.js +4 -2
  22. package/src/editor/slash-menu.js +6 -4
  23. package/src/files/components.js +1 -1
  24. package/src/files/file-ops.js +37 -16
  25. package/src/files/files.js +78 -52
  26. package/src/github/github-auth.js +122 -0
  27. package/src/github/github-publish.js +115 -0
  28. package/src/markdown/md-convert.js +172 -107
  29. package/src/new-project/new-project-modal.js +204 -0
  30. package/src/panels/activity-bar.js +22 -20
  31. package/src/panels/ai-panel.js +399 -0
  32. package/src/panels/block-action-bar.js +5 -5
  33. package/src/panels/canvas-dnd.js +8 -2
  34. package/src/panels/data-explorer.js +19 -13
  35. package/src/panels/dnd.js +144 -92
  36. package/src/panels/editors.js +55 -35
  37. package/src/panels/elements-panel.js +33 -19
  38. package/src/panels/events-panel.js +15 -10
  39. package/src/panels/git-panel.js +469 -98
  40. package/src/panels/head-panel.js +121 -81
  41. package/src/panels/imports-panel.js +88 -64
  42. package/src/panels/layers-panel.js +64 -43
  43. package/src/panels/left-panel.js +85 -47
  44. package/src/panels/overlays.js +1 -0
  45. package/src/panels/panel-events.js +45 -30
  46. package/src/panels/preview-render.js +23 -18
  47. package/src/panels/properties-panel.js +365 -249
  48. package/src/panels/pseudo-preview.js +2 -2
  49. package/src/panels/quick-search.js +7 -5
  50. package/src/panels/right-panel.js +26 -2
  51. package/src/panels/shared.js +4 -4
  52. package/src/panels/signals-panel.js +295 -166
  53. package/src/panels/statusbar.js +4 -4
  54. package/src/panels/style-inputs.js +67 -35
  55. package/src/panels/style-panel.js +233 -132
  56. package/src/panels/style-utils.js +34 -16
  57. package/src/panels/stylebook-layers-panel.js +12 -10
  58. package/src/panels/stylebook-panel.js +134 -66
  59. package/src/panels/toolbar.js +333 -109
  60. package/src/panels/welcome-screen.js +121 -0
  61. package/src/platform.js +2 -4
  62. package/src/platforms/devserver.js +113 -7
  63. package/src/resize-edges.js +98 -0
  64. package/src/services/cem-export.js +12 -7
  65. package/src/services/code-services.js +30 -12
  66. package/src/settings/content-types-editor.js +7 -7
  67. package/src/settings/css-vars-editor.js +30 -24
  68. package/src/settings/defs-editor.js +12 -7
  69. package/src/settings/general-settings.js +85 -3
  70. package/src/settings/head-editor.js +30 -24
  71. package/src/settings/schema-field-ui.js +58 -39
  72. package/src/site-context.js +41 -31
  73. package/src/state.js +70 -29
  74. package/src/store.js +21 -26
  75. package/src/studio.js +103 -81
  76. package/src/tabs/tab.js +67 -43
  77. package/src/tabs/transact.js +23 -13
  78. package/src/ui/button-group.js +24 -24
  79. package/src/ui/color-selector.js +28 -23
  80. package/src/ui/field-row.js +3 -3
  81. package/src/ui/icons.js +2 -2
  82. package/src/ui/media-picker.js +7 -7
  83. package/src/ui/spectrum.js +5 -1
  84. package/src/ui/unit-selector.js +18 -16
  85. package/src/ui/value-selector.js +21 -15
  86. package/src/ui/widgets.js +22 -19
  87. package/src/utils/canvas-media.js +5 -4
  88. package/src/utils/edit-display.js +31 -20
  89. package/src/utils/google-fonts.js +11 -11
  90. package/src/utils/inherited-style.js +9 -8
  91. package/src/utils/studio-utils.js +7 -7
  92. package/src/view.js +58 -1
  93. package/src/workspace/workspace.js +10 -7
@@ -1,17 +1,21 @@
1
1
  /**
2
- * Git panel — Source control sidebar with status, staging, commit, push/pull, and branch
3
- * management.
2
+ * Git panel — Source control sidebar with sync status, branch selector, Local Changes / History
3
+ * tabs, commit form, and changed-components view.
4
4
  */
5
5
 
6
6
  import { html, nothing } from "lit-html";
7
7
  import { live } from "lit-html/directives/live.js";
8
8
  import { repeat } from "lit-html/directives/repeat.js";
9
9
  import { getPlatform } from "../platform.js";
10
- import { updateUi, renderOnly } from "../store.js";
10
+ import { updateUi, renderOnly, projectState } from "../store.js";
11
11
  import { activeTab } from "../workspace/workspace.js";
12
12
  import { view } from "../view.js";
13
+ import { showDialog } from "../ui/layers.js";
14
+ import { statusMessage } from "./statusbar.js";
15
+ import { publishToGithub } from "../github/github-publish.js";
13
16
 
14
17
  export async function refreshGitStatus() {
18
+ if (!projectState) return;
15
19
  const plat = getPlatform();
16
20
  updateUi("gitLoading", true);
17
21
  updateUi("gitError", null);
@@ -19,39 +23,153 @@ export async function refreshGitStatus() {
19
23
  const [status, branches] = await Promise.all([plat.gitStatus(), plat.gitBranches()]);
20
24
  updateUi("gitStatus", status);
21
25
  updateUi("gitBranches", branches);
22
- } catch (/** @type {any} */ e) {
23
- updateUi("gitError", e.message);
26
+ _lastUpdated = new Date();
27
+ } catch (/** @type {unknown} */ e) {
28
+ updateUi("gitError", /** @type {Error} */ (e).message);
24
29
  } finally {
25
30
  updateUi("gitLoading", false);
26
31
  renderOnly("leftPanel");
27
32
  }
28
33
  }
29
34
 
35
+ /**
36
+ * Show a dialog to clone a git repository. Returns the cloned project root on success, or null.
37
+ *
38
+ * @param {{ openRecentProject: (root: string) => Promise<void> }} ctx
39
+ */
40
+ export async function cloneRepository(ctx) {
41
+ const platform = getPlatform();
42
+ if (!platform.gitClone) {
43
+ statusMessage("Clone not supported on this platform");
44
+ return;
45
+ }
46
+
47
+ const url = await showDialog(
48
+ (done) => html`
49
+ <sp-underlay open @close=${() => done(null)}></sp-underlay>
50
+ <sp-dialog-wrapper
51
+ headline="Clone Git Repository"
52
+ confirmLabel="Clone"
53
+ cancelLabel="Cancel"
54
+ open
55
+ @confirm=${(/** @type {Event} */ e) => {
56
+ const input = /** @type {HTMLInputElement | null} */ (
57
+ /** @type {HTMLElement} */ (
58
+ /** @type {HTMLElement} */ (e.target).parentElement
59
+ ).querySelector("sp-textfield")
60
+ );
61
+ done(input?.value?.trim() || null);
62
+ }}
63
+ @cancel=${() => done(null)}
64
+ @close=${() => done(null)}
65
+ >
66
+ <sp-textfield
67
+ label="Repository URL"
68
+ placeholder="https://github.com/user/repo.git"
69
+ style="width: 100%"
70
+ autofocus
71
+ ></sp-textfield>
72
+ </sp-dialog-wrapper>
73
+ `,
74
+ );
75
+
76
+ if (!url) return;
77
+
78
+ try {
79
+ statusMessage("Cloning repository...");
80
+ const result = await platform.gitClone(url);
81
+ if (result?.root) {
82
+ statusMessage("Clone complete");
83
+ await ctx.openRecentProject(result.root);
84
+ }
85
+ } catch (/** @type {unknown} */ e) {
86
+ statusMessage(`Clone failed: ${/** @type {Error} */ (e).message}`);
87
+ }
88
+ }
89
+
90
+ /** @returns {boolean} */
91
+ export function platformSupportsClone() {
92
+ return !!getPlatform().gitClone;
93
+ }
94
+
30
95
  /**
31
96
  * @param {string} action
32
- * @param {any} [body]
97
+ * @param {unknown} [body]
33
98
  */
34
99
  async function gitAction(action, body) {
35
- const plat = getPlatform();
100
+ const plat = /** @type {Record<string, Function> & StudioPlatform} */ (getPlatform());
36
101
  updateUi("gitLoading", true);
37
102
  updateUi("gitError", null);
38
103
  try {
39
104
  await plat[action](body);
40
105
  await refreshGitStatus();
41
- } catch (/** @type {any} */ e) {
42
- updateUi("gitError", e.message);
106
+ } catch (/** @type {unknown} */ e) {
107
+ updateUi("gitError", /** @type {Error} */ (e).message);
43
108
  updateUi("gitLoading", false);
44
109
  renderOnly("leftPanel");
45
110
  }
46
111
  }
47
112
 
48
- let _pollTimer = /** @type {any} */ (null);
113
+ let _pollTimer = /** @type {ReturnType<typeof setInterval> | null} */ (null);
114
+ let _lastUpdated = /** @type {Date | null} */ (null);
115
+ let _gitSubTab = "changes";
116
+
117
+ /** @typedef {{ path: string; status: string; staged: boolean }} GitFileEntry */
118
+ /** @typedef {{ hash: string; message: string; author: string; date: string }} GitLogEntry */
49
119
 
50
120
  /**
51
- * @param {any} S
52
- * @param {any} ctx
121
+ * @typedef {{
122
+ * gitStatus?: {
123
+ * files?: GitFileEntry[];
124
+ * branch?: string;
125
+ * ahead?: number;
126
+ * behind?: number;
127
+ * isRepo?: boolean;
128
+ * remotes?: string[];
129
+ * } | null;
130
+ * gitBranches?: { current?: string; branches?: string[] } | null;
131
+ * gitLoading?: boolean;
132
+ * gitError?: string | null;
133
+ * gitCommitMessage?: string;
134
+ * gitLogEntries?: GitLogEntry[] | null;
135
+ * [key: string]: unknown;
136
+ * }} GitUiState
137
+ */
138
+
139
+ async function fetchGitLog() {
140
+ const plat = getPlatform();
141
+ try {
142
+ const entries = await plat.gitLog(30);
143
+ updateUi("gitLogEntries", entries);
144
+ renderOnly("leftPanel");
145
+ } catch (/** @type {unknown} */ e) {
146
+ updateUi("gitError", /** @type {Error} */ (e).message);
147
+ renderOnly("leftPanel");
148
+ }
149
+ }
150
+
151
+ /**
152
+ * @param {{ ui: GitUiState }} S
153
+ * @param {{
154
+ * setCanvasMode?: (mode: string) => void;
155
+ * setGitDiffState?: (state: unknown) => void;
156
+ * cloneRepository?: () => void;
157
+ * }} ctx
53
158
  */
54
159
  export function renderGitPanel(S, ctx) {
160
+ if (!projectState) {
161
+ return html`<div class="git-panel git-panel-empty">
162
+ <div class="git-empty-state">
163
+ <p>Open a project to use source control.</p>
164
+ ${platformSupportsClone()
165
+ ? html`<sp-action-button size="m" @click=${() => ctx.cloneRepository?.()}>
166
+ <sp-icon-download slot="icon"></sp-icon-download>
167
+ Clone Git Repository
168
+ </sp-action-button>`
169
+ : nothing}
170
+ </div>
171
+ </div>`;
172
+ }
55
173
  const status = S.ui.gitStatus;
56
174
  const branches = S.ui.gitBranches;
57
175
  const loading = S.ui.gitLoading;
@@ -61,14 +179,43 @@ export function renderGitPanel(S, ctx) {
61
179
  return html`<div class="git-panel"><div class="git-loading">Loading...</div></div>`;
62
180
  }
63
181
 
182
+ if (status && !status.isRepo) {
183
+ return html`<div class="git-panel git-panel-empty">
184
+ <div class="git-empty-state">
185
+ <p>This project is not yet a git repository.</p>
186
+ <sp-action-button
187
+ size="m"
188
+ @click=${async () => {
189
+ statusMessage("Initializing repository…");
190
+ await getPlatform().gitInit();
191
+ statusMessage("Repository initialized");
192
+ await refreshGitStatus();
193
+ }}
194
+ ?disabled=${loading}
195
+ >
196
+ <sp-icon-add slot="icon"></sp-icon-add>
197
+ Initialize Repository
198
+ </sp-action-button>
199
+ <sp-action-button
200
+ size="m"
201
+ @click=${() => publishToGithub({ projectName: projectState?.name || "my-project" })}
202
+ ?disabled=${loading}
203
+ >
204
+ <sp-icon-share slot="icon"></sp-icon-share>
205
+ Publish to GitHub
206
+ </sp-action-button>
207
+ </div>
208
+ </div>`;
209
+ }
210
+
64
211
  if (!_pollTimer) {
65
212
  _pollTimer = setInterval(() => {
66
213
  if (view.leftTab === "git" && !S.ui.gitLoading) refreshGitStatus();
67
214
  }, 30000);
68
215
  }
69
216
 
70
- const stagedFiles = status?.files?.filter((/** @type {any} */ f) => f.staged) || [];
71
- const unstagedFiles = status?.files?.filter((/** @type {any} */ f) => !f.staged) || [];
217
+ const stagedFiles = status?.files?.filter((/** @type {GitFileEntry} */ f) => f.staged) || [];
218
+ const unstagedFiles = status?.files?.filter((/** @type {GitFileEntry} */ f) => !f.staged) || [];
72
219
  const totalChanges = status?.files?.length || 0;
73
220
 
74
221
  const doCommit = async () => {
@@ -79,83 +226,240 @@ export function renderGitPanel(S, ctx) {
79
226
  await gitAction("gitCommit", msg);
80
227
  };
81
228
 
82
- const branchPickerT = html`
83
- <sp-picker
84
- size="s"
85
- quiet
86
- class="git-branch-picker"
87
- .value=${live(branches?.current || "")}
88
- @change=${async (/** @type {any} */ e) => {
89
- const val = e.target.value;
90
- if (val === "__new__") {
91
- e.target.value = branches?.current || "";
92
- const name = prompt("New branch name:");
93
- if (name?.trim()) await gitAction("gitCreateBranch", name.trim());
94
- return;
95
- }
96
- if (val !== branches?.current) await gitAction("gitCheckout", val);
97
- }}
98
- >
99
- ${(branches?.branches || []).map(
100
- (/** @type {string} */ b) => html`<sp-menu-item value=${b}>${b}</sp-menu-item>`,
101
- )}
102
- <sp-menu-divider></sp-menu-divider>
103
- <sp-menu-item value="__new__">+ New branch...</sp-menu-item>
104
- </sp-picker>
229
+ const doCommitAndSync = async () => {
230
+ const tab = activeTab.value;
231
+ const msg = tab?.session.ui.gitCommitMessage?.trim();
232
+ if (!msg) return;
233
+ updateUi("gitCommitMessage", "");
234
+ updateUi("gitLoading", true);
235
+ updateUi("gitError", null);
236
+ const plat = getPlatform();
237
+ try {
238
+ await plat.gitCommit(msg);
239
+ await plat.gitPush();
240
+ await refreshGitStatus();
241
+ } catch (/** @type {unknown} */ e) {
242
+ updateUi("gitError", /** @type {Error} */ (e).message);
243
+ updateUi("gitLoading", false);
244
+ renderOnly("leftPanel");
245
+ }
246
+ };
247
+
248
+ // ─── 1. Sync status bar ──────────────────────────────────────────────────
249
+ const isUpToDate = !status?.ahead && !status?.behind;
250
+ const syncLabel = isUpToDate
251
+ ? "Up to date"
252
+ : `${status?.ahead ? `${status.ahead} ahead` : ""}${status?.ahead && status?.behind ? ", " : ""}${status?.behind ? `${status.behind} behind` : ""}`;
253
+ const lastUpdatedStr = _lastUpdated
254
+ ? _lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
255
+ : "";
256
+
257
+ const hasRemotes = (status?.remotes?.length ?? 0) > 0;
258
+
259
+ const syncBarT = hasRemotes
260
+ ? html`
261
+ <div class="git-sync-bar">
262
+ <sp-action-button
263
+ size="s"
264
+ quiet
265
+ class="git-sync-icon"
266
+ title="Refresh"
267
+ @click=${() => refreshGitStatus()}
268
+ ?disabled=${loading}
269
+ >
270
+ <sp-icon-refresh slot="icon"></sp-icon-refresh>
271
+ </sp-action-button>
272
+ <div class="git-sync-text">
273
+ <span class="git-sync-label">${syncLabel}</span>
274
+ ${lastUpdatedStr
275
+ ? html`<span class="git-sync-time">Last updated ${lastUpdatedStr}</span>`
276
+ : nothing}
277
+ </div>
278
+ <sp-action-group size="xs" quiet class="git-sync-actions">
279
+ <sp-action-button
280
+ title="Fetch"
281
+ @click=${() => gitAction("gitFetch")}
282
+ ?disabled=${loading}
283
+ >
284
+ <sp-icon-download slot="icon" size="xs"></sp-icon-download>
285
+ </sp-action-button>
286
+ <sp-action-button
287
+ title="Pull${status?.behind ? ` (${status.behind} behind)` : ""}"
288
+ @click=${() => gitAction("gitPull")}
289
+ ?disabled=${loading}
290
+ >
291
+ <sp-icon-arrow-down slot="icon" size="xs"></sp-icon-arrow-down>
292
+ </sp-action-button>
293
+ <sp-action-button
294
+ title="Push${status?.ahead ? ` (${status.ahead} ahead)` : ""}"
295
+ @click=${() => gitAction("gitPush")}
296
+ ?disabled=${loading}
297
+ >
298
+ <sp-icon-arrow-up slot="icon" size="xs"></sp-icon-arrow-up>
299
+ </sp-action-button>
300
+ </sp-action-group>
301
+ </div>
302
+ `
303
+ : html`
304
+ <div class="git-sync-bar git-sync-bar--no-remote">
305
+ <sp-action-button
306
+ size="s"
307
+ quiet
308
+ class="git-sync-icon"
309
+ title="Refresh"
310
+ @click=${() => refreshGitStatus()}
311
+ ?disabled=${loading}
312
+ >
313
+ <sp-icon-refresh slot="icon"></sp-icon-refresh>
314
+ </sp-action-button>
315
+ <div class="git-sync-text">
316
+ <span class="git-sync-label">Local only (no remote)</span>
317
+ </div>
318
+ <sp-action-button
319
+ size="s"
320
+ @click=${() => publishToGithub({ projectName: projectState?.name || "my-project" })}
321
+ ?disabled=${loading}
322
+ >
323
+ <sp-icon-share slot="icon"></sp-icon-share>
324
+ Publish to GitHub
325
+ </sp-action-button>
326
+ </div>
327
+ `;
328
+
329
+ // ─── 2. Branch selector ──────────────────────────────────────────────────
330
+ const branchSelectorT = html`
331
+ <div class="git-branch-row">
332
+ <svg
333
+ class="git-branch-icon"
334
+ xmlns="http://www.w3.org/2000/svg"
335
+ width="18"
336
+ height="18"
337
+ viewBox="0 0 24 24"
338
+ fill="none"
339
+ stroke="currentColor"
340
+ stroke-width="2"
341
+ stroke-linecap="round"
342
+ stroke-linejoin="round"
343
+ >
344
+ <line x1="6" y1="3" x2="6" y2="15"></line>
345
+ <circle cx="18" cy="6" r="3"></circle>
346
+ <circle cx="6" cy="18" r="3"></circle>
347
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
348
+ </svg>
349
+ <div class="git-branch-text">
350
+ <span class="git-branch-label">Active branch</span>
351
+ <span class="git-branch-name">${branches?.current || status?.branch || "—"}</span>
352
+ </div>
353
+ <sp-picker
354
+ size="s"
355
+ quiet
356
+ class="git-branch-picker"
357
+ .value=${live(branches?.current || "")}
358
+ @change=${async (/** @type {Event} */ e) => {
359
+ const val = /** @type {HTMLInputElement} */ (e.target).value;
360
+ if (val === "__new__") {
361
+ /** @type {HTMLInputElement} */ (e.target).value = branches?.current || "";
362
+ const name = prompt("New branch name:");
363
+ if (name?.trim()) await gitAction("gitCreateBranch", name.trim());
364
+ return;
365
+ }
366
+ if (val !== branches?.current) await gitAction("gitCheckout", val);
367
+ }}
368
+ >
369
+ ${(branches?.branches || []).map(
370
+ (/** @type {string} */ b) => html`<sp-menu-item value=${b}>${b}</sp-menu-item>`,
371
+ )}
372
+ <sp-menu-divider></sp-menu-divider>
373
+ <sp-menu-item value="__new__">+ New branch...</sp-menu-item>
374
+ </sp-picker>
375
+ </div>
105
376
  `;
106
377
 
107
- const toolbarT = html`
108
- <div class="git-toolbar">
109
- ${branchPickerT}
110
- <sp-action-group size="xs" quiet>
111
- <sp-action-button title="Fetch" @click=${() => gitAction("gitFetch")} ?disabled=${loading}>
112
- <sp-icon-download slot="icon" size="xs"></sp-icon-download>
113
- </sp-action-button>
114
- <sp-action-button
115
- title="Pull${status?.behind ? ` (${status.behind} behind)` : ""}"
116
- @click=${() => gitAction("gitPull")}
117
- ?disabled=${loading}
118
- >
119
- <sp-icon-arrow-down slot="icon" size="xs"></sp-icon-arrow-down>
120
- </sp-action-button>
121
- <sp-action-button
122
- title="Push${status?.ahead ? ` (${status.ahead} ahead)` : ""}"
123
- @click=${() => gitAction("gitPush")}
124
- ?disabled=${loading}
125
- >
126
- <sp-icon-arrow-up slot="icon" size="xs"></sp-icon-arrow-up>
127
- </sp-action-button>
128
- <sp-action-button title="Refresh" @click=${() => refreshGitStatus()} ?disabled=${loading}>
129
- <sp-icon-refresh slot="icon" size="xs"></sp-icon-refresh>
130
- </sp-action-button>
131
- </sp-action-group>
378
+ // ─── 3. Tabs: Local Changes / History ────────────────────────────────────
379
+ const switchTab = (/** @type {string} */ tab) => {
380
+ _gitSubTab = tab;
381
+ if (tab === "history" && !S.ui.gitLogEntries) fetchGitLog();
382
+ renderOnly("leftPanel");
383
+ };
384
+
385
+ const tabsT = html`
386
+ <div class="git-tabs">
387
+ <button
388
+ class="git-tab ${_gitSubTab === "changes" ? "active" : ""}"
389
+ @click=${() => switchTab("changes")}
390
+ >
391
+ Local Changes${totalChanges > 0 ? ` (${totalChanges})` : ""}
392
+ </button>
393
+ <button
394
+ class="git-tab ${_gitSubTab === "history" ? "active" : ""}"
395
+ @click=${() => switchTab("history")}
396
+ >
397
+ History
398
+ </button>
132
399
  </div>
133
400
  `;
134
401
 
402
+ // ─── 4. Commit form ──────────────────────────────────────────────────────
135
403
  const commitT = html`
136
404
  <div class="git-commit-area">
405
+ <label class="git-commit-label">Please write a commit message</label>
137
406
  <sp-textfield
138
407
  size="s"
139
408
  multiline
140
409
  class="git-commit-input"
141
- placeholder='Message (Ctrl+Enter to commit on "${status?.branch || ""}")'
410
+ placeholder="Describe your changes"
142
411
  .value=${live(S.ui.gitCommitMessage || "")}
143
- @input=${(/** @type {any} */ e) => updateUi("gitCommitMessage", e.target.value)}
144
- @keydown=${(/** @type {any} */ e) => {
412
+ @input=${(/** @type {Event} */ e) =>
413
+ updateUi("gitCommitMessage", /** @type {HTMLInputElement} */ (e.target).value)}
414
+ @keydown=${(/** @type {KeyboardEvent} */ e) => {
145
415
  if (e.ctrlKey && e.key === "Enter") {
146
416
  e.preventDefault();
147
417
  doCommit();
148
418
  }
149
419
  }}
150
420
  ></sp-textfield>
151
- <sp-action-button class="git-commit-btn" @click=${doCommit} ?disabled=${loading}>
152
- <sp-icon-checkmark slot="icon" size="xs"></sp-icon-checkmark>
153
- Commit
154
- </sp-action-button>
421
+ <div class="git-commit-actions">
422
+ <div class="git-split-btn">
423
+ <sp-action-button
424
+ class="git-commit-btn"
425
+ size="s"
426
+ @click=${doCommitAndSync}
427
+ ?disabled=${loading}
428
+ >
429
+ Commit and sync
430
+ </sp-action-button>
431
+ <sp-action-button
432
+ class="git-split-trigger"
433
+ size="s"
434
+ @click=${(/** @type {Event} */ e) => {
435
+ const menu = /** @type {HTMLElement} */ (
436
+ /** @type {HTMLElement} */ (e.currentTarget).parentElement
437
+ ).querySelector(".git-split-menu");
438
+ if (menu) menu.toggleAttribute("hidden");
439
+ }}
440
+ >
441
+ <sp-icon-chevron-down slot="icon" size="xs"></sp-icon-chevron-down>
442
+ </sp-action-button>
443
+ <div class="git-split-menu" hidden>
444
+ <button
445
+ class="git-split-menu-item"
446
+ @click=${(/** @type {Event} */ e) => {
447
+ /** @type {HTMLElement} */ (
448
+ /** @type {HTMLElement} */ (e.currentTarget).parentElement
449
+ ).setAttribute("hidden", "");
450
+ doCommit();
451
+ }}
452
+ >
453
+ Commit (don't sync)
454
+ </button>
455
+ </div>
456
+ </div>
457
+ </div>
155
458
  </div>
156
459
  `;
157
460
 
158
- const fileRowT = (/** @type {any} */ file) => {
461
+ // ─── 5. Changed Components ───────────────────────────────────────────────
462
+ const fileRowT = (/** @type {GitFileEntry} */ file) => {
159
463
  const parts = file.path.split("/");
160
464
  const name = parts.pop();
161
465
  const dir = parts.join("/");
@@ -190,8 +494,8 @@ export function renderGitPanel(S, ctx) {
190
494
  if (ctx.setGitDiffState) ctx.setGitDiffState(diffState);
191
495
  ctx.setCanvasMode("git-diff");
192
496
  }
193
- } catch (/** @type {any} */ e) {
194
- updateUi("gitError", `Failed to load diff: ${e.message}`);
497
+ } catch (/** @type {unknown} */ e) {
498
+ updateUi("gitError", `Failed to load diff: ${/** @type {Error} */ (e).message}`);
195
499
  } finally {
196
500
  updateUi("gitLoading", false);
197
501
  }
@@ -249,6 +553,27 @@ export function renderGitPanel(S, ctx) {
249
553
  `;
250
554
  };
251
555
 
556
+ /** Group files by component (parent directory for .json/.class.json, or "Other") */
557
+ const groupFilesByComponent = (/** @type {GitFileEntry[]} */ files) => {
558
+ /** @type {Map<string, GitFileEntry[]>} */
559
+ const groups = new Map();
560
+ for (const f of files) {
561
+ const parts = f.path.split("/");
562
+ let component;
563
+ if (f.path.endsWith(".json") || f.path.endsWith(".class.json") || f.path.endsWith(".md")) {
564
+ component = parts.length > 1 ? `/${parts[parts.length - 2]}` : `/${parts[0]}`;
565
+ } else {
566
+ component = "Other";
567
+ }
568
+ if (!groups.has(component)) groups.set(component, []);
569
+ /** @type {GitFileEntry[]} */ (groups.get(component)).push(f);
570
+ }
571
+ return groups;
572
+ };
573
+
574
+ const allFiles = [...stagedFiles, ...unstagedFiles];
575
+ const componentGroups = groupFilesByComponent(allFiles);
576
+
252
577
  const changesT = html`
253
578
  ${stagedFiles.length > 0
254
579
  ? html`
@@ -263,22 +588,22 @@ export function renderGitPanel(S, ctx) {
263
588
  @click=${() =>
264
589
  gitAction(
265
590
  "gitUnstage",
266
- stagedFiles.map((/** @type {any} */ f) => f.path),
591
+ stagedFiles.map((/** @type {GitFileEntry} */ f) => f.path),
267
592
  )}
268
593
  >
269
594
  <sp-icon-remove slot="icon" size="xs"></sp-icon-remove>
270
595
  </sp-action-button>
271
596
  </div>
272
- ${repeat(stagedFiles, (/** @type {any} */ f) => f.path, fileRowT)}
597
+ ${repeat(stagedFiles, (/** @type {GitFileEntry} */ f) => f.path, fileRowT)}
273
598
  </div>
274
599
  `
275
600
  : nothing}
276
- ${unstagedFiles.length > 0
277
- ? html`
278
- <div class="git-section">
279
- <div class="git-section-header">
280
- <span>Changes</span>
281
- <span class="git-count">${unstagedFiles.length}</span>
601
+ <div class="git-section">
602
+ <div class="git-section-header">
603
+ <span>Changed Components</span>
604
+ <span class="git-count">${allFiles.length}</span>
605
+ ${unstagedFiles.length > 0
606
+ ? html`
282
607
  <sp-action-button
283
608
  size="xs"
284
609
  quiet
@@ -286,38 +611,84 @@ export function renderGitPanel(S, ctx) {
286
611
  @click=${() =>
287
612
  gitAction(
288
613
  "gitStage",
289
- unstagedFiles.map((/** @type {any} */ f) => f.path),
614
+ unstagedFiles.map((/** @type {GitFileEntry} */ f) => f.path),
290
615
  )}
291
616
  >
292
617
  <sp-icon-add slot="icon" size="xs"></sp-icon-add>
293
618
  </sp-action-button>
294
- </div>
295
- ${repeat(unstagedFiles, (/** @type {any} */ f) => f.path, fileRowT)}
296
- </div>
297
- `
298
- : nothing}
299
- ${totalChanges === 0 && !loading ? html`<div class="git-empty">No changes</div>` : nothing}
619
+ `
620
+ : nothing}
621
+ </div>
622
+ ${allFiles.length > 0
623
+ ? html`
624
+ ${[...componentGroups.entries()].map(
625
+ ([comp, files]) => html`
626
+ <div class="git-component-group">
627
+ <div class="git-component-header">
628
+ <sp-action-button
629
+ size="xs"
630
+ quiet
631
+ class="git-component-overflow"
632
+ title="Actions"
633
+ >
634
+ <sp-icon-more slot="icon" size="xs"></sp-icon-more>
635
+ </sp-action-button>
636
+ <span class="git-component-name">${comp}</span>
637
+ </div>
638
+ ${repeat(files, (/** @type {GitFileEntry} */ f) => f.path, fileRowT)}
639
+ </div>
640
+ `,
641
+ )}
642
+ `
643
+ : html`<div class="git-empty">No changes</div>`}
644
+ </div>
300
645
  `;
301
646
 
302
- const syncInfoT =
303
- status?.ahead || status?.behind
304
- ? html`
305
- <div class="git-sync-info">
306
- ${status.ahead ? html`<span title="Commits ahead">↑${status.ahead}</span>` : nothing}
307
- ${status.behind ? html`<span title="Commits behind">↓${status.behind}</span>` : nothing}
308
- </div>
309
- `
310
- : nothing;
647
+ // ─── 6. History tab content ──────────────────────────────────────────────
648
+ const logEntries = S.ui.gitLogEntries || [];
649
+ const historyT = html`
650
+ <div class="git-history">
651
+ ${logEntries.length === 0
652
+ ? html`<div class="git-empty">No history</div>`
653
+ : repeat(
654
+ logEntries,
655
+ (/** @type {GitLogEntry} */ e) => e.hash,
656
+ (/** @type {GitLogEntry} */ entry) => html`
657
+ <div class="git-history-entry">
658
+ <span class="git-history-hash">${entry.hash.slice(0, 7)}</span>
659
+ <span class="git-history-message">${entry.message}</span>
660
+ <span class="git-history-meta">${entry.author} · ${_relativeDate(entry.date)}</span>
661
+ </div>
662
+ `,
663
+ )}
664
+ </div>
665
+ `;
311
666
 
312
667
  return html`
313
668
  <div class="git-panel">
314
- ${toolbarT} ${syncInfoT} ${commitT}
669
+ ${syncBarT} ${branchSelectorT} ${tabsT}
670
+ ${_gitSubTab === "changes" ? html`${commitT}${changesT}` : historyT}
315
671
  ${loading ? html`<div class="git-loading">Loading...</div>` : nothing}
316
- ${S.ui.gitError ? html`<div class="git-error">${S.ui.gitError}</div>` : nothing} ${changesT}
672
+ ${S.ui.gitError ? html`<div class="git-error">${S.ui.gitError}</div>` : nothing}
317
673
  </div>
318
674
  `;
319
675
  }
320
676
 
677
+ /** @param {string} iso */
678
+ function _relativeDate(iso) {
679
+ const d = new Date(iso);
680
+ const now = Date.now();
681
+ const diff = now - d.getTime();
682
+ const mins = Math.floor(diff / 60000);
683
+ if (mins < 1) return "just now";
684
+ if (mins < 60) return `${mins}m ago`;
685
+ const hours = Math.floor(mins / 60);
686
+ if (hours < 24) return `${hours}h ago`;
687
+ const days = Math.floor(hours / 24);
688
+ if (days < 30) return `${days}d ago`;
689
+ return d.toLocaleDateString();
690
+ }
691
+
321
692
  export function cleanupGitPanel() {
322
693
  if (_pollTimer) {
323
694
  clearInterval(_pollTimer);