blob: 90b417ba494b5a8af1f027aa8bf3a290bd437f01 [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
Philip Zeyliger38499cc2025-06-15 21:17:05 -070028 // Removed commitsExpanded state - always expanded now
Philip Zeyliger272a90e2025-05-16 14:49:51 -070029
30 @state()
31 private loading: boolean = true;
32
33 @state()
34 private error: string | null = null;
Autoformatter8c463622025-05-16 21:54:17 +000035
Philip Zeyliger272a90e2025-05-16 14:49:51 -070036 @property({ attribute: false, type: Object })
37 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +000038
Philip Zeyliger272a90e2025-05-16 14:49:51 -070039 constructor() {
40 super();
Autoformatter8c463622025-05-16 21:54:17 +000041 console.log("SketchDiffRangePicker initialized");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070042 }
43
44 static styles = css`
45 :host {
46 display: block;
47 width: 100%;
48 font-family: var(--font-family, system-ui, sans-serif);
49 color: var(--text-color, #333);
50 }
51
52 .range-picker {
53 display: flex;
David Crawshaw216d2fc2025-06-15 18:45:53 +000054 flex-direction: column;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070055 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070056 width: 100%;
57 box-sizing: border-box;
58 }
59
Philip Zeyliger38499cc2025-06-15 21:17:05 -070060 /* Removed commits-header and commits-label styles - no longer needed */
David Crawshaw216d2fc2025-06-15 18:45:53 +000061
Philip Zeyliger272a90e2025-05-16 14:49:51 -070062 .commit-selectors {
63 display: flex;
64 flex-direction: row;
65 align-items: center;
66 gap: 12px;
67 flex: 1;
68 flex-wrap: wrap; /* Allow wrapping on small screens */
69 }
70
71 .commit-selector {
72 display: flex;
73 align-items: center;
74 gap: 8px;
75 flex: 1;
76 min-width: 200px;
77 max-width: calc(50% - 12px); /* Half width minus half the gap */
78 overflow: hidden;
79 }
80
81 select {
82 padding: 6px 8px;
83 border-radius: 4px;
84 border: 1px solid var(--border-color, #e0e0e0);
85 background-color: var(--background, #fff);
86 max-width: 100%;
87 overflow: hidden;
88 text-overflow: ellipsis;
89 white-space: nowrap;
90 }
91
92 label {
93 font-weight: 500;
94 font-size: 14px;
95 }
96
97 .loading {
98 font-style: italic;
99 color: var(--text-muted, #666);
100 }
101
102 .error {
103 color: var(--error-color, #dc3545);
104 font-size: 14px;
105 }
Autoformatter8c463622025-05-16 21:54:17 +0000106
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700107 @media (max-width: 768px) {
108 .commit-selector {
109 max-width: 100%;
110 }
111 }
112 `;
113
114 connectedCallback() {
115 super.connectedCallback();
116 // Wait for DOM to be fully loaded to ensure proper initialization order
Autoformatter8c463622025-05-16 21:54:17 +0000117 if (document.readyState === "complete") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700118 this.loadCommits();
119 } else {
Autoformatter8c463622025-05-16 21:54:17 +0000120 window.addEventListener("load", () => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700121 setTimeout(() => this.loadCommits(), 0); // Give time for provider initialization
122 });
123 }
Autoformatter9abf8032025-06-14 23:24:08 +0000124
David Crawshaw938d2dc2025-06-14 22:17:33 +0000125 // Listen for popstate events to handle browser back/forward navigation
Autoformatter9abf8032025-06-14 23:24:08 +0000126 window.addEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000127 }
128
129 disconnectedCallback() {
130 super.disconnectedCallback();
Autoformatter9abf8032025-06-14 23:24:08 +0000131 window.removeEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000132 }
133
134 /**
135 * Handle browser back/forward navigation
136 */
137 private handlePopState() {
138 // Re-initialize from URL parameters when user navigates
139 if (this.commits.length > 0) {
140 const initializedFromUrl = this.initializeFromUrlParams();
141 if (initializedFromUrl) {
142 // Force re-render and dispatch event
143 this.requestUpdate();
144 this.dispatchRangeEvent();
145 }
146 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700147 }
148
149 render() {
150 return html`
151 <div class="range-picker">
152 ${this.loading
153 ? html`<div class="loading">Loading commits...</div>`
154 : this.error
Autoformatter8c463622025-05-16 21:54:17 +0000155 ? html`<div class="error">${this.error}</div>`
156 : this.renderRangePicker()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700157 </div>
158 `;
159 }
160
161 renderRangePicker() {
162 return html`
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700163 <div class="commit-selectors">${this.renderRangeSelectors()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700164 `;
165 }
166
167 renderRangeSelectors() {
168 return html`
169 <div class="commit-selector">
170 <label for="fromCommit">From:</label>
171 <select
172 id="fromCommit"
173 .value=${this.fromCommit}
174 @change=${this.handleFromChange}
175 >
176 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000177 (commit) => html`
178 <option
179 value=${commit.hash}
180 ?selected=${commit.hash === this.fromCommit}
181 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700182 ${this.formatCommitOption(commit)}
183 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000184 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700185 )}
186 </select>
187 </div>
188 <div class="commit-selector">
189 <label for="toCommit">To:</label>
190 <select
191 id="toCommit"
192 .value=${this.toCommit}
193 @change=${this.handleToChange}
194 >
Autoformatter8c463622025-05-16 21:54:17 +0000195 <option value="" ?selected=${this.toCommit === ""}>
196 Uncommitted Changes
197 </option>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700198 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000199 (commit) => html`
200 <option
201 value=${commit.hash}
202 ?selected=${commit.hash === this.toCommit}
203 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700204 ${this.formatCommitOption(commit)}
205 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000206 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700207 )}
208 </select>
209 </div>
210 `;
211 }
212
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700213 /**
214 * Format a commit for display in the dropdown
215 */
216 formatCommitOption(commit: GitLogEntry): string {
217 const shortHash = commit.hash.substring(0, 7);
Autoformatter8c463622025-05-16 21:54:17 +0000218
David Crawshawdbca8972025-06-14 23:46:58 +0000219 // Truncate subject if it's too long
220 let subject = commit.subject;
221 if (subject.length > 50) {
222 subject = subject.substring(0, 47) + "...";
223 }
224
225 let label = `${shortHash} ${subject}`;
226
227 // Add refs but keep them concise
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700228 if (commit.refs && commit.refs.length > 0) {
David Crawshawdbca8972025-06-14 23:46:58 +0000229 const refs = commit.refs.map((ref) => {
230 // Shorten common prefixes
231 if (ref.startsWith("origin/")) {
232 return ref.substring(7);
233 }
234 if (ref.startsWith("refs/heads/")) {
235 return ref.substring(11);
236 }
237 if (ref.startsWith("refs/remotes/origin/")) {
238 return ref.substring(20);
239 }
240 return ref;
241 });
242
243 // Limit to first 2 refs to avoid overcrowding
244 const displayRefs = refs.slice(0, 2);
245 if (refs.length > 2) {
246 displayRefs.push(`+${refs.length - 2} more`);
247 }
248
249 label += ` (${displayRefs.join(", ")})`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700250 }
Autoformatter8c463622025-05-16 21:54:17 +0000251
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700252 return label;
253 }
254
255 /**
256 * Load commits from the Git data service
257 */
258 async loadCommits() {
259 this.loading = true;
260 this.error = null;
261
262 if (!this.gitService) {
Autoformatter8c463622025-05-16 21:54:17 +0000263 console.error("GitService was not provided to sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700264 throw Error();
265 }
266
267 try {
268 // Get the base commit reference
269 const baseCommitRef = await this.gitService.getBaseCommitRef();
Autoformatter8c463622025-05-16 21:54:17 +0000270
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700271 // Load commit history
272 this.commits = await this.gitService.getCommitHistory(baseCommitRef);
Autoformatter8c463622025-05-16 21:54:17 +0000273
David Crawshaw938d2dc2025-06-14 22:17:33 +0000274 // Check if we should initialize from URL parameters first
275 const initializedFromUrl = this.initializeFromUrlParams();
Autoformatter9abf8032025-06-14 23:24:08 +0000276
David Crawshaw938d2dc2025-06-14 22:17:33 +0000277 // Set default selections only if not initialized from URL
278 if (this.commits.length > 0 && !initializedFromUrl) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700279 // For range, default is base to HEAD
280 // TODO: is sketch-base right in the unsafe context, where it's sketch-base-...
281 // should this be startswith?
Autoformatter8c463622025-05-16 21:54:17 +0000282 const baseCommit = this.commits.find(
283 (c) => c.refs && c.refs.some((ref) => ref.includes("sketch-base")),
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700284 );
Autoformatter8c463622025-05-16 21:54:17 +0000285
286 this.fromCommit = baseCommit
287 ? baseCommit.hash
288 : this.commits[this.commits.length - 1].hash;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700289 // Default to Uncommitted Changes by setting toCommit to empty string
Autoformatter8c463622025-05-16 21:54:17 +0000290 this.toCommit = ""; // Empty string represents uncommitted changes
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700291 }
Autoformatter9abf8032025-06-14 23:24:08 +0000292
David Crawshaw938d2dc2025-06-14 22:17:33 +0000293 // Always dispatch range event to ensure diff view is updated
294 this.dispatchRangeEvent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700295 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000296 console.error("Error loading commits:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700297 this.error = `Error loading commits: ${error.message}`;
298 } finally {
299 this.loading = false;
300 }
301 }
302
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700303 /**
304 * Handle From commit change
305 */
306 handleFromChange(event: Event) {
307 const select = event.target as HTMLSelectElement;
308 this.fromCommit = select.value;
309 this.dispatchRangeEvent();
310 }
311
312 /**
313 * Handle To commit change
314 */
315 handleToChange(event: Event) {
316 const select = event.target as HTMLSelectElement;
317 this.toCommit = select.value;
318 this.dispatchRangeEvent();
319 }
320
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700321 // Removed toggleCommitsExpansion method - always expanded now
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700322
323 /**
David Crawshaw216d2fc2025-06-15 18:45:53 +0000324 * Get a summary of the current commit range for display
Philip Zeyligere89b3082025-05-29 03:16:06 +0000325 */
David Crawshaw216d2fc2025-06-15 18:45:53 +0000326 getCommitSummary(): string {
327 if (!this.fromCommit && !this.toCommit) {
Autoformatter62554112025-06-15 19:23:33 +0000328 return "No commits selected";
David Crawshaw216d2fc2025-06-15 18:45:53 +0000329 }
330
Autoformatter62554112025-06-15 19:23:33 +0000331 const fromShort = this.fromCommit ? this.fromCommit.substring(0, 7) : "";
332 const toShort = this.toCommit
333 ? this.toCommit.substring(0, 7)
334 : "Uncommitted";
335
David Crawshaw216d2fc2025-06-15 18:45:53 +0000336 return `${fromShort}..${toShort}`;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000337 }
338
339 /**
David Crawshaw938d2dc2025-06-14 22:17:33 +0000340 * Validate that a commit hash exists in the loaded commits
341 */
342 private isValidCommitHash(hash: string): boolean {
Autoformatter9abf8032025-06-14 23:24:08 +0000343 if (!hash || hash.trim() === "") return true; // Empty is valid (uncommitted changes)
344 return this.commits.some(
345 (commit) => commit.hash.startsWith(hash) || commit.hash === hash,
346 );
David Crawshaw938d2dc2025-06-14 22:17:33 +0000347 }
348
349 /**
350 * Dispatch range change event and update URL parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700351 */
352 dispatchRangeEvent() {
Autoformatter62554112025-06-15 19:23:33 +0000353 const range: DiffRange = {
354 type: "range",
355 from: this.fromCommit,
356 to: this.toCommit,
357 };
Autoformatter8c463622025-05-16 21:54:17 +0000358
David Crawshaw938d2dc2025-06-14 22:17:33 +0000359 // Update URL parameters
360 this.updateUrlParams(range);
361
Autoformatter8c463622025-05-16 21:54:17 +0000362 const event = new CustomEvent("range-change", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700363 detail: { range },
364 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +0000365 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700366 });
Autoformatter8c463622025-05-16 21:54:17 +0000367
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700368 this.dispatchEvent(event);
369 }
David Crawshaw938d2dc2025-06-14 22:17:33 +0000370
371 /**
372 * Update URL parameters for from and to commits
373 */
374 private updateUrlParams(range: DiffRange) {
375 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000376
David Crawshaw938d2dc2025-06-14 22:17:33 +0000377 // Remove existing range parameters
Autoformatter9abf8032025-06-14 23:24:08 +0000378 url.searchParams.delete("from");
379 url.searchParams.delete("to");
380 url.searchParams.delete("commit");
381
David Crawshaw216d2fc2025-06-15 18:45:53 +0000382 // Add from parameter if not empty
383 if (range.from && range.from.trim() !== "") {
384 url.searchParams.set("from", range.from);
385 }
386 // Add to parameter if not empty (empty string means uncommitted changes)
387 if (range.to && range.to.trim() !== "") {
388 url.searchParams.set("to", range.to);
David Crawshaw938d2dc2025-06-14 22:17:33 +0000389 }
Autoformatter9abf8032025-06-14 23:24:08 +0000390
David Crawshaw938d2dc2025-06-14 22:17:33 +0000391 // Update the browser history without reloading the page
Autoformatter9abf8032025-06-14 23:24:08 +0000392 window.history.replaceState(window.history.state, "", url.toString());
David Crawshaw938d2dc2025-06-14 22:17:33 +0000393 }
394
395 /**
396 * Initialize from URL parameters if available
397 */
398 private initializeFromUrlParams() {
399 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000400 const fromParam = url.searchParams.get("from");
401 const toParam = url.searchParams.get("to");
Autoformatter9abf8032025-06-14 23:24:08 +0000402
David Crawshaw216d2fc2025-06-15 18:45:53 +0000403 // If from or to parameters are present, use them
David Crawshaw938d2dc2025-06-14 22:17:33 +0000404 if (fromParam || toParam) {
David Crawshaw938d2dc2025-06-14 22:17:33 +0000405 if (fromParam) {
406 this.fromCommit = fromParam;
407 }
408 if (toParam) {
409 this.toCommit = toParam;
410 } else {
411 // If no 'to' param, default to uncommitted changes (empty string)
Autoformatter9abf8032025-06-14 23:24:08 +0000412 this.toCommit = "";
David Crawshaw938d2dc2025-06-14 22:17:33 +0000413 }
414 return true; // Indicate that we initialized from URL
415 }
Autoformatter9abf8032025-06-14 23:24:08 +0000416
David Crawshaw938d2dc2025-06-14 22:17:33 +0000417 return false; // No URL params found
418 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700419}
420
421declare global {
422 interface HTMLElementTagNameMap {
423 "sketch-diff-range-picker": SketchDiffRangePicker;
424 }
425}