blob: c6e2ad7174c079383d6ab0c7c62729b04a13b9ae [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);
180 return `<a href="${cleanURL}" target="_blank" class="text-blue-600 hover:text-blue-800 underline">${cleanURL}</a>${trailingPunctuation}`;
181 });
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">
209 <label class="block text-xs font-medium mb-1">Remote:</label>
210 <div class="p-2 bg-gray-50 rounded text-xs text-gray-700">
211 ${this._renderRemoteDisplay(remote)}
212 </div>
213 </div>
214 `;
215 }
216
217 if (this._remotes.length === 2) {
218 // Two remotes - use radio buttons
219 return html`
220 <div class="mb-3">
221 <label class="block text-xs font-medium mb-1">Remote:</label>
222 <div class="space-y-2">
223 ${this._remotes.map(
224 (remote) => html`
225 <label class="flex items-center space-x-2 cursor-pointer">
226 <input
227 type="radio"
228 name="remote"
229 .value=${remote.name}
230 .checked=${remote.name === this._selectedRemote}
231 ?disabled=${this._loading}
232 @change=${(e: Event) => {
233 this._selectedRemote = (
234 e.target as HTMLInputElement
235 ).value;
236 }}
237 class="text-blue-600 focus:ring-blue-500"
238 />
239 <span class="text-xs text-gray-700"
240 >${this._renderRemoteDisplay(remote)}</span
241 >
242 </label>
243 `,
244 )}
245 </div>
246 </div>
247 `;
248 }
249
250 // Three or more remotes - use dropdown
251 return html`
252 <div class="mb-3">
253 <label class="block text-xs font-medium mb-1">Remote:</label>
254 <select
255 .value=${this._selectedRemote}
256 ?disabled=${this._loading}
257 @change=${(e: Event) => {
258 this._selectedRemote = (e.target as HTMLSelectElement).value;
259 }}
260 class="w-full p-2 border border-gray-300 rounded text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
261 >
262 <option value="">Select a remote...</option>
263 ${this._remotes.map(
264 (remote) => html`
265 <option
266 value="${remote.name}"
267 ?selected=${remote.name === this._selectedRemote}
268 >
269 ${this._formatRemoteDisplay(remote)}
270 </option>
271 `,
272 )}
273 </select>
274 </div>
275 `;
276 }
277
278 render() {
279 return html`
280 <div class="relative">
281 <!-- Push Button -->
282 <button
283 @click=${this._openModal}
284 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 +0000285 title="Open dialog box for pushing changes"
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700286 >
287 <svg
288 class="w-4 h-4"
289 viewBox="0 0 24 24"
290 fill="none"
291 stroke="currentColor"
292 stroke-width="2"
293 >
294 <path d="M12 19V5M5 12l7-7 7 7" />
295 </svg>
296 <span class="max-sm:hidden">Push</span>
297 </button>
298
299 <!-- Overlay Popup -->
300 <div
301 class="${this._modalOpen
302 ? "block"
303 : "hidden"} absolute top-full z-50 bg-white rounded-lg p-4 shadow-lg mt-1.5 border border-gray-200"
304 style="width: 420px; left: 50%; transform: translateX(-50%);"
305 >
306 <div class="flex justify-between items-center mb-3">
307 <h3 class="text-sm font-semibold">Push to Remote</h3>
308 <button
309 @click=${this._closeModal}
310 class="text-gray-500 hover:text-gray-700 transition-colors"
311 >
312 <svg
313 class="w-4 h-4"
314 viewBox="0 0 24 24"
315 fill="none"
316 stroke="currentColor"
317 stroke-width="2"
318 >
319 <path d="M18 6L6 18M6 6l12 12" />
320 </svg>
321 </button>
322 </div>
323
324 ${this._loading && !this._headCommit
325 ? html`
326 <div class="text-center py-4">
327 <div
328 class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
329 ></div>
330 <p class="mt-2 text-gray-600 text-xs">Loading...</p>
331 </div>
332 `
333 : html`
334 <!-- Current HEAD info -->
335 ${this._headCommit
336 ? html`
337 <div class="mb-3 p-2 bg-gray-50 rounded">
338 <p class="text-xs">
339 <span class="text-gray-600 font-mono"
340 >${this._headCommit.hash.substring(0, 7)}</span
341 >
342 <span class="text-gray-800 ml-2"
343 >${this._headCommit.subject}</span
344 >
345 </p>
346 </div>
347 `
348 : ""}
349
350 <!-- Remote selection -->
351 ${this._renderRemoteSelection()}
352
353 <!-- Branch input -->
354 <div class="mb-3">
355 <label class="block text-xs font-medium mb-1">Branch:</label>
356 <input
357 type="text"
358 .value=${this._branch}
359 ?disabled=${this._loading}
360 @input=${(e: Event) => {
361 this._branch = (e.target as HTMLInputElement).value;
362 }}
363 placeholder="Enter branch name..."
364 class="w-full p-2 border border-gray-300 rounded text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
365 />
366 </div>
367
368 <!-- Action buttons -->
369 <div class="flex gap-2 mb-3">
370 <button
371 @click=${(e: Event) => this._handlePush(true, e)}
372 ?disabled=${!this._selectedRemote ||
373 !this._branch ||
374 !this._headCommit ||
375 this._loading}
376 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"
377 >
378 ${this._pushingAction === "dry-run"
379 ? html`
380 <div
381 class="animate-spin rounded-full h-3 w-3 border-b border-white mr-1"
382 ></div>
383 `
384 : ""}
385 Dry Run
386 </button>
387 <button
388 @click=${(e: Event) => this._handlePush(false, e)}
389 ?disabled=${!this._selectedRemote ||
390 !this._branch ||
391 !this._headCommit ||
392 this._loading}
393 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"
394 >
395 ${this._pushingAction === "push"
396 ? html`
397 <div
398 class="animate-spin rounded-full h-3 w-3 border-b border-white mr-1"
399 ></div>
400 `
401 : ""}
402 Push
403 </button>
404 </div>
405
406 <!-- Push result -->
407 ${this._pushResult
408 ? html`
409 <div
410 class="p-3 rounded ${this._pushResult.success
411 ? "bg-green-50 border border-green-200"
412 : "bg-red-50 border border-red-200"} relative"
413 >
414 ${this._loading
415 ? html`
416 <div
417 class="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded"
418 >
419 <div
420 class="flex items-center text-xs text-gray-600"
421 >
422 <div
423 class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"
424 ></div>
425 Processing...
426 </div>
427 </div>
428 `
429 : ""}
430
431 <div class="flex items-center justify-between mb-2">
432 <p
433 class="text-xs font-medium ${this._pushResult
434 .success
435 ? "text-green-800"
436 : "text-red-800"}"
437 >
438 ${this._pushResult.dry_run ? "Dry Run" : "Push"}
439 ${this._pushResult.success
440 ? "Successful"
441 : "Failed"}
442 </p>
443 ${this._pushResult.success &&
444 !this._pushResult.dry_run
445 ? (() => {
446 const branchURL = this._computeBranchURL();
447 return branchURL
448 ? html`
449 <a
450 href="${branchURL}"
451 target="_blank"
452 class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-gray-900 hover:bg-gray-800 text-white rounded transition-colors"
453 >
454 <svg
455 class="w-3 h-3"
456 viewBox="0 0 24 24"
457 fill="currentColor"
458 >
459 <path
460 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"
461 />
462 </svg>
463 Open on GitHub
464 </a>
465 `
466 : "";
467 })()
468 : ""}
469 </div>
470 ${this._pushResult.output
471 ? html`
472 <pre
473 class="text-xs text-gray-700 whitespace-pre-wrap font-mono mb-2 break-words"
474 .innerHTML="${this._makeLinksClickable(
475 this._pushResult.output,
476 )}"
477 ></pre>
478 `
479 : ""}
480 ${this._pushResult.error
481 ? html`
482 <p class="text-xs text-red-700 mb-2">
483 ${this._pushResult.error}
484 </p>
485 `
486 : ""}
487
488 <div class="flex gap-2 items-center">
489 ${!this._pushResult.success
490 ? html`
491 <button
492 @click=${(e: Event) => this._handleRebase(e)}
493 class="px-3 py-1 bg-orange-600 hover:bg-orange-700 text-white text-xs rounded transition-colors"
494 >
495 Ask Agent to Rebase
496 </button>
497 `
498 : ""}
499
500 <button
501 @click=${(e: Event) => {
502 e.stopPropagation();
503 this._closeModal();
504 }}
505 class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded transition-colors ml-auto"
506 >
507 Close
508 </button>
509 </div>
510 </div>
511 `
512 : this._loading
513 ? html`
514 <div
515 class="p-3 rounded bg-gray-50 border border-gray-200"
516 >
517 <div class="flex items-center text-xs text-gray-600">
518 <div
519 class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"
520 ></div>
521 Processing...
522 </div>
523 </div>
524 `
525 : ""}
526 `}
527 </div>
528 </div>
529 `;
530 }
531}
532
533declare global {
534 interface HTMLElementTagNameMap {
535 "sketch-push-button": SketchPushButton;
536 }
537}