Bläddra i källkod

Merge pull request #7379 from jasongrout/logconsoleinit

Clean up log console api a bit
Jason Grout 5 år sedan
förälder
incheckning
23755988bb

+ 9 - 0
packages/application/style/icons.css

@@ -190,6 +190,15 @@
   fill: var(--jp-brand-color1);
 }
 
+/* CSS for icons in status bar */
+#jp-main-statusbar .jp-mod-selected .jp-icon-selectable[fill] {
+  fill: white;
+}
+
+#jp-main-statusbar .jp-mod-selected .jp-icon-selectable-inverse[fill] {
+  fill: var(--jp-brand-color1);
+}
+
 /* special handling for splash icon CSS. While the theme CSS reloads during
    splash, the splash icon can loose theming. To prevent that, we set a
    default for its color variable */

+ 2 - 0
packages/logconsole-extension/package.json

@@ -41,7 +41,9 @@
     "@jupyterlab/notebook": "^2.0.0-alpha.1",
     "@jupyterlab/rendermime": "^2.0.0-alpha.1",
     "@jupyterlab/statusbar": "^2.0.0-alpha.1",
+    "@jupyterlab/ui-components": "^2.0.0-alpha.1",
     "@phosphor/signaling": "^1.3.0",
+    "@phosphor/widgets": "^1.9.0",
     "react": "~16.8.4"
   },
   "devDependencies": {

+ 139 - 470
packages/logconsole-extension/src/index.tsx

@@ -3,51 +3,49 @@
 
 import {
   ILabShell,
+  ILayoutRestorer,
   JupyterFrontEnd,
-  JupyterFrontEndPlugin,
-  ILayoutRestorer
+  JupyterFrontEndPlugin
 } from '@jupyterlab/application';
 
 import {
+  CommandToolbarButton,
+  ICommandPalette,
   MainAreaWidget,
-  WidgetTracker,
-  ToolbarButton
+  WidgetTracker
 } from '@jupyterlab/apputils';
 
-import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
+import { ISettingRegistry } from '@jupyterlab/coreutils';
 
 import {
   ILoggerRegistry,
-  LoggerRegistry,
   LogConsolePanel,
-  ILogger,
-  ILoggerChange,
-  ILoggerRegistryChange,
-  DEFAULT_LOG_ENTRY_LIMIT
+  LoggerRegistry
 } from '@jupyterlab/logconsole';
 
-import { ICommandPalette, VDomModel, VDomRenderer } from '@jupyterlab/apputils';
-
-import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
-
 import { IMainMenu } from '@jupyterlab/mainmenu';
 
-import React from 'react';
+import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
 
-import {
-  IStatusBar,
-  GroupItem,
-  IconItem,
-  TextItem,
-  interactiveItem
-} from '@jupyterlab/statusbar';
+import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
-import { ISettingRegistry } from '@jupyterlab/coreutils';
+import { IStatusBar } from '@jupyterlab/statusbar';
 
-import { Signal } from '@phosphor/signaling';
+import { DockLayout, Widget } from '@phosphor/widgets';
+
+import { LogConsoleStatus } from './status';
 
 const LOG_CONSOLE_PLUGIN_ID = '@jupyterlab/logconsole-extension:plugin';
 
+/**
+ * The command IDs used by the plugin.
+ */
+namespace CommandIDs {
+  export const addTimestamp = 'logconsole:add-timestamp';
+  export const clear = 'logconsole:clear';
+  export const open = 'logconsole:open';
+}
+
 /**
  * The Log Console extension.
  */
@@ -55,342 +53,39 @@ const logConsolePlugin: JupyterFrontEndPlugin<ILoggerRegistry> = {
   activate: activateLogConsole,
   id: LOG_CONSOLE_PLUGIN_ID,
   provides: ILoggerRegistry,
-  requires: [
-    ILabShell,
-    IMainMenu,
+  requires: [ILabShell, IRenderMimeRegistry, INotebookTracker],
+  optional: [
     ICommandPalette,
-    INotebookTracker,
-    IStatusBar,
-    IRenderMimeRegistry
+    ILayoutRestorer,
+    IMainMenu,
+    ISettingRegistry,
+    IStatusBar
   ],
-  optional: [ILayoutRestorer, ISettingRegistry],
   autoStart: true
 };
 
-/*
- * A namespace for LogConsoleStatusComponent.
- */
-namespace LogConsoleStatusComponent {
-  /**
-   * The props for the LogConsoleStatusComponent.
-   */
-  export interface IProps {
-    /**
-     * A click handler for the item. By default
-     * Log Console panel is launched.
-     */
-    handleClick: () => void;
-
-    /**
-     * Number of logs.
-     */
-    logCount: number;
-  }
-}
-
-/**
- * A pure functional component for a Log Console status item.
- *
- * @param props - the props for the component.
- *
- * @returns a tsx component for rendering the Log Console status.
- */
-function LogConsoleStatusComponent(
-  props: LogConsoleStatusComponent.IProps
-): React.ReactElement<LogConsoleStatusComponent.IProps> {
-  return (
-    <GroupItem
-      spacing={0}
-      onClick={props.handleClick}
-      title={`${props.logCount} logs in Log Console`}
-    >
-      <IconItem source={'jp-LogConsoleIcon'} />
-      <TextItem source={props.logCount} />
-    </GroupItem>
-  );
-}
-
-/**
- * A VDomRenderer widget for displaying the status of Log Console logs.
- */
-export class LogConsoleStatus extends VDomRenderer<LogConsoleStatus.Model> {
-  /**
-   * Construct the log console status widget.
-   *
-   * @param options - The status widget initialization options.
-   */
-  constructor(options: LogConsoleStatus.IOptions) {
-    super();
-    this._handleClick = options.handleClick;
-    this.model = new LogConsoleStatus.Model(options.loggerRegistry);
-    this.addClass(interactiveItem);
-    this.addClass('jp-LogConsoleStatusItem');
-
-    let flashRequestTimer: number = null;
-
-    this.model.activeSourceChanged.connect(() => {
-      if (
-        this.model.activeSource &&
-        this.model.flashEnabled &&
-        !this.model.isSourceLogsViewed(this.model.activeSource) &&
-        this.model.logCount > 0
-      ) {
-        this._showHighlighted();
-      } else {
-        this._clearHighlight();
-      }
-    });
-
-    this.model.flashEnabledChanged.connect(() => {
-      if (!this.model.flashEnabled) {
-        this._clearHighlight();
-      }
-    });
-
-    this.model.logChanged.connect(() => {
-      if (!this.model.flashEnabled || this.model.logCount === 0) {
-        // cancel existing request
-        clearTimeout(flashRequestTimer);
-        flashRequestTimer = null;
-        this._clearHighlight();
-        return;
-      }
-
-      const wasFlashed = this.hasClass('hilite') || this.hasClass('hilited');
-      if (wasFlashed) {
-        this._clearHighlight();
-        // cancel previous request
-        clearTimeout(flashRequestTimer);
-        flashRequestTimer = setTimeout(() => {
-          this._flashHighlight();
-        }, 100);
-      } else {
-        this._flashHighlight();
-      }
-    });
-  }
-
-  /**
-   * Render the log console status item.
-   */
-  render() {
-    if (this.model === null) {
-      return null;
-    } else {
-      return (
-        <LogConsoleStatusComponent
-          handleClick={this._handleClick}
-          logCount={this.model.logCount}
-        />
-      );
-    }
-  }
-
-  private _flashHighlight() {
-    this.addClass('hilite');
-  }
-
-  private _showHighlighted() {
-    this.addClass('hilited');
-  }
-
-  private _clearHighlight() {
-    this.removeClass('hilite');
-    this.removeClass('hilited');
-  }
-
-  private _handleClick: () => void;
-}
-
-/**
- * A namespace for Log Console log status.
- */
-export namespace LogConsoleStatus {
-  /**
-   * A VDomModel for the LogConsoleStatus item.
-   */
-  export class Model extends VDomModel {
-    /**
-     * Create a new LogConsoleStatus model.
-     *
-     * @param loggerRegistry - The logger registry providing the logs.
-     */
-    constructor(loggerRegistry: ILoggerRegistry) {
-      super();
-
-      this._loggerRegistry = loggerRegistry;
-
-      this._loggerRegistry.registryChanged.connect(
-        (sender: ILoggerRegistry, args: ILoggerRegistryChange) => {
-          const loggers = this._loggerRegistry.getLoggers();
-          for (let logger of loggers) {
-            if (this._loggersWatched.has(logger.source)) {
-              continue;
-            }
-
-            logger.logChanged.connect(
-              (sender: ILogger, change: ILoggerChange) => {
-                if (sender.source === this._activeSource) {
-                  this.stateChanged.emit();
-                  this.logChanged.emit();
-                }
-
-                // mark logger as dirty
-                this._loggersWatched.set(sender.source, false);
-              }
-            );
-
-            // mark logger as viewed
-            this._loggersWatched.set(logger.source, true);
-          }
-        }
-      );
-    }
-
-    /**
-     * Number of logs.
-     */
-    get logCount(): number {
-      if (this._activeSource) {
-        const logger = this._loggerRegistry.getLogger(this._activeSource);
-        return Math.min(logger.length, this._entryLimit);
-      }
-
-      return 0;
-    }
-
-    /**
-     * The name of the active log source
-     */
-    get activeSource(): string {
-      return this._activeSource;
-    }
-
-    set activeSource(name: string) {
-      if (this._activeSource === name) {
-        return;
-      }
-
-      this._activeSource = name;
-      this.activeSourceChanged.emit();
-
-      // refresh rendering
-      this.stateChanged.emit();
-    }
-
-    /**
-     * Flag to toggle flashing when new logs added.
-     */
-    get flashEnabled(): boolean {
-      return this._flashEnabled;
-    }
-
-    set flashEnabled(enabled: boolean) {
-      if (this._flashEnabled === enabled) {
-        return;
-      }
-
-      this._flashEnabled = enabled;
-      this.flashEnabledChanged.emit();
-
-      // refresh rendering
-      this.stateChanged.emit();
-    }
-
-    /**
-     * Log output entry limit.
-     */
-    set entryLimit(limit: number) {
-      if (limit > 0) {
-        this._entryLimit = limit;
-
-        // refresh rendering
-        this.stateChanged.emit();
-      }
-    }
-
-    /**
-     * Mark logs from the source as viewed.
-     *
-     * @param source - The name of the log source.
-     */
-    markSourceLogsViewed(source: string) {
-      this._loggersWatched.set(source, true);
-    }
-
-    /**
-     * Check if logs from the source are viewed.
-     *
-     * @param source - The name of the log source.
-     *
-     * @returns True if logs from source are viewer.
-     */
-    isSourceLogsViewed(source: string): boolean {
-      return (
-        !this._loggersWatched.has(source) ||
-        this._loggersWatched.get(source) === true
-      );
-    }
-
-    /**
-     * A signal emitted when the log model changes.
-     */
-    public logChanged = new Signal<this, void>(this);
-    /**
-     * A signal emitted when the active log source changes.
-     */
-    public activeSourceChanged = new Signal<this, void>(this);
-    /**
-     * A signal emitted when the flash enablement changes.
-     */
-    public flashEnabledChanged = new Signal<this, void>(this);
-    private _flashEnabled: boolean = true;
-    private _loggerRegistry: ILoggerRegistry;
-    private _activeSource: string = null;
-    private _entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
-    // A map storing keys as source names of the loggers watched
-    // and values as whether logs from the source are viewed
-    private _loggersWatched: Map<string, boolean> = new Map();
-  }
-
-  /**
-   * Options for creating a new LogConsoleStatus item
-   */
-  export interface IOptions {
-    /**
-     * The logger registry providing the logs.
-     */
-    loggerRegistry: ILoggerRegistry;
-
-    /**
-     * A click handler for the item. By default
-     * Log Console panel is launched.
-     */
-    handleClick: () => void;
-  }
-}
-
 /**
  * Activate the Log Console extension.
  */
 function activateLogConsole(
   app: JupyterFrontEnd,
   labShell: ILabShell,
-  mainMenu: IMainMenu,
-  palette: ICommandPalette,
-  nbtracker: INotebookTracker,
-  statusBar: IStatusBar,
   rendermime: IRenderMimeRegistry,
+  nbtracker: INotebookTracker,
+  palette: ICommandPalette | null,
   restorer: ILayoutRestorer | null,
-  settingRegistry: ISettingRegistry | null
+  mainMenu: IMainMenu | null,
+  settingRegistry: ISettingRegistry | null,
+  statusBar: IStatusBar | null
 ): ILoggerRegistry {
   let logConsoleWidget: MainAreaWidget<LogConsolePanel> = null;
-  let entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
-  let flashEnabled: boolean = true;
+  let logConsolePanel: LogConsolePanel = null;
 
-  const loggerRegistry = new LoggerRegistry(rendermime);
-  const command = 'logconsole:open';
-  const category: string = 'Main Area';
+  const loggerRegistry = new LoggerRegistry({
+    defaultRendermime: rendermime,
+    // The maxLength is reset below from settings
+    maxLength: 1000
+  });
 
   const tracker = new WidgetTracker<MainAreaWidget<LogConsolePanel>>({
     namespace: 'logconsole'
@@ -398,11 +93,7 @@ function activateLogConsole(
 
   if (restorer) {
     void restorer.restore(tracker, {
-      command,
-      args: obj => ({
-        fromRestorer: true,
-        activeSource: obj.content.activeSource
-      }),
+      command: CommandIDs.open,
       name: () => 'logconsole'
     });
   }
@@ -411,48 +102,46 @@ function activateLogConsole(
     loggerRegistry: loggerRegistry,
     handleClick: () => {
       if (!logConsoleWidget) {
-        createLogConsoleWidget();
+        createLogConsoleWidget({
+          insertMode: 'split-bottom',
+          ref: app.shell.currentWidget.id
+        });
       } else {
-        logConsoleWidget.activate();
+        app.shell.activateById(logConsoleWidget.id);
       }
     }
   });
 
-  const createLogConsoleWidget = () => {
-    let activeSource: string = nbtracker.currentWidget
-      ? nbtracker.currentWidget.context.path
-      : null;
+  interface ILogConsoleOptions {
+    source?: string;
+    insertMode?: DockLayout.InsertMode;
+    ref?: string;
+  }
+
+  const createLogConsoleWidget = (options: ILogConsoleOptions = {}) => {
+    logConsolePanel = new LogConsolePanel(loggerRegistry);
+
+    logConsolePanel.source =
+      options.source !== undefined
+        ? options.source
+        : nbtracker.currentWidget
+        ? nbtracker.currentWidget.context.path
+        : null;
 
-    const logConsolePanel = new LogConsolePanel(loggerRegistry);
     logConsoleWidget = new MainAreaWidget({ content: logConsolePanel });
     logConsoleWidget.addClass('jp-LogConsole');
     logConsoleWidget.title.closable = true;
     logConsoleWidget.title.label = 'Log Console';
     logConsoleWidget.title.iconClass = 'jp-LogConsoleIcon';
-    logConsolePanel.entryLimit = entryLimit;
-
-    const addTimestampButton = new ToolbarButton({
-      onClick: (): void => {
-        if (!logConsolePanel.activeSource) {
-          return;
-        }
-
-        const logger = loggerRegistry.getLogger(logConsolePanel.activeSource);
-        logger.log({ type: 'html', data: '<hr>' });
-      },
-      iconClassName: 'jp-AddIcon',
-      tooltip: 'Add Timestamp',
-      label: 'Add Timestamp'
+
+    const addTimestampButton = new CommandToolbarButton({
+      commands: app.commands,
+      id: CommandIDs.addTimestamp
     });
 
-    const clearButton = new ToolbarButton({
-      onClick: (): void => {
-        const logger = loggerRegistry.getLogger(logConsolePanel.activeSource);
-        logger.clear();
-      },
-      iconClassName: 'fa fa-ban clear-icon',
-      tooltip: 'Clear Logs',
-      label: 'Clear Logs'
+    const clearButton = new CommandToolbarButton({
+      commands: app.commands,
+      id: CommandIDs.clear
     });
 
     logConsoleWidget.toolbar.addItem(
@@ -461,43 +150,38 @@ function activateLogConsole(
     );
     logConsoleWidget.toolbar.addItem('lab-output-console-clear', clearButton);
 
-    void tracker.add(logConsoleWidget);
+    logConsolePanel.sourceChanged.connect(() => {
+      app.commands.notifyCommandChanged();
+    });
 
-    logConsolePanel.attached.connect(() => {
-      status.model.markSourceLogsViewed(status.model.activeSource);
-      status.model.flashEnabled = false;
+    logConsolePanel.sourceDisplayed.connect((panel, { source, version }) => {
+      status.model.sourceDisplayed(source, version);
     });
 
     logConsoleWidget.disposed.connect(() => {
       logConsoleWidget = null;
-      status.model.flashEnabled = flashEnabled;
+      logConsolePanel = null;
+      app.commands.notifyCommandChanged();
     });
 
     app.shell.add(logConsoleWidget, 'main', {
-      ref: '',
-      mode: 'split-bottom'
+      ref: options.ref,
+      mode: options.insertMode
     });
+    void tracker.add(logConsoleWidget);
 
     logConsoleWidget.update();
-
-    app.shell.activateById(logConsoleWidget.id);
-
-    if (activeSource) {
-      logConsolePanel.activeSource = activeSource;
-    }
+    app.commands.notifyCommandChanged();
   };
 
-  app.commands.addCommand(command, {
+  app.commands.addCommand(CommandIDs.open, {
     label: 'Show Log Console',
-    execute: (args: any) => {
-      if (!logConsoleWidget) {
-        createLogConsoleWidget();
-
-        if (args && args.activeSource) {
-          logConsoleWidget.content.activeSource = args.activeSource;
-        }
-      } else if (!(args && args.fromRestorer)) {
+    execute: (options: ILogConsoleOptions = {}) => {
+      // Toggle the display
+      if (logConsoleWidget) {
         logConsoleWidget.dispose();
+      } else {
+        createLogConsoleWidget(options);
       }
     },
     isToggled: () => {
@@ -505,87 +189,74 @@ function activateLogConsole(
     }
   });
 
-  mainMenu.viewMenu.addGroup([{ command }]);
-  palette.addItem({ command, category });
-  app.contextMenu.addItem({
-    command: command,
-    selector: '.jp-Notebook'
+  app.commands.addCommand(CommandIDs.addTimestamp, {
+    label: 'Add Timestamp',
+    execute: () => {
+      const logger = loggerRegistry.getLogger(logConsolePanel.source);
+      logger.log({ type: 'html', data: '<hr>' });
+    },
+    isEnabled: () => logConsolePanel && logConsolePanel.source !== null,
+    iconClass: 'jp-AddIcon'
   });
 
-  let appRestored = false;
-
-  void app.restored.then(() => {
-    appRestored = true;
+  app.commands.addCommand(CommandIDs.clear, {
+    label: 'Clear Log',
+    execute: () => {
+      const logger = loggerRegistry.getLogger(logConsolePanel.source);
+      logger.clear();
+    },
+    isEnabled: () => logConsolePanel && logConsolePanel.source !== null,
+    iconClass: 'fa fa-ban clear-icon'
   });
 
-  statusBar.registerStatusItem('@jupyterlab/logconsole-extension:status', {
-    item: status,
-    align: 'left',
-    isActive: () => true,
-    activeStateChanged: status.model!.stateChanged
+  app.contextMenu.addItem({
+    command: CommandIDs.open,
+    selector: '.jp-Notebook'
   });
+  if (mainMenu) {
+    mainMenu.viewMenu.addGroup([{ command: CommandIDs.open }]);
+  }
+  if (palette) {
+    palette.addItem({ command: CommandIDs.open, category: 'Main Area' });
+  }
+  if (statusBar) {
+    statusBar.registerStatusItem('@jupyterlab/logconsole-extension:status', {
+      item: status,
+      align: 'left',
+      isActive: () => true,
+      activeStateChanged: status.model!.stateChanged
+    });
+  }
 
-  nbtracker.widgetAdded.connect(
-    (sender: INotebookTracker, nb: NotebookPanel) => {
-      nb.activated.connect((nb: NotebookPanel, args: void) => {
-        // set activeSource only after app is restored
-        // in order to allow restorer to restore previous activeSource
-        if (!appRestored) {
-          return;
-        }
-
-        const sourceName = nb.context.path;
-        if (logConsoleWidget) {
-          logConsoleWidget.content.activeSource = sourceName;
-          status.model.markSourceLogsViewed(sourceName);
-          void tracker.save(logConsoleWidget);
-        }
-        status.model.activeSource = sourceName;
-      });
-
-      nb.disposed.connect((nb: NotebookPanel, args: void) => {
-        const sourceName = nb.context.path;
-        if (
-          logConsoleWidget &&
-          logConsoleWidget.content.activeSource === sourceName
-        ) {
-          logConsoleWidget.content.activeSource = null;
-          void tracker.save(logConsoleWidget);
-        }
-        if (status.model.activeSource === sourceName) {
-          status.model.activeSource = null;
-        }
-      });
+  function setSource(newValue: Widget) {
+    if (logConsoleWidget && newValue === logConsoleWidget) {
+      // Do not change anything if we are just focusing on ourselves
+      return;
     }
-  );
-
-  labShell.currentChanged.connect((_, change) => {
-    const newValue = change.newValue;
-
-    // if a new tab is activated which is not a notebook,
-    // then reset log display and count
-    if (newValue && newValue !== logConsoleWidget && !nbtracker.has(newValue)) {
-      if (logConsoleWidget) {
-        logConsoleWidget.content.activeSource = null;
-        void tracker.save(logConsoleWidget);
-      }
 
-      status.model.activeSource = null;
+    let source: string | null;
+    if (newValue && nbtracker.has(newValue)) {
+      source = (newValue as NotebookPanel).context.path;
+    } else {
+      source = null;
+    }
+    if (logConsoleWidget) {
+      logConsolePanel.source = source;
     }
+    status.model.source = source;
+  }
+  void app.restored.then(() => {
+    // Set source only after app is restored in order to allow restorer to
+    // restore previous source first, which may set the renderer
+    setSource(labShell.currentWidget);
+    labShell.currentChanged.connect((_, { newValue }) => setSource(newValue));
   });
 
   if (settingRegistry) {
     const updateSettings = (settings: ISettingRegistry.ISettings): void => {
-      const maxLogEntries = settings.get('maxLogEntries').composite as number;
-      entryLimit = maxLogEntries;
-
-      if (logConsoleWidget) {
-        logConsoleWidget.content.entryLimit = entryLimit;
-      }
-      status.model.entryLimit = entryLimit;
-
-      flashEnabled = settings.get('flash').composite as boolean;
-      status.model.flashEnabled = !logConsoleWidget && flashEnabled;
+      loggerRegistry.maxLength = settings.get('maxLogEntries')
+        .composite as number;
+      status.model.flashEnabled = settings.get('flash').composite as boolean;
     };
 
     Promise.all([settingRegistry.load(LOG_CONSOLE_PLUGIN_ID), app.restored])
@@ -601,8 +272,6 @@ function activateLogConsole(
   }
 
   return loggerRegistry;
-  // The notebook can call this command.
-  // When is the output model disposed?
 }
 
 export default [logConsolePlugin];

+ 346 - 0
packages/logconsole-extension/src/status.tsx

@@ -0,0 +1,346 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { VDomModel, VDomRenderer } from '@jupyterlab/apputils';
+
+import {
+  ILogger,
+  ILoggerChange,
+  ILoggerRegistry
+} from '@jupyterlab/logconsole';
+
+import { GroupItem, TextItem, interactiveItem } from '@jupyterlab/statusbar';
+
+import { DefaultIconReact } from '@jupyterlab/ui-components';
+
+import { Signal } from '@phosphor/signaling';
+
+import React from 'react';
+
+/**
+ * A pure functional component for a Log Console status item.
+ *
+ * @param props - the props for the component.
+ *
+ * @returns a tsx component for rendering the Log Console status.
+ */
+function LogConsoleStatusComponent(
+  props: LogConsoleStatusComponent.IProps
+): React.ReactElement<LogConsoleStatusComponent.IProps> {
+  return (
+    <GroupItem
+      spacing={0}
+      onClick={props.handleClick}
+      title={`${props.messages} messages in current log`}
+    >
+      <DefaultIconReact name={'list'} top={'2px'} kind={'statusBar'} />
+      <TextItem source={props.messages} />
+    </GroupItem>
+  );
+}
+
+/*
+ * A namespace for LogConsoleStatusComponent.
+ */
+namespace LogConsoleStatusComponent {
+  /**
+   * The props for the LogConsoleStatusComponent.
+   */
+  export interface IProps {
+    /**
+     * A click handler for the item. By default
+     * Log Console panel is launched.
+     */
+    handleClick: () => void;
+
+    /**
+     * Number of log messages.
+     */
+    messages: number;
+  }
+}
+
+/**
+ * A VDomRenderer widget for displaying the status of Log Console logs.
+ */
+export class LogConsoleStatus extends VDomRenderer<LogConsoleStatus.Model> {
+  /**
+   * Construct the log console status widget.
+   *
+   * @param options - The status widget initialization options.
+   */
+  constructor(options: LogConsoleStatus.IOptions) {
+    super();
+    this._handleClick = options.handleClick;
+    this.model = new LogConsoleStatus.Model(options.loggerRegistry);
+    this.addClass(interactiveItem);
+    this.addClass('jp-LogConsoleStatusItem');
+  }
+
+  /**
+   * Render the log console status item.
+   */
+  render() {
+    if (this.model === null || this.model.version === 0) {
+      this.hide();
+      return null;
+    }
+    this.show();
+    let {
+      flashEnabled,
+      messages,
+      source,
+      version,
+      versionDisplayed,
+      versionNotified
+    } = this.model;
+    if (source !== null && flashEnabled && version > versionNotified) {
+      this._flashHighlight();
+      this.model.sourceNotified(source, version);
+    } else if (source !== null && flashEnabled && version > versionDisplayed) {
+      this._showHighlighted();
+    } else {
+      this._clearHighlight();
+    }
+
+    return (
+      <LogConsoleStatusComponent
+        handleClick={this._handleClick}
+        messages={messages}
+      />
+    );
+  }
+
+  private _flashHighlight() {
+    this._showHighlighted();
+
+    // To make sure the browser triggers the animation, we remove the class,
+    // wait for an animation frame, then add it back
+    this.removeClass('jp-LogConsole-flash');
+    requestAnimationFrame(() => {
+      this.addClass('jp-LogConsole-flash');
+    });
+  }
+
+  private _showHighlighted() {
+    this.addClass('jp-mod-selected');
+  }
+
+  private _clearHighlight() {
+    this.removeClass('jp-LogConsole-flash');
+    this.removeClass('jp-mod-selected');
+  }
+
+  private _handleClick: () => void;
+}
+
+/**
+ * A namespace for Log Console log status.
+ */
+export namespace LogConsoleStatus {
+  /**
+   * A VDomModel for the LogConsoleStatus item.
+   */
+  export class Model extends VDomModel {
+    /**
+     * Create a new LogConsoleStatus model.
+     *
+     * @param loggerRegistry - The logger registry providing the logs.
+     */
+    constructor(loggerRegistry: ILoggerRegistry) {
+      super();
+
+      this._loggerRegistry = loggerRegistry;
+      this._loggerRegistry.registryChanged.connect(
+        this._handleLogRegistryChange,
+        this
+      );
+      this._handleLogRegistryChange();
+    }
+
+    /**
+     * Number of messages currently in the current source.
+     */
+    get messages(): number {
+      if (this._source === null) {
+        return 0;
+      }
+      const logger = this._loggerRegistry.getLogger(this._source);
+      return logger.length;
+    }
+
+    /**
+     * The number of messages ever stored by the current source.
+     */
+    get version(): number {
+      if (this._source === null) {
+        return 0;
+      }
+      const logger = this._loggerRegistry.getLogger(this._source);
+      return logger.version;
+    }
+
+    /**
+     * The name of the active log source
+     */
+    get source(): string | null {
+      return this._source;
+    }
+
+    set source(name: string | null) {
+      if (this._source === name) {
+        return;
+      }
+
+      this._source = name;
+
+      // refresh rendering
+      this.stateChanged.emit();
+    }
+
+    /**
+     * The last source version that was displayed.
+     */
+    get versionDisplayed(): number {
+      if (this._source === null) {
+        return 0;
+      }
+      return this._sourceVersion.get(this.source).lastDisplayed;
+    }
+
+    /**
+     * The last source version we notified the user about.
+     */
+    get versionNotified(): number {
+      if (this._source === null) {
+        return 0;
+      }
+      return this._sourceVersion.get(this.source).lastNotified;
+    }
+
+    /**
+     * Flag to toggle flashing when new logs added.
+     */
+    get flashEnabled(): boolean {
+      return this._flashEnabled;
+    }
+
+    set flashEnabled(enabled: boolean) {
+      if (this._flashEnabled === enabled) {
+        return;
+      }
+
+      this._flashEnabled = enabled;
+      this.flashEnabledChanged.emit();
+
+      // refresh rendering
+      this.stateChanged.emit();
+    }
+
+    /**
+     * Record the last source version displayed to the user.
+     *
+     * @param source - The name of the log source.
+     * @param version - The version of the log that was displayed.
+     *
+     * #### Notes
+     * This will also update the last notified version so that the last
+     * notified version is always at least the last displayed version.
+     */
+    sourceDisplayed(source: string | null, version: number) {
+      if (source === null) {
+        return;
+      }
+      const versions = this._sourceVersion.get(source);
+      let change = false;
+      if (versions.lastDisplayed < version) {
+        versions.lastDisplayed = version;
+        change = true;
+      }
+      if (versions.lastNotified < version) {
+        versions.lastNotified = version;
+        change = true;
+      }
+      if (change && source === this._source) {
+        this.stateChanged.emit();
+      }
+    }
+
+    /**
+     * Record a source version we notified the user about.
+     *
+     * @param source - The name of the log source.
+     * @param version - The version of the log.
+     */
+    sourceNotified(source: string | null, version: number) {
+      if (source === null) {
+        return;
+      }
+      const versions = this._sourceVersion.get(source);
+      if (versions.lastNotified < version) {
+        versions.lastNotified = version;
+        if (source === this._source) {
+          this.stateChanged.emit();
+        }
+      }
+    }
+
+    private _handleLogRegistryChange() {
+      const loggers = this._loggerRegistry.getLoggers();
+      for (let logger of loggers) {
+        if (!this._sourceVersion.has(logger.source)) {
+          logger.logChanged.connect(this._handleLogChange, this);
+          this._sourceVersion.set(logger.source, {
+            lastDisplayed: 0,
+            lastNotified: 0
+          });
+        }
+      }
+    }
+
+    private _handleLogChange({ source }: ILogger, change: ILoggerChange) {
+      if (source === this._source) {
+        this.stateChanged.emit();
+      }
+    }
+
+    /**
+     * A signal emitted when the flash enablement changes.
+     */
+    public flashEnabledChanged = new Signal<this, void>(this);
+    private _flashEnabled: boolean = true;
+    private _loggerRegistry: ILoggerRegistry;
+    private _source: string = null;
+    /**
+     * The view status of each source.
+     *
+     * #### Notes
+     * Keys are source names, value is a list of two numbers. The first
+     * represents the version of the messages that was last displayed to the
+     * user, the second represents the version that we last notified the user
+     * about.
+     */
+    private _sourceVersion: Map<string, IVersionInfo> = new Map();
+  }
+
+  interface IVersionInfo {
+    lastDisplayed: number;
+    lastNotified: number;
+  }
+
+  /**
+   * Options for creating a new LogConsoleStatus item
+   */
+  export interface IOptions {
+    /**
+     * The logger registry providing the logs.
+     */
+    loggerRegistry: ILoggerRegistry;
+
+    /**
+     * A click handler for the item. By default
+     * Log Console panel is launched.
+     */
+    handleClick: () => void;
+  }
+}

+ 28 - 11
packages/logconsole-extension/style/base.css

@@ -1,21 +1,38 @@
-:root [data-jp-theme-light='true'] {
-  --jp-icon-output-console: url('./list-icon-light.svg');
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+.jp-LogConsoleIcon {
+  background-image: var(--jp-icon-list);
 }
 
-:root [data-jp-theme-light='false'] {
-  --jp-icon-output-console: url('./list-icon-dark.svg');
+@keyframes flash {
+  0% {
+    background-color: var(--jp-brand-color1);
+  }
+  50% {
+    color: unset;
+    background-color: unset;
+  }
+  100% {
+    background-color: var(--jp-brand-color1);
+  }
 }
 
-.jp-LogConsoleIcon {
-  background-image: var(--jp-icon-output-console);
+.jp-LogConsoleStatusItem.jp-LogConsole-flash {
+  animation: flash 300ms both;
 }
 
-.jp-LogConsoleStatusItem.hilite {
-  transition: background-color 1s ease-out;
-  background-color: var(--jp-info-color0);
+.jp-LogConsoleStatusItem.jp-mod-selected {
+  background-color: var(--jp-brand-color1);
 }
-.jp-LogConsoleStatusItem.hilited {
-  background-color: var(--jp-info-color0);
+
+/* Need to be very specific to override the typestyle styles on status bar components */
+.jp-LogConsoleStatusItem.jp-mod-selected,
+.jp-LogConsoleStatusItem.jp-mod-selected div,
+.jp-LogConsoleStatusItem.jp-mod-selected div span {
+  color: white;
 }
 
 .jp-LogConsole .clear-icon {

+ 2 - 0
packages/logconsole-extension/style/index.css

@@ -4,6 +4,8 @@
 |----------------------------------------------------------------------------*/
 
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
+@import url('~@phosphor/widgets/style/index.css');
+@import url('~@jupyterlab/ui-components/style/index.css');
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/statusbar/style/index.css');
 @import url('~@jupyterlab/rendermime/style/index.css');

+ 0 - 1
packages/logconsole-extension/style/list-icon-dark.svg

@@ -1 +0,0 @@
-<svg fill="#e0e0e0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 5v14H5V5h14m1.1-2H3.9c-.5 0-.9.4-.9.9v16.2c0 .4.4.9.9.9h16.2c.4 0 .9-.5.9-.9V3.9c0-.5-.5-.9-.9-.9zM11 7h6v2h-6V7zm0 4h6v2h-6v-2zm0 4h6v2h-6zM7 7h2v2H7zm0 4h2v2H7zm0 4h2v2H7z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

+ 0 - 1
packages/logconsole-extension/style/list-icon-light.svg

@@ -1 +0,0 @@
-<svg fill="#616161" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 5v14H5V5h14m1.1-2H3.9c-.5 0-.9.4-.9.9v16.2c0 .4.4.9.9.9h16.2c.4 0 .9-.5.9-.9V3.9c0-.5-.5-.9-.9-.9zM11 7h6v2h-6V7zm0 4h6v2h-6v-2zm0 4h6v2h-6zM7 7h2v2H7zm0 4h2v2H7zm0 4h2v2H7z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

+ 3 - 0
packages/logconsole-extension/tsconfig.json

@@ -29,6 +29,9 @@
     },
     {
       "path": "../statusbar"
+    },
+    {
+      "path": "../ui-components"
     }
   ]
 }

+ 2 - 0
packages/logconsole/src/index.ts

@@ -1,5 +1,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
+export * from './logger';
+export * from './registry';
 export * from './tokens';
 export * from './widget';

+ 299 - 0
packages/logconsole/src/logger.ts

@@ -0,0 +1,299 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { nbformat } from '@jupyterlab/coreutils';
+
+import { IOutputAreaModel, OutputAreaModel } from '@jupyterlab/outputarea';
+
+import {
+  IRenderMimeRegistry,
+  IOutputModel,
+  OutputModel
+} from '@jupyterlab/rendermime';
+
+import { ISignal, Signal } from '@phosphor/signaling';
+
+import {
+  ILogger,
+  ILoggerChange,
+  ILoggerOutputAreaModel,
+  ILogPayload
+} from './tokens';
+
+/**
+ * Custom Notebook Output with timestamp member.
+ */
+interface ITimestampedOutput extends nbformat.IBaseOutput {
+  /**
+   * Date & time when output is logged in integer representation.
+   */
+  timestamp: number;
+}
+
+/**
+ * Custom Notebook Output with optional timestamp.
+ */
+type IOutputWithTimestamp = nbformat.IOutput | ITimestampedOutput;
+
+/**
+ * Log Output Model with timestamp which provides
+ * item information for Output Area Model.
+ */
+export class LogOutputModel extends OutputModel {
+  /**
+   * Construct a LogOutputModel.
+   *
+   * @param options - The model initialization options.
+   */
+  constructor(options: LogOutputModel.IOptions) {
+    super(options);
+
+    this.timestamp = new Date(options.value.timestamp as number);
+  }
+
+  /**
+   * Date & time when output is logged.
+   */
+  timestamp: Date = null;
+}
+
+/**
+ * Log Output Model namespace that defines initialization options.
+ */
+namespace LogOutputModel {
+  export interface IOptions extends IOutputModel.IOptions {
+    value: IOutputWithTimestamp;
+  }
+}
+
+/**
+ * Implementation of `IContentFactory` for Output Area Model
+ * which creates LogOutputModel instances.
+ */
+class LogConsoleModelContentFactory extends OutputAreaModel.ContentFactory {
+  /**
+   * Create a rendermime output model from notebook output.
+   */
+  createOutputModel(options: IOutputModel.IOptions): LogOutputModel {
+    return new LogOutputModel(options);
+  }
+}
+
+/**
+ * Output Area Model implementation which is able to
+ * limit number of outputs stored.
+ */
+export class LoggerOutputAreaModel extends OutputAreaModel
+  implements ILoggerOutputAreaModel {
+  constructor({ maxLength, ...options }: LoggerOutputAreaModel.IOptions) {
+    super(options);
+    this.maxLength = maxLength;
+  }
+
+  /**
+   * Add an output, which may be combined with previous output.
+   *
+   * @returns The total number of outputs.
+   *
+   * #### Notes
+   * The output bundle is copied. Contiguous stream outputs of the same `name`
+   * are combined. The oldest outputs are possibly removed to ensure the total
+   * number of outputs is at most `.maxLength`.
+   */
+  add(output: nbformat.IOutput): number {
+    super.add(output);
+    this._applyMaxLength();
+    return this.length;
+  }
+
+  /**
+   * Maximum number of outputs to store in the model.
+   */
+  get maxLength(): number {
+    return this._maxLength;
+  }
+  set maxLength(value: number) {
+    this._maxLength = value;
+    this._applyMaxLength();
+  }
+
+  /**
+   * Manually apply length limit.
+   */
+  private _applyMaxLength() {
+    if (this.list.length > this._maxLength) {
+      this.list.removeRange(0, this.list.length - this._maxLength);
+    }
+  }
+
+  private _maxLength: number;
+}
+
+export namespace LoggerOutputAreaModel {
+  export interface IOptions extends IOutputAreaModel.IOptions {
+    /**
+     * The maximum number of messages stored.
+     */
+    maxLength: number;
+  }
+}
+
+/**
+ * A concrete implementation of ILogger.
+ */
+export class Logger implements ILogger {
+  /**
+   * Construct a Logger.
+   *
+   * @param source - The name of the log source.
+   */
+  constructor(options: Logger.IOptions) {
+    this.source = options.source;
+    this.outputAreaModel = new LoggerOutputAreaModel({
+      contentFactory: new LogConsoleModelContentFactory(),
+      maxLength: options.maxLength
+    });
+  }
+
+  /**
+   * The maximum number of outputs stored.
+   *
+   * #### Notes
+   * Oldest entries will be trimmed to ensure the length is at most
+   * `.maxLength`.
+   */
+  get maxLength() {
+    return this.outputAreaModel.maxLength;
+  }
+  set maxLength(value: number) {
+    this.outputAreaModel.maxLength = value;
+  }
+
+  /**
+   * Number of outputs logged.
+   */
+  get length(): number {
+    return this.outputAreaModel.length;
+  }
+
+  /**
+   * A signal emitted when the list of log messages changes.
+   */
+  get logChanged(): ISignal<this, ILoggerChange> {
+    return this._logChanged;
+  }
+
+  /**
+   * A signal emitted when the rendermime changes.
+   */
+  get rendermimeChanged(): ISignal<this, void> {
+    return this._rendermimeChanged;
+  }
+
+  /**
+   * Rendermime to use when rendering outputs logged.
+   */
+  get rendermime(): IRenderMimeRegistry | null {
+    return this._rendermime;
+  }
+  set rendermime(value: IRenderMimeRegistry | null) {
+    if (value !== this._rendermime) {
+      this._rendermime = value;
+      this._rendermimeChanged.emit();
+    }
+  }
+
+  /**
+   * The number of messages that have ever been stored.
+   */
+  get version(): number {
+    return this._version;
+  }
+
+  /**
+   * The source for the logger.
+   */
+  readonly source: string;
+
+  /**
+   * The output area model used for the logger.
+   *
+   * #### Notes
+   * This will usually not be accessed directly. It is a public attribute so
+   * that the renderer can access it.
+   */
+  readonly outputAreaModel: LoggerOutputAreaModel;
+
+  /**
+   * Log an output to logger.
+   *
+   * @param log - The output to be logged.
+   */
+  log(log: ILogPayload) {
+    const timestamp = new Date();
+    let output: nbformat.IOutput = null;
+
+    switch (log.type) {
+      case 'text':
+        output = {
+          output_type: 'display_data',
+          data: {
+            'text/plain': log.data
+          }
+        };
+        break;
+      case 'html':
+        output = {
+          output_type: 'display_data',
+          data: {
+            'text/html': log.data
+          }
+        };
+        break;
+      case 'output':
+        output = log.data;
+        break;
+      default:
+        break;
+    }
+
+    if (output) {
+      // First, make sure our version reflects the new message so things
+      // triggering from the signals below have the correct version.
+      this._version++;
+
+      // Next, trigger any displays of the message
+      this.outputAreaModel.add({ ...output, timestamp: timestamp.valueOf() });
+
+      // Finally, tell people that the message was appended (and possibly
+      // already displayed).
+      this._logChanged.emit('append');
+    }
+  }
+
+  /**
+   * Clear all outputs logged.
+   */
+  clear() {
+    this.outputAreaModel.clear(false);
+    this._logChanged.emit('clear');
+  }
+
+  private _logChanged = new Signal<this, ILoggerChange>(this);
+  private _rendermime: IRenderMimeRegistry | null = null;
+  private _rendermimeChanged = new Signal<this, void>(this);
+  private _version = 0;
+}
+
+export namespace Logger {
+  export interface IOptions {
+    /**
+     * The log source identifier.
+     */
+    source: string;
+    /**
+     * The maximum number of messages to store.
+     */
+    maxLength: number;
+  }
+}

+ 90 - 0
packages/logconsole/src/registry.ts

@@ -0,0 +1,90 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
+
+import { ISignal, Signal } from '@phosphor/signaling';
+
+import { Logger } from './logger';
+
+import { ILogger, ILoggerRegistry, ILoggerRegistryChange } from './tokens';
+
+/**
+ * A concrete implementation of ILoggerRegistry.
+ */
+export class LoggerRegistry implements ILoggerRegistry {
+  /**
+   * Construct a LoggerRegistry.
+   *
+   * @param defaultRendermime - Default rendermime to render outputs
+   * with when logger is not supplied with one.
+   */
+  constructor(options: LoggerRegistry.IOptions) {
+    this._defaultRendermime = options.defaultRendermime;
+    this._maxLength = options.maxLength;
+  }
+
+  /**
+   * Get the logger for the specified source.
+   *
+   * @param source - The name of the log source.
+   *
+   * @returns The logger for the specified source.
+   */
+  getLogger(source: string): ILogger {
+    const loggers = this._loggers;
+    let logger = loggers.get(source);
+    if (logger) {
+      return logger;
+    }
+
+    logger = new Logger({ source, maxLength: this.maxLength });
+    logger.rendermime = this._defaultRendermime;
+    loggers.set(source, logger);
+
+    this._registryChanged.emit('append');
+
+    return logger;
+  }
+
+  /**
+   * Get all loggers registered.
+   *
+   * @returns The array containing all registered loggers.
+   */
+  getLoggers(): ILogger[] {
+    return Array.from(this._loggers.values());
+  }
+
+  /**
+   * A signal emitted when the logger registry changes.
+   */
+  get registryChanged(): ISignal<this, ILoggerRegistryChange> {
+    return this._registryChanged;
+  }
+
+  /**
+   * The max length for loggers.
+   */
+  get maxLength(): number {
+    return this._maxLength;
+  }
+  set maxLength(value: number) {
+    this._maxLength = value;
+    this._loggers.forEach(logger => {
+      logger.maxLength = value;
+    });
+  }
+
+  private _defaultRendermime: IRenderMimeRegistry = null;
+  private _loggers = new Map<string, Logger>();
+  private _maxLength: number;
+  private _registryChanged = new Signal<this, ILoggerRegistryChange>(this);
+}
+
+export namespace LoggerRegistry {
+  export interface IOptions {
+    defaultRendermime: IRenderMimeRegistry;
+    maxLength: number;
+  }
+}

+ 35 - 15
packages/logconsole/src/tokens.ts

@@ -1,16 +1,16 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { Token } from '@phosphor/coreutils';
-
-import { ISignal } from '@phosphor/signaling';
-
 import { nbformat } from '@jupyterlab/coreutils';
 
 import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
 import { IOutputAreaModel } from '@jupyterlab/outputarea';
 
+import { Token } from '@phosphor/coreutils';
+
+import { ISignal } from '@phosphor/signaling';
+
 /* tslint:disable */
 /**
  * The Logger Registry token.
@@ -54,6 +54,11 @@ export interface ILogPayloadBase {
    * Type of log data.
    */
   type: string;
+
+  /**
+   * Data
+   */
+  data: any;
 }
 
 /**
@@ -105,24 +110,25 @@ export type ILogPayload = ITextLog | IHtmlLog | IOutputLog;
 
 export type ILoggerChange = 'append' | 'clear';
 
+export interface ILoggerOutputAreaModel extends IOutputAreaModel {
+  /**
+   * The maximum number of outputs to store.
+   */
+  maxLength: number;
+}
+
 /**
  * A Logger that manages logs from a particular source.
  */
 export interface ILogger {
-  /**
-   * Log an output to logger.
-   *
-   * @param log - The output to be logged.
-   */
-  log(log: ILogPayload): void;
-  /**
-   * Clear all outputs logged.
-   */
-  clear(): void;
   /**
    * Number of outputs logged.
    */
   readonly length: number;
+  /**
+   * Max number of messages.
+   */
+  maxLength: number;
   /**
    * Rendermime to use when rendering outputs logged.
    */
@@ -142,5 +148,19 @@ export interface ILogger {
   /**
    * Output Area Model used to manage log storage in memory.
    */
-  readonly outputAreaModel: IOutputAreaModel;
+  readonly outputAreaModel: ILoggerOutputAreaModel;
+  /**
+   * The cumulative number of messages the log has stored.
+   */
+  readonly version: number;
+  /**
+   * Log an output to logger.
+   *
+   * @param log - The output to be logged.
+   */
+  log(log: ILogPayload): void;
+  /**
+   * Clear all outputs logged.
+   */
+  clear(): void;
 }

+ 217 - 367
packages/logconsole/src/widget.ts

@@ -1,260 +1,29 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { StackedPanel, Widget, Panel } from '@phosphor/widgets';
+import { nbformat } from '@jupyterlab/coreutils';
 
-import { ISignal, Signal } from '@phosphor/signaling';
+import { OutputArea, IOutputPrompt } from '@jupyterlab/outputarea';
+
+import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
 import { Kernel, KernelMessage } from '@jupyterlab/services';
 
-import { nbformat } from '@jupyterlab/coreutils';
+import { Message } from '@phosphor/messaging';
 
-import {
-  OutputArea,
-  IOutputAreaModel,
-  OutputAreaModel,
-  IOutputPrompt
-} from '@jupyterlab/outputarea';
+import { ISignal, Signal } from '@phosphor/signaling';
 
-import {
-  IRenderMimeRegistry,
-  IOutputModel,
-  OutputModel
-} from '@jupyterlab/rendermime';
+import { Widget, Panel, PanelLayout, StackedPanel } from '@phosphor/widgets';
 
-import { Message } from '@phosphor/messaging';
+import { LogOutputModel, LoggerOutputAreaModel } from './logger';
 
 import {
   ILogger,
   ILoggerChange,
-  ILogPayload,
   ILoggerRegistry,
   ILoggerRegistryChange
 } from './tokens';
 
-/**
- * Custom Notebook Output with timestamp member.
- */
-interface ITimestampedOutput extends nbformat.IBaseOutput {
-  /**
-   * Date & time when output is logged in integer representation.
-   */
-  timestamp: number;
-}
-
-export const DEFAULT_LOG_ENTRY_LIMIT: number = 1000;
-
-/**
- * Custom Notebook Output with optional timestamp.
- */
-type IOutputWithTimestamp = nbformat.IOutput | ITimestampedOutput;
-
-/**
- * Log Output Model with timestamp which provides
- * item information for Output Area Model.
- */
-class LogOutputModel extends OutputModel {
-  /**
-   * Construct a LogOutputModel.
-   *
-   * @param options - The model initialization options.
-   */
-  constructor(options: LogOutputModel.IOptions) {
-    super(options);
-
-    this.timestamp = new Date(options.value.timestamp as number);
-  }
-
-  /**
-   * Date & time when output is logged.
-   */
-  timestamp: Date = null;
-}
-
-/**
- * Log Output Model namespace that defines initialization options.
- */
-namespace LogOutputModel {
-  export interface IOptions extends IOutputModel.IOptions {
-    value: IOutputWithTimestamp;
-  }
-}
-
-/**
- * Implementation of `IContentFactory` for Output Area Model
- * which creates LogOutputModel instances.
- */
-class LogConsoleModelContentFactory extends OutputAreaModel.ContentFactory {
-  /**
-   * Create a rendermime output model from notebook output.
-   */
-  createOutputModel(options: IOutputModel.IOptions): LogOutputModel {
-    return new LogOutputModel(options);
-  }
-}
-
-/**
- * A concrete implementation of ILogger.
- */
-export class Logger implements ILogger {
-  /**
-   * Construct a Logger.
-   *
-   * @param source - The name of the log source.
-   */
-  constructor(source: string) {
-    this.source = source;
-  }
-
-  /**
-   * Number of outputs logged.
-   */
-  get length(): number {
-    return this.outputAreaModel.length;
-  }
-
-  /**
-   * A signal emitted when the log model changes.
-   */
-  get logChanged(): ISignal<this, ILoggerChange> {
-    return this._logChanged;
-  }
-
-  /**
-   * A signal emitted when the rendermime changes.
-   */
-  get rendermimeChanged(): ISignal<this, void> {
-    return this._rendermimeChanged;
-  }
-
-  /**
-   * Log an output to logger.
-   *
-   * @param log - The output to be logged.
-   */
-  log(log: ILogPayload) {
-    const timestamp = new Date();
-    let output: nbformat.IOutput = null;
-
-    switch (log.type) {
-      case 'text':
-        output = {
-          output_type: 'display_data',
-          data: {
-            'text/plain': log.data
-          }
-        };
-        break;
-      case 'html':
-        output = {
-          output_type: 'display_data',
-          data: {
-            'text/html': log.data
-          }
-        };
-        break;
-      case 'output':
-        output = log.data;
-        break;
-      default:
-        break;
-    }
-
-    if (output) {
-      this.outputAreaModel.add({ ...output, timestamp: timestamp.valueOf() });
-      this._logChanged.emit('append');
-    }
-  }
-
-  /**
-   * Clear all outputs logged.
-   */
-  clear() {
-    this.outputAreaModel.clear(false);
-    this._logChanged.emit('clear');
-  }
-
-  set rendermime(value: IRenderMimeRegistry | null) {
-    if (value !== this._rendermime) {
-      this._rendermime = value;
-      this._rendermimeChanged.emit();
-    }
-  }
-
-  /**
-   * Rendermime to use when rendering outputs logged.
-   */
-  get rendermime(): IRenderMimeRegistry | null {
-    return this._rendermime;
-  }
-
-  readonly source: string;
-  readonly outputAreaModel = new LoggerOutputAreaModel({
-    contentFactory: new LogConsoleModelContentFactory()
-  });
-  private _logChanged = new Signal<this, ILoggerChange>(this);
-  private _rendermimeChanged = new Signal<this, void>(this);
-  private _rendermime: IRenderMimeRegistry | null = null;
-}
-
-/**
- * A concrete implementation of ILoggerRegistry.
- */
-export class LoggerRegistry implements ILoggerRegistry {
-  /**
-   * Construct a LoggerRegistry.
-   *
-   * @param defaultRendermime - Default rendermime to render outputs
-   * with when logger is not supplied with one.
-   */
-  constructor(defaultRendermime: IRenderMimeRegistry) {
-    this._defaultRendermime = defaultRendermime;
-  }
-
-  /**
-   * Get the logger for the specified source.
-   *
-   * @param source - The name of the log source.
-   *
-   * @returns The logger for the specified source.
-   */
-  getLogger(source: string): ILogger {
-    const loggers = this._loggers;
-    let logger = loggers.get(source);
-    if (logger) {
-      return logger;
-    }
-
-    logger = new Logger(source);
-    logger.rendermime = this._defaultRendermime;
-    loggers.set(source, logger);
-
-    this._registryChanged.emit('append');
-
-    return logger;
-  }
-
-  /**
-   * Get all loggers registered.
-   *
-   * @returns The array containing all registered loggers.
-   */
-  getLoggers(): ILogger[] {
-    return Array.from(this._loggers.values());
-  }
-
-  /**
-   * A signal emitted when the logger registry changes.
-   */
-  get registryChanged(): ISignal<this, ILoggerRegistryChange> {
-    return this._registryChanged;
-  }
-
-  private _loggers = new Map<string, Logger>();
-  private _registryChanged = new Signal<this, ILoggerRegistryChange>(this);
-  private _defaultRendermime: IRenderMimeRegistry = null;
-}
-
 /**
  * Log console output prompt implementation
  */
@@ -287,14 +56,13 @@ class LogConsoleOutputPrompt extends Widget implements IOutputPrompt {
  */
 class LogConsoleOutputArea extends OutputArea {
   /**
-   * Handle an input request from a kernel by doing nothing.
+   * The rendermime instance used by the widget.
    */
-  protected onInputRequest(
-    msg: KernelMessage.IInputRequestMsg,
-    future: Kernel.IShellFuture
-  ): void {
-    return;
-  }
+  rendermime: IRenderMimeRegistry;
+  /**
+   * Output area model used by the widget.
+   */
+  readonly model: LoggerOutputAreaModel;
 
   /**
    * Create an output item with a prompt and actual output
@@ -307,55 +75,113 @@ class LogConsoleOutputArea extends OutputArea {
   }
 
   /**
-   * The rendermime instance used by the widget.
+   * Handle an input request from a kernel by doing nothing.
    */
-  rendermime: IRenderMimeRegistry;
+  protected onInputRequest(
+    msg: KernelMessage.IInputRequestMsg,
+    future: Kernel.IShellFuture
+  ): void {
+    return;
+  }
+}
+
+/**
+ * Implementation of `IContentFactory` for Output Area
+ * which creates custom output prompts.
+ */
+class LogConsoleContentFactory extends OutputArea.ContentFactory {
   /**
-   * Output area model used by the widget.
+   * Create the output prompt for the widget.
    */
-  readonly model: LoggerOutputAreaModel;
+  createOutputPrompt(): LogConsoleOutputPrompt {
+    return new LogConsoleOutputPrompt();
+  }
 }
 
 /**
- * Output Area Model implementation which is able to
- * limit number of outputs stored.
+ * Implements a panel which supports pinning the position to the end if it is
+ * scrolled to the end.
+ *
+ * #### Notes
+ * This is useful for log viewing components or chat components that append
+ * elements at the end. We would like to automatically scroll when the user
+ * has scrolled to the bottom, but not change the scrolling when the user has
+ * changed the scroll position.
  */
-class LoggerOutputAreaModel extends OutputAreaModel {
-  constructor(options?: IOutputAreaModel.IOptions) {
+export class ScrollingWidget<T extends Widget> extends Widget {
+  constructor({ content, ...options }: ScrollingWidget.IOptions<T>) {
     super(options);
+    this.addClass('jp-Scrolling');
+    const layout = (this.layout = new PanelLayout());
+    layout.addWidget(content);
+
+    this._content = content;
+    this._sentinel = document.createElement('div');
+    this.node.appendChild(this._sentinel);
   }
 
   /**
-   * Maximum number of log entries to store in the model.
+   * The content widget.
    */
-  set entryLimit(limit: number) {
-    this._entryLimit = limit;
-    this.applyLimit();
+  get content(): T {
+    return this._content;
   }
 
-  /**
-   * Manually apply entry limit.
-   */
-  applyLimit() {
-    if (this.list.length > this._entryLimit) {
-      const diff = this.list.length - this._entryLimit;
-      this.list.removeRange(0, diff);
+  protected onAfterAttach(msg: Message) {
+    super.onAfterAttach(msg);
+    // defer so content gets a chance to attach first
+    requestAnimationFrame(() => {
+      this._sentinel.scrollIntoView();
+      this._scrollHeight = this.node.scrollHeight;
+    });
+
+    // Set up intersection observer for the sentinel
+    this._observer = new IntersectionObserver(
+      args => {
+        this._handleScroll(args);
+      },
+      { root: this.node, threshold: 1 }
+    );
+    this._observer.observe(this._sentinel);
+  }
+
+  protected onBeforeDetach(msg: Message) {
+    this._observer.disconnect();
+  }
+
+  protected onAfterShow(msg: Message) {
+    if (this._tracking) {
+      this._sentinel.scrollIntoView();
+    }
+  }
+
+  private _handleScroll([entry]: IntersectionObserverEntry[]) {
+    if (entry.isIntersecting) {
+      this._tracking = true;
+    } else if (this.isVisible) {
+      const currentHeight = this.node.scrollHeight;
+      if (currentHeight === this._scrollHeight) {
+        // Likely the user scrolled manually
+        this._tracking = false;
+      } else {
+        // We assume we scrolled because our size changed, so scroll to the end.
+        this._sentinel.scrollIntoView();
+        this._scrollHeight = currentHeight;
+        this._tracking = true;
+      }
     }
   }
 
-  private _entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
+  private _content: T;
+  private _observer: IntersectionObserver;
+  private _scrollHeight: number;
+  private _sentinel: HTMLDivElement;
+  private _tracking: boolean;
 }
 
-/**
- * Implementation of `IContentFactory` for Output Area
- * which creates custom output prompts.
- */
-class LogConsoleContentFactory extends OutputArea.ContentFactory {
-  /**
-   * Create the output prompt for the widget.
-   */
-  createOutputPrompt(): LogConsoleOutputPrompt {
-    return new LogConsoleOutputPrompt();
+export namespace ScrollingWidget {
+  export interface IOptions<T extends Widget> extends Widget.IOptions {
+    content: T;
   }
 }
 
@@ -387,16 +213,66 @@ export class LogConsolePanel extends StackedPanel {
 
     this._placeholder = new Widget();
     this._placeholder.addClass('jp-LogConsoleListPlaceholder');
-    this._placeholder.node.innerHTML = 'No log messages.';
-
     this.addWidget(this._placeholder);
   }
 
+  /**
+   * The logger registry providing the logs.
+   */
+  get loggerRegistry(): ILoggerRegistry {
+    return this._loggerRegistry;
+  }
+
+  /**
+   * The log source displayed
+   */
+  get source(): string | null {
+    return this._source;
+  }
+  set source(name: string | null) {
+    this._source = name;
+    this._showOutputFromSource(this._source);
+    this._handlePlaceholder();
+    this._sourceChanged.emit(name);
+  }
+
+  /**
+   * The source version displayed.
+   */
+  get sourceVersion(): number | null {
+    const source = this.source;
+    return source && this._loggerRegistry.getLogger(source).version;
+  }
+
+  /**
+   * Signal for source changes
+   */
+  get sourceChanged(): ISignal<this, string | null> {
+    return this._sourceChanged;
+  }
+
+  /**
+   * Signal for source changes
+   */
+  get sourceDisplayed(): ISignal<this, ISourceDisplayed> {
+    return this._sourceDisplayed;
+  }
+
   protected onAfterAttach(msg: Message): void {
+    super.onAfterAttach(msg);
     this._updateOutputAreas();
-    this._showOutputFromSource(this._activeSource);
-    this._showPlaceholderIfNoMessage();
-    this.attached.emit();
+    this._showOutputFromSource(this._source);
+    this._handlePlaceholder();
+  }
+
+  protected onAfterShow(msg: Message) {
+    super.onAfterShow(msg);
+    if (this.source !== null) {
+      this._sourceDisplayed.emit({
+        source: this.source,
+        version: this.sourceVersion
+      });
+    }
   }
 
   private _bindLoggerSignals() {
@@ -408,7 +284,7 @@ export class LogConsolePanel extends StackedPanel {
 
       logger.logChanged.connect((sender: ILogger, args: ILoggerChange) => {
         this._updateOutputAreas();
-        this._showPlaceholderIfNoMessage();
+        this._handlePlaceholder();
       }, this);
 
       logger.rendermimeChanged.connect((sender: ILogger) => {
@@ -423,76 +299,52 @@ export class LogConsolePanel extends StackedPanel {
     }
   }
 
-  /**
-   * The logger registry providing the logs.
-   */
-  get loggerRegistry(): ILoggerRegistry {
-    return this._loggerRegistry;
-  }
-
-  private _showOutputFromSource(source: string) {
-    const viewId = `source:${source}`;
+  private _showOutputFromSource(source: string | null) {
+    // If the source is null, pick a unique name so all output areas hide.
+    const viewId = source === null ? 'null source' : `source:${source}`;
 
     this._outputAreas.forEach(
       (outputArea: LogConsoleOutputArea, name: string) => {
+        // Show/hide the output area parents, the scrolling windows.
         if (outputArea.id === viewId) {
-          outputArea.show();
-          setTimeout(() => {
-            this._scrollOuputAreaToBottom(outputArea, false);
-          }, 50);
+          outputArea.parent.show();
+          if (outputArea.isVisible) {
+            this._sourceDisplayed.emit({
+              source: this.source,
+              version: this.sourceVersion
+            });
+          }
         } else {
-          outputArea.hide();
+          outputArea.parent.hide();
         }
       }
     );
 
-    const title = source ? `Log: ${source}` : 'Log Console';
+    const title = source === null ? 'Log Console' : `Log: ${source}`;
     this.title.label = title;
     this.title.caption = title;
   }
 
-  set activeSource(name: string) {
-    this._activeSource = name;
-    this._showOutputFromSource(this._activeSource);
-    this._showPlaceholderIfNoMessage();
-  }
-
-  /**
-   * The name of the active log source
-   */
-  get activeSource(): string {
-    return this._activeSource;
-  }
-
-  private _showPlaceholderIfNoMessage() {
-    const noMessage =
-      !this.activeSource ||
-      this._loggerRegistry.getLogger(this.activeSource).length === 0;
-
-    if (noMessage) {
+  private _handlePlaceholder() {
+    if (this.source === null) {
+      this._placeholder.node.textContent = 'No source selected.';
+      this._placeholder.show();
+    } else if (this._loggerRegistry.getLogger(this.source).length === 0) {
+      this._placeholder.node.textContent = 'No log messages.';
       this._placeholder.show();
     } else {
       this._placeholder.hide();
+      this._placeholder.node.textContent = '';
     }
   }
 
-  private _scrollOuputAreaToBottom(
-    outputArea: LogConsoleOutputArea,
-    animate: boolean = true
-  ) {
-    outputArea.node.scrollTo({
-      left: 0,
-      top: outputArea.node.scrollHeight,
-      behavior: animate ? 'smooth' : 'auto'
-    });
-  }
-
   private _updateOutputAreas() {
     const loggerIds = new Set<string>();
     const loggers = this._loggerRegistry.getLoggers();
 
     for (let logger of loggers) {
-      const viewId = `source:${logger.source}`;
+      const source = logger.source;
+      const viewId = `source:${source}`;
       loggerIds.add(viewId);
 
       // add view for logger if not exist
@@ -503,25 +355,36 @@ export class LogConsolePanel extends StackedPanel {
           model: logger.outputAreaModel
         });
         outputArea.id = viewId;
-        outputArea.model.entryLimit = this.entryLimit;
-
-        logger.logChanged.connect((sender: ILogger, args: ILoggerChange) => {
-          this._scrollOuputAreaToBottom(outputArea);
-        }, this);
-
-        outputArea.outputLengthChanged.connect(
-          (sender: LogConsoleOutputArea, args: number) => {
-            outputArea.model.applyLimit();
-            clearTimeout(this._scrollTimer);
-            this._scrollTimer = setTimeout(() => {
-              this._scrollOuputAreaToBottom(outputArea);
-            }, 50);
-          },
-          this
-        );
-
-        this.addWidget(outputArea);
+
+        // Attach the output area so it is visible, so the accounting
+        // functions below record the outputs actually displayed.
+        let w = new ScrollingWidget({
+          content: outputArea
+        });
+        this.addWidget(w);
         this._outputAreas.set(viewId, outputArea);
+
+        // This is where the source object is associated with the output area.
+        // We capture the source from this environment in the closure.
+        const outputUpdate = (sender: LogConsoleOutputArea) => {
+          // If the current log console panel source is the source associated
+          // with this output area, and the output area is visible, then emit
+          // the logConsolePanel source displayed signal.
+          if (this.source === source && sender.isVisible) {
+            // We assume that the output area has been updated to the current
+            // version of the source.
+            this._sourceDisplayed.emit({
+              source: this.source,
+              version: this.sourceVersion
+            });
+          }
+        };
+        // Notify messages were displayed any time the output area is updated
+        // and update for any outputs rendered on construction.
+        outputArea.outputLengthChanged.connect(outputUpdate, this);
+        // Since the output area was attached above, we can rely on its
+        // visibility to account for the messages displayed.
+        outputUpdate(outputArea);
       }
     }
 
@@ -537,29 +400,16 @@ export class LogConsolePanel extends StackedPanel {
     }
   }
 
-  /**
-   * Log output entry limit.
-   */
-  get entryLimit(): number {
-    return this._entryLimit;
-  }
-
-  set entryLimit(limit: number) {
-    if (limit > 0) {
-      this._outputAreas.forEach((outputView: LogConsoleOutputArea) => {
-        outputView.model.entryLimit = limit;
-      });
-
-      this._entryLimit = limit;
-    }
-  }
-
-  readonly attached = new Signal<this, void>(this);
   private _loggerRegistry: ILoggerRegistry;
   private _outputAreas = new Map<string, LogConsoleOutputArea>();
-  private _activeSource: string = null;
-  private _entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
-  private _scrollTimer: number = null;
+  private _source: string | null = null;
+  private _sourceChanged = new Signal<this, string | null>(this);
+  private _sourceDisplayed = new Signal<this, ISourceDisplayed>(this);
   private _placeholder: Widget;
   private _loggersWatched: Set<string> = new Set();
 }
+
+export interface ISourceDisplayed {
+  source: string;
+  version: number;
+}

+ 4 - 0
packages/logconsole/style/base.css

@@ -23,3 +23,7 @@
   font-size: 13px;
   color: var(--jp-ui-font-color3);
 }
+
+.jp-Scrolling {
+  overflow-y: auto;
+}

+ 4 - 0
packages/outputarea/src/model.ts

@@ -51,6 +51,8 @@ export interface IOutputAreaModel extends IDisposable {
   /**
    * Add an output, which may be combined with previous output.
    *
+   * @returns The total number of outputs.
+   *
    * #### Notes
    * The output bundle is copied.
    * Contiguous stream outputs of the same `name` are combined.
@@ -238,6 +240,8 @@ export class OutputAreaModel implements IOutputAreaModel {
   /**
    * Add an output, which may be combined with previous output.
    *
+   * @returns The total number of outputs.
+   *
    * #### Notes
    * The output bundle is copied.
    * Contiguous stream outputs of the same `name` are combined.

+ 2 - 0
packages/ui-components/src/icon/iconimports.ts

@@ -28,6 +28,7 @@ import tabSvg from '../../style/icons/sidebar/tab.svg';
 import jupyterFaviconSvg from '../../style/icons/splash/jupyter-favicon.svg';
 import kernelSvg from '../../style/icons/statusbar/kernel.svg';
 import lineFormSvg from '../../style/icons/statusbar/line-form.svg';
+import listSvg from '../../style/icons/statusbar/list.svg';
 import notTrustedSvg from '../../style/icons/statusbar/not-trusted.svg';
 import terminalSvg from '../../style/icons/statusbar/terminal.svg';
 import trustedSvg from '../../style/icons/statusbar/trusted.svg';
@@ -63,6 +64,7 @@ export namespace IconImports {
     { name: 'jupyter-favicon', svg: jupyterFaviconSvg },
     { name: 'kernel', svg: kernelSvg },
     { name: 'line-form', svg: lineFormSvg },
+    { name: 'list', svg: listSvg },
     { name: 'not-trusted', svg: notTrustedSvg },
     { name: 'terminal', svg: terminalSvg },
     { name: 'trusted', svg: trustedSvg },

+ 4 - 0
packages/ui-components/style/deprecated.css

@@ -32,6 +32,7 @@
   --jp-icon-jupyter-favicon: url('icons/splash/jupyter-favicon.svg');
   --jp-icon-kernel: url('icons/statusbar/kernel.svg');
   --jp-icon-line-form: url('icons/statusbar/line-form.svg');
+  --jp-icon-list: url('icons/statusbar/list.svg');
   --jp-icon-not-trusted: url('icons/statusbar/not-trusted.svg');
   --jp-icon-terminal: url('icons/statusbar/terminal.svg');
   --jp-icon-trusted: url('icons/statusbar/trusted.svg');
@@ -107,6 +108,9 @@
 .jp-LineFormIcon {
   background-image: var(--jp-icon-line-form);
 }
+.jp-ListIcon {
+  background-image: var(--jp-icon-list);
+}
 .jp-NotTrustedIcon {
   background-image: var(--jp-icon-not-trusted);
 }

+ 3 - 0
packages/ui-components/style/icons/statusbar/list.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <path class="jp-icon2 jp-icon-selectable" fill="#616161" d="M19 5v14H5V5h14m1.1-2H3.9c-.5 0-.9.4-.9.9v16.2c0 .4.4.9.9.9h16.2c.4 0 .9-.5.9-.9V3.9c0-.5-.5-.9-.9-.9zM11 7h6v2h-6V7zm0 4h6v2h-6v-2zm0 4h6v2h-6zM7 7h2v2H7zm0 4h2v2H7zm0 4h2v2H7z"/>
+</svg>