webui: add mobile diff view with Monaco inline diffing
Thanks, Sketch. There are still some rough edges, but it's not bad.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: se4f6567dc0dabd31k
diff --git a/webui/src/web-components/mobile-diff.ts b/webui/src/web-components/mobile-diff.ts
new file mode 100644
index 0000000..92d0f95
--- /dev/null
+++ b/webui/src/web-components/mobile-diff.ts
@@ -0,0 +1,423 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/webui/src/web-components/mobile-shell.ts b/webui/src/web-components/mobile-shell.ts
index ea9c305..65b698b 100644
--- a/webui/src/web-components/mobile-shell.ts
+++ b/webui/src/web-components/mobile-shell.ts
@@ -7,6 +7,7 @@
import "./mobile-title";
import "./mobile-chat";
import "./mobile-chat-input";
+import "./mobile-diff";
@customElement("mobile-shell")
export class MobileShell extends LitElement {
@@ -21,6 +22,9 @@
@state()
connectionStatus: ConnectionStatus = "disconnected";
+ @state()
+ currentView: "chat" | "diff" = "chat";
+
static styles = css`
:host {
display: flex;
@@ -54,10 +58,19 @@
mobile-chat {
flex: 1;
overflow: hidden;
+ min-height: 0;
+ }
+
+ mobile-diff {
+ flex: 1;
+ overflow: hidden;
+ min-height: 0;
}
mobile-chat-input {
flex-shrink: 0;
+ /* Ensure proper height calculation */
+ min-height: 64px;
}
`;
@@ -141,6 +154,10 @@
}
};
+ private handleViewChange = (event: CustomEvent<{ view: "chat" | "diff" }>) => {
+ this.currentView = event.detail.view;
+ };
+
render() {
const isThinking =
this.state?.outstanding_llm_calls > 0 ||
@@ -152,12 +169,21 @@
.connectionStatus=${this.connectionStatus}
.isThinking=${isThinking}
.skabandAddr=${this.state?.skaband_addr}
+ .currentView=${this.currentView}
+ .slug=${this.state?.slug || ""}
+ @view-change=${this.handleViewChange}
></mobile-title>
- <mobile-chat
- .messages=${this.messages}
- .isThinking=${isThinking}
- ></mobile-chat>
+ ${this.currentView === "chat"
+ ? html`
+ <mobile-chat
+ .messages=${this.messages}
+ .isThinking=${isThinking}
+ ></mobile-chat>
+ `
+ : html`
+ <mobile-diff></mobile-diff>
+ `}
<mobile-chat-input
.disabled=${this.connectionStatus !== "connected"}
diff --git a/webui/src/web-components/mobile-title.ts b/webui/src/web-components/mobile-title.ts
index d000a3f..510f60a 100644
--- a/webui/src/web-components/mobile-title.ts
+++ b/webui/src/web-components/mobile-title.ts
@@ -13,6 +13,12 @@
@property({ type: String })
skabandAddr?: string;
+ @property({ type: String })
+ currentView: "chat" | "diff" = "chat";
+
+ @property({ type: String })
+ slug: string = "";
+
static styles = css`
:host {
display: block;
@@ -23,10 +29,45 @@
.title-container {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
}
+ .title-section {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .right-section {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .view-selector {
+ background: none;
+ border: 1px solid #e9ecef;
+ border-radius: 6px;
+ padding: 6px 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ color: #495057;
+ min-width: 60px;
+ }
+
+ .view-selector:hover {
+ background-color: #f8f9fa;
+ border-color: #dee2e6;
+ }
+
+ .view-selector:focus {
+ outline: none;
+ border-color: #007acc;
+ box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
+ }
+
.title {
font-size: 18px;
font-weight: 600;
@@ -34,6 +75,8 @@
margin: 0;
}
+
+
.title a {
color: inherit;
text-decoration: none;
@@ -147,23 +190,47 @@
}
}
+ private handleViewChange(event: Event) {
+ const select = event.target as HTMLSelectElement;
+ const view = select.value as "chat" | "diff";
+ if (view !== this.currentView) {
+ const changeEvent = new CustomEvent("view-change", {
+ detail: { view },
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(changeEvent);
+ }
+ }
+
render() {
return html`
<div class="title-container">
- <h1 class="title">
- ${this.skabandAddr
- ? html`<a
- href="${this.skabandAddr}"
- target="_blank"
- rel="noopener noreferrer"
- >
- <img src="${this.skabandAddr}/sketch.dev.png" alt="sketch" />
- Sketch
- </a>`
- : html`Sketch`}
- </h1>
+ <div class="title-section">
+ <h1 class="title">
+ ${this.skabandAddr
+ ? html`<a
+ href="${this.skabandAddr}"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <img src="${this.skabandAddr}/sketch.dev.png" alt="sketch" />
+ ${this.slug || "Sketch"}
+ </a>`
+ : html`${this.slug || "Sketch"}`}
+ </h1>
+ </div>
- <div class="status-indicator">
+ <div class="right-section">
+ <select
+ class="view-selector"
+ .value=${this.currentView}
+ @change=${this.handleViewChange}
+ >
+ <option value="chat">Chat</option>
+ <option value="diff">Diff</option>
+ </select>
+
${this.isThinking
? html`
<div class="thinking-indicator">
@@ -177,7 +244,6 @@
`
: html`
<span class="status-dot ${this.connectionStatus}"></span>
- <span>${this.getStatusText()}</span>
`}
</div>
</div>
diff --git a/webui/src/web-components/sketch-monaco-view.ts b/webui/src/web-components/sketch-monaco-view.ts
index e15f3c9..6e783fa 100644
--- a/webui/src/web-components/sketch-monaco-view.ts
+++ b/webui/src/web-components/sketch-monaco-view.ts
@@ -135,6 +135,10 @@
// Editable state
@property({ type: Boolean, attribute: "editable-right" })
editableRight?: boolean;
+
+ // Inline diff mode (for mobile)
+ @property({ type: Boolean, attribute: "inline" })
+ inline?: boolean;
private container: Ref<HTMLElement> = createRef();
editor?: monaco.editor.IStandaloneDiffEditor;
@@ -704,7 +708,7 @@
automaticLayout: false, // We'll resize manually
readOnly: true,
theme: "vs", // Always use light mode
- renderSideBySide: true,
+ renderSideBySide: !this.inline,
ignoreTrimWhitespace: false,
renderOverviewRuler: false, // Disable the overview ruler
scrollbar: {