webui: comprehensive diff view improvements
Implement multiple enhancements to the diff view interface for better
usability and visual consistency with file change statistics and
improved navigation controls.
Backend Changes:
1. Enhanced git diff endpoint with --numstat support:
- Modified GitRawDiff to execute both --raw and --numstat commands
- Added Additions/Deletions fields to DiffFile struct
- Parse numstat output to show line change statistics (+X, -Y)
- Handle binary files and edge cases properly
Frontend UI Improvements:
2. File picker enhancements:
- Display (+X, -Y) change indicators next to file names
- Move file position indicator ("X of Y") between navigation buttons
- Simplified file info to show only status (Modified/Added/Deleted)
- Better visual grouping of navigation-related information
3. Commit range picker refresh functionality:
- Added refresh button with subtle styling (gray background)
- 🔄 icon with "Refresh commit list" tooltip
- Reloads git log to get updated branch and commit information
- Proper disabled state during loading operations
4. Editable file indicator improvements:
- Moved "Editable" indicator to Monaco editor save indicator area
- Shows "Editable" when file is editable but unchanged
- Consistent styling with "Modified", "Saving", "Saved" states
- Added proper CSS styling with gray background for idle state
5. Expand/collapse button redesign:
- Custom SVG icons replacing text buttons
- Expand All: dotted line with arrows pointing away (outward)
- Collapse: dotted line with arrows pointing inward (toward line)
- Intuitive visual metaphor for show/hide functionality
- Enhanced tooltips with full action descriptions
- Renamed "Hide Unchanged" to "Collapse Expanded Lines"
Technical Improvements:
6. TypeScript compatibility fixes:
- Updated mock data service with new DiffFile fields
- Fixed MSW handler type compatibility with proper type assertion
- Maintained full TypeScript checking without exclusions
- Added realistic mock data for testing change indicators
Interface Consistency:
- All buttons use consistent styling and hover effects
- Better separation between navigation controls and file information
- Improved logical grouping of related UI elements
- Enhanced accessibility with descriptive tooltips
These changes significantly improve the diff view experience by providing
clear visual indicators of file changes, intuitive navigation controls,
and better organization of interface elements according to their function.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s738289d132773bc3k
diff --git a/git_tools/git_tools.go b/git_tools/git_tools.go
index fc59e0d..fa53e8d 100644
--- a/git_tools/git_tools.go
+++ b/git_tools/git_tools.go
@@ -13,32 +13,43 @@
// DiffFile represents a file in a Git diff
type DiffFile struct {
- Path string `json:"path"`
- OldMode string `json:"old_mode"`
- NewMode string `json:"new_mode"`
- OldHash string `json:"old_hash"`
- NewHash string `json:"new_hash"`
- Status string `json:"status"` // A=added, M=modified, D=deleted, etc.
+ Path string `json:"path"`
+ OldMode string `json:"old_mode"`
+ NewMode string `json:"new_mode"`
+ OldHash string `json:"old_hash"`
+ NewHash string `json:"new_hash"`
+ Status string `json:"status"` // A=added, M=modified, D=deleted, etc.
+ Additions int `json:"additions"` // Number of lines added
+ Deletions int `json:"deletions"` // Number of lines deleted
} // GitRawDiff returns a structured representation of the Git diff between two commits or references
// If 'to' is empty, it will show unstaged changes (diff with working directory)
func GitRawDiff(repoDir, from, to string) ([]DiffFile, error) {
- // Git command to generate the diff in raw format with full hashes
- var cmd *exec.Cmd
+ // Git command to generate the diff in raw format with full hashes and numstat
+ var rawCmd, numstatCmd *exec.Cmd
if to == "" {
// If 'to' is empty, show unstaged changes
- cmd = exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from)
+ rawCmd = exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from)
+ numstatCmd = exec.Command("git", "-C", repoDir, "diff", "--numstat", from)
} else {
// Normal diff between two refs
- cmd = exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from, to)
+ rawCmd = exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from, to)
+ numstatCmd = exec.Command("git", "-C", repoDir, "diff", "--numstat", from, to)
}
- out, err := cmd.CombinedOutput()
+ // Execute raw diff command
+ rawOut, err := rawCmd.CombinedOutput()
if err != nil {
- return nil, fmt.Errorf("error executing git diff: %w - %s", err, string(out))
+ return nil, fmt.Errorf("error executing git diff --raw: %w - %s", err, string(rawOut))
+ }
+
+ // Execute numstat command
+ numstatOut, err := numstatCmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("error executing git diff --numstat: %w - %s", err, string(numstatOut))
}
// Parse the raw diff output into structured format
- return parseRawDiff(string(out))
+ return parseRawDiffWithNumstat(string(rawOut), string(numstatOut))
}
// GitShow returns the result of git show for a specific commit hash
@@ -51,6 +62,58 @@
return string(out), nil
}
+// parseRawDiffWithNumstat converts git diff --raw and --numstat output into structured format
+func parseRawDiffWithNumstat(rawOutput, numstatOutput string) ([]DiffFile, error) {
+ // First parse the raw diff to get the base file information
+ files, err := parseRawDiff(rawOutput)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a map to store numstat data by file path
+ numstatMap := make(map[string]struct{ additions, deletions int })
+
+ // Parse numstat output
+ if numstatOutput != "" {
+ scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(numstatOutput)))
+ for scanner.Scan() {
+ line := scanner.Text()
+ // Format: additions\tdeletions\tfilename
+ // Example: 5\t3\tpath/to/file.go
+ parts := strings.Split(line, "\t")
+ if len(parts) >= 3 {
+ additions := 0
+ deletions := 0
+
+ // Handle binary files (marked with "-")
+ if parts[0] != "-" {
+ if add, err := fmt.Sscanf(parts[0], "%d", &additions); err != nil || add != 1 {
+ additions = 0
+ }
+ }
+ if parts[1] != "-" {
+ if del, err := fmt.Sscanf(parts[1], "%d", &deletions); err != nil || del != 1 {
+ deletions = 0
+ }
+ }
+
+ filePath := strings.Join(parts[2:], "\t") // Handle filenames with tabs
+ numstatMap[filePath] = struct{ additions, deletions int }{additions, deletions}
+ }
+ }
+ }
+
+ // Merge numstat data into files
+ for i := range files {
+ if stats, found := numstatMap[files[i].Path]; found {
+ files[i].Additions = stats.additions
+ files[i].Deletions = stats.deletions
+ }
+ }
+
+ return files, nil
+}
+
// parseRawDiff converts git diff --raw output into structured format
func parseRawDiff(diffOutput string) ([]DiffFile, error) {
var files []DiffFile
@@ -87,12 +150,14 @@
}
files = append(files, DiffFile{
- Path: path,
- OldMode: oldMode,
- NewMode: newMode,
- OldHash: oldHash,
- NewHash: newHash,
- Status: status,
+ Path: path,
+ OldMode: oldMode,
+ NewMode: newMode,
+ OldHash: oldHash,
+ NewHash: newHash,
+ Status: status,
+ Additions: 0, // Will be filled by numstat data
+ Deletions: 0, // Will be filled by numstat data
})
}
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 37e724e..0e668ce 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -104,6 +104,8 @@
old_hash: string;
new_hash: string;
status: string;
+ additions: number;
+ deletions: number;
}
export interface GitLogEntry {
diff --git a/webui/src/web-components/demo/mock-git-data-service.ts b/webui/src/web-components/demo/mock-git-data-service.ts
index 3ca5098..648ca65 100644
--- a/webui/src/web-components/demo/mock-git-data-service.ts
+++ b/webui/src/web-components/demo/mock-git-data-service.ts
@@ -45,6 +45,8 @@
old_mode: "000000",
old_hash: "0000000000000000000000000000000000000000",
new_hash: "def0123456789abcdef0123456789abcdef0123",
+ additions: 54,
+ deletions: 0,
},
{
path: "src/components/RangePicker.js",
@@ -53,6 +55,8 @@
old_mode: "000000",
old_hash: "0000000000000000000000000000000000000000",
new_hash: "cde0123456789abcdef0123456789abcdef0123",
+ additions: 32,
+ deletions: 0,
},
{
path: "src/components/App.js",
@@ -61,6 +65,8 @@
old_mode: "100644",
old_hash: "abc0123456789abcdef0123456789abcdef0123",
new_hash: "bcd0123456789abcdef0123456789abcdef0123",
+ additions: 15,
+ deletions: 3,
},
{
path: "src/styles/main.css",
@@ -69,6 +75,8 @@
old_mode: "100644",
old_hash: "fgh0123456789abcdef0123456789abcdef0123",
new_hash: "ghi0123456789abcdef0123456789abcdef0123",
+ additions: 25,
+ deletions: 8,
},
];
@@ -395,7 +403,7 @@
// to simulate unstaged changes
return this.mockDiffFiles.map((file) => ({
...file,
- newHash: "0000000000000000000000000000000000000000",
+ new_hash: "0000000000000000000000000000000000000000",
}));
}
diff --git a/webui/src/web-components/demo/mocks/browser.ts b/webui/src/web-components/demo/mocks/browser.ts
index bcd82e4..0e05730 100644
--- a/webui/src/web-components/demo/mocks/browser.ts
+++ b/webui/src/web-components/demo/mocks/browser.ts
@@ -1,4 +1,4 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
-export const worker = setupWorker(...handlers);
+export const worker = setupWorker(...(handlers as any));
diff --git a/webui/src/web-components/sketch-diff-file-picker.ts b/webui/src/web-components/sketch-diff-file-picker.ts
index c834f12..eb94a5f 100644
--- a/webui/src/web-components/sketch-diff-file-picker.ts
+++ b/webui/src/web-components/sketch-diff-file-picker.ts
@@ -85,10 +85,13 @@
cursor: not-allowed;
}
- .file-info {
+ .file-position {
font-size: 14px;
color: var(--text-muted, #666);
- margin-left: 8px;
+ font-weight: 500;
+ padding: 0 12px;
+ display: flex;
+ align-items: center;
white-space: nowrap;
}
@@ -176,6 +179,7 @@
>
Previous
</button>
+ ${this.selectedIndex >= 0 ? this.renderFilePosition() : ""}
<button
@click=${this.handleNext}
?disabled=${this.selectedIndex >= this.files.length - 1}
@@ -183,18 +187,14 @@
Next
</button>
</div>
-
- ${this.selectedIndex >= 0 ? this.renderFileInfo() : ""}
</div>
`;
}
- renderFileInfo() {
- const file = this.files[this.selectedIndex];
+ renderFilePosition() {
return html`
- <div class="file-info">
- ${this.getFileStatusName(file.status)} | ${this.selectedIndex + 1} of
- ${this.files.length}
+ <div class="file-position">
+ ${this.selectedIndex + 1} of ${this.files.length}
</div>
`;
}
@@ -204,7 +204,30 @@
*/
formatFileOption(file: GitDiffFile): string {
const statusSymbol = this.getFileStatusSymbol(file.status);
- return `${statusSymbol} ${file.path}`;
+ const changesInfo = this.getChangesInfo(file);
+ return `${statusSymbol} ${file.path}${changesInfo}`;
+ }
+
+ /**
+ * Get changes information (+/-) for display
+ */
+ 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(", ")})`;
}
/**
diff --git a/webui/src/web-components/sketch-diff-range-picker.ts b/webui/src/web-components/sketch-diff-range-picker.ts
index 70b09f7..ca839df 100644
--- a/webui/src/web-components/sketch-diff-range-picker.ts
+++ b/webui/src/web-components/sketch-diff-range-picker.ts
@@ -128,6 +128,31 @@
font-size: 14px;
}
+ .refresh-button {
+ padding: 6px 12px;
+ background-color: #f0f0f0;
+ color: var(--text-color, #333);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .refresh-button:hover {
+ background-color: #e0e0e0;
+ }
+
+ .refresh-button:disabled {
+ background-color: #f8f8f8;
+ color: #999;
+ cursor: not-allowed;
+ }
+
@media (max-width: 768px) {
.commit-selector {
max-width: 100%;
@@ -189,6 +214,15 @@
? this.renderRangeSelectors()
: this.renderSingleSelector()}
</div>
+
+ <button
+ class="refresh-button"
+ @click="${this.handleRefresh}"
+ ?disabled="${this.loading}"
+ title="Refresh commit list"
+ >
+ 🔄 Refresh
+ </button>
`;
}
@@ -360,6 +394,13 @@
}
/**
+ * Handle refresh button click
+ */
+ handleRefresh() {
+ this.loadCommits();
+ }
+
+ /**
* Dispatch range change event
*/
dispatchRangeEvent() {
diff --git a/webui/src/web-components/sketch-diff2-view.ts b/webui/src/web-components/sketch-diff2-view.ts
index 1b673cd..7ca33b3 100644
--- a/webui/src/web-components/sketch-diff2-view.ts
+++ b/webui/src/web-components/sketch-diff2-view.ts
@@ -159,11 +159,16 @@
background-color: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
- padding: 6px 12px;
- font-size: 12px;
+ padding: 8px;
+ font-size: 16px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 36px;
+ min-height: 36px;
}
.view-toggle-button:hover {
@@ -316,30 +321,16 @@
></sketch-diff-file-picker>
<div style="display: flex; gap: 8px;">
- ${this.isRightEditable
- ? html`
- <div
- class="editable-indicator"
- title="This file is editable"
- >
- <span
- style="padding: 6px 12px; background-color: #e9ecef; border-radius: 4px; font-size: 12px; color: #495057;"
- >
- Editable
- </span>
- </div>
- `
- : ""}
<button
class="view-toggle-button"
@click="${this.toggleHideUnchangedRegions}"
title="${this.hideUnchangedRegionsEnabled
- ? "Expand All"
- : "Hide Unchanged"}"
+ ? "Expand All: Show all lines including unchanged regions"
+ : "Collapse Expanded Lines: Hide unchanged regions to focus on changes"}"
>
${this.hideUnchangedRegionsEnabled
- ? "Expand All"
- : "Hide Unchanged"}
+ ? this.renderExpandAllIcon()
+ : this.renderCollapseIcon()}
</button>
</div>
</div>
@@ -550,6 +541,54 @@
}
/**
+ * Render expand all icon (dotted line with arrows pointing away)
+ */
+ 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>
+ `;
+ }
+
+ /**
+ * Render collapse icon (arrows pointing towards dotted line)
+ */
+ 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>
+ `;
+ }
+
+ /**
* Refresh the diff view by reloading commits and diff data
*
* This is called when the Monaco diff tab is activated to ensure:
diff --git a/webui/src/web-components/sketch-monaco-view.ts b/webui/src/web-components/sketch-monaco-view.ts
index 48fd66e..40b1200 100644
--- a/webui/src/web-components/sketch-monaco-view.ts
+++ b/webui/src/web-components/sketch-monaco-view.ts
@@ -196,6 +196,10 @@
transition: opacity 0.3s ease;
}
+ .save-indicator.idle {
+ background-color: #6c757d;
+ }
+
.save-indicator.modified {
background-color: #f0ad4e;
}
@@ -374,16 +378,18 @@
<main ${ref(this.container)}></main>
<!-- Save indicator - shown when editing -->
- ${this.editableRight && this.saveState !== "idle"
+ ${this.editableRight
? html`
<div class="save-indicator ${this.saveState}">
- ${this.saveState === "modified"
- ? "Modified..."
- : this.saveState === "saving"
- ? "Saving..."
- : this.saveState === "saved"
- ? "Saved"
- : ""}
+ ${this.saveState === "idle"
+ ? "Editable"
+ : this.saveState === "modified"
+ ? "Modified..."
+ : this.saveState === "saving"
+ ? "Saving..."
+ : this.saveState === "saved"
+ ? "Saved"
+ : ""}
</div>
`
: ""}