From 80a90a2a4b9f4f408ca598728f0e5a6ebc0785d7 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Fri, 22 May 2026 23:00:34 -0700 Subject: [PATCH 01/43] Add mobile multi-diff view with per-file folding Introduces MobileMultiDiffView with sticky per-file headers, fold/unfold via a chevron-only toggle target (click, tap, keyboard), and async per-file content loading; wires it into the mobile overlay/titlebar plumbing and adds a Vite playground entry for iteration. --- build/vite/mobile-multi-diff.html | 103 +++ build/vite/mobile-multi-diff.ts | 306 +++++++++ .../media/mobileMultiDiffView.css | 170 +++++ .../media/mobileOverlayViews.css | 21 +- .../mobile/contributions/mobileDiffView.ts | 3 +- .../contributions/mobileMultiDiffView.ts | 636 ++++++++++++++++++ .../parts/mobile/mobileTitlebarPart.ts | 11 +- .../mobile/mobileOverlayContribution.ts | 60 +- 8 files changed, 1276 insertions(+), 34 deletions(-) create mode 100644 build/vite/mobile-multi-diff.html create mode 100644 build/vite/mobile-multi-diff.ts create mode 100644 src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css create mode 100644 src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts diff --git a/build/vite/mobile-multi-diff.html b/build/vite/mobile-multi-diff.html new file mode 100644 index 0000000000000..09f34327ba794 --- /dev/null +++ b/build/vite/mobile-multi-diff.html @@ -0,0 +1,103 @@ + + + + + + + + + +
+
+
+ + + + diff --git a/build/vite/mobile-multi-diff.ts b/build/vite/mobile-multi-diff.ts new file mode 100644 index 0000000000000..a6057c5653624 --- /dev/null +++ b/build/vite/mobile-multi-diff.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable local/code-no-dangerous-type-assertions */ + +// Import the mobile diff view CSS +import '../../src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css'; +import '../../src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css'; + +import { URI } from '../../src/vs/base/common/uri.js'; +import { MobileMultiDiffView, IMobileMultiDiffViewData } from '../../src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.js'; +import { IFileDiffViewData } from '../../src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.js'; +import { ITextFileService } from '../../src/vs/workbench/services/textfile/common/textfiles.js'; +import { ILanguageService } from '../../src/vs/editor/common/languages/language.js'; +import { IFileService } from '../../src/vs/platform/files/common/files.js'; +import { VSBuffer } from '../../src/vs/base/common/buffer.js'; + +// --- Sample file contents --- + +const FILES: Record = { + 'inmemory://original/src/greet.ts': `function greet(name: string): string { + return 'Hello, ' + name; +} + +function main() { + console.log(greet('World')); +}`, + + 'inmemory://modified/src/greet.ts': `function greet(name: string, greeting = 'Hello'): string { + return \`\${greeting}, \${name}!\`; +} + +function farewell(name: string): string { + return \`Goodbye, \${name}!\`; +} + +function main() { + console.log(greet('World')); + console.log(farewell('World')); +}`, + + 'inmemory://original/src/config.ts': `export interface Config { + host: string; + port: number; +} + +export const defaultConfig: Config = { + host: 'localhost', + port: 3000, +}; + +export function validateConfig(config: Config): boolean { + if (!config.host) { + return false; + } + if (config.port < 0 || config.port > 65535) { + return false; + } + return true; +} + +export function mergeConfig(base: Config, overrides: Partial): Config { + return { ...base, ...overrides }; +}`, + + 'inmemory://modified/src/config.ts': `export interface Config { + host: string; + port: number; + secure: boolean; + timeout: number; +} + +export const defaultConfig: Config = { + host: 'localhost', + port: 8080, + secure: true, + timeout: 30000, +}; + +export function validateConfig(config: Config): boolean { + if (!config.host) { + throw new Error('Host is required'); + } + if (config.port < 0 || config.port > 65535) { + throw new Error(\`Invalid port: \${config.port}\`); + } + if (config.timeout < 0) { + throw new Error('Timeout must be non-negative'); + } + return true; +} + +export function mergeConfig(base: Config, overrides: Partial): Config { + const merged = { ...base, ...overrides }; + validateConfig(merged); + return merged; +}`, + + 'inmemory://original/src/server.ts': `import { Config } from './config'; + +export function createServer(config: Config) { + return { config }; +}`, + + 'inmemory://modified/src/server.ts': `import { Config } from './config'; + +export function createServer(config: Config) { + const { host, port, secure } = config; + const protocol = secure ? 'https' : 'http'; + console.log(\`Starting server at \${protocol}://\${host}:\${port}\`); + return { config, url: \`\${protocol}://\${host}:\${port}\` }; +}`, + + 'inmemory://original/src/middleware.ts': `import { Request, Response, NextFunction } from 'express'; + +export function logMiddleware(req: Request, res: Response, next: NextFunction) { + console.log(\`\${req.method} \${req.url}\`); + next(); +}`, + + 'inmemory://modified/src/middleware.ts': `import { Request, Response, NextFunction } from 'express'; + +export interface LogOptions { + verbose: boolean; + timestamp: boolean; +} + +const defaultLogOptions: LogOptions = { + verbose: false, + timestamp: true, +}; + +export function logMiddleware(req: Request, res: Response, next: NextFunction, options: LogOptions = defaultLogOptions) { + const timestamp = options.timestamp ? \`[\${new Date().toISOString()}] \` : ''; + const method = req.method; + const url = req.url; + + if (options.verbose) { + console.log(\`\${timestamp}\${method} \${url} - Headers: \${JSON.stringify(req.headers)}\`); + } else { + console.log(\`\${timestamp}\${method} \${url}\`); + } + + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log(\`\${timestamp}\${method} \${url} - \${res.statusCode} (\${duration}ms)\`); + }); + + next(); +} + +export function errorMiddleware(err: Error, req: Request, res: Response, next: NextFunction) { + console.error(\`Error: \${err.message}\`); + res.status(500).json({ error: err.message }); +}`, + + 'inmemory://original/src/utils.ts': `export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function retry(fn: () => Promise, attempts: number): Promise { + return fn().catch(err => { + if (attempts <= 1) throw err; + return retry(fn, attempts - 1); + }); +}`, + + 'inmemory://modified/src/utils.ts': `export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export interface RetryOptions { + attempts: number; + delay: number; + backoff: 'linear' | 'exponential'; +} + +const defaultRetryOptions: RetryOptions = { + attempts: 3, + delay: 1000, + backoff: 'exponential', +}; + +export async function retry(fn: () => Promise, options: RetryOptions = defaultRetryOptions): Promise { + let lastError: Error | undefined; + + for (let i = 0; i < options.attempts; i++) { + try { + return await fn(); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (i < options.attempts - 1) { + const delay = options.backoff === 'exponential' + ? options.delay * Math.pow(2, i) + : options.delay * (i + 1); + await sleep(delay); + } + } + } + + throw lastError; +} + +export function debounce void>(fn: T, ms: number): T { + let timer: ReturnType | undefined; + return ((...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +}`, +}; + +// --- Mock services --- + +const mockTextFileService = { + read(uri: URI) { + const content = FILES[uri.toString()] ?? ''; + return Promise.resolve({ value: content }); + } +} as unknown as ITextFileService; + +const mockFileService = { + readFile(uri: URI) { + const content = FILES[uri.toString()] ?? ''; + return Promise.resolve({ value: VSBuffer.fromString(content) }); + } +} as unknown as IFileService; + +const mockLanguageService = { + guessLanguageIdByFilepathOrFirstLine(uri: URI): string { + const path = uri.path; + if (path.endsWith('.ts') || path.endsWith('.tsx')) { return 'typescript'; } + if (path.endsWith('.js') || path.endsWith('.jsx')) { return 'javascript'; } + if (path.endsWith('.py')) { return 'python'; } + if (path.endsWith('.css')) { return 'css'; } + if (path.endsWith('.html')) { return 'html'; } + if (path.endsWith('.json')) { return 'json'; } + return 'unknown'; + } +} as unknown as ILanguageService; + +// --- Build diff data --- + +const diffs: IFileDiffViewData[] = [ + { + originalURI: URI.parse('inmemory://original/src/greet.ts'), + modifiedURI: URI.parse('inmemory://modified/src/greet.ts'), + identical: false, + added: 6, + removed: 2, + }, + { + originalURI: URI.parse('inmemory://original/src/config.ts'), + modifiedURI: URI.parse('inmemory://modified/src/config.ts'), + identical: false, + added: 12, + removed: 5, + }, + { + originalURI: URI.parse('inmemory://original/src/server.ts'), + modifiedURI: URI.parse('inmemory://modified/src/server.ts'), + identical: false, + added: 4, + removed: 1, + }, + { + originalURI: URI.parse('inmemory://original/src/middleware.ts'), + modifiedURI: URI.parse('inmemory://modified/src/middleware.ts'), + identical: false, + added: 30, + removed: 2, + }, + { + originalURI: URI.parse('inmemory://original/src/utils.ts'), + modifiedURI: URI.parse('inmemory://modified/src/utils.ts'), + identical: false, + added: 32, + removed: 5, + }, +]; + +// --- Render --- + +function init() { + const container = document.getElementById('container')!; + + const data: IMobileMultiDiffViewData = { + diffs, + initialIndex: 0, + }; + + const view = new MobileMultiDiffView(container, data, mockTextFileService, mockFileService, mockLanguageService); + + // Clean up on page unload + window.addEventListener('beforeunload', () => view.dispose()); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css new file mode 100644 index 0000000000000..054be0ef37a13 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Mobile Multi-Diff View — stacked multi-file diff with sticky file headers. */ + +/* Override the scroll wrapper: disable horizontal scroll at the container level; + * each file content area scrolls horizontally on its own. */ +.mobile-multi-diff-view .mobile-overlay-scroll { + overflow-x: hidden; + min-width: 0; +} + +/* Prevent the scroll wrapper's children from expanding horizontally */ +.mobile-multi-diff-view .mobile-overlay-scroll > * { + max-width: 100%; +} + +/* -- Top bar (fixed, shows Back + file count) -------------------------------- */ + +.mobile-multi-diff-topbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + min-height: 44px; + padding: 0 8px; + padding-top: env(safe-area-inset-top); + border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); + flex-shrink: 0; + box-sizing: content-box; +} + +.mobile-multi-diff-file-count { + font-size: 13px; + color: var(--vscode-descriptionForeground); + padding-right: 8px; +} + +/* -- File sections ----------------------------------------------------------- */ + +.mobile-multi-diff-file-section { + min-width: 0; /* Allow flex child to shrink below content width */ +} + +.mobile-multi-diff-file-section:last-child { + border-bottom: none; +} + +/* -- Sticky file header ------------------------------------------------------ */ + +.mobile-multi-diff-file-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + position: sticky; + top: 0; + z-index: 2; + background: var(--vscode-multiDiffEditor-headerBackground); + border-top: 1px solid var(--vscode-multiDiffEditor-border, var(--vscode-sideBarSectionHeader-border, transparent)); + border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-multiDiffEditor-border, transparent)); + color: var(--vscode-foreground); + font-size: 13px; + line-height: 20px; + user-select: none; + -webkit-user-select: none; +} + +.mobile-multi-diff-file-chevron.codicon { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + padding: 2px; + border-radius: 3px; + cursor: pointer; +} + +.mobile-multi-diff-file-chevron::before { + font-size: 14px; + line-height: 1; +} + +.mobile-multi-diff-file-chevron:hover { + background: var(--vscode-toolbar-hoverBackground, transparent); +} + +.mobile-multi-diff-file-chevron:focus-visible { + outline: 1px solid var(--vscode-focusBorder, transparent); + outline-offset: -1px; +} + +/* Hide the content area when the section is collapsed */ +.mobile-multi-diff-file-section.collapsed > .mobile-multi-diff-file-content { + display: none; +} + +.mobile-multi-diff-file-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); + letter-spacing: 0.01em; +} + +.mobile-multi-diff-file-dir { + color: var(--vscode-descriptionForeground); + font-weight: 400; +} + +.mobile-multi-diff-file-base { + color: var(--vscode-foreground); + font-weight: 600; +} + +.mobile-multi-diff-file-stats { + display: flex; + gap: 8px; + flex-shrink: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.mobile-multi-diff-stat-added { + color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); +} + +.mobile-multi-diff-stat-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground, #f14c4c); +} + +/* -- File content area ------------------------------------------------------- */ + +.mobile-multi-diff-file-content { + font-family: var(--monaco-monospace-font, 'SF Mono', Menlo, Monaco, Consolas, monospace); + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* + * Inner wrapper stretches to the widest line so that shorter lines + * still have their background color fill the full scrollable width — + * same as Monaco's isWholeLine decoration behavior. + */ +.mobile-multi-diff-file-content-inner { + min-width: max-content; + width: 100%; +} + +/* Lines must not wrap so they overflow horizontally into the scroll container */ +.mobile-multi-diff-file-content .mobile-diff-line { + white-space: nowrap; +} + +.mobile-multi-diff-file-content .mobile-diff-hunk-header { + white-space: nowrap; +} + +/* In the multi-diff view, hunk headers are sticky within each file section. */ +.mobile-multi-diff-file-section .mobile-diff-hunk-header { + position: sticky; + top: 0; + z-index: 1; + background: var(--vscode-editor-background, #1e1e1e); +} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css index eb5bee04f047d..2230c9b275021 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css @@ -68,6 +68,12 @@ justify-content: center; } +.mobile-overlay-header-info.inline { + flex-direction: row; + align-items: baseline; + gap: 8px; +} + .mobile-overlay-header-title { font-size: 14px; font-weight: 500; @@ -273,8 +279,8 @@ .mobile-diff-hunk-header { display: block; color: var(--vscode-descriptionForeground); - background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent); - padding: 4px 16px; + background: var(--vscode-editor-background, #1e1e1e); + padding: 4px 12px; font-size: 11px; user-select: none; -webkit-user-select: none; @@ -302,19 +308,20 @@ .mobile-diff-line-num { flex-shrink: 0; - width: 40px; - padding: 0 8px; + width: 28px; + padding: 0 4px; color: var(--vscode-editorLineNumber-foreground); text-align: right; user-select: none; -webkit-user-select: none; + font-variant-numeric: tabular-nums; } .mobile-diff-gutter { flex-shrink: 0; - width: 16px; + width: 14px; text-align: center; - padding: 0 2px; + padding: 0; user-select: none; -webkit-user-select: none; } @@ -331,7 +338,7 @@ * token color CSS automatically. */ flex: 0 0 auto; - padding: 0 16px 0 4px; + padding: 0 16px 0 2px; user-select: text; -webkit-user-select: text; white-space: pre; diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts index b20c4947e807e..d47658c7c9048 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts @@ -177,12 +177,11 @@ export class MobileDiffView extends Disposable { const backBtn = DOM.append(header, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement; backBtn.setAttribute('aria-label', localize('diffView.back', "Back")); DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft)); - DOM.append(backBtn, $('span.back-btn-label')).textContent = localize('diffView.backLabel', "Back"); this.viewStore.add(Gesture.addTarget(backBtn)); this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose())); this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose())); - const info = DOM.append(header, $('div.mobile-overlay-header-info')); + const info = DOM.append(header, $('div.mobile-overlay-header-info.inline')); this.titleEl = DOM.append(info, $('div.mobile-overlay-header-title')); this.subtitleEl = DOM.append(info, $('div.mobile-overlay-header-subtitle')); diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts new file mode 100644 index 0000000000000..c6cec2f905f14 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts @@ -0,0 +1,636 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/mobileOverlayViews.css'; +import './media/mobileMultiDiffView.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; +import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { generateTokensCSSForColorMap } from '../../../../../editor/common/languages/supports/tokenization.js'; +import { IFileDiffViewData } from './mobileDiffView.js'; + +const $ = DOM.$; + +/** Hardcoded extension→languageId fallback (same as mobileDiffView.ts). */ +const EXTENSION_LANGUAGE_MAP: Record = { + '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', + '.jsx': 'javascriptreact', + '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', + '.tsx': 'typescriptreact', + '.py': 'python', '.pyw': 'python', + '.java': 'java', + '.c': 'c', '.h': 'c', + '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', + '.cs': 'csharp', + '.go': 'go', + '.rs': 'rust', + '.rb': 'ruby', + '.php': 'php', + '.html': 'html', '.htm': 'html', + '.css': 'css', '.scss': 'scss', '.less': 'less', + '.json': 'json', '.jsonc': 'jsonc', + '.md': 'markdown', + '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', + '.yaml': 'yaml', '.yml': 'yaml', + '.xml': 'xml', + '.sql': 'sql', + '.swift': 'swift', + '.kt': 'kotlin', '.kts': 'kotlin', + '.r': 'r', + '.lua': 'lua', + '.dart': 'dart', +}; + +/** + * Data passed to {@link MobileMultiDiffView}. + */ +export interface IMobileMultiDiffViewData { + readonly diffs: readonly IFileDiffViewData[]; + /** Index of the file to scroll to initially. */ + readonly initialIndex?: number; +} + +/** + * Full-screen overlay for viewing **multiple** file diffs produced by a + * coding agent session on phone viewports. + * + * All files are rendered in a single scrollable container with sticky + * per-file headers. This allows the user to scroll through all changes + * continuously, with the current file header always visible. + */ +export class MobileMultiDiffView extends Disposable { + + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose: Event = this._onDidDispose.event; + + private readonly viewStore = this._register(new DisposableStore()); + + private disposed = false; + private renderGeneration = 0; + + private scrollWrapper!: HTMLElement; + private readonly fileElements: HTMLElement[] = []; + private readonly fileContentElements: HTMLElement[] = []; + + constructor( + workbenchContainer: HTMLElement, + private readonly data: IMobileMultiDiffViewData, + private readonly textFileService: ITextFileService, + private readonly fileService: IFileService, + private readonly languageService: ILanguageService, + ) { + super(); + this.render(workbenchContainer); + this.loadAllFiles(); + } + + private render(workbenchContainer: HTMLElement): void { + // -- Root overlay + const overlay = DOM.append(workbenchContainer, $('div.mobile-overlay-view.mobile-multi-diff-view')); + this.viewStore.add(DOM.addDisposableListener(overlay, DOM.EventType.CONTEXT_MENU, e => e.preventDefault())); + this.viewStore.add(toDisposable(() => overlay.remove())); + + // -- Top bar (fixed) + const topBar = DOM.append(overlay, $('div.mobile-multi-diff-topbar')); + + const backBtn = DOM.append(topBar, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement; + backBtn.setAttribute('aria-label', localize('multiDiffView.back', "Back")); + DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft)); + this.viewStore.add(Gesture.addTarget(backBtn)); + this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose())); + this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose())); + + const fileCount = DOM.append(topBar, $('span.mobile-multi-diff-file-count')); + fileCount.textContent = localize( + 'multiDiffView.fileCount', + "{0} {1}", + this.data.diffs.length, + this.data.diffs.length === 1 ? localize('multiDiffView.file', "file") : localize('multiDiffView.files', "files"), + ); + + // -- Scroll body + const body = DOM.append(overlay, $('div.mobile-overlay-body')); + this.scrollWrapper = DOM.append(body, $('div.mobile-overlay-scroll')); + + // Render file sections + for (let i = 0; i < this.data.diffs.length; i++) { + const diff = this.data.diffs[i]; + const fileSection = this.renderFileSection(diff); + this.fileElements.push(fileSection); + this.scrollWrapper.appendChild(fileSection); + } + + // Scroll to initial file if specified + if (this.data.initialIndex !== undefined && this.data.initialIndex > 0) { + DOM.getWindow(this.scrollWrapper).requestAnimationFrame(() => { + const target = this.fileElements[this.data.initialIndex!]; + if (target) { + target.scrollIntoView({ block: 'start' }); + } + }); + } + } + + private formatDirSegment(uri: URI): string { + // Take the last 2 directory segments of the parent path to provide + // context without overwhelming the header on narrow phone widths. + const parent = dirname(uri); + const parentPath = parent.path.replace(/^\/+/, ''); + if (!parentPath || parentPath === '.') { + return ''; + } + const segments = parentPath.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return ''; + } + const tail = segments.slice(-2).join('/'); + const prefix = segments.length > 2 ? '…/' : ''; + return `${prefix}${tail}/`; + } + + private renderFileSection(diff: IFileDiffViewData): HTMLElement { + const section = $('div.mobile-multi-diff-file-section'); + + const header = DOM.append(section, $('div.mobile-multi-diff-file-header')); + + const fileNameUri = diff.modifiedURI ?? diff.originalURI; + const fileName = fileNameUri ? basename(fileNameUri) : ''; + const dirPath = fileNameUri ? this.formatDirSegment(fileNameUri) : ''; + + // Chevron acts as the fold toggle. + const chevronEl = DOM.append(header, $('span.mobile-multi-diff-file-chevron', { + role: 'button', + tabindex: '0', + 'aria-expanded': 'true', + })); + chevronEl.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevronEl.setAttribute('aria-label', localize('multiDiffView.toggleFile', "Toggle {0}", fileName || 'file')); + + const nameEl = DOM.append(header, $('span.mobile-multi-diff-file-name')); + if (dirPath) { + DOM.append(nameEl, $('span.mobile-multi-diff-file-dir')).textContent = dirPath; + } + DOM.append(nameEl, $('span.mobile-multi-diff-file-base')).textContent = fileName; + + const statsEl = DOM.append(header, $('span.mobile-multi-diff-file-stats')); + if (!diff.identical) { + if (diff.added) { + DOM.append(statsEl, $('span.mobile-multi-diff-stat-added')).textContent = `+${diff.added}`; + } + if (diff.removed) { + DOM.append(statsEl, $('span.mobile-multi-diff-stat-removed')).textContent = `-${diff.removed}`; + } + } + + // Content area (will be populated async) + const content = DOM.append(section, $('div.mobile-multi-diff-file-content')); + this.fileContentElements.push(content); + + // Loading placeholder + const loadingEl = DOM.append(content, $('div.mobile-diff-empty-state')); + loadingEl.textContent = localize('multiDiffView.loading', "Loading…"); + + const toggle = (e: UIEvent) => { + e.stopPropagation(); + const collapsed = section.classList.toggle('collapsed'); + chevronEl.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + chevronEl.classList.remove(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronDown : Codicon.chevronRight)); + chevronEl.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + }; + this.viewStore.add(Gesture.addTarget(chevronEl)); + this.viewStore.add(DOM.addDisposableListener(chevronEl, DOM.EventType.CLICK, toggle)); + this.viewStore.add(DOM.addDisposableListener(chevronEl, TouchEventType.Tap, e => { e.preventDefault(); toggle(e); })); + this.viewStore.add(DOM.addDisposableListener(chevronEl, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(e); + } + })); + + return section; + } + + private loadAllFiles(): void { + this.renderGeneration++; + const generation = this.renderGeneration; + + for (let i = 0; i < this.data.diffs.length; i++) { + const diff = this.data.diffs[i]; + const content = this.fileContentElements[i]; + if (content) { + void this.loadFileContent(content, diff, generation); + } + } + } + + private async loadFileContent(container: HTMLElement, diff: IFileDiffViewData, generation: number): Promise { + if (diff.identical) { + DOM.clearNode(container); + const empty = DOM.append(container, $('div.mobile-diff-empty-state')); + empty.textContent = localize('multiDiffView.noChanges', "No changes in this file."); + return; + } + + const languageId = this.resolveLanguageId(diff); + + const [originalText, modifiedText] = await Promise.all([ + this.readTextContent(diff.originalURI), + this.readTextContent(diff.modifiedURI), + ]); + + if (this.disposed || generation !== this.renderGeneration) { + return; + } + + const hunks = computeUnifiedDiff(originalText, modifiedText); + if (hunks.length === 0) { + DOM.clearNode(container); + const empty = DOM.append(container, $('div.mobile-diff-empty-state')); + empty.textContent = localize('multiDiffView.noChanges', "No changes in this file."); + return; + } + + const [origLineHtml, modLineHtml] = await Promise.all([ + tokenizeFileLines(this.languageService, originalText, languageId), + tokenizeFileLines(this.languageService, modifiedText, languageId), + ]); + + const hasRealTokens = hasMultipleTokenClasses(origLineHtml) || hasMultipleTokenClasses(modLineHtml); + const origLines = hasRealTokens ? origLineHtml : regexTokenizeLines(originalText, languageId); + const modLines = hasRealTokens ? modLineHtml : regexTokenizeLines(modifiedText, languageId); + + if (this.disposed || generation !== this.renderGeneration) { + return; + } + + DOM.clearNode(container); + + // Inner wrapper: stretches to widest line so all line backgrounds fill equally + const inner = DOM.append(container, $('div.mobile-multi-diff-file-content-inner')); + + const colorMap = TokenizationRegistry.getColorMap(); + if (colorMap && hasRealTokens) { + const styleEl = document.createElement('style'); + styleEl.textContent = generateTokensCSSForColorMap(colorMap); + inner.appendChild(styleEl); + } + + this.renderHunks(inner, hunks, origLines, modLines); + } + + private async readTextContent(resource: URI | undefined): Promise { + if (!resource) { + return ''; + } + + try { + const model = await this.textFileService.read(resource, { acceptTextOnly: true }); + return model.value; + } catch { + try { + const file = await this.fileService.readFile(resource); + return file.value.toString(); + } catch { + return ''; + } + } + } + + private resolveLanguageId(diff: IFileDiffViewData): string { + const uri = diff.modifiedURI ?? diff.originalURI; + if (!uri) { + return 'plaintext'; + } + const guessed = this.languageService.guessLanguageIdByFilepathOrFirstLine(uri); + if (guessed && guessed !== 'unknown') { + return guessed; + } + const name = basename(uri); + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''; + return EXTENSION_LANGUAGE_MAP[ext] ?? 'plaintext'; + } + + private renderHunks( + container: HTMLElement, + hunks: IDiffHunk[], + origLineHtml: readonly string[], + modLineHtml: readonly string[], + ): void { + for (const hunk of hunks) { + const headerEl = DOM.append(container, $('div.mobile-diff-hunk-header')); + headerEl.textContent = hunk.header; + + for (const line of hunk.lines) { + const row = DOM.append(container, $('div.mobile-diff-line')); + row.classList.add(line.type); + + const numEl = DOM.append(row, $('span.mobile-diff-line-num')); + numEl.textContent = line.lineNum !== undefined ? String(line.lineNum) : ''; + + const gutter = DOM.append(row, $('span.mobile-diff-gutter')); + gutter.textContent = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '; + + const content = DOM.append(row, $('span.mobile-diff-content')); + if (line.lineNum !== undefined) { + const source = line.type === 'added' ? modLineHtml : origLineHtml; + const html = source[line.lineNum - 1]; + if (html !== undefined) { + content.innerHTML = html; + } else if (line.text) { + content.textContent = line.text; + } + } else if (line.text) { + content.textContent = line.text; + } + } + } + } + + override dispose(): void { + this.disposed = true; + this._onDidDispose.fire(); + this.viewStore.dispose(); + super.dispose(); + } +} + +// -- Tokenization helpers (same as mobileDiffView.ts) ------------------------- + +async function tokenizeFileLines(languageService: ILanguageService, text: string, languageId: string): Promise { + if (!text) { + return ['']; + } + const html = await tokenizeToString(languageService, text, languageId); + const inner = stripTokenizedWrapper(html); + return inner.split('
'); +} + +function stripTokenizedWrapper(html: string): string { + const openTag = '
'; + const closeTag = '
'; + if (html.startsWith(openTag) && html.endsWith(closeTag)) { + return html.slice(openTag.length, html.length - closeTag.length); + } + return html; +} + +function hasMultipleTokenClasses(lines: readonly string[]): boolean { + for (const line of lines) { + if (line && /class="mtk[2-9]|class="mtk[1-9][0-9]/.test(line)) { + return true; + } + } + return false; +} + +// -- Regex tokenizer (same as mobileDiffView.ts) ------------------------------ + +type RegexTokenKind = 'comment' | 'string' | 'keyword' | 'number' | 'default'; + +interface IRegexToken { + start: number; + end: number; + kind: RegexTokenKind; +} + +type LangFamily = 'js' | 'python' | 'css' | 'html' | 'json' | 'shell' | 'generic'; + +const LANG_FAMILY: Record = { + javascript: 'js', javascriptreact: 'js', + typescript: 'js', typescriptreact: 'js', + java: 'js', csharp: 'js', go: 'js', rust: 'js', + cpp: 'js', c: 'js', swift: 'js', kotlin: 'js', dart: 'js', php: 'js', ruby: 'js', + python: 'python', + css: 'css', scss: 'css', less: 'css', + html: 'html', xml: 'html', + json: 'json', jsonc: 'json', + shellscript: 'shell', powershell: 'shell', +}; + +const JS_KEYWORDS = new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', + 'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', + 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', + 'of', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', + 'try', 'typeof', 'undefined', 'var', 'void', 'while', 'with', 'yield', + 'async', 'await', 'from', 'as', 'interface', 'type', 'enum', 'declare', + 'abstract', 'override', 'readonly', 'namespace', 'module', 'public', 'private', 'protected', +]); + +const PY_KEYWORDS = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', + 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', + 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', + 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', + 'try', 'while', 'with', 'yield', +]); + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function buildSpan(kind: RegexTokenKind, text: string): string { + if (kind === 'default' || !text) { + return escapeHtml(text); + } + return `${escapeHtml(text)}`; +} + +function regexTokenizeLine(line: string, lang: LangFamily): string { + const tokens: IRegexToken[] = []; + let pos = 0; + const len = line.length; + + while (pos < len) { + let matched = false; + + const commentPfx = lang === 'python' ? '#' : lang === 'shell' ? '#' : '//'; + if (line.startsWith(commentPfx, pos) || (lang === 'generic' && line.startsWith('#', pos))) { + tokens.push({ start: pos, end: len, kind: 'comment' }); + pos = len; + matched = true; + } + + if (!matched && lang !== 'python' && lang !== 'shell' && line.startsWith('/*', pos)) { + const end = line.indexOf('*/', pos + 2); + const tokenEnd = end === -1 ? len : end + 2; + tokens.push({ start: pos, end: tokenEnd, kind: 'comment' }); + pos = tokenEnd; + matched = true; + } + + if (!matched && (lang === 'js') && line[pos] === '`') { + let i = pos + 1; + while (i < len) { + if (line[i] === '\\') { i += 2; continue; } + if (line[i] === '`') { i++; break; } + i++; + } + tokens.push({ start: pos, end: i, kind: 'string' }); + pos = i; + matched = true; + } + + if (!matched && (line[pos] === '"' || line[pos] === '\'')) { + const q = line[pos]; + let i = pos + 1; + while (i < len) { + if (line[i] === '\\') { i += 2; continue; } + if (line[i] === q) { i++; break; } + i++; + } + tokens.push({ start: pos, end: i, kind: 'string' }); + pos = i; + matched = true; + } + + if (!matched && /[0-9]/.test(line[pos])) { + const m = line.slice(pos).match(/^0x[0-9a-fA-F]+|^[0-9]+\.?[0-9]*(?:[eE][+-]?[0-9]+)?/); + if (m) { + tokens.push({ start: pos, end: pos + m[0].length, kind: 'number' }); + pos += m[0].length; + matched = true; + } + } + + if (!matched && /[a-zA-Z_$]/.test(line[pos])) { + const m = line.slice(pos).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/); + if (m) { + const word = m[0]; + const keywords = lang === 'python' ? PY_KEYWORDS : JS_KEYWORDS; + const kind: RegexTokenKind = keywords.has(word) ? 'keyword' : 'default'; + tokens.push({ start: pos, end: pos + word.length, kind }); + pos += word.length; + matched = true; + } + } + + if (!matched) { + const prevTok = tokens[tokens.length - 1]; + if (prevTok && prevTok.kind === 'default') { + prevTok.end = pos + 1; + } else { + tokens.push({ start: pos, end: pos + 1, kind: 'default' }); + } + pos++; + } + } + + return tokens.map(t => buildSpan(t.kind, line.slice(t.start, t.end))).join(''); +} + +function regexTokenizeLines(text: string, languageId: string): string[] { + if (!text) { + return ['']; + } + const lang: LangFamily = LANG_FAMILY[languageId] ?? 'generic'; + return text.split(/\r?\n/).map(line => regexTokenizeLine(line, lang)); +} + +// -- Unified diff computation (same as mobileDiffView.ts) --------------------- + +interface IDiffLine { + type: 'context' | 'added' | 'removed'; + lineNum?: number; + text: string; +} + +interface IDiffHunk { + header: string; + lines: IDiffLine[]; +} + +const CONTEXT_LINES = 3; + +function computeUnifiedDiff(original: string, modified: string): IDiffHunk[] { + const origLines = original.split(/\r?\n/); + const modLines = modified.split(/\r?\n/); + + const result = linesDiffComputers.getDefault().computeDiff(origLines, modLines, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 1000, + computeMoves: false, + }); + + if (result.changes.length === 0) { + return []; + } + + type Sub = { origStart: number; origEnd: number; modStart: number; modEnd: number }; + type Group = { subs: Sub[] }; + const groups: Group[] = []; + for (const change of result.changes) { + const sub: Sub = { + origStart: change.original.startLineNumber, + origEnd: change.original.endLineNumberExclusive, + modStart: change.modified.startLineNumber, + modEnd: change.modified.endLineNumberExclusive, + }; + const last = groups[groups.length - 1]; + const lastSub = last?.subs[last.subs.length - 1]; + if (lastSub && sub.origStart - lastSub.origEnd <= CONTEXT_LINES * 2) { + last!.subs.push(sub); + } else { + groups.push({ subs: [sub] }); + } + } + + const hunks: IDiffHunk[] = []; + for (const group of groups) { + const first = group.subs[0]; + const last = group.subs[group.subs.length - 1]; + const origLeading = Math.max(1, first.origStart - CONTEXT_LINES); + const modLeading = Math.max(1, first.modStart - CONTEXT_LINES); + const origTrailing = Math.min(origLines.length + 1, last.origEnd + CONTEXT_LINES); + const modTrailing = Math.min(modLines.length + 1, last.modEnd + CONTEXT_LINES); + + const lines: IDiffLine[] = []; + + for (let i = origLeading; i < first.origStart; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + + for (let s = 0; s < group.subs.length; s++) { + const sub = group.subs[s]; + for (let i = sub.origStart; i < sub.origEnd; i++) { + lines.push({ type: 'removed', lineNum: i, text: origLines[i - 1] ?? '' }); + } + for (let i = sub.modStart; i < sub.modEnd; i++) { + lines.push({ type: 'added', lineNum: i, text: modLines[i - 1] ?? '' }); + } + const next = group.subs[s + 1]; + if (next) { + for (let i = sub.origEnd; i < next.origStart; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + } + } + + for (let i = last.origEnd; i < origTrailing; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + + const origCount = origTrailing - origLeading; + const modCount = modTrailing - modLeading; + hunks.push({ + header: `@@ -${origLeading},${origCount} +${modLeading},${modCount} @@`, + lines, + }); + } + + return hunks; +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts index b203431bd52d7..80ddfd8b29dc1 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts @@ -215,13 +215,18 @@ export class MobileTitlebarPart extends Disposable { added += c.insertions; removed += c.deletions; } - const hasChanges = changes.length > 0 && (added > 0 || removed > 0); + const hasChanges = changes.length > 0; // Hide on welcome / new-chat — no session changes to view there. const visible = hasChanges && !isNewChatRef.value; changesPill.style.display = visible ? '' : 'none'; if (visible) { - changesAddedEl.textContent = `+${added}`; - changesRemovedEl.textContent = `-${removed}`; + if (added > 0 || removed > 0) { + changesAddedEl.textContent = `+${added}`; + changesRemovedEl.textContent = `-${removed}`; + } else { + changesAddedEl.textContent = `${changes.length} file${changes.length > 1 ? 's' : ''}`; + changesRemovedEl.textContent = ''; + } changesPill.title = localize('mobileTopBar.changesTooltip', "{0} files changed (+{1} -{2})", changes.length, added, removed); } }; diff --git a/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts b/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts index 1dfb809a31144..9bb98607e1516 100644 --- a/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts @@ -8,14 +8,17 @@ import { Event } from '../../../../../base/common/event.js'; import { registerAction2, Action2 } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; import { ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; import { IFileDiffViewData, IMobileDiffViewData, MobileDiffView, MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, openMobileDiffView } from '../../../../../sessions/browser/parts/mobile/contributions/mobileDiffView.js'; -import { MobileChangesView, MOBILE_OPEN_CHANGES_VIEW_COMMAND_ID, openMobileChangesView, toRow, rowToDiffData } from '../../../../../sessions/browser/parts/mobile/contributions/mobileChangesView.js'; +import { MOBILE_OPEN_CHANGES_VIEW_COMMAND_ID, toRow, rowToDiffData } from '../../../../../sessions/browser/parts/mobile/contributions/mobileChangesView.js'; +import { MobileMultiDiffView, IMobileMultiDiffViewData } from '../../../../../sessions/browser/parts/mobile/contributions/mobileMultiDiffView.js'; import { IsPhoneLayoutContext } from '../../../../../sessions/common/contextkeys.js'; -import { localize2 } from '../../../../../nls.js'; +import { localize, localize2 } from '../../../../../nls.js'; // Module-level slots for the active overlays so a re-invocation of the // command (e.g. rapid double-tap) closes the prior overlay before opening @@ -24,7 +27,7 @@ import { localize2 } from '../../../../../nls.js'; // so `MutableDisposable.value === undefined` correctly tracks "no overlay // open" — guarding against stale references after self-dispose. const activeDiffView = new MutableDisposable(); -const activeChangesView = new MutableDisposable(); +const activeMultiDiffView = new MutableDisposable(); class MobileOpenDiffViewAction extends Action2 { constructor() { @@ -75,34 +78,47 @@ class MobileOpenChangesViewAction extends Action2 { run(accessor: ServicesAccessor): void { const layoutService = accessor.get(ILayoutService); - const instantiationService = accessor.get(IInstantiationService); - const commandService = accessor.get(ICommandService); + const textFileService = accessor.get(ITextFileService); + const fileService = accessor.get(IFileService); + const languageService = accessor.get(ILanguageService); + const notificationService = accessor.get(INotificationService); const sessionsManagementService = accessor.get(ISessionsManagementService); - // Single-file shortcut: bypass the list when only one change - // exists — opening a list to show one row would be a useless tap. const session = sessionsManagementService.activeSession.get(); const changes = session?.changes.get() ?? []; - if (changes.length === 1) { - const diff = rowToDiffData(toRow(changes[0])); - commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, { diff }); + + // Build per-file diff data, filtering out synthetic aggregate entries + // (entries with no original/modified URIs can't be diffed). + const rows = changes.map(c => toRow(c)); + const diffs: IFileDiffViewData[] = rows + .map(r => rowToDiffData(r)) + .filter(d => d.originalURI || d.modifiedURI); + + if (diffs.length === 0) { + notificationService.info(localize('mobileChangesNotAvailable', "File-level changes are not available for this session yet.")); + return; + } + + // Single-file shortcut: bypass the multi-diff when only one change + // exists — jump straight to the single-file diff view. + if (diffs.length === 1) { + const commandService = accessor.get(ICommandService); + commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, { diff: diffs[0] }); return; } - activeChangesView.value = openMobileChangesView( - instantiationService, + const data: IMobileMultiDiffViewData = { diffs }; + activeMultiDiffView.value = new MobileMultiDiffView( layoutService.mainContainer, - (diff, siblings, index) => { - // Routing through the command keeps the diff overlay - // lifecycle (the `activeDiffView` slot) consistent with - // every other entry point. - commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, { diff, siblings, index } satisfies IMobileDiffViewData); - }, + data, + textFileService, + fileService, + languageService, ); - const view = activeChangesView.value; + const view = activeMultiDiffView.value; Event.once(view.onDidDispose)(() => { - if (activeChangesView.value === view) { - activeChangesView.clear(); + if (activeMultiDiffView.value === view) { + activeMultiDiffView.clear(); } }); } From b07b8896699b48a22845fe6ebc6525f86e995328 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Sat, 23 May 2026 16:25:39 -0700 Subject: [PATCH 02/43] Address mobile diff PR feedback --- .../media/mobileMultiDiffView.css | 3 +- .../mobile/contributions/mobileDiffHelpers.ts | 337 +++++++++++++++ .../mobile/contributions/mobileDiffView.ts | 401 +----------------- .../contributions/mobileMultiDiffView.ts | 318 +------------- .../parts/mobile/mobileTitlebarPart.ts | 9 +- 5 files changed, 352 insertions(+), 716 deletions(-) create mode 100644 src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css index 054be0ef37a13..9e996ca4e8af0 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css @@ -74,6 +74,7 @@ padding: 2px; border-radius: 3px; cursor: pointer; + touch-action: manipulation; } .mobile-multi-diff-file-chevron::before { @@ -164,7 +165,7 @@ /* In the multi-diff view, hunk headers are sticky within each file section. */ .mobile-multi-diff-file-section .mobile-diff-hunk-header { position: sticky; - top: 0; + top: 34px; z-index: 1; background: var(--vscode-editor-background, #1e1e1e); } diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts new file mode 100644 index 0000000000000..cebe471da0938 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { basename } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; + +interface IFileDiffLike { + readonly originalURI: URI | undefined; + readonly modifiedURI: URI | undefined; +} + +/** Hardcoded extension→languageId fallback for common languages. + * + * The agents window does not load language services / built-in language + * extensions yet, so `ILanguageService.guessLanguageIdByFilepathOrFirstLine` + * returns `'unknown'` for everything except a small core set. Once the + * agents window starts loading language services this map becomes a + * pure fallback for the leftover `'unknown'` cases. The IDs match + * VS Code's built-in extension `package.json` contributions. */ +const EXTENSION_LANGUAGE_MAP: Record = { + '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', + '.jsx': 'javascriptreact', + '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', + '.tsx': 'typescriptreact', + '.py': 'python', '.pyw': 'python', + '.java': 'java', + '.c': 'c', '.h': 'c', + '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', + '.cs': 'csharp', + '.go': 'go', + '.rs': 'rust', + '.rb': 'ruby', + '.php': 'php', + '.html': 'html', '.htm': 'html', + '.css': 'css', '.scss': 'scss', '.less': 'less', + '.json': 'json', '.jsonc': 'jsonc', + '.md': 'markdown', + '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', + '.yaml': 'yaml', '.yml': 'yaml', + '.xml': 'xml', + '.sql': 'sql', + '.swift': 'swift', + '.kt': 'kotlin', '.kts': 'kotlin', + '.r': 'r', + '.lua': 'lua', + '.dart': 'dart', +}; + +export function resolveMobileDiffLanguageId(languageService: ILanguageService, diff: IFileDiffLike): string { + const uri = diff.modifiedURI ?? diff.originalURI; + if (!uri) { + return 'plaintext'; + } + // `guessLanguageIdByFilepathOrFirstLine` already handles unknown + // URI schemes (like `vscode-agent-host://`) through resource paths + // and basenames for extension matching. + const guessed = languageService.guessLanguageIdByFilepathOrFirstLine(uri); + if (guessed && guessed !== 'unknown') { + return guessed; + } + const name = basename(uri); + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''; + return EXTENSION_LANGUAGE_MAP[ext] ?? 'plaintext'; +} + +/** + * Tokenize a full text and return the per-line HTML (one entry per + * source line, in order). Uses `tokenizeToString` which awaits + * `TokenizationRegistry.getOrCreate(languageId)` — without that, sync + * tokenization returns null highlighting for any language whose + * textmate grammar hasn't been activated yet. + */ +export async function tokenizeFileLines(languageService: ILanguageService, text: string, languageId: string): Promise { + if (!text) { + return ['']; + } + const html = await tokenizeToString(languageService, text, languageId); + const inner = stripTokenizedWrapper(html); + return inner.split('
'); +} + +function stripTokenizedWrapper(html: string): string { + const openTag = '
'; + const closeTag = '
'; + if (html.startsWith(openTag) && html.endsWith(closeTag)) { + return html.slice(openTag.length, html.length - closeTag.length); + } + return html; +} + +export function hasMultipleTokenClasses(lines: readonly string[]): boolean { + for (const line of lines) { + if (line && /class="mtk[2-9]|class="mtk[1-9][0-9]/.test(line)) { + return true; + } + } + return false; +} + +type RegexTokenKind = 'comment' | 'string' | 'keyword' | 'number' | 'default'; + +interface IRegexToken { + start: number; + end: number; + kind: RegexTokenKind; +} + +type LangFamily = 'js' | 'python' | 'css' | 'html' | 'json' | 'shell' | 'generic'; + +const LANG_FAMILY: Record = { + javascript: 'js', javascriptreact: 'js', + typescript: 'js', typescriptreact: 'js', + java: 'js', csharp: 'js', go: 'js', rust: 'js', + cpp: 'js', c: 'js', swift: 'js', kotlin: 'js', dart: 'js', php: 'js', ruby: 'js', + python: 'python', + css: 'css', scss: 'css', less: 'css', + html: 'html', xml: 'html', + json: 'json', jsonc: 'json', + shellscript: 'shell', powershell: 'shell', +}; + +const JS_KEYWORDS = new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', + 'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', + 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', + 'of', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', + 'try', 'typeof', 'undefined', 'var', 'void', 'while', 'with', 'yield', + 'async', 'await', 'from', 'as', 'interface', 'type', 'enum', 'declare', + 'abstract', 'override', 'readonly', 'namespace', 'module', 'public', 'private', 'protected', +]); + +const PY_KEYWORDS = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', + 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', + 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', + 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', + 'try', 'while', 'with', 'yield', +]); + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function buildSpan(kind: RegexTokenKind, text: string): string { + if (kind === 'default' || !text) { + return escapeHtml(text); + } + return `${escapeHtml(text)}`; +} + +function regexTokenizeLine(line: string, lang: LangFamily): string { + const tokens: IRegexToken[] = []; + let pos = 0; + const len = line.length; + + while (pos < len) { + let matched = false; + + const commentPfx = lang === 'python' ? '#' : lang === 'shell' ? '#' : '//'; + if (line.startsWith(commentPfx, pos) || (lang === 'generic' && line.startsWith('#', pos))) { + tokens.push({ start: pos, end: len, kind: 'comment' }); + pos = len; + matched = true; + } + + if (!matched && lang !== 'python' && lang !== 'shell' && line.startsWith('/*', pos)) { + const end = line.indexOf('*/', pos + 2); + const tokenEnd = end === -1 ? len : end + 2; + tokens.push({ start: pos, end: tokenEnd, kind: 'comment' }); + pos = tokenEnd; + matched = true; + } + + if (!matched && (lang === 'js') && line[pos] === '`') { + let i = pos + 1; + while (i < len) { + if (line[i] === '\\') { i += 2; continue; } + if (line[i] === '`') { i++; break; } + i++; + } + tokens.push({ start: pos, end: i, kind: 'string' }); + pos = i; + matched = true; + } + + if (!matched && (line[pos] === '"' || line[pos] === '\'')) { + const q = line[pos]; + let i = pos + 1; + while (i < len) { + if (line[i] === '\\') { i += 2; continue; } + if (line[i] === q) { i++; break; } + i++; + } + tokens.push({ start: pos, end: i, kind: 'string' }); + pos = i; + matched = true; + } + + if (!matched && /[0-9]/.test(line[pos])) { + const m = line.slice(pos).match(/^0x[0-9a-fA-F]+|^[0-9]+\.?[0-9]*(?:[eE][+-]?[0-9]+)?/); + if (m) { + tokens.push({ start: pos, end: pos + m[0].length, kind: 'number' }); + pos += m[0].length; + matched = true; + } + } + + if (!matched && /[a-zA-Z_$]/.test(line[pos])) { + const m = line.slice(pos).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/); + if (m) { + const word = m[0]; + const keywords = lang === 'python' ? PY_KEYWORDS : JS_KEYWORDS; + const kind: RegexTokenKind = keywords.has(word) ? 'keyword' : 'default'; + tokens.push({ start: pos, end: pos + word.length, kind }); + pos += word.length; + matched = true; + } + } + + if (!matched) { + const prevTok = tokens[tokens.length - 1]; + if (prevTok && prevTok.kind === 'default') { + prevTok.end = pos + 1; + } else { + tokens.push({ start: pos, end: pos + 1, kind: 'default' }); + } + pos++; + } + } + + return tokens.map(t => buildSpan(t.kind, line.slice(t.start, t.end))).join(''); +} + +export function regexTokenizeLines(text: string, languageId: string): string[] { + if (!text) { + return ['']; + } + const lang: LangFamily = LANG_FAMILY[languageId] ?? 'generic'; + return text.split(/\r?\n/).map(line => regexTokenizeLine(line, lang)); +} + +export interface IDiffLine { + type: 'context' | 'added' | 'removed'; + lineNum?: number; + text: string; +} + +export interface IDiffHunk { + header: string; + lines: IDiffLine[]; +} + +const CONTEXT_LINES = 3; + +export function computeUnifiedDiff(original: string, modified: string): IDiffHunk[] { + const origLines = original.split(/\r?\n/); + const modLines = modified.split(/\r?\n/); + + const result = linesDiffComputers.getDefault().computeDiff(origLines, modLines, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 1000, + computeMoves: false, + }); + + if (result.changes.length === 0) { + return []; + } + + type Sub = { origStart: number; origEnd: number; modStart: number; modEnd: number }; + type Group = { subs: Sub[] }; + const groups: Group[] = []; + for (const change of result.changes) { + const sub: Sub = { + origStart: change.original.startLineNumber, + origEnd: change.original.endLineNumberExclusive, + modStart: change.modified.startLineNumber, + modEnd: change.modified.endLineNumberExclusive, + }; + const last = groups[groups.length - 1]; + const lastSub = last?.subs[last.subs.length - 1]; + if (lastSub && sub.origStart - lastSub.origEnd <= CONTEXT_LINES * 2) { + last!.subs.push(sub); + } else { + groups.push({ subs: [sub] }); + } + } + + const hunks: IDiffHunk[] = []; + for (const group of groups) { + const first = group.subs[0]; + const last = group.subs[group.subs.length - 1]; + const origLeading = Math.max(1, first.origStart - CONTEXT_LINES); + const modLeading = Math.max(1, first.modStart - CONTEXT_LINES); + const origTrailing = Math.min(origLines.length + 1, last.origEnd + CONTEXT_LINES); + const modTrailing = Math.min(modLines.length + 1, last.modEnd + CONTEXT_LINES); + + const lines: IDiffLine[] = []; + + for (let i = origLeading; i < first.origStart; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + + for (let s = 0; s < group.subs.length; s++) { + const sub = group.subs[s]; + for (let i = sub.origStart; i < sub.origEnd; i++) { + lines.push({ type: 'removed', lineNum: i, text: origLines[i - 1] ?? '' }); + } + for (let i = sub.modStart; i < sub.modEnd; i++) { + lines.push({ type: 'added', lineNum: i, text: modLines[i - 1] ?? '' }); + } + const next = group.subs[s + 1]; + if (next) { + for (let i = sub.origEnd; i < next.origStart; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + } + } + + for (let i = last.origEnd; i < origTrailing; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + + const origCount = origTrailing - origLeading; + const modCount = modTrailing - modLeading; + hunks.push({ + header: `@@ -${origLeading},${origCount} +${modLeading},${modCount} @@`, + lines, + }); + } + + return hunks; +} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts index d47658c7c9048..a4cd5d38f1e11 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts @@ -15,51 +15,13 @@ import { localize } from '../../../../../nls.js'; import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; import { URI } from '../../../../../base/common/uri.js'; import { basename } from '../../../../../base/common/resources.js'; -import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; import { generateTokensCSSForColorMap } from '../../../../../editor/common/languages/supports/tokenization.js'; +import { computeUnifiedDiff, hasMultipleTokenClasses, type IDiffHunk, regexTokenizeLines, resolveMobileDiffLanguageId, tokenizeFileLines } from './mobileDiffHelpers.js'; const $ = DOM.$; -/** Hardcoded extension→languageId fallback for common languages. - * - * The agents window does not load language services / built-in language - * extensions yet, so `ILanguageService.guessLanguageIdByFilepathOrFirstLine` - * returns `'unknown'` for everything except a small core set. Once the - * agents window starts loading language services this map becomes a - * pure fallback for the leftover `'unknown'` cases. The IDs match - * VS Code's built-in extension `package.json` contributions. */ -const EXTENSION_LANGUAGE_MAP: Record = { - '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', - '.jsx': 'javascriptreact', - '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', - '.tsx': 'typescriptreact', - '.py': 'python', '.pyw': 'python', - '.java': 'java', - '.c': 'c', '.h': 'c', - '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', - '.cs': 'csharp', - '.go': 'go', - '.rs': 'rust', - '.rb': 'ruby', - '.php': 'php', - '.html': 'html', '.htm': 'html', - '.css': 'css', '.scss': 'scss', '.less': 'less', - '.json': 'json', '.jsonc': 'jsonc', - '.md': 'markdown', - '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', - '.yaml': 'yaml', '.yml': 'yaml', - '.xml': 'xml', - '.sql': 'sql', - '.swift': 'swift', - '.kt': 'kotlin', '.kts': 'kotlin', - '.r': 'r', - '.lua': 'lua', - '.dart': 'dart', -}; - /** * Command ID for opening the {@link MobileDiffView}. * @@ -343,7 +305,7 @@ export class MobileDiffView extends Disposable { loadingEl.textContent = localize('diffView.loading', "Loading…"); const generation = this.renderGeneration; - const languageId = this.resolveLanguageId(diff); + const languageId = resolveMobileDiffLanguageId(this.languageService, diff); void this.loadAndRender(container, diff, languageId, generation); } @@ -400,7 +362,7 @@ export class MobileDiffView extends Disposable { // Inject a