blob: 02dafd97518f1d7b8b59eed83dc94e7e3e0c81e3 [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 */
David Crawshaw216d2fc2025-06-15 18:45:53 +000012export type DiffRange = { type: "range"; from: string; to: string };
Philip Zeyliger272a90e2025-05-16 14:49:51 -070013
14/**
15 * Component for selecting commit range for diffs
16 */
17@customElement("sketch-diff-range-picker")
18export class SketchDiffRangePicker extends LitElement {
19 @property({ type: Array })
20 commits: GitLogEntry[] = [];
21
22 @state()
Autoformatter8c463622025-05-16 21:54:17 +000023 private fromCommit: string = "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070024
25 @state()
Autoformatter8c463622025-05-16 21:54:17 +000026 private toCommit: string = "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070027
28 @state()
David Crawshaw216d2fc2025-06-15 18:45:53 +000029 private commitsExpanded: boolean = false;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070030
31 @state()
32 private loading: boolean = true;
33
34 @state()
35 private error: string | null = null;
Autoformatter8c463622025-05-16 21:54:17 +000036
Philip Zeyliger272a90e2025-05-16 14:49:51 -070037 @property({ attribute: false, type: Object })
38 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +000039
Philip Zeyliger272a90e2025-05-16 14:49:51 -070040 constructor() {
41 super();
Autoformatter8c463622025-05-16 21:54:17 +000042 console.log("SketchDiffRangePicker initialized");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070043 }
44
45 static styles = css`
46 :host {
47 display: block;
48 width: 100%;
49 font-family: var(--font-family, system-ui, sans-serif);
50 color: var(--text-color, #333);
51 }
52
53 .range-picker {
54 display: flex;
David Crawshaw216d2fc2025-06-15 18:45:53 +000055 flex-direction: column;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070056 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070057 width: 100%;
58 box-sizing: border-box;
59 }
60
David Crawshaw216d2fc2025-06-15 18:45:53 +000061 .commits-header {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070062 display: flex;
63 align-items: center;
David Crawshaw216d2fc2025-06-15 18:45:53 +000064 width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070065 }
66
David Crawshaw216d2fc2025-06-15 18:45:53 +000067 .commits-toggle {
68 background-color: transparent;
69 border: 1px solid var(--border-color, #e0e0e0);
70 border-radius: 4px;
71 padding: 8px 12px;
72 cursor: pointer;
73 font-size: 14px;
74 font-weight: 500;
75 transition: background-color 0.2s;
76 display: flex;
77 align-items: center;
78 gap: 8px;
79 color: var(--text-color, #333);
80 }
81
82 .commits-toggle:hover {
83 background-color: var(--background-hover, #e8e8e8);
84 }
85
Philip Zeyliger272a90e2025-05-16 14:49:51 -070086 .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 Zeyliger272a90e2025-05-16 14:49:51 -0700131 @media (max-width: 768px) {
132 .commit-selector {
133 max-width: 100%;
134 }
135 }
136 `;
137
138 connectedCallback() {
139 super.connectedCallback();
140 // Wait for DOM to be fully loaded to ensure proper initialization order
Autoformatter8c463622025-05-16 21:54:17 +0000141 if (document.readyState === "complete") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700142 this.loadCommits();
143 } else {
Autoformatter8c463622025-05-16 21:54:17 +0000144 window.addEventListener("load", () => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700145 setTimeout(() => this.loadCommits(), 0); // Give time for provider initialization
146 });
147 }
Autoformatter9abf8032025-06-14 23:24:08 +0000148
David Crawshaw938d2dc2025-06-14 22:17:33 +0000149 // Listen for popstate events to handle browser back/forward navigation
Autoformatter9abf8032025-06-14 23:24:08 +0000150 window.addEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000151 }
152
153 disconnectedCallback() {
154 super.disconnectedCallback();
Autoformatter9abf8032025-06-14 23:24:08 +0000155 window.removeEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000156 }
157
158 /**
159 * Handle browser back/forward navigation
160 */
161 private handlePopState() {
162 // Re-initialize from URL parameters when user navigates
163 if (this.commits.length > 0) {
164 const initializedFromUrl = this.initializeFromUrlParams();
165 if (initializedFromUrl) {
166 // Force re-render and dispatch event
167 this.requestUpdate();
168 this.dispatchRangeEvent();
169 }
170 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700171 }
172
173 render() {
174 return html`
175 <div class="range-picker">
176 ${this.loading
177 ? html`<div class="loading">Loading commits...</div>`
178 : this.error
Autoformatter8c463622025-05-16 21:54:17 +0000179 ? html`<div class="error">${this.error}</div>`
180 : this.renderRangePicker()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700181 </div>
182 `;
183 }
184
185 renderRangePicker() {
186 return html`
David Crawshaw216d2fc2025-06-15 18:45:53 +0000187 <div class="commits-header">
188 <button
189 class="commits-toggle"
190 @click="${this.toggleCommitsExpansion}"
Autoformatter62554112025-06-15 19:23:33 +0000191 title="${this.commitsExpanded
192 ? "Hide"
193 : "Show"} commit range selection"
David Crawshaw216d2fc2025-06-15 18:45:53 +0000194 >
Autoformatter62554112025-06-15 19:23:33 +0000195 ${this.commitsExpanded ? "â–¼" : "â–¶"} Commits
David Crawshaw216d2fc2025-06-15 18:45:53 +0000196 </button>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700197 </div>
Autoformatter62554112025-06-15 19:23:33 +0000198
David Crawshaw216d2fc2025-06-15 18:45:53 +0000199 ${this.commitsExpanded
200 ? html`
Autoformatter62554112025-06-15 19:23:33 +0000201 <div class="commit-selectors">${this.renderRangeSelectors()}</div>
David Crawshaw216d2fc2025-06-15 18:45:53 +0000202 `
Autoformatter62554112025-06-15 19:23:33 +0000203 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700204 `;
205 }
206
207 renderRangeSelectors() {
208 return html`
209 <div class="commit-selector">
210 <label for="fromCommit">From:</label>
211 <select
212 id="fromCommit"
213 .value=${this.fromCommit}
214 @change=${this.handleFromChange}
215 >
216 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000217 (commit) => html`
218 <option
219 value=${commit.hash}
220 ?selected=${commit.hash === this.fromCommit}
221 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700222 ${this.formatCommitOption(commit)}
223 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000224 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700225 )}
226 </select>
227 </div>
228 <div class="commit-selector">
229 <label for="toCommit">To:</label>
230 <select
231 id="toCommit"
232 .value=${this.toCommit}
233 @change=${this.handleToChange}
234 >
Autoformatter8c463622025-05-16 21:54:17 +0000235 <option value="" ?selected=${this.toCommit === ""}>
236 Uncommitted Changes
237 </option>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700238 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000239 (commit) => html`
240 <option
241 value=${commit.hash}
242 ?selected=${commit.hash === this.toCommit}
243 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700244 ${this.formatCommitOption(commit)}
245 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000246 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700247 )}
248 </select>
249 </div>
250 `;
251 }
252
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700253 /**
254 * Format a commit for display in the dropdown
255 */
256 formatCommitOption(commit: GitLogEntry): string {
257 const shortHash = commit.hash.substring(0, 7);
Autoformatter8c463622025-05-16 21:54:17 +0000258
David Crawshawdbca8972025-06-14 23:46:58 +0000259 // Truncate subject if it's too long
260 let subject = commit.subject;
261 if (subject.length > 50) {
262 subject = subject.substring(0, 47) + "...";
263 }
264
265 let label = `${shortHash} ${subject}`;
266
267 // Add refs but keep them concise
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700268 if (commit.refs && commit.refs.length > 0) {
David Crawshawdbca8972025-06-14 23:46:58 +0000269 const refs = commit.refs.map((ref) => {
270 // Shorten common prefixes
271 if (ref.startsWith("origin/")) {
272 return ref.substring(7);
273 }
274 if (ref.startsWith("refs/heads/")) {
275 return ref.substring(11);
276 }
277 if (ref.startsWith("refs/remotes/origin/")) {
278 return ref.substring(20);
279 }
280 return ref;
281 });
282
283 // Limit to first 2 refs to avoid overcrowding
284 const displayRefs = refs.slice(0, 2);
285 if (refs.length > 2) {
286 displayRefs.push(`+${refs.length - 2} more`);
287 }
288
289 label += ` (${displayRefs.join(", ")})`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700290 }
Autoformatter8c463622025-05-16 21:54:17 +0000291
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700292 return label;
293 }
294
295 /**
296 * Load commits from the Git data service
297 */
298 async loadCommits() {
299 this.loading = true;
300 this.error = null;
301
302 if (!this.gitService) {
Autoformatter8c463622025-05-16 21:54:17 +0000303 console.error("GitService was not provided to sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700304 throw Error();
305 }
306
307 try {
308 // Get the base commit reference
309 const baseCommitRef = await this.gitService.getBaseCommitRef();
Autoformatter8c463622025-05-16 21:54:17 +0000310
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700311 // Load commit history
312 this.commits = await this.gitService.getCommitHistory(baseCommitRef);
Autoformatter8c463622025-05-16 21:54:17 +0000313
David Crawshaw938d2dc2025-06-14 22:17:33 +0000314 // Check if we should initialize from URL parameters first
315 const initializedFromUrl = this.initializeFromUrlParams();
Autoformatter9abf8032025-06-14 23:24:08 +0000316
David Crawshaw938d2dc2025-06-14 22:17:33 +0000317 // Set default selections only if not initialized from URL
318 if (this.commits.length > 0 && !initializedFromUrl) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700319 // For range, default is base to HEAD
320 // TODO: is sketch-base right in the unsafe context, where it's sketch-base-...
321 // should this be startswith?
Autoformatter8c463622025-05-16 21:54:17 +0000322 const baseCommit = this.commits.find(
323 (c) => c.refs && c.refs.some((ref) => ref.includes("sketch-base")),
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700324 );
Autoformatter8c463622025-05-16 21:54:17 +0000325
326 this.fromCommit = baseCommit
327 ? baseCommit.hash
328 : this.commits[this.commits.length - 1].hash;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700329 // Default to Uncommitted Changes by setting toCommit to empty string
Autoformatter8c463622025-05-16 21:54:17 +0000330 this.toCommit = ""; // Empty string represents uncommitted changes
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700331 }
Autoformatter9abf8032025-06-14 23:24:08 +0000332
David Crawshaw938d2dc2025-06-14 22:17:33 +0000333 // Always dispatch range event to ensure diff view is updated
334 this.dispatchRangeEvent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700335 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000336 console.error("Error loading commits:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700337 this.error = `Error loading commits: ${error.message}`;
338 } finally {
339 this.loading = false;
340 }
341 }
342
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700343 /**
344 * Handle From commit change
345 */
346 handleFromChange(event: Event) {
347 const select = event.target as HTMLSelectElement;
348 this.fromCommit = select.value;
349 this.dispatchRangeEvent();
350 }
351
352 /**
353 * Handle To commit change
354 */
355 handleToChange(event: Event) {
356 const select = event.target as HTMLSelectElement;
357 this.toCommit = select.value;
358 this.dispatchRangeEvent();
359 }
360
361 /**
David Crawshaw216d2fc2025-06-15 18:45:53 +0000362 * Toggle the expansion of commit selectors
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700363 */
David Crawshaw216d2fc2025-06-15 18:45:53 +0000364 toggleCommitsExpansion() {
365 this.commitsExpanded = !this.commitsExpanded;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700366 }
367
368 /**
David Crawshaw216d2fc2025-06-15 18:45:53 +0000369 * Get a summary of the current commit range for display
Philip Zeyligere89b3082025-05-29 03:16:06 +0000370 */
David Crawshaw216d2fc2025-06-15 18:45:53 +0000371 getCommitSummary(): string {
372 if (!this.fromCommit && !this.toCommit) {
Autoformatter62554112025-06-15 19:23:33 +0000373 return "No commits selected";
David Crawshaw216d2fc2025-06-15 18:45:53 +0000374 }
375
Autoformatter62554112025-06-15 19:23:33 +0000376 const fromShort = this.fromCommit ? this.fromCommit.substring(0, 7) : "";
377 const toShort = this.toCommit
378 ? this.toCommit.substring(0, 7)
379 : "Uncommitted";
380
David Crawshaw216d2fc2025-06-15 18:45:53 +0000381 return `${fromShort}..${toShort}`;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000382 }
383
384 /**
David Crawshaw938d2dc2025-06-14 22:17:33 +0000385 * Validate that a commit hash exists in the loaded commits
386 */
387 private isValidCommitHash(hash: string): boolean {
Autoformatter9abf8032025-06-14 23:24:08 +0000388 if (!hash || hash.trim() === "") return true; // Empty is valid (uncommitted changes)
389 return this.commits.some(
390 (commit) => commit.hash.startsWith(hash) || commit.hash === hash,
391 );
David Crawshaw938d2dc2025-06-14 22:17:33 +0000392 }
393
394 /**
395 * Dispatch range change event and update URL parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700396 */
397 dispatchRangeEvent() {
Autoformatter62554112025-06-15 19:23:33 +0000398 const range: DiffRange = {
399 type: "range",
400 from: this.fromCommit,
401 to: this.toCommit,
402 };
Autoformatter8c463622025-05-16 21:54:17 +0000403
David Crawshaw938d2dc2025-06-14 22:17:33 +0000404 // Update URL parameters
405 this.updateUrlParams(range);
406
Autoformatter8c463622025-05-16 21:54:17 +0000407 const event = new CustomEvent("range-change", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700408 detail: { range },
409 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +0000410 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700411 });
Autoformatter8c463622025-05-16 21:54:17 +0000412
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700413 this.dispatchEvent(event);
414 }
David Crawshaw938d2dc2025-06-14 22:17:33 +0000415
416 /**
417 * Update URL parameters for from and to commits
418 */
419 private updateUrlParams(range: DiffRange) {
420 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000421
David Crawshaw938d2dc2025-06-14 22:17:33 +0000422 // Remove existing range parameters
Autoformatter9abf8032025-06-14 23:24:08 +0000423 url.searchParams.delete("from");
424 url.searchParams.delete("to");
425 url.searchParams.delete("commit");
426
David Crawshaw216d2fc2025-06-15 18:45:53 +0000427 // Add from parameter if not empty
428 if (range.from && range.from.trim() !== "") {
429 url.searchParams.set("from", range.from);
430 }
431 // Add to parameter if not empty (empty string means uncommitted changes)
432 if (range.to && range.to.trim() !== "") {
433 url.searchParams.set("to", range.to);
David Crawshaw938d2dc2025-06-14 22:17:33 +0000434 }
Autoformatter9abf8032025-06-14 23:24:08 +0000435
David Crawshaw938d2dc2025-06-14 22:17:33 +0000436 // Update the browser history without reloading the page
Autoformatter9abf8032025-06-14 23:24:08 +0000437 window.history.replaceState(window.history.state, "", url.toString());
David Crawshaw938d2dc2025-06-14 22:17:33 +0000438 }
439
440 /**
441 * Initialize from URL parameters if available
442 */
443 private initializeFromUrlParams() {
444 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000445 const fromParam = url.searchParams.get("from");
446 const toParam = url.searchParams.get("to");
Autoformatter9abf8032025-06-14 23:24:08 +0000447
David Crawshaw216d2fc2025-06-15 18:45:53 +0000448 // If from or to parameters are present, use them
David Crawshaw938d2dc2025-06-14 22:17:33 +0000449 if (fromParam || toParam) {
David Crawshaw938d2dc2025-06-14 22:17:33 +0000450 if (fromParam) {
451 this.fromCommit = fromParam;
452 }
453 if (toParam) {
454 this.toCommit = toParam;
455 } else {
456 // If no 'to' param, default to uncommitted changes (empty string)
Autoformatter9abf8032025-06-14 23:24:08 +0000457 this.toCommit = "";
David Crawshaw938d2dc2025-06-14 22:17:33 +0000458 }
459 return true; // Indicate that we initialized from URL
460 }
Autoformatter9abf8032025-06-14 23:24:08 +0000461
David Crawshaw938d2dc2025-06-14 22:17:33 +0000462 return false; // No URL params found
463 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700464}
465
466declare global {
467 interface HTMLElementTagNameMap {
468 "sketch-diff-range-picker": SketchDiffRangePicker;
469 }
470}