blob: 7511602cb0949acafdae53bd5e6dfdf7506741d1 [file] [log] [blame]
/* eslint-disable @typescript-eslint/no-explicit-any */
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
GitDiffFile,
GitDataService,
DefaultGitDataService,
} from "./git-data-service";
import "./sketch-monaco-view";
@customElement("mobile-diff")
export class MobileDiff extends LitElement {
private gitService: GitDataService = new DefaultGitDataService();
@state()
private files: GitDiffFile[] = [];
@state()
private fileContents: Map<string, { original: string; modified: string }> =
new Map();
@state()
private loading: boolean = false;
@state()
private error: string | null = null;
@state()
private baseCommit: string = "";
@state()
private fileExpandStates: Map<string, boolean> = new Map();
static styles = css`
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background-color: #ffffff;
}
.diff-container {
flex: 1;
overflow: auto;
min-height: 0;
/* Ensure proper scrolling behavior */
-webkit-overflow-scrolling: touch;
}
.loading,
.error,
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 16px;
color: #6c757d;
text-align: center;
padding: 20px;
}
.error {
color: #dc3545;
}
.file-diff {
margin-bottom: 16px;
}
.file-diff:last-child {
margin-bottom: 0;
}
.file-header {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-bottom: none;
padding: 12px 16px;
font-family: monospace;
font-size: 14px;
font-weight: 500;
color: #495057;
position: sticky;
top: 0;
z-index: 10;
}
.file-status {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
margin-right: 8px;
font-family: sans-serif;
}
.file-status.added {
background-color: #d4edda;
color: #155724;
}
.file-status.modified {
background-color: #fff3cd;
color: #856404;
}
.file-status.deleted {
background-color: #f8d7da;
color: #721c24;
}
.file-status.renamed {
background-color: #d1ecf1;
color: #0c5460;
}
.file-changes {
margin-left: 8px;
font-size: 12px;
color: #6c757d;
}
.monaco-container {
border: 1px solid #e9ecef;
border-top: none;
min-height: 200px;
/* Prevent artifacts */
overflow: hidden;
background-color: #ffffff;
}
sketch-monaco-view {
width: 100%;
min-height: 200px;
}
`;
connectedCallback() {
super.connectedCallback();
this.loadDiffData();
}
private async loadDiffData() {
this.loading = true;
this.error = null;
this.files = [];
this.fileContents.clear();
try {
// Get base commit reference
this.baseCommit = await this.gitService.getBaseCommitRef();
// Get diff from base commit to untracked changes (empty string for working directory)
this.files = await this.gitService.getDiff(this.baseCommit, "");
// Ensure files is always an array
if (!this.files) {
this.files = [];
}
if (this.files.length > 0) {
await this.loadAllFileContents();
}
} catch (error) {
console.error("Error loading diff data:", error);
this.error = `Error loading diff: ${error instanceof Error ? error.message : String(error)}`;
// Ensure files is always an array even on error
this.files = [];
} finally {
this.loading = false;
}
}
private async loadAllFileContents() {
try {
const promises = this.files.map(async (file) => {
try {
let originalCode = "";
let modifiedCode = "";
// Load original content (from the base commit)
if (file.status !== "A") {
// For modified, renamed, or deleted files: load original content
originalCode = await this.gitService.getFileContent(
file.old_hash || "",
);
}
// Load modified content (from working directory)
if (file.status === "D") {
// Deleted file: empty modified content
modifiedCode = "";
} else {
// Added/modified/renamed: use working copy content
try {
modifiedCode = await this.gitService.getWorkingCopyContent(
file.path,
);
} catch (error) {
console.warn(
`Could not get working copy for ${file.path}:`,
error,
);
modifiedCode = "";
}
}
this.fileContents.set(file.path, {
original: originalCode,
modified: modifiedCode,
});
} catch (error) {
console.error(`Error loading content for file ${file.path}:`, error);
// Store empty content for failed files
this.fileContents.set(file.path, {
original: "",
modified: "",
});
}
});
await Promise.all(promises);
} catch (error) {
console.error("Error loading file contents:", error);
throw error;
}
}
private getFileStatusClass(status: string): string {
switch (status.toUpperCase()) {
case "A":
return "added";
case "M":
return "modified";
case "D":
return "deleted";
case "R":
default:
if (status.toUpperCase().startsWith("R")) {
return "renamed";
}
return "modified";
}
}
private getFileStatusText(status: string): string {
switch (status.toUpperCase()) {
case "A":
return "Added";
case "M":
return "Modified";
case "D":
return "Deleted";
case "R":
default:
if (status.toUpperCase().startsWith("R")) {
return "Renamed";
}
return "Modified";
}
}
private getChangesInfo(file: GitDiffFile): string {
const additions = file.additions || 0;
const deletions = file.deletions || 0;
if (additions === 0 && deletions === 0) {
return "";
}
const parts = [];
if (additions > 0) {
parts.push(`+${additions}`);
}
if (deletions > 0) {
parts.push(`-${deletions}`);
}
return `(${parts.join(", ")})`;
}
private getPathInfo(file: GitDiffFile): string {
if (file.old_path && file.old_path !== "") {
// For renames, show old_path → new_path
return `${file.old_path} → ${file.path}`;
}
// For regular files, just show the path
return file.path;
}
private toggleFileExpansion(filePath: string) {
const currentState = this.fileExpandStates.get(filePath) ?? false;
const newState = !currentState;
this.fileExpandStates.set(filePath, newState);
// Apply to the specific Monaco view component for this file
const monacoView = this.shadowRoot?.querySelector(
`sketch-monaco-view[data-file-path="${filePath}"]`,
);
if (monacoView) {
(monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
}
// Force a re-render to update the button state
this.requestUpdate();
}
private renderExpandAllIcon() {
return html`
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<!-- Dotted line in the middle -->
<line
x1="2"
y1="8"
x2="14"
y2="8"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="2,1"
/>
<!-- Large arrow pointing up -->
<path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
<!-- Large arrow pointing down -->
<path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
</svg>
`;
}
private renderCollapseIcon() {
return html`
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<!-- Dotted line in the middle -->
<line
x1="2"
y1="8"
x2="14"
y2="8"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="2,1"
/>
<!-- Large arrow pointing down towards line -->
<path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
<!-- Large arrow pointing up towards line -->
<path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
</svg>
`;
}
private renderFileDiff(file: GitDiffFile) {
const content = this.fileContents.get(file.path);
const isExpanded = this.fileExpandStates.get(file.path) ?? false;
if (!content) {
return html`
<div class="file-diff">
<div class="file-header">
<div class="file-header-left">
<span class="file-status ${this.getFileStatusClass(file.status)}">
${this.getFileStatusText(file.status)}
</span>
${this.getPathInfo(file)}
${this.getChangesInfo(file)
? html`<span class="file-changes"
>${this.getChangesInfo(file)}</span
>`
: ""}
</div>
<button class="file-expand-button" disabled>
${this.renderExpandAllIcon()}
</button>
</div>
<div class="monaco-container">
<div class="loading">Loading ${file.path}...</div>
</div>
</div>
`;
}
return html`
<div class="file-diff">
<div class="file-header">
<div class="file-header-left">
<span class="file-status ${this.getFileStatusClass(file.status)}">
${this.getFileStatusText(file.status)}
</span>
${this.getPathInfo(file)}
${this.getChangesInfo(file)
? html`<span class="file-changes"
>${this.getChangesInfo(file)}</span
>`
: ""}
</div>
<button
class="file-expand-button"
@click="${() => this.toggleFileExpansion(file.path)}"
title="${isExpanded
? "Collapse: Hide unchanged regions to focus on changes"
: "Expand: Show all lines including unchanged regions"}"
>
${isExpanded
? this.renderCollapseIcon()
: this.renderExpandAllIcon()}
</button>
</div>
<div class="monaco-container">
<sketch-monaco-view
.originalCode="${content.original}"
.modifiedCode="${content.modified}"
.originalFilename="${file.path}"
.modifiedFilename="${file.path}"
?readOnly="true"
?inline="true"
data-file-path="${file.path}"
></sketch-monaco-view>
</div>
</div>
`;
}
render() {
return html`
<div class="diff-container">
${this.loading
? html`<div class="loading">Loading diff...</div>`
: this.error
? html`<div class="error">${this.error}</div>`
: !this.files || this.files.length === 0
? html`<div class="empty">No changes to show</div>`
: this.files.map((file) => this.renderFileDiff(file))}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"mobile-diff": MobileDiff;
}
}