Path: blob/main/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/mobileOverlayViews.css';6import * as DOM from '../../../../../base/browser/dom.js';7import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';8import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js';9import { Codicon } from '../../../../../base/common/codicons.js';10import { ThemeIcon } from '../../../../../base/common/themables.js';11import { localize } from '../../../../../nls.js';12import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js';13import { URI } from '../../../../../base/common/uri.js';14import { basename } from '../../../../../base/common/resources.js';15import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js';1617const $ = DOM.$;1819/**20* Command ID for opening the {@link MobileDiffView}.21*22* Accepts {@link IFileDiffViewData} as the single argument. Phone-only.23*/24export const MOBILE_OPEN_DIFF_VIEW_COMMAND_ID = 'sessions.mobile.openDiffView';2526/**27* Minimal subset of diff entry fields consumed by the mobile diff view.28* Defined locally to avoid importing from vs/workbench/contrib in vs/sessions/browser.29*/30export interface IFileDiffViewData {31/**32* URI of the file before the change. `undefined` when the file is33* newly added by the agent and there is no prior content; the diff34* is rendered against an empty original (all lines as additions).35*/36readonly originalURI: URI | undefined;37readonly modifiedURI: URI;38readonly identical: boolean;39readonly added: number;40readonly removed: number;41}4243/**44* Data passed to {@link MobileDiffView} when opening a diff view.45*/46export interface IMobileDiffViewData {47readonly diff: IFileDiffViewData;48}4950/**51* Full-screen overlay for viewing file changes produced by a coding agent52* session on phone viewports.53*54* Renders a unified diff with coloured +/- gutters and line numbers. Text is55* read from the file service via the modified/original URIs stored in56* {@link IFileDiffViewData}. This keeps the view lightweight — it avoids57* embedding a full Monaco diff editor while still giving users a readable58* view of what changed.59*60* Follows the account-sheet overlay pattern: appends to the workbench61* container, disposes on back-button tap.62*/63export class MobileDiffView extends Disposable {6465private readonly viewStore = this._register(new DisposableStore());66private disposed = false;6768constructor(69workbenchContainer: HTMLElement,70data: IMobileDiffViewData,71private readonly textFileService: ITextFileService,72) {73super();74this.render(workbenchContainer, data);75}7677private render(workbenchContainer: HTMLElement, data: IMobileDiffViewData): void {78const { diff } = data;79const fileName = basename(diff.modifiedURI);8081// -- Root overlay -----------------------------------------82const overlay = DOM.append(workbenchContainer, $('div.mobile-overlay-view'));83this.viewStore.add(DOM.addDisposableListener(overlay, DOM.EventType.CONTEXT_MENU, e => e.preventDefault()));84this.viewStore.add(toDisposable(() => overlay.remove()));8586// -- Header -----------------------------------------------87const header = DOM.append(overlay, $('div.mobile-overlay-header'));8889const backBtn = DOM.append(header, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement;90backBtn.setAttribute('aria-label', localize('diffView.back', "Back"));91DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft));92DOM.append(backBtn, $('span.back-btn-label')).textContent = localize('diffView.backLabel', "Back");93this.viewStore.add(Gesture.addTarget(backBtn));94this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose()));95this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose()));9697const info = DOM.append(header, $('div.mobile-overlay-header-info'));98DOM.append(info, $('div.mobile-overlay-header-title')).textContent = fileName;99100if (!diff.identical) {101const sub = DOM.append(info, $('div.mobile-overlay-header-subtitle'));102const parts: string[] = [];103if (diff.added) {104parts.push(`+${diff.added}`);105}106if (diff.removed) {107parts.push(`-${diff.removed}`);108}109sub.textContent = parts.join(' ');110}111112// -- Body -------------------------------------------------113const body = DOM.append(overlay, $('div.mobile-overlay-body'));114const scrollWrapper = DOM.append(body, $('div.mobile-overlay-scroll'));115const contentArea = DOM.append(scrollWrapper, $('div.mobile-diff-output'));116117this.loadDiffContent(contentArea, diff);118}119120private loadDiffContent(container: HTMLElement, diff: IFileDiffViewData): void {121if (diff.identical) {122const empty = DOM.append(container, $('div.mobile-diff-empty-state'));123empty.textContent = localize('diffView.noChanges', "No changes in this file.");124return;125}126127const loadingEl = DOM.append(container, $('div.mobile-diff-empty-state'));128loadingEl.textContent = localize('diffView.loading', "Loading…");129130Promise.all([131diff.originalURI132? this.textFileService.read(diff.originalURI, { acceptTextOnly: true }).then(m => m.value).catch(() => '')133: Promise.resolve(''),134this.textFileService.read(diff.modifiedURI, { acceptTextOnly: true }).then(m => m.value).catch(() => ''),135]).then(([originalText, modifiedText]) => {136if (this.disposed) {137return;138}139DOM.clearNode(container);140const hunks = computeUnifiedDiff(originalText, modifiedText);141if (hunks.length === 0) {142const empty = DOM.append(container, $('div.mobile-diff-empty-state'));143empty.textContent = localize('diffView.noChanges', "No changes in this file.");144return;145}146this.renderHunks(container, hunks);147});148}149150private renderHunks(container: HTMLElement, hunks: IDiffHunk[]): void {151for (const hunk of hunks) {152// Hunk header153const headerEl = DOM.append(container, $('span.mobile-diff-hunk-header'));154headerEl.textContent = hunk.header;155156// Lines157for (const line of hunk.lines) {158const row = DOM.append(container, $('div.mobile-diff-line'));159row.classList.add(line.type);160161const numEl = DOM.append(row, $('span.mobile-diff-line-num'));162numEl.textContent = line.lineNum !== undefined ? String(line.lineNum) : '';163164const gutter = DOM.append(row, $('span.mobile-diff-gutter'));165gutter.textContent = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' ';166167const content = DOM.append(row, $('span.mobile-diff-content'));168content.textContent = line.text;169}170}171}172173override dispose(): void {174this.disposed = true;175this.viewStore.dispose();176super.dispose();177}178}179180// -- Unified diff hunk rendering ---------------------------------------------181// Uses the workbench's `linesDiffComputers` so we get the same diff quality as182// the diff editor — no in-tree diff algorithm to maintain.183184interface IDiffLine {185type: 'context' | 'added' | 'removed';186lineNum?: number;187text: string;188}189190interface IDiffHunk {191header: string;192lines: IDiffLine[];193}194195const CONTEXT_LINES = 3;196197function computeUnifiedDiff(original: string, modified: string): IDiffHunk[] {198const origLines = original.split(/\r?\n/);199const modLines = modified.split(/\r?\n/);200201const result = linesDiffComputers.getDefault().computeDiff(origLines, modLines, {202ignoreTrimWhitespace: false,203maxComputationTimeMs: 1000,204computeMoves: false,205});206207if (result.changes.length === 0) {208return [];209}210211// Merge changes that are within 2*CONTEXT_LINES of each other into a212// single hunk so consecutive edits aren't visually fragmented.213type Group = { origStart: number; origEnd: number; modStart: number; modEnd: number };214const groups: Group[] = [];215for (const change of result.changes) {216const g: Group = {217origStart: change.original.startLineNumber,218origEnd: change.original.endLineNumberExclusive,219modStart: change.modified.startLineNumber,220modEnd: change.modified.endLineNumberExclusive,221};222const last = groups[groups.length - 1];223if (last && g.origStart - last.origEnd <= CONTEXT_LINES * 2) {224last.origEnd = g.origEnd;225last.modEnd = g.modEnd;226} else {227groups.push(g);228}229}230231const hunks: IDiffHunk[] = [];232for (const group of groups) {233const origLeading = Math.max(1, group.origStart - CONTEXT_LINES);234const modLeading = Math.max(1, group.modStart - CONTEXT_LINES);235const origTrailing = Math.min(origLines.length + 1, group.origEnd + CONTEXT_LINES);236const modTrailing = Math.min(modLines.length + 1, group.modEnd + CONTEXT_LINES);237238const lines: IDiffLine[] = [];239240// Leading context (taken from original — same as modified in unchanged regions).241for (let i = origLeading; i < group.origStart; i++) {242lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' });243}244245// Removed lines (from original).246for (let i = group.origStart; i < group.origEnd; i++) {247lines.push({ type: 'removed', lineNum: i, text: origLines[i - 1] ?? '' });248}249250// Added lines (from modified).251for (let i = group.modStart; i < group.modEnd; i++) {252lines.push({ type: 'added', lineNum: i, text: modLines[i - 1] ?? '' });253}254255// Trailing context.256for (let i = group.origEnd; i < origTrailing; i++) {257lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' });258}259260const origCount = origTrailing - origLeading;261const modCount = modTrailing - modLeading;262hunks.push({263header: `@@ -${origLeading},${origCount} +${modLeading},${modCount} @@`,264lines,265});266}267268return hunks;269}270271/**272* Opens a {@link MobileDiffView} for the given file diff.273* Returns the view instance; dispose it to close.274*/275export function openMobileDiffView(276workbenchContainer: HTMLElement,277data: IMobileDiffViewData,278textFileService: ITextFileService,279): MobileDiffView {280return new MobileDiffView(workbenchContainer, data, textFileService);281}282283284