blob: 7511602cb0949acafdae53bd5e6dfdf7506741d1 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
philip.zeyliger6b8b7662025-06-16 03:06:30 +00002import { css, html, LitElement } from "lit";
3import { customElement, state } from "lit/decorators.js";
Autoformatterf964b502025-06-17 04:30:35 +00004import {
5 GitDiffFile,
6 GitDataService,
7 DefaultGitDataService,
8} from "./git-data-service";
philip.zeyliger6b8b7662025-06-16 03:06:30 +00009import "./sketch-monaco-view";
10
11@customElement("mobile-diff")
12export class MobileDiff extends LitElement {
13 private gitService: GitDataService = new DefaultGitDataService();
14
15 @state()
16 private files: GitDiffFile[] = [];
17
18 @state()
Autoformatterf964b502025-06-17 04:30:35 +000019 private fileContents: Map<string, { original: string; modified: string }> =
20 new Map();
philip.zeyliger6b8b7662025-06-16 03:06:30 +000021
22 @state()
23 private loading: boolean = false;
24
25 @state()
26 private error: string | null = null;
27
28 @state()
29 private baseCommit: string = "";
30
31 @state()
32 private fileExpandStates: Map<string, boolean> = new Map();
33
34 static styles = css`
35 :host {
36 display: flex;
37 flex-direction: column;
38 height: 100%;
39 min-height: 0;
40 overflow: hidden;
41 background-color: #ffffff;
42 }
43
philip.zeyliger6b8b7662025-06-16 03:06:30 +000044 .diff-container {
45 flex: 1;
46 overflow: auto;
47 min-height: 0;
48 /* Ensure proper scrolling behavior */
49 -webkit-overflow-scrolling: touch;
50 }
51
52 .loading,
53 .error,
54 .empty {
55 display: flex;
56 align-items: center;
57 justify-content: center;
58 height: 100%;
59 font-size: 16px;
60 color: #6c757d;
61 text-align: center;
62 padding: 20px;
63 }
64
65 .error {
66 color: #dc3545;
67 }
68
69 .file-diff {
70 margin-bottom: 16px;
71 }
72
73 .file-diff:last-child {
74 margin-bottom: 0;
75 }
76
77 .file-header {
78 background-color: #f8f9fa;
79 border: 1px solid #e9ecef;
80 border-bottom: none;
81 padding: 12px 16px;
82 font-family: monospace;
83 font-size: 14px;
84 font-weight: 500;
85 color: #495057;
86 position: sticky;
87 top: 0;
88 z-index: 10;
89 }
90
91 .file-status {
92 display: inline-block;
93 padding: 2px 6px;
94 border-radius: 3px;
95 font-size: 12px;
96 font-weight: bold;
97 margin-right: 8px;
98 font-family: sans-serif;
99 }
100
101 .file-status.added {
102 background-color: #d4edda;
103 color: #155724;
104 }
105
106 .file-status.modified {
107 background-color: #fff3cd;
108 color: #856404;
109 }
110
111 .file-status.deleted {
112 background-color: #f8d7da;
113 color: #721c24;
114 }
115
116 .file-status.renamed {
117 background-color: #d1ecf1;
118 color: #0c5460;
119 }
120
121 .file-changes {
122 margin-left: 8px;
123 font-size: 12px;
124 color: #6c757d;
125 }
126
127 .monaco-container {
128 border: 1px solid #e9ecef;
129 border-top: none;
130 min-height: 200px;
131 /* Prevent artifacts */
132 overflow: hidden;
133 background-color: #ffffff;
134 }
135
136 sketch-monaco-view {
137 width: 100%;
138 min-height: 200px;
139 }
140 `;
141
142 connectedCallback() {
143 super.connectedCallback();
144 this.loadDiffData();
145 }
146
147 private async loadDiffData() {
148 this.loading = true;
149 this.error = null;
150 this.files = [];
151 this.fileContents.clear();
152
153 try {
154 // Get base commit reference
155 this.baseCommit = await this.gitService.getBaseCommitRef();
Autoformatterf964b502025-06-17 04:30:35 +0000156
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000157 // Get diff from base commit to untracked changes (empty string for working directory)
158 this.files = await this.gitService.getDiff(this.baseCommit, "");
159
160 // Ensure files is always an array
161 if (!this.files) {
162 this.files = [];
163 }
164
165 if (this.files.length > 0) {
166 await this.loadAllFileContents();
167 }
168 } catch (error) {
169 console.error("Error loading diff data:", error);
170 this.error = `Error loading diff: ${error instanceof Error ? error.message : String(error)}`;
171 // Ensure files is always an array even on error
172 this.files = [];
173 } finally {
174 this.loading = false;
175 }
176 }
177
178 private async loadAllFileContents() {
179 try {
180 const promises = this.files.map(async (file) => {
181 try {
182 let originalCode = "";
183 let modifiedCode = "";
184
185 // Load original content (from the base commit)
186 if (file.status !== "A") {
187 // For modified, renamed, or deleted files: load original content
Autoformatterf964b502025-06-17 04:30:35 +0000188 originalCode = await this.gitService.getFileContent(
189 file.old_hash || "",
190 );
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000191 }
192
193 // Load modified content (from working directory)
194 if (file.status === "D") {
195 // Deleted file: empty modified content
196 modifiedCode = "";
197 } else {
198 // Added/modified/renamed: use working copy content
199 try {
Autoformatterf964b502025-06-17 04:30:35 +0000200 modifiedCode = await this.gitService.getWorkingCopyContent(
201 file.path,
202 );
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000203 } catch (error) {
Autoformatterf964b502025-06-17 04:30:35 +0000204 console.warn(
205 `Could not get working copy for ${file.path}:`,
206 error,
207 );
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000208 modifiedCode = "";
209 }
210 }
211
212 this.fileContents.set(file.path, {
213 original: originalCode,
214 modified: modifiedCode,
215 });
216 } catch (error) {
217 console.error(`Error loading content for file ${file.path}:`, error);
218 // Store empty content for failed files
219 this.fileContents.set(file.path, {
220 original: "",
221 modified: "",
222 });
223 }
224 });
225
226 await Promise.all(promises);
227 } catch (error) {
228 console.error("Error loading file contents:", error);
229 throw error;
230 }
231 }
232
233 private getFileStatusClass(status: string): string {
234 switch (status.toUpperCase()) {
235 case "A":
236 return "added";
237 case "M":
238 return "modified";
239 case "D":
240 return "deleted";
241 case "R":
242 default:
243 if (status.toUpperCase().startsWith("R")) {
244 return "renamed";
245 }
246 return "modified";
247 }
248 }
249
250 private getFileStatusText(status: string): string {
251 switch (status.toUpperCase()) {
252 case "A":
253 return "Added";
254 case "M":
255 return "Modified";
256 case "D":
257 return "Deleted";
258 case "R":
259 default:
260 if (status.toUpperCase().startsWith("R")) {
261 return "Renamed";
262 }
263 return "Modified";
264 }
265 }
266
267 private getChangesInfo(file: GitDiffFile): string {
268 const additions = file.additions || 0;
269 const deletions = file.deletions || 0;
270
271 if (additions === 0 && deletions === 0) {
272 return "";
273 }
274
275 const parts = [];
276 if (additions > 0) {
277 parts.push(`+${additions}`);
278 }
279 if (deletions > 0) {
280 parts.push(`-${deletions}`);
281 }
282
283 return `(${parts.join(", ")})`;
284 }
285
286 private getPathInfo(file: GitDiffFile): string {
287 if (file.old_path && file.old_path !== "") {
288 // For renames, show old_path → new_path
289 return `${file.old_path} → ${file.path}`;
290 }
291 // For regular files, just show the path
292 return file.path;
293 }
294
295 private toggleFileExpansion(filePath: string) {
296 const currentState = this.fileExpandStates.get(filePath) ?? false;
297 const newState = !currentState;
298 this.fileExpandStates.set(filePath, newState);
299
300 // Apply to the specific Monaco view component for this file
301 const monacoView = this.shadowRoot?.querySelector(
302 `sketch-monaco-view[data-file-path="${filePath}"]`,
303 );
304 if (monacoView) {
305 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
306 }
307
308 // Force a re-render to update the button state
309 this.requestUpdate();
310 }
311
312 private renderExpandAllIcon() {
313 return html`
314 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
315 <!-- Dotted line in the middle -->
316 <line
317 x1="2"
318 y1="8"
319 x2="14"
320 y2="8"
321 stroke="currentColor"
322 stroke-width="1"
323 stroke-dasharray="2,1"
324 />
325 <!-- Large arrow pointing up -->
326 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
327 <!-- Large arrow pointing down -->
328 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
329 </svg>
330 `;
331 }
332
333 private renderCollapseIcon() {
334 return html`
335 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
336 <!-- Dotted line in the middle -->
337 <line
338 x1="2"
339 y1="8"
340 x2="14"
341 y2="8"
342 stroke="currentColor"
343 stroke-width="1"
344 stroke-dasharray="2,1"
345 />
346 <!-- Large arrow pointing down towards line -->
347 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
348 <!-- Large arrow pointing up towards line -->
349 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
350 </svg>
351 `;
352 }
353
354 private renderFileDiff(file: GitDiffFile) {
355 const content = this.fileContents.get(file.path);
356 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatterf964b502025-06-17 04:30:35 +0000357
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000358 if (!content) {
359 return html`
360 <div class="file-diff">
361 <div class="file-header">
362 <div class="file-header-left">
363 <span class="file-status ${this.getFileStatusClass(file.status)}">
364 ${this.getFileStatusText(file.status)}
365 </span>
366 ${this.getPathInfo(file)}
Autoformatterf964b502025-06-17 04:30:35 +0000367 ${this.getChangesInfo(file)
368 ? html`<span class="file-changes"
369 >${this.getChangesInfo(file)}</span
370 >`
371 : ""}
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000372 </div>
373 <button class="file-expand-button" disabled>
374 ${this.renderExpandAllIcon()}
375 </button>
376 </div>
377 <div class="monaco-container">
378 <div class="loading">Loading ${file.path}...</div>
379 </div>
380 </div>
381 `;
382 }
383
384 return html`
385 <div class="file-diff">
386 <div class="file-header">
387 <div class="file-header-left">
388 <span class="file-status ${this.getFileStatusClass(file.status)}">
389 ${this.getFileStatusText(file.status)}
390 </span>
391 ${this.getPathInfo(file)}
Autoformatterf964b502025-06-17 04:30:35 +0000392 ${this.getChangesInfo(file)
393 ? html`<span class="file-changes"
394 >${this.getChangesInfo(file)}</span
395 >`
396 : ""}
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000397 </div>
398 <button
399 class="file-expand-button"
400 @click="${() => this.toggleFileExpansion(file.path)}"
401 title="${isExpanded
402 ? "Collapse: Hide unchanged regions to focus on changes"
403 : "Expand: Show all lines including unchanged regions"}"
404 >
Autoformatterf964b502025-06-17 04:30:35 +0000405 ${isExpanded
406 ? this.renderCollapseIcon()
407 : this.renderExpandAllIcon()}
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000408 </button>
409 </div>
410 <div class="monaco-container">
411 <sketch-monaco-view
412 .originalCode="${content.original}"
413 .modifiedCode="${content.modified}"
414 .originalFilename="${file.path}"
415 .modifiedFilename="${file.path}"
416 ?readOnly="true"
417 ?inline="true"
418 data-file-path="${file.path}"
419 ></sketch-monaco-view>
420 </div>
421 </div>
422 `;
423 }
424
425 render() {
426 return html`
427 <div class="diff-container">
428 ${this.loading
429 ? html`<div class="loading">Loading diff...</div>`
Autoformatterf964b502025-06-17 04:30:35 +0000430 : this.error
431 ? html`<div class="error">${this.error}</div>`
432 : !this.files || this.files.length === 0
433 ? html`<div class="empty">No changes to show</div>`
434 : this.files.map((file) => this.renderFileDiff(file))}
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000435 </div>
436 `;
437 }
438}
439
440declare global {
441 interface HTMLElementTagNameMap {
442 "mobile-diff": MobileDiff;
443 }
Autoformatterf964b502025-06-17 04:30:35 +0000444}