blob: e13049b14a3dda1f183097986b389eaf5f5b6d01 [file] [log] [blame]
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001import { html } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import { SketchTailwindElement } from "./sketch-tailwind-element.js";
4import type { Remote } from "../types.js";
5
6@customElement("sketch-push-button")
7export class SketchPushButton extends SketchTailwindElement {
8 @state()
9 private _modalOpen = false;
10
11 @state()
12 private _loading = false;
13
14 @state()
15 private _pushingAction: "dry-run" | "push" | null = null;
16
17 @state()
18 private _headCommit: { hash: string; subject: string } | null = null;
19
20 @state()
21 private _remotes: Remote[] = [];
22
23 @state()
24 private _selectedRemote = "";
25
26 @state()
27 private _branch = "";
28
29 @state()
30 private _pushResult: {
31 success: boolean;
32 output: string;
33 error?: string;
34 dry_run: boolean;
35 } | null = null;
36
37 private async _openModal() {
38 this._modalOpen = true;
39 this._loading = true;
40 this._pushResult = null;
41
42 try {
43 // Fetch push info (HEAD commit and remotes)
44 const response = await fetch("./git/pushinfo");
45 if (response.ok) {
46 const data = await response.json();
47 this._headCommit = {
48 hash: data.hash,
49 subject: data.subject,
50 };
51 this._remotes = data.remotes;
52
53 // Auto-select first remote if available
54 if (this._remotes.length > 0) {
55 this._selectedRemote = this._remotes[0].name;
56 }
57 }
58 } catch (error) {
59 console.error("Error fetching git data:", error);
60 } finally {
61 this._loading = false;
62 }
63 }
64
65 private _closeModal() {
66 this._modalOpen = false;
67 this._pushResult = null;
68 }
69
70 private _clickOutsideHandler = (event: MouseEvent) => {
71 if (this._modalOpen && !this.contains(event.target as Node)) {
72 this._closeModal();
73 }
74 };
75
76 // Close the modal when clicking outside
77 connectedCallback() {
78 super.connectedCallback();
79 document.addEventListener("click", this._clickOutsideHandler);
80 }
81
82 disconnectedCallback() {
83 super.disconnectedCallback();
84 document.removeEventListener("click", this._clickOutsideHandler);
85 }
86
87 private async _handlePush(dryRun: boolean = false, event?: Event) {
88 if (event) {
89 event.stopPropagation();
90 }
91
92 if (!this._selectedRemote || !this._branch || !this._headCommit) {
93 return;
94 }
95
96 this._loading = true;
97 this._pushingAction = dryRun ? "dry-run" : "push";
98
99 try {
100 const response = await fetch("./git/push", {
101 method: "POST",
102 headers: {
103 "Content-Type": "application/json",
104 },
105 body: JSON.stringify({
106 remote: this._selectedRemote,
107 branch: this._branch,
108 commit: this._headCommit.hash,
109 dry_run: dryRun,
110 }),
111 });
112
113 if (response.ok) {
114 this._pushResult = await response.json();
115 } else {
116 this._pushResult = {
117 success: false,
118 output: "",
119 error: `HTTP ${response.status}: ${response.statusText}`,
120 dry_run: dryRun,
121 };
122 }
123 } catch (error) {
124 this._pushResult = {
125 success: false,
126 output: "",
127 error: `Network error: ${error}`,
128 dry_run: dryRun,
129 };
130 } finally {
131 this._loading = false;
132 this._pushingAction = null;
133 }
134 }
135
136 private _handleRebase(event?: Event) {
137 if (event) {
138 event.stopPropagation();
139 }
140
141 // Send message to chat asking agent to rebase
142 const message = `fetch and rebase onto ${this._selectedRemote}/${this._branch}; force tag ${this._selectedRemote}/${this._branch} as the new sketch-base`;
143
144 // Dispatch custom event to send message to chat
145 const chatEvent = new CustomEvent("push-rebase-request", {
146 detail: { message },
147 bubbles: true,
148 composed: true,
149 });
150
151 window.dispatchEvent(chatEvent);
152 }
153
154 private _formatRemoteDisplay(remote: Remote): string {
155 return `${remote.display_name} (${remote.name})`;
156 }
157
158 private _renderRemoteDisplay(remote: Remote) {
159 const displayText = this._formatRemoteDisplay(remote);
160 if (remote.is_github) {
161 const githubURL = `https://github.com/${remote.display_name}`;
162 if (githubURL) {
163 return html`<a
164 href="${githubURL}"
165 target="_blank"
166 class="text-blue-600 hover:text-blue-800 underline"
167 >${displayText}</a
168 >`;
169 }
170 }
171 return html`<span>${displayText}</span>`;
172 }
173
174 private _makeLinksClickable(output: string): string {
175 // Regex to match http:// or https:// URLs
176 return output.replace(/(https?:\/\/[^\s]+)/g, (match) => {
177 // Clean up URL (remove trailing punctuation)
178 const cleanURL = match.replace(/[.,!?;]+$/, "");
179 const trailingPunctuation = match.substring(cleanURL.length);
banksean3eaa4332025-07-19 02:19:06 +0000180 return `<a href="${cleanURL}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline">${cleanURL}</a>${trailingPunctuation}`;
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700181 });
182 }
183
184 private _getSelectedRemote(): Remote | null {
185 return this._remotes.find((r) => r.name === this._selectedRemote) || null;
186 }
187
188 private _computeBranchURL(): string {
189 const selectedRemote = this._getSelectedRemote();
Josh Bleecher Snyder7de3bdd2025-07-18 01:51:53 +0000190 if (!selectedRemote || !selectedRemote.is_github) {
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700191 return "";
192 }
193 return `https://github.com/${selectedRemote?.display_name}/tree/${this._branch}`;
194 }
195
196 private _renderRemoteSelection() {
197 if (this._remotes.length === 0) {
198 return html``;
199 }
200
201 if (this._remotes.length === 1) {
202 // Single remote - just show it, no selection needed
203 const remote = this._remotes[0];
204 if (!this._selectedRemote) {
205 this._selectedRemote = remote.name;
206 }
207 return html`
208 <div class="mb-3">
banksean3eaa4332025-07-19 02:19:06 +0000209 <label
210 class="block text-xs font-medium mb-1 text-gray-900 dark:text-gray-100"
211 >Remote:</label
212 >
213 <div
214 class="p-2 bg-gray-50 dark:bg-gray-700 rounded text-xs text-gray-700 dark:text-gray-300"
215 >
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700216 ${this._renderRemoteDisplay(remote)}
217 </div>
218 </div>
219 `;
220 }
221
222 if (this._remotes.length === 2) {
223 // Two remotes - use radio buttons
224 return html`
225 <div class="mb-3">
banksean3eaa4332025-07-19 02:19:06 +0000226 <label
227 class="block text-xs font-medium mb-1 text-gray-900 dark:text-gray-100"
228 >Remote:</label
229 >
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700230 <div class="space-y-2">
231 ${this._remotes.map(
232 (remote) => html`
233 <label class="flex items-center space-x-2 cursor-pointer">
234 <input
235 type="radio"
236 name="remote"
237 .value=${remote.name}
238 .checked=${remote.name === this._selectedRemote}
239 ?disabled=${this._loading}
240 @change=${(e: Event) => {
241 this._selectedRemote = (
242 e.target as HTMLInputElement
243 ).value;
244 }}
banksean3eaa4332025-07-19 02:19:06 +0000245 class="text-blue-600 focus:ring-blue-500 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700246 />
banksean3eaa4332025-07-19 02:19:06 +0000247 <span class="text-xs text-gray-700 dark:text-gray-300"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700248 >${this._renderRemoteDisplay(remote)}</span
249 >
250 </label>
251 `,
252 )}
253 </div>
254 </div>
255 `;
256 }
257
258 // Three or more remotes - use dropdown
259 return html`
260 <div class="mb-3">
banksean3eaa4332025-07-19 02:19:06 +0000261 <label
262 class="block text-xs font-medium mb-1 text-gray-900 dark:text-gray-100"
263 >Remote:</label
264 >
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700265 <select
266 .value=${this._selectedRemote}
267 ?disabled=${this._loading}
268 @change=${(e: Event) => {
269 this._selectedRemote = (e.target as HTMLSelectElement).value;
270 }}
banksean3eaa4332025-07-19 02:19:06 +0000271 class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700272 >
273 <option value="">Select a remote...</option>
274 ${this._remotes.map(
275 (remote) => html`
276 <option
277 value="${remote.name}"
278 ?selected=${remote.name === this._selectedRemote}
279 >
280 ${this._formatRemoteDisplay(remote)}
281 </option>
282 `,
283 )}
284 </select>
285 </div>
286 `;
287 }
288
289 render() {
290 return html`
291 <div class="relative">
292 <!-- Push Button -->
293 <button
294 @click=${this._openModal}
295 class="flex items-center gap-1.5 px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
Josh Bleecher Snyder3a41f152025-07-18 01:51:54 +0000296 title="Open dialog box for pushing changes"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700297 >
298 <svg
299 class="w-4 h-4"
300 viewBox="0 0 24 24"
301 fill="none"
302 stroke="currentColor"
303 stroke-width="2"
304 >
305 <path d="M12 19V5M5 12l7-7 7 7" />
306 </svg>
307 <span class="max-sm:hidden">Push</span>
308 </button>
309
310 <!-- Overlay Popup -->
311 <div
312 class="${this._modalOpen
313 ? "block"
banksean3eaa4332025-07-19 02:19:06 +0000314 : "hidden"} absolute top-full z-50 bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg mt-1.5 border border-gray-200 dark:border-gray-600"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700315 style="width: 420px; left: 50%; transform: translateX(-50%);"
316 >
317 <div class="flex justify-between items-center mb-3">
banksean3eaa4332025-07-19 02:19:06 +0000318 <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
319 Push to Remote
320 </h3>
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700321 <button
322 @click=${this._closeModal}
banksean3eaa4332025-07-19 02:19:06 +0000323 class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700324 >
325 <svg
326 class="w-4 h-4"
327 viewBox="0 0 24 24"
328 fill="none"
329 stroke="currentColor"
330 stroke-width="2"
331 >
332 <path d="M18 6L6 18M6 6l12 12" />
333 </svg>
334 </button>
335 </div>
336
337 ${this._loading && !this._headCommit
338 ? html`
339 <div class="text-center py-4">
340 <div
341 class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
342 ></div>
banksean3eaa4332025-07-19 02:19:06 +0000343 <p class="mt-2 text-gray-600 dark:text-gray-400 text-xs">
344 Loading...
345 </p>
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700346 </div>
347 `
348 : html`
349 <!-- Current HEAD info -->
350 ${this._headCommit
351 ? html`
banksean3eaa4332025-07-19 02:19:06 +0000352 <div class="mb-3 p-2 bg-gray-50 dark:bg-gray-700 rounded">
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700353 <p class="text-xs">
banksean3eaa4332025-07-19 02:19:06 +0000354 <span
355 class="text-gray-600 dark:text-gray-400 font-mono"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700356 >${this._headCommit.hash.substring(0, 7)}</span
357 >
banksean3eaa4332025-07-19 02:19:06 +0000358 <span class="text-gray-800 dark:text-gray-200 ml-2"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700359 >${this._headCommit.subject}</span
360 >
361 </p>
362 </div>
363 `
364 : ""}
365
366 <!-- Remote selection -->
367 ${this._renderRemoteSelection()}
368
369 <!-- Branch input -->
370 <div class="mb-3">
banksean3eaa4332025-07-19 02:19:06 +0000371 <label
372 class="block text-xs font-medium mb-1 text-gray-900 dark:text-gray-100"
373 >Branch:</label
374 >
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700375 <input
376 type="text"
377 .value=${this._branch}
378 ?disabled=${this._loading}
379 @input=${(e: Event) => {
380 this._branch = (e.target as HTMLInputElement).value;
381 }}
382 placeholder="Enter branch name..."
banksean3eaa4332025-07-19 02:19:06 +0000383 class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700384 />
385 </div>
386
387 <!-- Action buttons -->
388 <div class="flex gap-2 mb-3">
389 <button
390 @click=${(e: Event) => this._handlePush(true, e)}
391 ?disabled=${!this._selectedRemote ||
392 !this._branch ||
393 !this._headCommit ||
394 this._loading}
395 class="flex-1 px-3 py-1.5 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors flex items-center justify-center"
396 >
397 ${this._pushingAction === "dry-run"
398 ? html`
399 <div
400 class="animate-spin rounded-full h-3 w-3 border-b border-white mr-1"
401 ></div>
402 `
403 : ""}
404 Dry Run
405 </button>
406 <button
407 @click=${(e: Event) => this._handlePush(false, e)}
408 ?disabled=${!this._selectedRemote ||
409 !this._branch ||
410 !this._headCommit ||
411 this._loading}
412 class="flex-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded text-xs transition-colors flex items-center justify-center"
413 >
414 ${this._pushingAction === "push"
415 ? html`
416 <div
417 class="animate-spin rounded-full h-3 w-3 border-b border-white mr-1"
418 ></div>
419 `
420 : ""}
421 Push
422 </button>
423 </div>
424
425 <!-- Push result -->
426 ${this._pushResult
427 ? html`
428 <div
429 class="p-3 rounded ${this._pushResult.success
banksean3eaa4332025-07-19 02:19:06 +0000430 ? "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800"
431 : "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"} relative"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700432 >
433 ${this._loading
434 ? html`
435 <div
banksean3eaa4332025-07-19 02:19:06 +0000436 class="absolute inset-0 bg-white dark:bg-gray-800 bg-opacity-75 dark:bg-opacity-75 flex items-center justify-center rounded"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700437 >
438 <div
banksean3eaa4332025-07-19 02:19:06 +0000439 class="flex items-center text-xs text-gray-600 dark:text-gray-400"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700440 >
441 <div
442 class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"
443 ></div>
444 Processing...
445 </div>
446 </div>
447 `
448 : ""}
449
450 <div class="flex items-center justify-between mb-2">
451 <p
452 class="text-xs font-medium ${this._pushResult
453 .success
banksean3eaa4332025-07-19 02:19:06 +0000454 ? "text-green-800 dark:text-green-400"
455 : "text-red-800 dark:text-red-400"}"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700456 >
457 ${this._pushResult.dry_run ? "Dry Run" : "Push"}
458 ${this._pushResult.success
459 ? "Successful"
460 : "Failed"}
461 </p>
462 ${this._pushResult.success &&
463 !this._pushResult.dry_run
464 ? (() => {
465 const branchURL = this._computeBranchURL();
466 return branchURL
467 ? html`
468 <a
469 href="${branchURL}"
470 target="_blank"
banksean3eaa4332025-07-19 02:19:06 +0000471 class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-gray-900 dark:bg-gray-700 hover:bg-gray-800 dark:hover:bg-gray-600 text-white rounded transition-colors"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700472 >
473 <svg
474 class="w-3 h-3"
475 viewBox="0 0 24 24"
476 fill="currentColor"
477 >
478 <path
479 d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"
480 />
481 </svg>
482 Open on GitHub
483 </a>
484 `
485 : "";
486 })()
487 : ""}
488 </div>
489 ${this._pushResult.output
490 ? html`
491 <pre
banksean3eaa4332025-07-19 02:19:06 +0000492 class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono mb-2 break-words"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700493 .innerHTML="${this._makeLinksClickable(
494 this._pushResult.output,
495 )}"
496 ></pre>
497 `
498 : ""}
499 ${this._pushResult.error
500 ? html`
banksean3eaa4332025-07-19 02:19:06 +0000501 <p
502 class="text-xs text-red-700 dark:text-red-400 mb-2"
503 >
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700504 ${this._pushResult.error}
505 </p>
506 `
507 : ""}
508
509 <div class="flex gap-2 items-center">
510 ${!this._pushResult.success
511 ? html`
512 <button
513 @click=${(e: Event) => this._handleRebase(e)}
514 class="px-3 py-1 bg-orange-600 hover:bg-orange-700 text-white text-xs rounded transition-colors"
515 >
516 Ask Agent to Rebase
517 </button>
518 `
519 : ""}
520
521 <button
522 @click=${(e: Event) => {
523 e.stopPropagation();
524 this._closeModal();
525 }}
526 class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded transition-colors ml-auto"
527 >
528 Close
529 </button>
530 </div>
531 </div>
532 `
533 : this._loading
534 ? html`
535 <div
536 class="p-3 rounded bg-gray-50 border border-gray-200"
537 >
538 <div class="flex items-center text-xs text-gray-600">
539 <div
540 class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"
541 ></div>
542 Processing...
543 </div>
544 </div>
545 `
546 : ""}
547 `}
548 </div>
549 </div>
550 `;
551 }
552}
553
554declare global {
555 interface HTMLElementTagNameMap {
556 "sketch-push-button": SketchPushButton;
557 }
558}