blob: 37508eb244b33137dc925a1441cd6c330010057b [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;
57 padding: 12px;
58 background-color: var(--background-light, #f8f8f8);
59 border-radius: 4px;
60 border: 1px solid var(--border-color, #e0e0e0);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070061 width: 100%;
62 box-sizing: border-box;
63 }
64
David Crawshaw216d2fc2025-06-15 18:45:53 +000065 .commits-header {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070066 display: flex;
67 align-items: center;
David Crawshaw216d2fc2025-06-15 18:45:53 +000068 justify-content: space-between;
69 width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070070 }
71
David Crawshaw216d2fc2025-06-15 18:45:53 +000072 .commits-toggle {
73 background-color: transparent;
74 border: 1px solid var(--border-color, #e0e0e0);
75 border-radius: 4px;
76 padding: 8px 12px;
77 cursor: pointer;
78 font-size: 14px;
79 font-weight: 500;
80 transition: background-color 0.2s;
81 display: flex;
82 align-items: center;
83 gap: 8px;
84 color: var(--text-color, #333);
85 }
86
87 .commits-toggle:hover {
88 background-color: var(--background-hover, #e8e8e8);
89 }
90
91 .commits-summary {
92 font-size: 14px;
93 color: var(--text-secondary-color, #666);
94 font-family: monospace;
95 }
96
97
98
Philip Zeyliger272a90e2025-05-16 14:49:51 -070099 .commit-selectors {
100 display: flex;
101 flex-direction: row;
102 align-items: center;
103 gap: 12px;
104 flex: 1;
105 flex-wrap: wrap; /* Allow wrapping on small screens */
106 }
107
108 .commit-selector {
109 display: flex;
110 align-items: center;
111 gap: 8px;
112 flex: 1;
113 min-width: 200px;
114 max-width: calc(50% - 12px); /* Half width minus half the gap */
115 overflow: hidden;
116 }
117
118 select {
119 padding: 6px 8px;
120 border-radius: 4px;
121 border: 1px solid var(--border-color, #e0e0e0);
122 background-color: var(--background, #fff);
123 max-width: 100%;
124 overflow: hidden;
125 text-overflow: ellipsis;
126 white-space: nowrap;
127 }
128
129 label {
130 font-weight: 500;
131 font-size: 14px;
132 }
133
134 .loading {
135 font-style: italic;
136 color: var(--text-muted, #666);
137 }
138
139 .error {
140 color: var(--error-color, #dc3545);
141 font-size: 14px;
142 }
Autoformatter8c463622025-05-16 21:54:17 +0000143
Philip Zeyligere89b3082025-05-29 03:16:06 +0000144
Philip Zeyligere89b3082025-05-29 03:16:06 +0000145
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700146 @media (max-width: 768px) {
147 .commit-selector {
148 max-width: 100%;
149 }
150 }
151 `;
152
153 connectedCallback() {
154 super.connectedCallback();
155 // Wait for DOM to be fully loaded to ensure proper initialization order
Autoformatter8c463622025-05-16 21:54:17 +0000156 if (document.readyState === "complete") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700157 this.loadCommits();
158 } else {
Autoformatter8c463622025-05-16 21:54:17 +0000159 window.addEventListener("load", () => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700160 setTimeout(() => this.loadCommits(), 0); // Give time for provider initialization
161 });
162 }
Autoformatter9abf8032025-06-14 23:24:08 +0000163
David Crawshaw938d2dc2025-06-14 22:17:33 +0000164 // Listen for popstate events to handle browser back/forward navigation
Autoformatter9abf8032025-06-14 23:24:08 +0000165 window.addEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000166 }
167
168 disconnectedCallback() {
169 super.disconnectedCallback();
Autoformatter9abf8032025-06-14 23:24:08 +0000170 window.removeEventListener("popstate", this.handlePopState.bind(this));
David Crawshaw938d2dc2025-06-14 22:17:33 +0000171 }
172
173 /**
174 * Handle browser back/forward navigation
175 */
176 private handlePopState() {
177 // Re-initialize from URL parameters when user navigates
178 if (this.commits.length > 0) {
179 const initializedFromUrl = this.initializeFromUrlParams();
180 if (initializedFromUrl) {
181 // Force re-render and dispatch event
182 this.requestUpdate();
183 this.dispatchRangeEvent();
184 }
185 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700186 }
187
188 render() {
189 return html`
190 <div class="range-picker">
191 ${this.loading
192 ? html`<div class="loading">Loading commits...</div>`
193 : this.error
Autoformatter8c463622025-05-16 21:54:17 +0000194 ? html`<div class="error">${this.error}</div>`
195 : this.renderRangePicker()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700196 </div>
197 `;
198 }
199
200 renderRangePicker() {
201 return html`
David Crawshaw216d2fc2025-06-15 18:45:53 +0000202 <div class="commits-header">
203 <button
204 class="commits-toggle"
205 @click="${this.toggleCommitsExpansion}"
206 title="${this.commitsExpanded ? 'Hide' : 'Show'} commit range selection"
207 >
208 ${this.commitsExpanded ? 'â–¼' : 'â–¶'} Commits
209 </button>
210 <div class="commits-summary">
211 ${this.getCommitSummary()}
212 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700213 </div>
David Crawshaw216d2fc2025-06-15 18:45:53 +0000214
215 ${this.commitsExpanded
216 ? html`
217 <div class="commit-selectors">
218 ${this.renderRangeSelectors()}
219 </div>
220 `
221 : ''}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700222 `;
223 }
224
225 renderRangeSelectors() {
226 return html`
227 <div class="commit-selector">
228 <label for="fromCommit">From:</label>
229 <select
230 id="fromCommit"
231 .value=${this.fromCommit}
232 @change=${this.handleFromChange}
233 >
234 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000235 (commit) => html`
236 <option
237 value=${commit.hash}
238 ?selected=${commit.hash === this.fromCommit}
239 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700240 ${this.formatCommitOption(commit)}
241 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000242 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700243 )}
244 </select>
245 </div>
246 <div class="commit-selector">
247 <label for="toCommit">To:</label>
248 <select
249 id="toCommit"
250 .value=${this.toCommit}
251 @change=${this.handleToChange}
252 >
Autoformatter8c463622025-05-16 21:54:17 +0000253 <option value="" ?selected=${this.toCommit === ""}>
254 Uncommitted Changes
255 </option>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700256 ${this.commits.map(
Autoformatter8c463622025-05-16 21:54:17 +0000257 (commit) => html`
258 <option
259 value=${commit.hash}
260 ?selected=${commit.hash === this.toCommit}
261 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700262 ${this.formatCommitOption(commit)}
263 </option>
Autoformatter8c463622025-05-16 21:54:17 +0000264 `,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700265 )}
266 </select>
267 </div>
268 `;
269 }
270
David Crawshaw216d2fc2025-06-15 18:45:53 +0000271
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700272
273 /**
274 * Format a commit for display in the dropdown
275 */
276 formatCommitOption(commit: GitLogEntry): string {
277 const shortHash = commit.hash.substring(0, 7);
Autoformatter8c463622025-05-16 21:54:17 +0000278
David Crawshawdbca8972025-06-14 23:46:58 +0000279 // Truncate subject if it's too long
280 let subject = commit.subject;
281 if (subject.length > 50) {
282 subject = subject.substring(0, 47) + "...";
283 }
284
285 let label = `${shortHash} ${subject}`;
286
287 // Add refs but keep them concise
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700288 if (commit.refs && commit.refs.length > 0) {
David Crawshawdbca8972025-06-14 23:46:58 +0000289 const refs = commit.refs.map((ref) => {
290 // Shorten common prefixes
291 if (ref.startsWith("origin/")) {
292 return ref.substring(7);
293 }
294 if (ref.startsWith("refs/heads/")) {
295 return ref.substring(11);
296 }
297 if (ref.startsWith("refs/remotes/origin/")) {
298 return ref.substring(20);
299 }
300 return ref;
301 });
302
303 // Limit to first 2 refs to avoid overcrowding
304 const displayRefs = refs.slice(0, 2);
305 if (refs.length > 2) {
306 displayRefs.push(`+${refs.length - 2} more`);
307 }
308
309 label += ` (${displayRefs.join(", ")})`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700310 }
Autoformatter8c463622025-05-16 21:54:17 +0000311
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700312 return label;
313 }
314
315 /**
316 * Load commits from the Git data service
317 */
318 async loadCommits() {
319 this.loading = true;
320 this.error = null;
321
322 if (!this.gitService) {
Autoformatter8c463622025-05-16 21:54:17 +0000323 console.error("GitService was not provided to sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700324 throw Error();
325 }
326
327 try {
328 // Get the base commit reference
329 const baseCommitRef = await this.gitService.getBaseCommitRef();
Autoformatter8c463622025-05-16 21:54:17 +0000330
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700331 // Load commit history
332 this.commits = await this.gitService.getCommitHistory(baseCommitRef);
Autoformatter8c463622025-05-16 21:54:17 +0000333
David Crawshaw938d2dc2025-06-14 22:17:33 +0000334 // Check if we should initialize from URL parameters first
335 const initializedFromUrl = this.initializeFromUrlParams();
Autoformatter9abf8032025-06-14 23:24:08 +0000336
David Crawshaw938d2dc2025-06-14 22:17:33 +0000337 // Set default selections only if not initialized from URL
338 if (this.commits.length > 0 && !initializedFromUrl) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700339 // For range, default is base to HEAD
340 // TODO: is sketch-base right in the unsafe context, where it's sketch-base-...
341 // should this be startswith?
Autoformatter8c463622025-05-16 21:54:17 +0000342 const baseCommit = this.commits.find(
343 (c) => c.refs && c.refs.some((ref) => ref.includes("sketch-base")),
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700344 );
Autoformatter8c463622025-05-16 21:54:17 +0000345
346 this.fromCommit = baseCommit
347 ? baseCommit.hash
348 : this.commits[this.commits.length - 1].hash;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700349 // Default to Uncommitted Changes by setting toCommit to empty string
Autoformatter8c463622025-05-16 21:54:17 +0000350 this.toCommit = ""; // Empty string represents uncommitted changes
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700351 }
Autoformatter9abf8032025-06-14 23:24:08 +0000352
David Crawshaw938d2dc2025-06-14 22:17:33 +0000353 // Always dispatch range event to ensure diff view is updated
354 this.dispatchRangeEvent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700355 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000356 console.error("Error loading commits:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700357 this.error = `Error loading commits: ${error.message}`;
358 } finally {
359 this.loading = false;
360 }
361 }
362
Autoformatter9abf8032025-06-14 23:24:08 +0000363
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700364
365 /**
366 * Handle From commit change
367 */
368 handleFromChange(event: Event) {
369 const select = event.target as HTMLSelectElement;
370 this.fromCommit = select.value;
371 this.dispatchRangeEvent();
372 }
373
374 /**
375 * Handle To commit change
376 */
377 handleToChange(event: Event) {
378 const select = event.target as HTMLSelectElement;
379 this.toCommit = select.value;
380 this.dispatchRangeEvent();
381 }
382
383 /**
David Crawshaw216d2fc2025-06-15 18:45:53 +0000384 * Toggle the expansion of commit selectors
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700385 */
David Crawshaw216d2fc2025-06-15 18:45:53 +0000386 toggleCommitsExpansion() {
387 this.commitsExpanded = !this.commitsExpanded;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700388 }
389
390 /**
David Crawshaw216d2fc2025-06-15 18:45:53 +0000391 * Get a summary of the current commit range for display
Philip Zeyligere89b3082025-05-29 03:16:06 +0000392 */
David Crawshaw216d2fc2025-06-15 18:45:53 +0000393 getCommitSummary(): string {
394 if (!this.fromCommit && !this.toCommit) {
395 return 'No commits selected';
396 }
397
398 const fromShort = this.fromCommit ? this.fromCommit.substring(0, 7) : '';
399 const toShort = this.toCommit ? this.toCommit.substring(0, 7) : 'Uncommitted';
400
401 return `${fromShort}..${toShort}`;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000402 }
403
David Crawshaw216d2fc2025-06-15 18:45:53 +0000404
405
406
407
Philip Zeyligere89b3082025-05-29 03:16:06 +0000408 /**
David Crawshaw938d2dc2025-06-14 22:17:33 +0000409 * Validate that a commit hash exists in the loaded commits
410 */
411 private isValidCommitHash(hash: string): boolean {
Autoformatter9abf8032025-06-14 23:24:08 +0000412 if (!hash || hash.trim() === "") return true; // Empty is valid (uncommitted changes)
413 return this.commits.some(
414 (commit) => commit.hash.startsWith(hash) || commit.hash === hash,
415 );
David Crawshaw938d2dc2025-06-14 22:17:33 +0000416 }
417
418 /**
419 * Dispatch range change event and update URL parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700420 */
421 dispatchRangeEvent() {
David Crawshaw216d2fc2025-06-15 18:45:53 +0000422 const range: DiffRange = { type: "range", from: this.fromCommit, to: this.toCommit };
Autoformatter8c463622025-05-16 21:54:17 +0000423
David Crawshaw938d2dc2025-06-14 22:17:33 +0000424 // Update URL parameters
425 this.updateUrlParams(range);
426
Autoformatter8c463622025-05-16 21:54:17 +0000427 const event = new CustomEvent("range-change", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700428 detail: { range },
429 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +0000430 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700431 });
Autoformatter8c463622025-05-16 21:54:17 +0000432
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700433 this.dispatchEvent(event);
434 }
David Crawshaw938d2dc2025-06-14 22:17:33 +0000435
436 /**
437 * Update URL parameters for from and to commits
438 */
439 private updateUrlParams(range: DiffRange) {
440 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000441
David Crawshaw938d2dc2025-06-14 22:17:33 +0000442 // Remove existing range parameters
Autoformatter9abf8032025-06-14 23:24:08 +0000443 url.searchParams.delete("from");
444 url.searchParams.delete("to");
445 url.searchParams.delete("commit");
446
David Crawshaw216d2fc2025-06-15 18:45:53 +0000447 // Add from parameter if not empty
448 if (range.from && range.from.trim() !== "") {
449 url.searchParams.set("from", range.from);
450 }
451 // Add to parameter if not empty (empty string means uncommitted changes)
452 if (range.to && range.to.trim() !== "") {
453 url.searchParams.set("to", range.to);
David Crawshaw938d2dc2025-06-14 22:17:33 +0000454 }
Autoformatter9abf8032025-06-14 23:24:08 +0000455
David Crawshaw938d2dc2025-06-14 22:17:33 +0000456 // Update the browser history without reloading the page
Autoformatter9abf8032025-06-14 23:24:08 +0000457 window.history.replaceState(window.history.state, "", url.toString());
David Crawshaw938d2dc2025-06-14 22:17:33 +0000458 }
459
460 /**
461 * Initialize from URL parameters if available
462 */
463 private initializeFromUrlParams() {
464 const url = new URL(window.location.href);
Autoformatter9abf8032025-06-14 23:24:08 +0000465 const fromParam = url.searchParams.get("from");
466 const toParam = url.searchParams.get("to");
Autoformatter9abf8032025-06-14 23:24:08 +0000467
David Crawshaw216d2fc2025-06-15 18:45:53 +0000468 // If from or to parameters are present, use them
David Crawshaw938d2dc2025-06-14 22:17:33 +0000469 if (fromParam || toParam) {
David Crawshaw938d2dc2025-06-14 22:17:33 +0000470 if (fromParam) {
471 this.fromCommit = fromParam;
472 }
473 if (toParam) {
474 this.toCommit = toParam;
475 } else {
476 // If no 'to' param, default to uncommitted changes (empty string)
Autoformatter9abf8032025-06-14 23:24:08 +0000477 this.toCommit = "";
David Crawshaw938d2dc2025-06-14 22:17:33 +0000478 }
479 return true; // Indicate that we initialized from URL
480 }
Autoformatter9abf8032025-06-14 23:24:08 +0000481
David Crawshaw938d2dc2025-06-14 22:17:33 +0000482 return false; // No URL params found
483 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700484}
485
486declare global {
487 interface HTMLElementTagNameMap {
488 "sketch-diff-range-picker": SketchDiffRangePicker;
489 }
490}