Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/mobileOverlayViews.css';
7
import * as DOM from '../../../../../base/browser/dom.js';
8
import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
9
import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js';
10
import { Codicon } from '../../../../../base/common/codicons.js';
11
import { ThemeIcon } from '../../../../../base/common/themables.js';
12
import { localize } from '../../../../../nls.js';
13
import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js';
14
import { URI } from '../../../../../base/common/uri.js';
15
import { basename } from '../../../../../base/common/resources.js';
16
import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js';
17
18
const $ = DOM.$;
19
20
/**
21
* Command ID for opening the {@link MobileDiffView}.
22
*
23
* Accepts {@link IFileDiffViewData} as the single argument. Phone-only.
24
*/
25
export const MOBILE_OPEN_DIFF_VIEW_COMMAND_ID = 'sessions.mobile.openDiffView';
26
27
/**
28
* Minimal subset of diff entry fields consumed by the mobile diff view.
29
* Defined locally to avoid importing from vs/workbench/contrib in vs/sessions/browser.
30
*/
31
export interface IFileDiffViewData {
32
/**
33
* URI of the file before the change. `undefined` when the file is
34
* newly added by the agent and there is no prior content; the diff
35
* is rendered against an empty original (all lines as additions).
36
*/
37
readonly originalURI: URI | undefined;
38
readonly modifiedURI: URI;
39
readonly identical: boolean;
40
readonly added: number;
41
readonly removed: number;
42
}
43
44
/**
45
* Data passed to {@link MobileDiffView} when opening a diff view.
46
*/
47
export interface IMobileDiffViewData {
48
readonly diff: IFileDiffViewData;
49
}
50
51
/**
52
* Full-screen overlay for viewing file changes produced by a coding agent
53
* session on phone viewports.
54
*
55
* Renders a unified diff with coloured +/- gutters and line numbers. Text is
56
* read from the file service via the modified/original URIs stored in
57
* {@link IFileDiffViewData}. This keeps the view lightweight — it avoids
58
* embedding a full Monaco diff editor while still giving users a readable
59
* view of what changed.
60
*
61
* Follows the account-sheet overlay pattern: appends to the workbench
62
* container, disposes on back-button tap.
63
*/
64
export class MobileDiffView extends Disposable {
65
66
private readonly viewStore = this._register(new DisposableStore());
67
private disposed = false;
68
69
constructor(
70
workbenchContainer: HTMLElement,
71
data: IMobileDiffViewData,
72
private readonly textFileService: ITextFileService,
73
) {
74
super();
75
this.render(workbenchContainer, data);
76
}
77
78
private render(workbenchContainer: HTMLElement, data: IMobileDiffViewData): void {
79
const { diff } = data;
80
const fileName = basename(diff.modifiedURI);
81
82
// -- Root overlay -----------------------------------------
83
const overlay = DOM.append(workbenchContainer, $('div.mobile-overlay-view'));
84
this.viewStore.add(DOM.addDisposableListener(overlay, DOM.EventType.CONTEXT_MENU, e => e.preventDefault()));
85
this.viewStore.add(toDisposable(() => overlay.remove()));
86
87
// -- Header -----------------------------------------------
88
const header = DOM.append(overlay, $('div.mobile-overlay-header'));
89
90
const backBtn = DOM.append(header, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement;
91
backBtn.setAttribute('aria-label', localize('diffView.back', "Back"));
92
DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft));
93
DOM.append(backBtn, $('span.back-btn-label')).textContent = localize('diffView.backLabel', "Back");
94
this.viewStore.add(Gesture.addTarget(backBtn));
95
this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose()));
96
this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose()));
97
98
const info = DOM.append(header, $('div.mobile-overlay-header-info'));
99
DOM.append(info, $('div.mobile-overlay-header-title')).textContent = fileName;
100
101
if (!diff.identical) {
102
const sub = DOM.append(info, $('div.mobile-overlay-header-subtitle'));
103
const parts: string[] = [];
104
if (diff.added) {
105
parts.push(`+${diff.added}`);
106
}
107
if (diff.removed) {
108
parts.push(`-${diff.removed}`);
109
}
110
sub.textContent = parts.join(' ');
111
}
112
113
// -- Body -------------------------------------------------
114
const body = DOM.append(overlay, $('div.mobile-overlay-body'));
115
const scrollWrapper = DOM.append(body, $('div.mobile-overlay-scroll'));
116
const contentArea = DOM.append(scrollWrapper, $('div.mobile-diff-output'));
117
118
this.loadDiffContent(contentArea, diff);
119
}
120
121
private loadDiffContent(container: HTMLElement, diff: IFileDiffViewData): void {
122
if (diff.identical) {
123
const empty = DOM.append(container, $('div.mobile-diff-empty-state'));
124
empty.textContent = localize('diffView.noChanges', "No changes in this file.");
125
return;
126
}
127
128
const loadingEl = DOM.append(container, $('div.mobile-diff-empty-state'));
129
loadingEl.textContent = localize('diffView.loading', "Loading…");
130
131
Promise.all([
132
diff.originalURI
133
? this.textFileService.read(diff.originalURI, { acceptTextOnly: true }).then(m => m.value).catch(() => '')
134
: Promise.resolve(''),
135
this.textFileService.read(diff.modifiedURI, { acceptTextOnly: true }).then(m => m.value).catch(() => ''),
136
]).then(([originalText, modifiedText]) => {
137
if (this.disposed) {
138
return;
139
}
140
DOM.clearNode(container);
141
const hunks = computeUnifiedDiff(originalText, modifiedText);
142
if (hunks.length === 0) {
143
const empty = DOM.append(container, $('div.mobile-diff-empty-state'));
144
empty.textContent = localize('diffView.noChanges', "No changes in this file.");
145
return;
146
}
147
this.renderHunks(container, hunks);
148
});
149
}
150
151
private renderHunks(container: HTMLElement, hunks: IDiffHunk[]): void {
152
for (const hunk of hunks) {
153
// Hunk header
154
const headerEl = DOM.append(container, $('span.mobile-diff-hunk-header'));
155
headerEl.textContent = hunk.header;
156
157
// Lines
158
for (const line of hunk.lines) {
159
const row = DOM.append(container, $('div.mobile-diff-line'));
160
row.classList.add(line.type);
161
162
const numEl = DOM.append(row, $('span.mobile-diff-line-num'));
163
numEl.textContent = line.lineNum !== undefined ? String(line.lineNum) : '';
164
165
const gutter = DOM.append(row, $('span.mobile-diff-gutter'));
166
gutter.textContent = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' ';
167
168
const content = DOM.append(row, $('span.mobile-diff-content'));
169
content.textContent = line.text;
170
}
171
}
172
}
173
174
override dispose(): void {
175
this.disposed = true;
176
this.viewStore.dispose();
177
super.dispose();
178
}
179
}
180
181
// -- Unified diff hunk rendering ---------------------------------------------
182
// Uses the workbench's `linesDiffComputers` so we get the same diff quality as
183
// the diff editor — no in-tree diff algorithm to maintain.
184
185
interface IDiffLine {
186
type: 'context' | 'added' | 'removed';
187
lineNum?: number;
188
text: string;
189
}
190
191
interface IDiffHunk {
192
header: string;
193
lines: IDiffLine[];
194
}
195
196
const CONTEXT_LINES = 3;
197
198
function computeUnifiedDiff(original: string, modified: string): IDiffHunk[] {
199
const origLines = original.split(/\r?\n/);
200
const modLines = modified.split(/\r?\n/);
201
202
const result = linesDiffComputers.getDefault().computeDiff(origLines, modLines, {
203
ignoreTrimWhitespace: false,
204
maxComputationTimeMs: 1000,
205
computeMoves: false,
206
});
207
208
if (result.changes.length === 0) {
209
return [];
210
}
211
212
// Merge changes that are within 2*CONTEXT_LINES of each other into a
213
// single hunk so consecutive edits aren't visually fragmented.
214
type Group = { origStart: number; origEnd: number; modStart: number; modEnd: number };
215
const groups: Group[] = [];
216
for (const change of result.changes) {
217
const g: Group = {
218
origStart: change.original.startLineNumber,
219
origEnd: change.original.endLineNumberExclusive,
220
modStart: change.modified.startLineNumber,
221
modEnd: change.modified.endLineNumberExclusive,
222
};
223
const last = groups[groups.length - 1];
224
if (last && g.origStart - last.origEnd <= CONTEXT_LINES * 2) {
225
last.origEnd = g.origEnd;
226
last.modEnd = g.modEnd;
227
} else {
228
groups.push(g);
229
}
230
}
231
232
const hunks: IDiffHunk[] = [];
233
for (const group of groups) {
234
const origLeading = Math.max(1, group.origStart - CONTEXT_LINES);
235
const modLeading = Math.max(1, group.modStart - CONTEXT_LINES);
236
const origTrailing = Math.min(origLines.length + 1, group.origEnd + CONTEXT_LINES);
237
const modTrailing = Math.min(modLines.length + 1, group.modEnd + CONTEXT_LINES);
238
239
const lines: IDiffLine[] = [];
240
241
// Leading context (taken from original — same as modified in unchanged regions).
242
for (let i = origLeading; i < group.origStart; i++) {
243
lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' });
244
}
245
246
// Removed lines (from original).
247
for (let i = group.origStart; i < group.origEnd; i++) {
248
lines.push({ type: 'removed', lineNum: i, text: origLines[i - 1] ?? '' });
249
}
250
251
// Added lines (from modified).
252
for (let i = group.modStart; i < group.modEnd; i++) {
253
lines.push({ type: 'added', lineNum: i, text: modLines[i - 1] ?? '' });
254
}
255
256
// Trailing context.
257
for (let i = group.origEnd; i < origTrailing; i++) {
258
lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' });
259
}
260
261
const origCount = origTrailing - origLeading;
262
const modCount = modTrailing - modLeading;
263
hunks.push({
264
header: `@@ -${origLeading},${origCount} +${modLeading},${modCount} @@`,
265
lines,
266
});
267
}
268
269
return hunks;
270
}
271
272
/**
273
* Opens a {@link MobileDiffView} for the given file diff.
274
* Returns the view instance; dispose it to close.
275
*/
276
export function openMobileDiffView(
277
workbenchContainer: HTMLElement,
278
data: IMobileDiffViewData,
279
textFileService: ITextFileService,
280
): MobileDiffView {
281
return new MobileDiffView(workbenchContainer, data, textFileService);
282
}
283
284