@jxsuite/studio 0.6.2 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
32
32
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
33
- "@jxsuite/runtime": "^0.5.5",
33
+ "@jxsuite/runtime": "^0.6.2",
34
34
  "@spectrum-web-components/accordion": "^1.12.0",
35
35
  "@spectrum-web-components/action-bar": "1.12.0",
36
36
  "@spectrum-web-components/action-button": "^1.12.0",
@@ -14,6 +14,7 @@ import { unified } from "unified";
14
14
  import remarkStringify from "remark-stringify";
15
15
  import remarkDirective from "remark-directive";
16
16
  import { MD_ALL } from "./md-allowlist.js";
17
+ import { htmlToJx } from "@jxsuite/parser/transpile";
17
18
 
18
19
  // ─── mdast → Jx ──────────────────────────────────────────────────────────
19
20
 
@@ -40,7 +41,6 @@ const MDAST_TAG_MAP = {
40
41
  table: () => "table",
41
42
  tableRow: () => "tr",
42
43
  tableCell: (/** @type {any} */ n) => (n.isHeader ? "th" : "td"),
43
- html: () => "div",
44
44
  break: () => "br",
45
45
  };
46
46
 
@@ -55,7 +55,7 @@ export function mdToJx(mdast) {
55
55
  return {
56
56
  children: (mdast.children ?? [])
57
57
  .filter((/** @type {any} */ n) => n.type !== "yaml" && n.type !== "toml")
58
- .map(convertMdastNode)
58
+ .flatMap(convertMdastNode)
59
59
  .filter(Boolean),
60
60
  };
61
61
  }
@@ -78,6 +78,12 @@ function convertMdastNode(node) {
78
78
  return convertDirective(node);
79
79
  }
80
80
 
81
+ if (node.type === "html") {
82
+ if (!node.value) return null;
83
+ const nodes = htmlToJx(node.value);
84
+ return nodes.length === 1 ? nodes[0] : { tagName: "div", children: nodes };
85
+ }
86
+
81
87
  const tagFn = MDAST_TAG_MAP[node.type];
82
88
  if (!tagFn) return null;
83
89
 
@@ -92,7 +98,7 @@ function convertMdastNode(node) {
92
98
  if (node.children?.length === 1 && node.children[0].type === "text") {
93
99
  el.textContent = node.children[0].value;
94
100
  } else if (node.children?.length > 0) {
95
- el.children = node.children.map(convertMdastNode).filter(Boolean);
101
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
96
102
  }
97
103
  break;
98
104
  }
@@ -107,7 +113,7 @@ function convertMdastNode(node) {
107
113
  if (node.children?.length === 1 && node.children[0].type === "text") {
108
114
  el.textContent = node.children[0].value;
109
115
  } else if (node.children?.length > 0) {
110
- el.children = node.children.map(convertMdastNode).filter(Boolean);
116
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
111
117
  }
112
118
  break;
113
119
  }
