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