blob: 61f92355ab6bbb4683fe0cd5bfbac2d8b9e511e2 [file] [log] [blame]
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001// sketch-diff-range-picker.ts
2// Component for selecting commit range for diffs
3
4import { css, html, LitElement } from "lit";
5import { customElement, property, state } from "lit/decorators.js";
6import { GitDataService, DefaultGitDataService } from "./git-data-service";
7import { GitLogEntry } from "../types";
8
9/**
10 * Range type for diff views
11 */
Autoformatter8c463622025-05-16 21:54:17 +000012export type DiffRange =
13 | { type: "range"; from: string; to: string }
14 | { type: "single"; commit: string };
Philip Zeyliger272a90e2025-05-16 14:49:51 -070015
16/**
17 * Component for selecting commit range for diffs
18 */
19@customElement("sketch-diff-range-picker")
20export class SketchDiffRangePicker extends LitElement {
21 @property({ type: Array })
22 commits: GitLogEntry[] = [];
23
24 @state()
Autoformatter8c463622025-05-16 21:54:17 +000025 private rangeType: "range" | "single" = "range";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070026
27 @state()
Autoformatter8c463622025-05-16 21:54:17 +000028 private fromCommit: string = "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070029
30 @state()
Autoformatter8c463622025-05-16 21:54:17 +000031 private toCommit: string = "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070032
33 @state()
Autoformatter8c463622025-05-16 21:54:17 +000034 private singleCommit: string = "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070035
36 @state()
37 private loading: boolean = true;
38
39 @state()
40 private error: string | null = null;
Autoformatter8c463622025-05-16 21:54:17 +000041
Philip Zeyliger272a90e2025-05-16 14:49:51 -070042 @property({ attribute: false, type: Object })
43 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +000044
Philip Zeyliger272a90e2025-05-16 14:49:51 -070045 constructor() {
46 super();
Autoformatter8c463622025-05-16 21:54:17 +000047 console.log("SketchDiffRangePicker initialized");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070048 }
49
50 static styles = css`
51 :host {
52 display: block;
53 width: 100%;
54 font-family: var(--font-family, system-ui, sans-serif);
55 color: var(--text-color, #333);
56 }
57
58 .range-picker {
59 display: flex;
60 flex-direction: row;
61 align-items: center;
62 gap: 12px;
63 padding: 12px;
64 background-color: var(--background-light, #f8f8f8);
65 border-radius: 4px;
66 border: 1px solid var(--border-color, #e0e0e0);
67 flex-wrap: wrap; /* Allow wrapping on small screens */
68 width: 100%;
69 box-sizing: border-box;
70 }
71
72 .range-type-selector {
73 display: flex;
74 gap: 16px;
75 flex-shrink: 0;
76 }
77
78 .range-type-option {
79 display: flex;
80 align-items: center;
81 gap: 6px;
82 cursor: pointer;
83 white-space: nowrap;
84 }
85
86 .commit-selectors {
87 display: flex;
88 flex-direction: row;
89 align-items: center;
90 gap: 12px;
91 flex: 1;
92 flex-wrap: wrap; /* Allow wrapping on small screens */
93 }
94
95 .commit-selector {
96 display: flex;
97 align-items: center;
98 gap: 8px;
99 flex: 1;
100 min-width: 200px;
101 max-width: calc(50% - 12px); /* Half width minus half the gap */
102 overflow: hidden;
103 }
104
105 select {
106 padding: 6px 8px;
107 border-radius: 4px;
108 border: 1px solid var(--border-color, #e0e0e0);
109 background-color: var(--background, #fff);
110 max-width: 100%;
111 overflow: hidden;
112 text-overflow: ellipsis;
113 white-space: nowrap;
114 }
115
116 label {
117 font-weight: 500;
118 font-size: 14px;
119 }
120
121 .loading {
122 font-style: italic;
123 color: var(--text-muted, #666);
124 }
125
126 .error {
127 color: var(--error-color, #dc3545);
128 font-size: 14px;
129 }
Autoformatter8c463622025-05-16 21:54:17 +0000130
Philip Zeyligere89b3082025-05-29 03:16:06 +0000131 .refresh-button {
132 padding: 6px 12px;
133 background-color: #f0f0f0;
134 color: var(--text-color, #333);
135 border: 1px solid var(--border-color, #e0e0e0);
136 border-radius: 4px;
137 cursor: pointer;
138 font-size: 14px;
139 transition: background-color 0.2s;
140 white-space: nowrap;
141 display: flex;
142 align-items: center;
143 gap: 4px;
144 }
145
146 .refresh-button:hover {
147 background-color: #e0e0e0;
148 }
149
150 .refresh-button:disabled {
151 background-color: #f8f8f8;
152 color: #999;
153 cursor: not-allowed;
154 }
155
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700156 @media (max-width: 768px) {
157 .commit-selector {
158 max-width: 100%;
159 }
160 }
161 `;
162
163 connectedCallback() {
164 super.connectedCallback();
165 // Wait for DOM to be fully loaded to ensure proper initialization order
Autoformatter8c463622025-05-16 21:54:17 +0000166 if (document.readyState === "complete") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700167 this.loadCommits();
168 } else {
Autoformatter8c463622025-05-16 21:54:17 +0000169 window.addEventListener("load", () => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700170 setTimeout(() => this.loadCommits(), 0); // Give time for provider initialization
171 });
172 }
Autoformatter9abf8032025-06-14 23:24:08 +0000173
David Crawshaw938d2dc2025-06-14 22:17:33 +0000174 // Listen for popstate events to handle browser back/forward navigation
Autoformatter9abf8032025-06-14 23:24:08 +0000175 window.addEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000176 }
177
178 disconnectedCallback() {
179 super.disconnectedCallback();
Autoformatter9abf8032025-06-14 23:24:08 +0000180 window.removeEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000181 }
182
183 /**
184 * Handle browser back/forward navigation
185 */
186 private handlePopState() {
187 // Re-initialize from URL parameters when user navigates
188 if (this.commits.length > 0) {
189 const initializedFromUrl = this.initializeFromUrlParams();
190 if (initializedFromUrl) {
191 // Force re-render and dispatch event
192 this.requestUpdate();
193 this.dispatchRangeEvent();
194 }
195 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700196 }
197
198 render() {
199 return html`
200 <div class="range-picker">
201 ${this.loading
202 ? html`<div class="loading">Loading commits...</div>`
203 : this.error
Autoformatter8c463622025-05-16 21:54:17 +0000204 ? html`<div class="error">${this.error}</div>`
205 : this.renderRangePicker()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700206 </div>
207 `;
208 }
209
210 renderRangePicker() {
211 return html`
212 <div class="range-type-selector">
213 <label class="range-type-option">
214 <input
215 type="radio"
216 name="rangeType"
217 value="range"
Autoformatter8c463622025-05-16 21:54:17 +0000218 ?checked=${this.rangeType === "range"}
219 @change=${() => this.setRangeType("range")}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700220 />
221 Commit Range
222 </label>
223 <label class="range-type-option">
224 <input
225 type="radio"
226 name="rangeType"
227 value="single"
Autoformatter8c463622025-05-16 21:54:17 +0000228 ?checked=${this.rangeType === "single"}
229 @change=${() => this.setRangeType("single")}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 />
231 Single Commit
232 </label>
233 </div>
234
235 <div class="commit-selectors">
Autoformatter8c463622025-05-16 21:54:17 +0000236 ${this.rangeType === "range"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700237 ? this.renderRangeSelectors()
Autoformatter8c463622025-05-16 21:54:17 +0000238 : this.renderSingleSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700239 </div>
Philip Zeyligere89b3082025-05-29 03:16:06 +0000240
241 <button
242 class="refresh-button"
243 @click="${this.handleRefresh}"
244 ?disabled="${this.loading}"
245 title="Refresh commit list"
246 >
247 🔄 Refresh
248 </button>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700249 `;
250 }
251
252 renderRangeSelectors() {
253 return html`
254 <div class="commit-selector">
255 <label for="fromCommit">From:</label>
256 <select
257 id="fromCommit"
258 .value=${this.fromCommit}
259 @change=${this.handleFromChange}
260 >
261 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000262 (commit) => html`
263 <option
264 value=${commit.hash}
265 ?selected=${commit.hash === this.fromCommit}
266 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700267 ${this.formatCommitOption(commit)}
268 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000269 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700270 )}
271 </select>
272 </div>
273 <div class="commit-selector">
274 <label for="toCommit">To:</label>
275 <select
276 id="toCommit"
277 .value=${this.toCommit}
278 @change=${this.handleToChange}
279 >
Autoformatter8c463622025-05-16 21:54:17 +0000280 <option value="" ?selected=${this.toCommit === ""}>
281 Uncommitted Changes
282 </option>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700283 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000284 (commit) => html`
285 <option
286 value=${commit.hash}
287 ?selected=${commit.hash === this.toCommit}
288 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700289 ${this.formatCommitOption(commit)}
290 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000291 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700292 )}
293 </select>
294 </div>
295 `;
296 }
297
298 renderSingleSelector() {
299 return html`
300 <div class="commit-selector">
301 <label for="singleCommit">Commit:</label>
302 <select
303 id="singleCommit"
304 .value=${this.singleCommit}
305 @change=${this.handleSingleChange}
306 >
307 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000308 (commit) => html`
309 <option
310 value=${commit.hash}
311 ?selected=${commit.hash === this.singleCommit}
312 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700313 ${this.formatCommitOption(commit)}
314 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000315 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700316 )}
317 </select>
318 </div>
319 `;
320 }
321
322 /**
323 * Format a commit for display in the dropdown
324 */
325 formatCommitOption(commit: GitLogEntry): string {
326 const shortHash = commit.hash.substring(0, 7);
Autoformatter8c463622025-05-16 21:54:17 +0000327
David Crawshawdbca8972025-06-14 23:46:58 +0000328 // Truncate subject if it's too long
329 let subject = commit.subject;
330 if (subject.length > 50) {
331 subject = subject.substring(0, 47) + "...";
332 }
333
334 let label = `${shortHash} ${subject}`;
335
336 // Add refs but keep them concise
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700337 if (commit.refs && commit.refs.length > 0) {
David Crawshawdbca8972025-06-14 23:46:58 +0000338 const refs = commit.refs.map((ref) => {
339 // Shorten common prefixes
340 if (ref.startsWith("origin/")) {
341 return ref.substring(7);
342 }
343 if (ref.startsWith("refs/heads/")) {
344 return ref.substring(11);
345 }
346 if (ref.startsWith("refs/remotes/origin/")) {
347 return ref.substring(20);
348 }
349 return ref;
350 });
351
352 // Limit to first 2 refs to avoid overcrowding
353 const displayRefs = refs.slice(0, 2);
354 if (refs.length > 2) {
355 displayRefs.push(`+${refs.length - 2} more`);
356 }
357
358 label += ` (${displayRefs.join(", ")})`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700359 }
Autoformatter8c463622025-05-16 21:54:17 +0000360
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700361 return label;
362 }
363
364 /**
365 * Load commits from the Git data service
366 */
367 async loadCommits() {
368 this.loading = true;
369 this.error = null;
370
371 if (!this.gitService) {
Autoformatter8c463622025-05-16 21:54:17 +0000372 console.error("GitService was not provided to sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700373 throw Error();
374 }
375
376 try {
377 // Get the base commit reference
378 const baseCommitRef = await this.gitService.getBaseCommitRef();
Autoformatter8c463622025-05-16 21:54:17 +0000379
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700380 // Load commit history
381 this.commits = await this.gitService.getCommitHistory(baseCommitRef);
Autoformatter8c463622025-05-16 21:54:17 +0000382
David Crawshaw938d2dc2025-06-14 22:17:33 +0000383 // Check if we should initialize from URL parameters first
384 const initializedFromUrl = this.initializeFromUrlParams();
Autoformatter9abf8032025-06-14 23:24:08 +0000385
David Crawshaw938d2dc2025-06-14 22:17:33 +0000386 // Set default selections only if not initialized from URL
387 if (this.commits.length > 0 && !initializedFromUrl) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700388 // For range, default is base to HEAD
389 // TODO: is sketch-base right in the unsafe context, where it's sketch-base-...
390 // should this be startswith?
Autoformatter8c463622025-05-16 21:54:17 +0000391 const baseCommit = this.commits.find(
392 (c) => c.refs && c.refs.some((ref) => ref.includes("sketch-base")),
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700393 );
Autoformatter8c463622025-05-16 21:54:17 +0000394
395 this.fromCommit = baseCommit
396 ? baseCommit.hash
397 : this.commits[this.commits.length - 1].hash;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700398 // Default to Uncommitted Changes by setting toCommit to empty string
Autoformatter8c463622025-05-16 21:54:17 +0000399 this.toCommit = ""; // Empty string represents uncommitted changes
400
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700401 // For single, default to HEAD
402 this.singleCommit = this.commits[0].hash;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700403 }
Autoformatter9abf8032025-06-14 23:24:08 +0000404
David Crawshaw938d2dc2025-06-14 22:17:33 +0000405 // Always dispatch range event to ensure diff view is updated
406 this.dispatchRangeEvent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700407 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000408 console.error("Error loading commits:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700409 this.error = `Error loading commits: ${error.message}`;
410 } finally {
411 this.loading = false;
412 }
413 }
414
415 /**
416 * Handle range type change
417 */
Autoformatter8c463622025-05-16 21:54:17 +0000418 setRangeType(type: "range" | "single") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700419 this.rangeType = type;
Autoformatter9abf8032025-06-14 23:24:08 +0000420
David Crawshaw938d2dc2025-06-14 22:17:33 +0000421 // If switching to range mode and we don't have valid commits set,
422 // initialize with sensible defaults
Autoformatter9abf8032025-06-14 23:24:08 +0000423 if (
424 type === "range" &&
425 (!this.fromCommit || !this.toCommit === undefined)
426 ) {
David Crawshaw938d2dc2025-06-14 22:17:33 +0000427 if (this.commits.length > 0) {
428 const baseCommit = this.commits.find(
429 (c) => c.refs && c.refs.some((ref) => ref.includes("sketch-base")),
430 );
431 if (!this.fromCommit) {
432 this.fromCommit = baseCommit
433 ? baseCommit.hash
434 : this.commits[this.commits.length - 1].hash;
435 }
436 if (this.toCommit === undefined) {
Autoformatter9abf8032025-06-14 23:24:08 +0000437 this.toCommit = ""; // Default to uncommitted changes
David Crawshaw938d2dc2025-06-14 22:17:33 +0000438 }
439 }
440 }
Autoformatter9abf8032025-06-14 23:24:08 +0000441
David Crawshaw938d2dc2025-06-14 22:17:33 +0000442 // If switching to single mode and we don't have a valid commit set,
443 // initialize with HEAD
Autoformatter9abf8032025-06-14 23:24:08 +0000444 if (type === "single" && !this.singleCommit && this.commits.length > 0) {
David Crawshaw938d2dc2025-06-14 22:17:33 +0000445 this.singleCommit = this.commits[0].hash;
446 }
Autoformatter9abf8032025-06-14 23:24:08 +0000447
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700448 this.dispatchRangeEvent();
449 }
450
451 /**
452 * Handle From commit change
453 */
454 handleFromChange(event: Event) {
455 const select = event.target as HTMLSelectElement;
456 this.fromCommit = select.value;
457 this.dispatchRangeEvent();
458 }
459
460 /**
461 * Handle To commit change
462 */
463 handleToChange(event: Event) {
464 const select = event.target as HTMLSelectElement;
465 this.toCommit = select.value;
466 this.dispatchRangeEvent();
467 }
468
469 /**
470 * Handle Single commit change
471 */
472 handleSingleChange(event: Event) {
473 const select = event.target as HTMLSelectElement;
474 this.singleCommit = select.value;
475 this.dispatchRangeEvent();
476 }
477
478 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000479 * Handle refresh button click
480 */
481 handleRefresh() {
482 this.loadCommits();
483 }
484
485 /**
David Crawshaw938d2dc2025-06-14 22:17:33 +0000486 * Validate that a commit hash exists in the loaded commits
487 */
488 private isValidCommitHash(hash: string): boolean {
Autoformatter9abf8032025-06-14 23:24:08 +0000489 if (!hash || hash.trim() === "") return true; // Empty is valid (uncommitted changes)
490 return this.commits.some(
491 (commit) => commit.hash.startsWith(hash) || commit.hash === hash,
492 );
David Crawshaw938d2dc2025-06-14 22:17:33 +0000493 }
494
495 /**
496 * Dispatch range change event and update URL parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700497 */
498 dispatchRangeEvent() {
Autoformatter8c463622025-05-16 21:54:17 +0000499 const range: DiffRange =
500 this.rangeType === "range"
501 ? { type: "range", from: this.fromCommit, to: this.toCommit }
502 : { type: "single", commit: this.singleCommit };
503
David Crawshaw938d2dc2025-06-14 22:17:33 +0000504 // Update URL parameters
505 this.updateUrlParams(range);
506
Autoformatter8c463622025-05-16 21:54:17 +0000507 const event = new CustomEvent("range-change", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700508 detail: { range },
509 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +0000510 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700511 });
Autoformatter8c463622025-05-16 21:54:17 +0000512
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700513 this.dispatchEvent(event);
514 }
David Crawshaw938d2dc2025-06-14 22:17:33 +0000515
516 /**
517 * Update URL parameters for from and to commits
518 */
519 private updateUrlParams(range: DiffRange) {
520 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000521
David Crawshaw938d2dc2025-06-14 22:17:33 +0000522 // Remove existing range parameters
Autoformatter9abf8032025-06-14 23:24:08 +0000523 url.searchParams.delete("from");
524 url.searchParams.delete("to");
525 url.searchParams.delete("commit");
526
527 if (range.type === "range") {
David Crawshaw938d2dc2025-06-14 22:17:33 +0000528 // Add from parameter if not empty
Autoformatter9abf8032025-06-14 23:24:08 +0000529 if (range.from && range.from.trim() !== "") {
530 url.searchParams.set("from", range.from);
David Crawshaw938d2dc2025-06-14 22:17:33 +0000531 }
532 // Add to parameter if not empty (empty string means uncommitted changes)
Autoformatter9abf8032025-06-14 23:24:08 +0000533 if (range.to && range.to.trim() !== "") {
534 url.searchParams.set("to", range.to);
David Crawshaw938d2dc2025-06-14 22:17:33 +0000535 }
536 } else {
537 // Single commit mode
Autoformatter9abf8032025-06-14 23:24:08 +0000538 if (range.commit && range.commit.trim() !== "") {
539 url.searchParams.set("commit", range.commit);
David Crawshaw938d2dc2025-06-14 22:17:33 +0000540 }
541 }
Autoformatter9abf8032025-06-14 23:24:08 +0000542
David Crawshaw938d2dc2025-06-14 22:17:33 +0000543 // Update the browser history without reloading the page
Autoformatter9abf8032025-06-14 23:24:08 +0000544 window.history.replaceState(window.history.state, "", url.toString());
David Crawshaw938d2dc2025-06-14 22:17:33 +0000545 }
546
547 /**
548 * Initialize from URL parameters if available
549 */
550 private initializeFromUrlParams() {
551 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000552 const fromParam = url.searchParams.get("from");
553 const toParam = url.searchParams.get("to");
554 const commitParam = url.searchParams.get("commit");
555
David Crawshaw938d2dc2025-06-14 22:17:33 +0000556 // If commit parameter is present, switch to single commit mode
557 if (commitParam) {
Autoformatter9abf8032025-06-14 23:24:08 +0000558 this.rangeType = "single";
David Crawshaw938d2dc2025-06-14 22:17:33 +0000559 this.singleCommit = commitParam;
560 return true; // Indicate that we initialized from URL
561 }
Autoformatter9abf8032025-06-14 23:24:08 +0000562
David Crawshaw938d2dc2025-06-14 22:17:33 +0000563 // If from or to parameters are present, use range mode
564 if (fromParam || toParam) {
Autoformatter9abf8032025-06-14 23:24:08 +0000565 this.rangeType = "range";
David Crawshaw938d2dc2025-06-14 22:17:33 +0000566 if (fromParam) {
567 this.fromCommit = fromParam;
568 }
569 if (toParam) {
570 this.toCommit = toParam;
571 } else {
572 // If no 'to' param, default to uncommitted changes (empty string)
Autoformatter9abf8032025-06-14 23:24:08 +0000573 this.toCommit = "";
David Crawshaw938d2dc2025-06-14 22:17:33 +0000574 }
575 return true; // Indicate that we initialized from URL
576 }
Autoformatter9abf8032025-06-14 23:24:08 +0000577
David Crawshaw938d2dc2025-06-14 22:17:33 +0000578 return false; // No URL params found
579 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700580}
581
582declare global {
583 interface HTMLElementTagNameMap {
584 "sketch-diff-range-picker": SketchDiffRangePicker;
585 }
586}