chromium/chrome/browser/resources/extensions/navigation_helper.ts

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from 'chrome://resources/js/assert.js';


/**
 * The different pages that can be shown at a time.
 * Note: This must remain in sync with the page ids in manager.html!
 */
export enum Page {
  LIST = 'items-list',
  DETAILS = 'details-view',
  ACTIVITY_LOG = 'activity-log',
  SITE_PERMISSIONS = 'site-permissions',
  SITE_PERMISSIONS_ALL_SITES = 'site-permissions-by-site',
  SHORTCUTS = 'keyboard-shortcuts',
  ERRORS = 'error-page',
}

export enum Dialog {
  OPTIONS = 'options',
}

export interface PageState {
  page: Page;
  extensionId?: string;
  subpage?: Dialog;
}

type Listener = (pageState: PageState) => void;

/** @return Whether a and b are equal. */
function isPageStateEqual(a: PageState, b: PageState): boolean {
  return a.page === b.page && a.subpage === b.subpage &&
      a.extensionId === b.extensionId;
}

/**
 * Regular expression that captures the leading slash, the content and the
 * trailing slash in three different groups.
 */
const CANONICAL_PATH_REGEX: RegExp = /(^\/)([\/-\w]+)(\/$)/;

/**
 * A helper object to manage in-page navigations. Since the extensions page
 * needs to support different urls for different subpages (like the details
 * page), we use this object to manage the history and url conversions.
 */
export class NavigationHelper {
  private nextListenerId_: number = 1;
  private listeners_: Map<number, Listener> = new Map();
  private previousPage_: PageState;

  constructor() {
    this.processRoute_();

    window.addEventListener('popstate', () => {
      this.notifyRouteChanged_(this.getCurrentPage());
    });
  }

  private get currentPath_(): string {
    return location.pathname.replace(CANONICAL_PATH_REGEX, '$1$2');
  }

  /**
   * Going to /configureCommands and /shortcuts should land you on /shortcuts,
   * and going to /sitePermissions should land you on /sitePermissions.
   * These are the only three supported routes, so all other cases will redirect
   * you to root path if not already on it.
   */
  private processRoute_() {
    if (this.currentPath_ === '/configureCommands' ||
        this.currentPath_ === '/shortcuts') {
      window.history.replaceState(
          undefined /* stateObject */, '', '/shortcuts');
    } else if (this.currentPath_ === '/sitePermissions') {
      window.history.replaceState(
          undefined /* stateObject */, '', '/sitePermissions');
    } else if (this.currentPath_ === '/sitePermissions/allSites') {
      window.history.replaceState(
          undefined /* stateObject */, '', '/sitePermissions/allSites');
    } else if (this.currentPath_ !== '/') {
      window.history.replaceState(undefined /* stateObject */, '', '/');
    }
  }

  /**
   * @return The page that should be displayed for the current URL.
   */
  getCurrentPage(): PageState {
    const search = new URLSearchParams(location.search);
    let id = search.get('id');
    if (id) {
      return {page: Page.DETAILS, extensionId: id};
    }
    id = search.get('activity');
    if (id) {
      return {page: Page.ACTIVITY_LOG, extensionId: id};
    }
    id = search.get('options');
    if (id) {
      return {page: Page.DETAILS, extensionId: id, subpage: Dialog.OPTIONS};
    }
    id = search.get('errors');
    if (id) {
      return {page: Page.ERRORS, extensionId: id};
    }

    if (this.currentPath_ === '/shortcuts') {
      return {page: Page.SHORTCUTS};
    }
    if (this.currentPath_ === '/sitePermissions') {
      return {page: Page.SITE_PERMISSIONS};
    }
    if (this.currentPath_ === '/sitePermissions/allSites') {
      return {page: Page.SITE_PERMISSIONS_ALL_SITES};
    }

    return {page: Page.LIST};
  }

  /**
   * Function to add subscribers.
   * @param {!function(!PageState)} listener
   * @return A numerical ID to be used for removing the listener.
   */
  addListener(listener: Listener): number {
    const nextListenerId = this.nextListenerId_++;
    this.listeners_.set(nextListenerId, listener);
    return nextListenerId;
  }

  /**
   * Remove a previously registered listener.
   * @return Whether a listener with the given ID was actually found and
   *   removed.
   */
  removeListener(id: number): boolean {
    return this.listeners_.delete(id);
  }

  /**
   * Function to notify subscribers.
   */
  private notifyRouteChanged_(newPage: PageState) {
    for (const listener of this.listeners_.values()) {
      listener(newPage);
    }
  }

  /**
   * @param newPage the page to navigate to.
   */
  navigateTo(newPage: PageState) {
    const currentPage = this.getCurrentPage();
    if (currentPage && isPageStateEqual(currentPage, newPage)) {
      return;
    }

    this.updateHistory(newPage, false /* replaceState */);
    this.notifyRouteChanged_(newPage);
  }

  /**
   * @param newPage the page to replace the current page with.
   */
  replaceWith(newPage: PageState) {
    this.updateHistory(newPage, true /* replaceState */);
    if (this.previousPage_ && isPageStateEqual(this.previousPage_, newPage)) {
      // Skip the duplicate history entry.
      history.back();
      return;
    }
    this.notifyRouteChanged_(newPage);
  }

  /**
   * Called when a page changes, and pushes state to history to reflect it.
   */
  updateHistory(entry: PageState, replaceState: boolean) {
    let path;
    switch (entry.page) {
      case Page.LIST:
        path = '/';
        break;
      case Page.ACTIVITY_LOG:
        path = '/?activity=' + entry.extensionId;
        break;
      case Page.DETAILS:
        if (entry.subpage) {
          assert(entry.subpage === Dialog.OPTIONS);
          path = '/?options=' + entry.extensionId;
        } else {
          path = '/?id=' + entry.extensionId;
        }
        break;
      case Page.SITE_PERMISSIONS:
        path = '/sitePermissions';
        break;
      case Page.SITE_PERMISSIONS_ALL_SITES:
        path = '/sitePermissions/allSites';
        break;
      case Page.SHORTCUTS:
        path = '/shortcuts';
        break;
      case Page.ERRORS:
        path = '/?errors=' + entry.extensionId;
        break;
    }
    assert(path);
    const state = {url: path};
    const currentPage = this.getCurrentPage();
    const isDialogNavigation = currentPage.page === entry.page &&
        currentPage.extensionId === entry.extensionId;
    // Navigating to a dialog doesn't visually change pages; it just opens
    // a dialog. As such, we replace state rather than pushing a new state
    // on the stack so that hitting the back button doesn't just toggle the
    // dialog.
    if (replaceState || isDialogNavigation) {
      history.replaceState(state, '', path);
    } else {
      this.previousPage_ = currentPage;
      history.pushState(state, '', path);
    }
  }
}

export const navigation = new NavigationHelper();