Files
theia-code-os/packages/preview/src/browser/preview-widget.ts
mawkone 8bb5110148
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled
deploy: current vibn theia state
Made-with: Cursor
2026-02-27 12:01:08 -08:00

278 lines
11 KiB
TypeScript

// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import throttle = require('@theia/core/shared/lodash.throttle');
import { inject, injectable } from '@theia/core/shared/inversify';
import { Resource, MaybePromise } from '@theia/core';
import { Navigatable } from '@theia/core/lib/browser/navigatable';
import { BaseWidget, Message, addEventListener, codicon } from '@theia/core/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { Event, Emitter } from '@theia/core/lib/common';
import { PreviewHandler, PreviewHandlerProvider } from './preview-handler';
import { ThemeService } from '@theia/core/lib/browser/theming';
import { EditorPreferences } from '@theia/editor/lib/common/editor-preferences';
import { Disposable } from '@theia/core/lib/common/disposable';
import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
import { Range, Location } from '@theia/core/shared/vscode-languageserver-protocol';
export const PREVIEW_WIDGET_CLASS = 'theia-preview-widget';
const DEFAULT_ICON = codicon('eye');
let widgetCounter: number = 0;
export const PreviewWidgetOptions = Symbol('PreviewWidgetOptions');
export interface PreviewWidgetOptions {
resource: Resource
}
@injectable()
export class PreviewWidget extends BaseWidget implements Navigatable {
readonly uri: URI;
protected readonly resource: Resource;
protected previewHandler: PreviewHandler | undefined;
protected firstUpdate: (() => void) | undefined = undefined;
protected readonly onDidScrollEmitter = new Emitter<number>();
protected readonly onDidDoubleClickEmitter = new Emitter<Location>();
protected scrollBeyondLastLine: boolean;
constructor(
@inject(PreviewWidgetOptions) protected readonly options: PreviewWidgetOptions,
@inject(PreviewHandlerProvider) protected readonly previewHandlerProvider: PreviewHandlerProvider,
@inject(ThemeService) protected readonly themeService: ThemeService,
@inject(MonacoWorkspace) protected readonly workspace: MonacoWorkspace,
@inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences,
) {
super();
this.resource = this.options.resource;
this.uri = this.resource.uri;
this.id = 'preview-widget-' + widgetCounter++;
this.title.closable = true;
this.title.label = `Preview ${this.uri.path.base}`;
this.title.caption = this.title.label;
this.title.closable = true;
this.toDispose.push(this.onDidScrollEmitter);
this.toDispose.push(this.onDidDoubleClickEmitter);
this.addClass(PREVIEW_WIDGET_CLASS);
this.node.tabIndex = 0;
const previewHandler = this.previewHandler = this.previewHandlerProvider.findContribution(this.uri)[0];
if (!previewHandler) {
return;
}
this.title.iconClass = previewHandler.iconClass || DEFAULT_ICON;
this.initialize();
}
async initialize(): Promise<void> {
this.scrollBeyondLastLine = !!this.editorPreferences['editor.scrollBeyondLastLine'];
this.toDispose.push(this.editorPreferences.onPreferenceChanged(e => {
if (e.preferenceName === 'editor.scrollBeyondLastLine') {
this.scrollBeyondLastLine = !!this.editorPreferences['editor.scrollBeyondLastLine'];
this.forceUpdate();
}
}));
this.toDispose.push(this.resource);
if (this.resource.onDidChangeContents) {
this.toDispose.push(this.resource.onDidChangeContents(() => this.update()));
}
const updateIfAffected = (affectedUri?: string) => {
if (!affectedUri || affectedUri === this.uri.toString()) {
this.update();
}
};
this.toDispose.push(this.workspace.onDidOpenTextDocument(document => updateIfAffected(document.uri)));
this.toDispose.push(this.workspace.onDidChangeTextDocument(params => updateIfAffected(params.model.uri)));
this.toDispose.push(this.workspace.onDidCloseTextDocument(document => updateIfAffected(document.uri)));
this.toDispose.push(this.themeService.onDidColorThemeChange(() => this.update()));
this.firstUpdate = () => {
this.revealFragment(this.uri);
};
this.update();
}
protected override onBeforeAttach(msg: Message): void {
super.onBeforeAttach(msg);
this.toDispose.push(this.startScrollSync());
this.toDispose.push(this.startDoubleClickListener());
}
protected preventScrollNotification: boolean = false;
protected startScrollSync(): Disposable {
return addEventListener(this.node, 'scroll', throttle((event: UIEvent) => {
if (this.preventScrollNotification) {
return;
}
const scrollTop = this.node.scrollTop;
this.didScroll(scrollTop);
}, 50));
}
protected startDoubleClickListener(): Disposable {
return addEventListener(this.node, 'dblclick', (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target as HTMLElement;
let node: HTMLElement | null = target;
while (node && node instanceof HTMLElement) {
if (node.tagName === 'A') {
return;
}
node = node.parentElement;
}
const offsetParent = target.offsetParent as HTMLElement;
const offset = offsetParent.classList.contains(PREVIEW_WIDGET_CLASS) ? target.offsetTop : offsetParent.offsetTop;
this.didDoubleClick(offset);
});
}
getUri(): URI {
return this.uri;
}
getResourceUri(): URI | undefined {
return this.uri;
}
createMoveToUri(resourceUri: URI): URI | undefined {
return this.uri.withPath(resourceUri.path);
}
override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.node.focus();
this.update();
}
override onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
this.performUpdate();
}
protected forceUpdate(): void {
this.previousContent = undefined;
this.update();
}
protected previousContent: string | undefined = undefined;
protected async performUpdate(): Promise<void> {
if (!this.resource) {
return;
}
const uri = this.resource.uri;
const document = this.workspace.textDocuments.find(d => d.uri === uri.toString());
const content: MaybePromise<string> = document ? document.getText() : await this.resource.readContents();
if (content === this.previousContent) {
return;
}
this.previousContent = content;
const contentElement = await this.render(content, uri);
this.node.innerHTML = '';
if (contentElement) {
if (this.scrollBeyondLastLine) {
contentElement.classList.add('scrollBeyondLastLine');
}
this.node.appendChild(contentElement);
if (this.firstUpdate) {
this.firstUpdate();
this.firstUpdate = undefined;
}
}
}
protected async render(content: string, originUri: URI): Promise<HTMLElement | undefined> {
if (!this.previewHandler || !this.resource) {
return undefined;
}
return this.previewHandler.renderContent({ content, originUri });
}
protected revealFragment(uri: URI): void {
if (uri.fragment === '' || !this.previewHandler || !this.previewHandler.findElementForFragment) {
return;
}
const elementToReveal = this.previewHandler.findElementForFragment(this.node, uri.fragment);
if (elementToReveal) {
this.preventScrollNotification = true;
elementToReveal.scrollIntoView();
window.setTimeout(() => {
this.preventScrollNotification = false;
}, 50);
}
}
revealForSourceLine(sourceLine: number): void {
this.internalRevealForSourceLine(sourceLine);
}
protected readonly internalRevealForSourceLine: (sourceLine: number) => void = throttle((sourceLine: number) => {
if (!this.previewHandler || !this.previewHandler.findElementForSourceLine) {
return;
}
const elementToReveal = this.previewHandler.findElementForSourceLine(this.node, sourceLine);
if (elementToReveal) {
this.preventScrollNotification = true;
elementToReveal.scrollIntoView();
window.setTimeout(() => {
this.preventScrollNotification = false;
}, 50);
}
}, 50);
get onDidScroll(): Event<number> {
return this.onDidScrollEmitter.event;
}
protected fireDidScrollToSourceLine(line: number): void {
this.onDidScrollEmitter.fire(line);
}
protected didScroll(scrollTop: number): void {
if (!this.previewHandler || !this.previewHandler.getSourceLineForOffset) {
return;
}
const offset = scrollTop;
const line = this.previewHandler.getSourceLineForOffset(this.node, offset);
if (line) {
this.fireDidScrollToSourceLine(line);
}
}
get onDidDoubleClick(): Event<Location> {
return this.onDidDoubleClickEmitter.event;
}
protected fireDidDoubleClickToSourceLine(line: number): void {
if (!this.resource) {
return;
}
this.onDidDoubleClickEmitter.fire({
uri: this.resource.uri.toString(),
range: Range.create({ line, character: 0 }, { line, character: 0 })
});
}
protected didDoubleClick(offsetTop: number): void {
if (!this.previewHandler || !this.previewHandler.getSourceLineForOffset) {
return;
}
const line = this.previewHandler.getSourceLineForOffset(this.node, offsetTop) || 0;
this.fireDidDoubleClickToSourceLine(line);
}
}