@@ -122,7 +128,7 @@ function convertMdastNode(node) {
122
128
  if (node.children?.length === 1 && node.children[0].type === "text") {
123
129
  el.textContent = node.children[0].value;
124
130
  } else if (node.children?.length > 0) {
125
- el.children = node.children.map(convertMdastNode).filter(Boolean);
131
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
126
132
  }
127
133
  break;
128
134
 
@@ -134,13 +140,13 @@ function convertMdastNode(node) {
134
140
  case "blockquote":
135
141
  case "listItem":
136
142
  if (node.children?.length > 0) {
137
- el.children = node.children.map(convertMdastNode).filter(Boolean);
143
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
138
144
  }
139
145
  break;
140
146
 
141
147
  case "list":
142
148
  if (node.children?.length > 0) {
143
- el.children = node.children.map(convertMdastNode).filter(Boolean);
149
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
144
150
  }
145
151
  if (node.start != null && node.start !== 1) {
146
152
  el.attributes = { start: String(node.start) };
@@ -165,7 +171,7 @@ function convertMdastNode(node) {
165
171
 
166
172
  case "table": {
167
173
  // Mdast tables have rows directly; split into thead/tbody
168
- const rows = (node.children ?? []).map(convertMdastNode).filter(Boolean);
174
+ const rows = (node.children ?? []).flatMap(convertMdastNode).filter(Boolean);
169
175
  const thead = rows.length > 0 ? { tagName: "thead", children: [rows[0]] } : null;
170
176
  const tbody = rows.length > 1 ? { tagName: "tbody", children: rows.slice(1) } : null;
171
177
  el.children = [thead, tbody].filter(Boolean);
@@ -174,7 +180,7 @@ function convertMdastNode(node) {
174
180
 
175
181
  case "tableRow":
176
182
  if (node.children?.length > 0) {
177
- el.children = node.children.map(convertMdastNode).filter(Boolean);
183
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
178
184
  }
179
185
  break;
180
186
 
@@ -182,13 +188,9 @@ function convertMdastNode(node) {
182
188
  if (node.children?.length === 1 && node.children[0].type === "text") {
183
189
  el.textContent = node.children[0].value;
184
190
  } else if (node.children?.length > 0) {
185
- el.children = node.children.map(convertMdastNode).filter(Boolean);
191
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
186
192
  }
187
193
  break;
188
-
189
- case "html":
190
- el.innerHTML = node.value;
191
- break;
192
194
  }
193
195
 
194
196
  return el;
@@ -209,10 +211,10 @@ function convertDirective(node) {
209
211
  if (node.children?.length === 1 && node.children[0].type === "text") {
210
212
  el.textContent = node.children[0].value;
211
213
  } else if (node.children?.length > 0) {
212
- el.children = node.children.map(convertMdastNode).filter(Boolean);
214
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
213
215
  }
214
216
  } else if (node.type === "containerDirective" && node.children?.length > 0) {
215
- el.children = node.children.map(convertMdastNode).filter(Boolean);
217
+ el.children = node.children.flatMap(convertMdastNode).filter(Boolean);
216
218
  }
217
219
  return el;
218
220
  }
@@ -3,6 +3,26 @@
3
3
  import { html, render as litRender, nothing } from "lit-html";
4
4
  import { activityBar, update, getState, renderOnly } from "../store.js";
5
5
 
6
+ const gitBranchIcon = (/** @type {any} */ s) => html`
7
+ <svg
8
+ slot="icon"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width=${s === "m" ? 20 : 16}
11
+ height=${s === "m" ? 20 : 16}
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ stroke-width="2"
16
+ stroke-linecap="round"
17
+ stroke-linejoin="round"
18
+ >
19
+ <line x1="6" y1="3" x2="6" y2="15"></line>
20
+ <circle cx="18" cy="6" r="3"></circle>
21
+ <circle cx="6" cy="18" r="3"></circle>
22
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
23
+ </svg>
24
+ `;
25
+
6
26
  /**
7
27
  * @param {any} tag
8
28
  * @param {any} size
@@ -32,6 +52,7 @@ export function tabIcon(tag, size) {
32
52
  html`<sp-icon-artboard slot="icon" size=${s}></sp-icon-artboard>`,
33
53
  "sp-icon-box": (/** @type {any} */ s) =>
34
54
  html`<sp-icon-box slot="icon" size=${s}></sp-icon-box>`,
55
+ "sp-icon-git-branch": gitBranchIcon,
35
56
  };
36
57
  const fn = m[tag];
37
58
  return fn ? fn(size || "s") : nothing;
@@ -47,6 +68,7 @@ export function renderActivityBar(S) {
47
68
  { value: "state", icon: "sp-icon-brackets", label: "State" },
48
69
  { value: "data", icon: "sp-icon-data", label: "Data" },
49
70
  { value: "head", icon: "sp-icon-file-single-web-page", label: "Head" },
71
+ { value: "git", icon: "sp-icon-git-branch", label: "Source Control" },
50
72
  ];
51
73
  const tpl = html`
52
74
  <sp-tabs
@@ -0,0 +1,148 @@
1
+ /** Elements panel — block/component palette with categorized accordion and search filter. */
2
+
3
+ import { html, nothing } from "lit-html";
4
+ import { getState, update, getNodeAtPath, insertNode } from "../store.js";
5
+ import { view } from "../view.js";
6
+ import { getEffectiveElements } from "../site-context.js";
7
+ import { componentRegistry } from "../files/components.js";
8
+
9
+ /**
10
+ * @param {{ webdata: any; defaultDef: (tag: string) => any; rerender: () => void }} ctx
11
+ * @returns {import("lit-html").TemplateResult}
12
+ */
13
+ export function renderElementsTemplate(ctx) {
14
+ const S = getState();
15
+
16
+ const categories = Object.entries(ctx.webdata.elements).map(
17
+ (/** @type {any} */ [category, elements]) => {
18
+ const filtered = view.elementsFilter
19
+ ? elements.filter((/** @type {any} */ e) => e.tag.includes(view.elementsFilter))
20
+ : elements;
21
+ if (filtered.length === 0) return nothing;
22
+
23
+ return html`
24
+ <sp-accordion-item
25
+ label=${category}
26
+ ?open=${!view.elementsCollapsed.has(category)}
27
+ @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
28
+ if (e.target.open) view.elementsCollapsed.delete(category);
29
+ else view.elementsCollapsed.add(category);
30
+ }}
31
+ >
32
+ ${filtered.map((/** @type {any} */ { tag }) => {
33
+ const def = ctx.defaultDef(tag);
34
+ return html`
35
+ <div
36
+ class="element-card"
37
+ data-block-tag=${tag}
38
+ @click=${() => {
39
+ const s = getState();
40
+ const parentPath = s.selection || [];
41
+ const parent = getNodeAtPath(s.document, parentPath);
42
+ const idx = parent?.children ? parent.children.length : 0;
43
+ update(insertNode(s, parentPath, idx, structuredClone(def)));
44
+ }}
45
+ >
46
+ <div class="element-card-preview"></div>
47
+ <div class="element-card-label">&lt;${tag}&gt;</div>
48
+ </div>
49
+ `;
50
+ })}
51
+ </sp-accordion-item>
52
+ `;
53
+ },
54
+ );
55
+
56
+ const effectiveEls = getEffectiveElements(S.document?.$elements);
57
+ /** @type {Set<string>} */
58
+ const enabledTags = new Set();
59
+ for (const entry of effectiveEls) {
60
+ if (typeof entry !== "string") continue;
61
+ const comp = componentRegistry.find(
62
+ (/** @type {any} */ c) =>
63
+ c.source === "npm" && c.modulePath && entry === `${c.package}/${c.modulePath}`,
64
+ );
65
+ if (comp) {
66
+ enabledTags.add(comp.tagName);
67
+ } else {
68
+ for (const c of componentRegistry) {
69
+ if (c.source === "npm" && c.package === entry) enabledTags.add(c.tagName);
70
+ }
71
+ }
72
+ }
73
+ const compsFiltered =
74
+ componentRegistry.length > 0
75
+ ? componentRegistry
76
+ .filter((/** @type {any} */ c) => c.source !== "npm" || enabledTags.has(c.tagName))
77
+ .filter(
78
+ (/** @type {any} */ c) =>
79
+ !view.elementsFilter || c.tagName.toLowerCase().includes(view.elementsFilter),
80
+ )
81
+ : [];
82
+
83
+ const componentsAccordion =
84
+ compsFiltered.length > 0
85
+ ? html`
86
+ <sp-accordion-item
87
+ label="Components"
88
+ ?open=${!view.elementsCollapsed.has("Components")}
89
+ @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
90
+ if (e.target.open) view.elementsCollapsed.delete("Components");
91
+ else view.elementsCollapsed.add("Components");
92
+ }}
93
+ >
94
+ <div class="components-section">
95
+ ${compsFiltered.map(
96
+ (/** @type {any} */ comp) => html`
97
+ <div
98
+ class="element-card"
99
+ data-component-tag=${comp.tagName}
100
+ title=${comp.source === "npm"
101
+ ? `${comp.package}: <${comp.tagName}>`
102
+ : comp.path}
103
+ @click=${() => {
104
+ const s = getState();
105
+ const parentPath = s.selection || [];
106
+ const parent = getNodeAtPath(s.document, parentPath);
107
+ const idx = parent?.children ? parent.children.length : 0;
108
+ const instanceDef = {
109
+ tagName: comp.tagName,
110
+ $props: Object.fromEntries(
111
+ (comp.props || []).map((/** @type {any} */ p) => [
112
+ p.name,
113
+ p.default !== undefined ? p.default : "",
114
+ ]),
115
+ ),
116
+ };
117
+ update(insertNode(s, parentPath, idx, structuredClone(instanceDef)));
118
+ }}
119
+ >
120
+ <div class="element-card-preview">
121
+ <span style="color:var(--fg-dim);font-size:11px;font-style:italic"
122
+ >&lt;${comp.tagName}&gt;</span
123
+ >
124
+ </div>
125
+ <div class="element-card-label">${comp.tagName}</div>
126
+ </div>
127
+ `,
128
+ )}
129
+ </div>
130
+ </sp-accordion-item>
131
+ `
132
+ : nothing;
133
+
134
+ return html`
135
+ <sp-search
136
+ size="s"
137
+ placeholder="Filter elements…"
138
+ value=${view.elementsFilter}
139
+ @input=${(/** @type {any} */ e) => {
140
+ view.elementsFilter = e.target.value.toLowerCase();
141
+ ctx.rerender();
142
+ }}
143
+ ></sp-search>
144
+ <sp-accordion class="elements-list" allow-multiple
145
+ >${componentsAccordion}${categories}</sp-accordion
146
+ >
147
+ `;
148
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Git panel — Source control sidebar with status, staging, commit, push/pull, and branch
3
+ * management.
4
+ */
5
+
6
+ import { html, nothing } from "lit-html";
7
+ import { live } from "lit-html/directives/live.js";
8
+ import { getPlatform } from "../platform.js";
9
+ import { updateUi, renderOnly } from "../store.js";
10
+
11
+ async function refreshGitStatus() {
12
+ const plat = getPlatform();
13
+ updateUi("gitLoading", true);
14
+ updateUi("gitError", null);
15
+ try {
16
+ const [status, branches] = await Promise.all([plat.gitStatus(), plat.gitBranches()]);
17
+ updateUi("gitStatus", status);
18
+ updateUi("gitBranches", branches);
19
+ } catch (/** @type {any} */ e) {
20
+ updateUi("gitError", e.message);
21
+ } finally {
22
+ updateUi("gitLoading", false);
23
+ renderOnly("leftPanel");
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @param {string} action
29
+ * @param {any} [body]
30
+ */
31
+ async function gitAction(action, body) {
32
+ const plat = getPlatform();
33
+ updateUi("gitLoading", true);
34
+ updateUi("gitError", null);
35
+ try {
36
+ await plat[action](body);
37
+ await refreshGitStatus();
38
+ } catch (/** @type {any} */ e) {
39
+ updateUi("gitError", e.message);
40
+ updateUi("gitLoading", false);
41
+ renderOnly("leftPanel");
42
+ }
43
+ }
44
+
45
+ let _pollTimer = /** @type {any} */ (null);
46
+
47
+ /** @param {any} S */
48
+ export function renderGitPanel(S) {
49
+ const status = S.ui.gitStatus;
50
+ const branches = S.ui.gitBranches;
51
+ const loading = S.ui.gitLoading;
52
+
53
+ if (!status && !loading) {
54
+ refreshGitStatus();
55
+ return html`<div class="git-panel"><div class="git-loading">Loading...</div></div>`;
56
+ }
57
+
58
+ if (!_pollTimer) {
59
+ _pollTimer = setInterval(() => {
60
+ if (S.ui.leftTab === "git" && !S.ui.gitLoading) refreshGitStatus();
61
+ }, 30000);
62
+ }
63
+
64
+ const stagedFiles = status?.files?.filter((/** @type {any} */ f) => f.staged) || [];
65
+ const unstagedFiles = status?.files?.filter((/** @type {any} */ f) => !f.staged) || [];
66
+ const totalChanges = status?.files?.length || 0;
67
+
68
+ const doCommit = async () => {
69
+ const msg = S.ui.gitCommitMessage?.trim();
70
+ if (!msg) return;
71
+ updateUi("gitCommitMessage", "");
72
+ await gitAction("gitCommit", msg);
73
+ };
74
+
75
+ const branchPickerT = html`
76
+ <sp-picker
77
+ size="s"
78
+ quiet
79
+ class="git-branch-picker"
80
+ .value=${live(branches?.current || "")}
81
+ @change=${async (/** @type {any} */ e) => {
82
+ const val = e.target.value;
83
+ if (val === "__new__") {
84
+ e.target.value = branches?.current || "";
85
+ const name = prompt("New branch name:");
86
+ if (name?.trim()) await gitAction("gitCreateBranch", name.trim());
87
+ return;
88
+ }
89
+ if (val !== branches?.current) await gitAction("gitCheckout", val);
90
+ }}
91
+ >
92
+ ${(branches?.branches || []).map(
93
+ (/** @type {string} */ b) => html`<sp-menu-item value=${b}>${b}</sp-menu-item>`,
94
+ )}
95
+ <sp-menu-divider></sp-menu-divider>
96
+ <sp-menu-item value="__new__">+ New branch...</sp-menu-item>
97
+ </sp-picker>
98
+ `;
99
+
100
+ const toolbarT = html`
101
+ <div class="git-toolbar">
102
+ ${branchPickerT}
103
+ <sp-action-group size="xs" quiet>
104
+ <sp-action-button title="Fetch" @click=${() => gitAction("gitFetch")} ?disabled=${loading}>
105
+ <sp-icon-download slot="icon" size="xs"></sp-icon-download>
106
+ </sp-action-button>
107
+ <sp-action-button
108
+ title="Pull${status?.behind ? ` (${status.behind} behind)` : ""}"
109
+ @click=${() => gitAction("gitPull")}
110
+ ?disabled=${loading}
111
+ >
112
+ <sp-icon-arrow-down slot="icon" size="xs"></sp-icon-arrow-down>
113
+ </sp-action-button>
114
+ <sp-action-button
115
+ title="Push${status?.ahead ? ` (${status.ahead} ahead)` : ""}"
116
+ @click=${() => gitAction("gitPush")}
117
+ ?disabled=${loading}
118
+ >
119
+ <sp-icon-arrow-up slot="icon" size="xs"></sp-icon-arrow-up>
120
+ </sp-action-button>
121
+ <sp-action-button title="Refresh" @click=${() => refreshGitStatus()} ?disabled=${loading}>
122
+ <sp-icon-refresh slot="icon" size="xs"></sp-icon-refresh>
123
+ </sp-action-button>
124
+ </sp-action-group>
125
+ </div>
126
+ `;
127
+
128
+ const commitT = html`
129
+ <div class="git-commit-area">
130
+ <sp-textfield
131
+ size="s"
132
+ multiline
133
+ class="git-commit-input"
134
+ placeholder='Message (Ctrl+Enter to commit on "${status?.branch || ""}")'
135
+ .value=${live(S.ui.gitCommitMessage || "")}
136
+ @input=${(/** @type {any} */ e) => updateUi("gitCommitMessage", e.target.value)}
137
+ @keydown=${(/** @type {any} */ e) => {
138
+ if (e.ctrlKey && e.key === "Enter") {
139
+ e.preventDefault();
140
+ doCommit();
141
+ }
142
+ }}
143
+ ></sp-textfield>
144
+ <sp-action-button
145
+ class="git-commit-btn"
146
+ @click=${doCommit}
147
+ ?disabled=${!S.ui.gitCommitMessage?.trim() || loading}
148
+ >
149
+ <sp-icon-checkmark slot="icon" size="xs"></sp-icon-checkmark>
150
+ Commit
151
+ </sp-action-button>
152
+ </div>
153
+ `;
154
+
155
+ const fileRowT = (/** @type {any} */ file) => {
156
+ const parts = file.path.split("/");
157
+ const name = parts.pop();
158
+ const dir = parts.join("/");
159
+ return html`
160
+ <div class="git-file-row">
161
+ <span class="git-file-info">
162
+ <span class="git-file-name" title=${file.path}>${name}</span>
163
+ ${dir ? html`<span class="git-file-dir">${dir}</span>` : nothing}
164
+ </span>
165
+ <span class="git-file-actions">
166
+ ${file.staged
167
+ ? html`
168
+ <sp-action-button
169
+ size="xs"
170
+ quiet
171
+ title="Unstage"
172
+ @click=${() => gitAction("gitUnstage", [file.path])}
173
+ >
174
+ <sp-icon-remove slot="icon" size="xs"></sp-icon-remove>
175
+ </sp-action-button>
176
+ `
177
+ : html`
178
+ <sp-action-button
179
+ size="xs"
180
+ quiet
181
+ title="Discard changes"
182
+ @click=${async () => {
183
+ if (file.status === "U") return;
184
+ if (!confirm(`Discard changes to ${file.path}?`)) return;
185
+ await gitAction("gitDiscard", [file.path]);
186
+ }}
187
+ ?disabled=${file.status === "U"}
188
+ >
189
+ <sp-icon-undo slot="icon" size="xs"></sp-icon-undo>
190
+ </sp-action-button>
191
+ <sp-action-button
192
+ size="xs"
193
+ quiet
194
+ title="Stage"
195
+ @click=${() => gitAction("gitStage", [file.path])}
196
+ >
197
+ <sp-icon-add slot="icon" size="xs"></sp-icon-add>
198
+ </sp-action-button>
199
+ `}
200
+ </span>
201
+ <span class="git-file-badge git-status-${file.status}">${file.status}</span>
202
+ </div>
203
+ `;
204
+ };
205
+
206
+ const changesT = html`
207
+ ${stagedFiles.length > 0
208
+ ? html`
209
+ <div class="git-section">
210
+ <div class="git-section-header">
211
+ <span>Staged Changes</span>
212
+ <span class="git-count">${stagedFiles.length}</span>
213
+ <sp-action-button
214
+ size="xs"
215
+ quiet
216
+ title="Unstage all"
217
+ @click=${() =>
218
+ gitAction(
219
+ "gitUnstage",
220
+ stagedFiles.map((/** @type {any} */ f) => f.path),
221
+ )}
222
+ >
223
+ <sp-icon-remove slot="icon" size="xs"></sp-icon-remove>
224
+ </sp-action-button>
225
+ </div>
226
+ ${stagedFiles.map(fileRowT)}
227
+ </div>
228
+ `
229
+ : nothing}
230
+ ${unstagedFiles.length > 0
231
+ ? html`
232
+ <div class="git-section">
233
+ <div class="git-section-header">
234
+ <span>Changes</span>
235
+ <span class="git-count">${unstagedFiles.length}</span>
236
+ <sp-action-button
237
+ size="xs"
238
+ quiet
239
+ title="Stage all"
240
+ @click=${() =>
241
+ gitAction(
242
+ "gitStage",
243
+ unstagedFiles.map((/** @type {any} */ f) => f.path),
244
+ )}
245
+ >
246
+ <sp-icon-add slot="icon" size="xs"></sp-icon-add>
247
+ </sp-action-button>
248
+ </div>
249
+ ${unstagedFiles.map(fileRowT)}
250
+ </div>
251
+ `
252
+ : nothing}
253
+ ${totalChanges === 0 && !loading ? html`<div class="git-empty">No changes</div>` : nothing}
254
+ `;
255
+
256
+ const syncInfoT =
257
+ status?.ahead || status?.behind
258
+ ? html`
259
+ <div class="git-sync-info">
260
+ ${status.ahead ? html`<span title="Commits ahead">↑${status.ahead}</span>` : nothing}
261
+ ${status.behind ? html`<span title="Commits behind">↓${status.behind}</span>` : nothing}
262
+ </div>
263
+ `
264
+ : nothing;
265
+
266
+ return html`
267
+ <div class="git-panel">
268
+ ${toolbarT} ${syncInfoT} ${commitT}
269
+ ${loading ? html`<div class="git-loading">Loading...</div>` : nothing}
270
+ ${S.ui.gitError ? html`<div class="git-error">${S.ui.gitError}</div>` : nothing} ${changesT}
271
+ </div>
272
+ `;
273
+ }
274
+
275
+ export function cleanupGitPanel() {
276
+ if (_pollTimer) {
277
+ clearInterval(_pollTimer);
278
+ _pollTimer = null;
279
+ }
280
+ }