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