Skip to content

Commit

Permalink
GH-7347: Added scroll-lock to the Output view.
Browse files Browse the repository at this point in the history
Closes #7347.

Signed-off-by: Akos Kitta <[email protected]>
  • Loading branch information
Akos Kitta committed Apr 14, 2020
1 parent c3d6b9f commit 6902633
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 16 deletions.
15 changes: 15 additions & 0 deletions packages/output/src/browser/output-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-con
import { Widget, KeybindingRegistry, KeybindingContext, ApplicationShell } from '@theia/core/lib/browser';
import { OUTPUT_WIDGET_KIND, OutputWidget } from './output-widget';
import { Command, CommandRegistry } from '@theia/core/lib/common';
import { OutputChannelManager } from '../common/output-channel';

export namespace OutputCommands {

Expand All @@ -37,6 +38,12 @@ export namespace OutputCommands {
label: 'Select All'
};

export const SCROLL_LOCK: Command = {
id: 'output:scrollLock',
label: 'Toggle Auto Scroll in Selected Channel',
category: OUTPUT_CATEGORY
};

}

/**
Expand Down Expand Up @@ -64,6 +71,9 @@ export class OutputContribution extends AbstractViewContribution<OutputWidget> {
@inject(OutputWidgetIsActiveContext)
protected readonly outputIsActiveContext: OutputWidgetIsActiveContext;

@inject(OutputChannelManager)
protected readonly outputChannelManager: OutputChannelManager;

constructor() {
super({
widgetId: OUTPUT_WIDGET_KIND,
Expand All @@ -88,6 +98,11 @@ export class OutputContribution extends AbstractViewContribution<OutputWidget> {
isVisible: () => this.outputIsActiveContext.isEnabled(),
execute: widget => this.withWidget(widget, outputWidget => outputWidget.selectAll())
});
commands.registerCommand(OutputCommands.SCROLL_LOCK, {
isEnabled: () => this.outputIsActiveContext.isEnabled(),
isVisible: () => this.outputIsActiveContext.isEnabled(),
execute: () => this.outputChannelManager.toggleScrollLock()
});
}

registerKeybindings(registry: KeybindingRegistry): void {
Expand Down
3 changes: 2 additions & 1 deletion packages/output/src/browser/output-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { ContainerModule } from 'inversify';
import { OutputWidget, OUTPUT_WIDGET_KIND } from './output-widget';
import { WidgetFactory, bindViewContribution, KeybindingContext } from '@theia/core/lib/browser';
import { WidgetFactory, bindViewContribution, KeybindingContext, FrontendApplicationContribution } from '@theia/core/lib/browser';
import { OutputContribution, OutputWidgetIsActiveContext } from './output-contribution';
import { OutputToolbarContribution } from './output-toolbar-contribution';
import { OutputChannelManager } from '../common/output-channel';
Expand All @@ -27,6 +27,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bindOutputPreferences(bind);
bind(OutputWidget).toSelf();
bind(OutputChannelManager).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(OutputChannelManager);

bind(WidgetFactory).toDynamicValue(context => ({
id: OUTPUT_WIDGET_KIND,
Expand Down
86 changes: 84 additions & 2 deletions packages/output/src/browser/output-toolbar-contribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
import { inject, injectable } from 'inversify';
import { OutputWidget } from './output-widget';
import { OutputChannelManager } from '../common/output-channel';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { OutputCommands } from './output-contribution';
import { OutputCommands, OutputContribution } from './output-contribution';
import * as React from 'react';

@injectable()
Expand All @@ -27,20 +28,30 @@ export class OutputToolbarContribution implements TabBarToolbarContribution {
@inject(OutputChannelManager)
protected readonly outputChannelManager: OutputChannelManager;

@inject(OutputContribution)
protected readonly outputContribution: OutputContribution;

async registerToolbarItems(toolbarRegistry: TabBarToolbarRegistry): Promise<void> {
toolbarRegistry.registerItem({
id: 'channels',
render: () => this.renderChannelSelector(),
isVisible: widget => (widget instanceof OutputWidget),
onDidChange: this.outputChannelManager.onListOrSelectionChange
});

toolbarRegistry.registerItem({
id: OutputCommands.CLEAR_OUTPUT_TOOLBAR.id,
command: OutputCommands.CLEAR_OUTPUT_TOOLBAR.id,
tooltip: 'Clear Output',
priority: 1,
});
toolbarRegistry.registerItem({
id: OutputCommands.SCROLL_LOCK.id,
render: () => <ScrollLockToolbarItem
key={OutputCommands.SCROLL_LOCK.id}
outputChannelManager={this.outputChannelManager} />,
isVisible: widget => widget instanceof OutputWidget,
priority: 2
});
}

protected readonly NONE = '<no channels>';
Expand Down Expand Up @@ -71,3 +82,74 @@ export class OutputToolbarContribution implements TabBarToolbarContribution {
}
};
}

export namespace ScrollLockToolbarItem {
export interface Props {
readonly outputChannelManager: OutputChannelManager;
}
export interface State {
readonly lockedChannels: Array<string>;
}
}
class ScrollLockToolbarItem extends React.Component<ScrollLockToolbarItem.Props, ScrollLockToolbarItem.State> {

protected readonly toDispose = new DisposableCollection();

constructor(props: Readonly<ScrollLockToolbarItem.Props>) {
super(props);
const lockedChannels = this.manager.getChannels().filter(({ isLocked: hasScrollLock }) => hasScrollLock).map(({ name }) => name);
this.state = { lockedChannels };
}

componentDidMount(): void {
this.toDispose.pushAll([
// Update when the selected channel changes.
this.manager.onSelectedChannelChange(() => this.setState({ lockedChannels: this.state.lockedChannels })),
// Update when the selected channel's scroll-lock state changes.
this.manager.onLockChange(({ name, isLocked: hasScrollLock }) => {
const lockedChannels = this.state.lockedChannels.slice();
if (hasScrollLock) {
lockedChannels.push(name);
} else {
const index = lockedChannels.indexOf(name);
if (index === -1) {
console.warn(`Could not unlock channel '${name}'. It was not locked.`);
} else {
lockedChannels.splice(index, 1);
}
}
this.setState({ lockedChannels });
}),
]);
}

componentWillUnmount(): void {
this.toDispose.dispose();
}

render(): React.ReactNode {
const { selectedChannel } = this.manager;
if (!selectedChannel) {
return undefined;
}
return <div
key='output:toggleScrollLock'
className={`fa fa-${selectedChannel.isLocked ? 'lock' : 'unlock'} item enabled`}
title={`Turn Auto Scrolling ${selectedChannel.isLocked ? 'On' : 'Off'}`}
onClick={this.toggleScrollLock} />;
}

protected readonly toggleScrollLock = (e: React.MouseEvent<HTMLElement>) => this.doToggleScrollLock(e);
protected doToggleScrollLock(e: React.MouseEvent<HTMLElement>): void {
const { selectedChannel } = this.manager;
if (selectedChannel) {
selectedChannel.toggleLocked();
e.stopPropagation();
}
}

private get manager(): OutputChannelManager {
return this.props.outputChannelManager;
}

}
17 changes: 11 additions & 6 deletions packages/output/src/browser/output-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,18 @@ export class OutputWidget extends ReactWidget {

protected onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
setTimeout(() => {
const div = document.getElementById(OutputWidget.IDs.CONTENTS) as HTMLDivElement;
if (div && div.children.length > 0) {
div.children[div.children.length - 1].scrollIntoView(false);
}
});
const { selectedChannel } = this.outputChannelManager;
const isLocked = selectedChannel && selectedChannel.isLocked || false;
if (!isLocked) {
setTimeout(() => {
const div = document.getElementById(OutputWidget.IDs.CONTENTS);
if (div && div.children.length > 0) {
div.children[div.children.length - 1].scrollIntoView(false);
}
});
}
}

}

export namespace OutputWidget {
Expand Down
96 changes: 89 additions & 7 deletions packages/output/src/common/output-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,60 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { Emitter, Event } from '@theia/core';
import { injectable, inject, postConstruct } from 'inversify';
import { Emitter, Event, Disposable, DisposableCollection } from '@theia/core';
import { StorageService, FrontendApplicationContribution } from '@theia/core/lib/browser';
import { OutputPreferences } from './output-preferences';
import { Disposable, DisposableCollection } from 'vscode-ws-jsonrpc';

@injectable()
export class OutputChannelManager implements Disposable {
export class OutputChannelManager implements FrontendApplicationContribution, Disposable {
protected readonly channels = new Map<string, OutputChannel>();
protected selectedChannelValue: OutputChannel | undefined;

protected readonly channelDeleteEmitter = new Emitter<{ channelName: string }>();
protected readonly channelAddedEmitter = new Emitter<OutputChannel>();
protected readonly selectedChannelEmitter: Emitter<void> = new Emitter<void>();
protected readonly listOrSelectionEmitter: Emitter<void> = new Emitter<void>();
protected readonly channelLockedEmitter = new Emitter<OutputChannel>();
readonly onChannelDelete = this.channelDeleteEmitter.event;
readonly onChannelAdded = this.channelAddedEmitter.event;
readonly onSelectedChannelChange = this.selectedChannelEmitter.event;
readonly onListOrSelectionChange = this.listOrSelectionEmitter.event;
readonly onLockChange = this.channelLockedEmitter.event;

protected toDispose = new DisposableCollection();
protected toDisposeOnChannelDeletion = new Map<string, DisposableCollection>();
protected lockedChannels = new Set<string>();

@inject(OutputPreferences)
protected readonly preferences: OutputPreferences;

@inject(StorageService)
protected readonly storageService: StorageService;

async onStart(): Promise<void> {
const lockedChannels = await this.storageService.getData<Array<string>>('theia:output-channel-manager:lockedChannels');
if (Array.isArray(lockedChannels)) {
for (const channelName of lockedChannels) {
this.lockedChannels.add(channelName);
}
}
}

onStop(): void {
const lockedChannels = Array.from(this.channels.values()).filter(({ isLocked }) => isLocked).map(({ name }) => name);
this.storageService.setData('theia:output-channel-manager:lockedChannels', lockedChannels);
}

@postConstruct()
protected init(): void {
this.toDispose.pushAll([
this.channelDeleteEmitter,
this.channelAddedEmitter,
this.selectedChannelEmitter,
this.listOrSelectionEmitter,
this.channelLockedEmitter
]);
this.getChannels().forEach(this.registerListener.bind(this));
this.toDispose.push(this.onChannelAdded(channel => {
this.listOrSelectionEmitter.fire(undefined);
Expand All @@ -54,16 +82,29 @@ export class OutputChannelManager implements Disposable {
}

protected registerListener(outputChannel: OutputChannel): void {
const { name } = outputChannel;
if (!this.selectedChannel) {
this.selectedChannel = outputChannel;
}
this.toDispose.push(outputChannel.onVisibilityChange(event => {
let toDisposePerChannel = this.toDisposeOnChannelDeletion.get(name);
if (!toDisposePerChannel) {
toDisposePerChannel = new DisposableCollection();
this.toDisposeOnChannelDeletion.set(name, toDisposePerChannel);
}
toDisposePerChannel.push(outputChannel);
toDisposePerChannel.push(outputChannel.onVisibilityChange(event => {
if (event.visible) {
this.selectedChannel = outputChannel;
} else if (outputChannel === this.selectedChannel) {
this.selectedChannel = this.getVisibleChannels()[0];
}
}));
toDisposePerChannel.push(outputChannel.onLockChange(() => this.channelLockedEmitter.fire(outputChannel)));
if (this.lockedChannels.has(name)) {
if (!outputChannel.isLocked) {
outputChannel.toggleLocked();
}
}
}

getChannel(name: string): OutputChannel {
Expand All @@ -78,7 +119,16 @@ export class OutputChannelManager implements Disposable {
}

deleteChannel(name: string): void {
const existing = this.channels.get(name);
if (!existing) {
console.warn(`Could not delete channel '${name}'. The channel does not exist.`);
return;
}
this.channels.delete(name);
const toDisposePerChannel = this.toDisposeOnChannelDeletion.get(name);
if (toDisposePerChannel) {
toDisposePerChannel.dispose();
}
this.channelDeleteEmitter.fire({ channelName: name });
}

Expand All @@ -90,7 +140,7 @@ export class OutputChannelManager implements Disposable {
return this.getChannels().filter(channel => channel.isVisible);
}

public dispose(): void {
dispose(): void {
this.toDispose.dispose();
}

Expand All @@ -103,20 +153,38 @@ export class OutputChannelManager implements Disposable {
this.selectedChannelEmitter.fire(undefined);
this.listOrSelectionEmitter.fire(undefined);
}

toggleScrollLock(channel: OutputChannel | undefined = this.selectedChannel): void {
if (!channel) {
console.warn(`Channel '${name}' does not exist.`);
return;
}
channel.toggleLocked();
}
}

export class OutputChannel {
export class OutputChannel implements Disposable {

private readonly visibilityChangeEmitter = new Emitter<{ visible: boolean }>();
private readonly lockedChangeEmitter = new Emitter<{ scrollLock: boolean }>();
private readonly contentChangeEmitter = new Emitter<OutputChannel>();
private readonly toDispose = new DisposableCollection();
private lines: string[] = [];
private currentLine: string | undefined;
private visible: boolean = true;
private locked: boolean = false;

readonly onVisibilityChange: Event<{ visible: boolean }> = this.visibilityChangeEmitter.event;
readonly onLockChange: Event<{ scrollLock: boolean }> = this.lockedChangeEmitter.event;
readonly onContentChange: Event<OutputChannel> = this.contentChangeEmitter.event;

constructor(readonly name: string, readonly preferences: OutputPreferences) { }
constructor(readonly name: string, readonly preferences: OutputPreferences) {
this.toDispose.pushAll([
this.visibilityChangeEmitter,
this.lockedChangeEmitter,
this.contentChangeEmitter
]);
}

append(value: string): void {
if (this.currentLine === undefined) {
Expand Down Expand Up @@ -163,4 +231,18 @@ export class OutputChannel {
get isVisible(): boolean {
return this.visible;
}

toggleLocked(): void {
this.locked = !this.locked;
this.lockedChangeEmitter.fire({ scrollLock: this.locked });
}

get isLocked(): boolean {
return this.locked;
}

dispose(): void {
this.toDispose.dispose();
}

}

0 comments on commit 6902633

Please sign in to comment.