summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/reducers/tabs.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/reducers/tabs.js')
-rw-r--r--devtools/client/debugger/src/reducers/tabs.js308
1 files changed, 308 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/reducers/tabs.js b/devtools/client/debugger/src/reducers/tabs.js
new file mode 100644
index 0000000000..2deaea665a
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tabs.js
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * Tabs reducer
+ * @module reducers/tabs
+ */
+
+import { createSelector } from "reselect";
+import { isOriginalId } from "devtools-source-map";
+import move from "lodash-move";
+
+import { isSimilarTab, persistTabs } from "../utils/tabs";
+import { makeShallowQuery } from "../utils/resource";
+import { getPrettySourceURL } from "../utils/source";
+
+import {
+ getSource,
+ getSpecificSourceByURL,
+ getSources,
+ resourceAsSourceBase,
+} from "./sources";
+
+import type { Source, SourceId, URL } from "../types";
+import type { Action } from "../actions/types";
+import type { Selector, State } from "./types";
+import type { SourceBase } from "./sources";
+
+export type PersistedTab = {|
+ url: URL,
+ framework?: string | null,
+ isOriginal: boolean,
+ sourceId: SourceId,
+|};
+
+export type VisibleTab = {| ...Tab, sourceId: SourceId |};
+
+export type Tab = PersistedTab | VisibleTab;
+
+export type TabList = Tab[];
+
+export type TabsSources = $ReadOnlyArray<SourceBase>;
+
+export type TabsState = {
+ tabs: TabList,
+};
+
+export function initialTabState(): TabsState {
+ return { tabs: [] };
+}
+
+function resetTabState(state): TabsState {
+ const tabs = persistTabs(state.tabs);
+ return { tabs };
+}
+
+function update(
+ state: TabsState = initialTabState(),
+ action: Action
+): TabsState {
+ switch (action.type) {
+ case "ADD_TAB":
+ case "UPDATE_TAB":
+ return updateTabList(state, action);
+
+ case "MOVE_TAB":
+ return moveTabInList(state, action);
+ case "MOVE_TAB_BY_SOURCE_ID":
+ return moveTabInListBySourceId(state, action);
+
+ case "CLOSE_TAB":
+ return removeSourceFromTabList(state, action);
+
+ case "CLOSE_TABS":
+ return removeSourcesFromTabList(state, action);
+
+ case "ADD_SOURCE":
+ return addVisibleTabs(state, [action.source]);
+
+ case "ADD_SOURCES":
+ return addVisibleTabs(state, action.sources);
+
+ case "SET_SELECTED_LOCATION": {
+ return addSelectedSource(state, action.source);
+ }
+
+ case "NAVIGATE": {
+ return resetTabState(state);
+ }
+
+ default:
+ return state;
+ }
+}
+
+/**
+ * Gets the next tab to select when a tab closes. Heuristics:
+ * 1. if the selected tab is available, it remains selected
+ * 2. if it is gone, the next available tab to the left should be active
+ * 3. if the first tab is active and closed, select the second tab
+ *
+ * @memberof reducers/tabs
+ * @static
+ */
+export function getNewSelectedSourceId(state: State, tabList: TabList): string {
+ const { selectedLocation } = state.sources;
+ const availableTabs = state.tabs.tabs;
+ if (!selectedLocation) {
+ return "";
+ }
+
+ const selectedTab = getSource(state, selectedLocation.sourceId);
+ if (!selectedTab) {
+ return "";
+ }
+
+ const matchingTab = availableTabs.find(tab =>
+ isSimilarTab(tab, selectedTab.url, isOriginalId(selectedLocation.sourceId))
+ );
+
+ if (matchingTab) {
+ const { sources } = state.sources;
+ if (!sources) {
+ return "";
+ }
+
+ const selectedSource = getSpecificSourceByURL(
+ state,
+ selectedTab.url,
+ selectedTab.isOriginal
+ );
+
+ if (selectedSource) {
+ return selectedSource.id;
+ }
+
+ return "";
+ }
+
+ const tabUrls = tabList.map(tab => tab.url);
+ const leftNeighborIndex = Math.max(tabUrls.indexOf(selectedTab.url) - 1, 0);
+ const lastAvailbleTabIndex = availableTabs.length - 1;
+ const newSelectedTabIndex = Math.min(leftNeighborIndex, lastAvailbleTabIndex);
+ const availableTab = availableTabs[newSelectedTabIndex];
+
+ if (availableTab) {
+ const tabSource = getSpecificSourceByURL(
+ state,
+ availableTab.url,
+ availableTab.isOriginal
+ );
+
+ if (tabSource) {
+ return tabSource.id;
+ }
+ }
+
+ return "";
+}
+
+function matchesSource(tab: VisibleTab, source: Source): boolean {
+ return tab.sourceId === source.id || matchesUrl(tab, source);
+}
+
+function matchesUrl(tab: Tab, source: Source): boolean {
+ return tab.url === source.url && tab.isOriginal == isOriginalId(source.id);
+}
+
+function addSelectedSource(state: TabsState, source: Source) {
+ if (
+ state.tabs
+ .filter(({ sourceId }) => sourceId)
+ .map(({ sourceId }) => sourceId)
+ .includes(source.id)
+ ) {
+ return state;
+ }
+
+ const isOriginal = isOriginalId(source.id);
+ return updateTabList(state, {
+ url: source.url,
+ isOriginal,
+ framework: null,
+ sourceId: source.id,
+ });
+}
+
+function addVisibleTabs(state: TabsState, sources) {
+ const tabCount = state.tabs.filter(({ sourceId }) => sourceId).length;
+ const tabs = state.tabs
+ .map(tab => {
+ const source = sources.find(src => matchesUrl(tab, src));
+ if (!source) {
+ return tab;
+ }
+ return { ...tab, sourceId: source.id };
+ })
+ .filter(tab => tab.sourceId);
+
+ if (tabs.length == tabCount) {
+ return state;
+ }
+
+ return { tabs };
+}
+
+function removeSourceFromTabList(state: TabsState, { source }): TabsState {
+ const { tabs } = state;
+ const newTabs = tabs.filter(tab => !matchesSource(tab, source));
+ return { tabs: newTabs };
+}
+
+function removeSourcesFromTabList(state: TabsState, { sources }) {
+ const { tabs } = state;
+
+ const newTabs = sources.reduce(
+ (tabList, source) => tabList.filter(tab => !matchesSource(tab, source)),
+ tabs
+ );
+
+ return { tabs: newTabs };
+}
+
+/**
+ * Adds the new source to the tab list if it is not already there
+ * @memberof reducers/tabs
+ * @static
+ */
+function updateTabList(
+ state: TabsState,
+ { url, framework = null, sourceId, isOriginal = false }
+): TabsState {
+ let { tabs } = state;
+ // Set currentIndex to -1 for URL-less tabs so that they aren't
+ // filtered by isSimilarTab
+ const currentIndex = url
+ ? tabs.findIndex(tab => isSimilarTab(tab, url, isOriginal))
+ : -1;
+
+ if (currentIndex === -1) {
+ const newTab = {
+ url,
+ framework,
+ sourceId,
+ isOriginal,
+ };
+ tabs = [newTab, ...tabs];
+ } else if (framework) {
+ tabs[currentIndex].framework = framework;
+ }
+
+ return { ...state, tabs };
+}
+
+function moveTabInList(
+ state: TabsState,
+ { url, tabIndex: newIndex }
+): TabsState {
+ let { tabs } = state;
+ const currentIndex = tabs.findIndex(tab => tab.url == url);
+ tabs = move(tabs, currentIndex, newIndex);
+ return { tabs };
+}
+
+function moveTabInListBySourceId(
+ state: TabsState,
+ { sourceId, tabIndex: newIndex }
+): TabsState {
+ let { tabs } = state;
+ const currentIndex = tabs.findIndex(tab => tab.sourceId == sourceId);
+ tabs = move(tabs, currentIndex, newIndex);
+ return { tabs };
+}
+
+// Selectors
+
+export const getTabs = (state: State): TabList => state.tabs.tabs;
+
+export const getSourceTabs: Selector<VisibleTab[]> = createSelector(
+ state => state.tabs,
+ ({ tabs }) => tabs.filter(tab => tab.sourceId)
+);
+
+export const getSourcesForTabs: Selector<TabsSources> = state => {
+ const tabs = getSourceTabs(state);
+ const sources = getSources(state);
+ return querySourcesForTabs(sources, tabs);
+};
+
+const querySourcesForTabs = makeShallowQuery({
+ filter: (_, tabs) => tabs.map(({ sourceId }) => sourceId),
+ map: resourceAsSourceBase,
+ reduce: items => items,
+});
+
+export function tabExists(state: State, sourceId: SourceId): boolean {
+ return !!getSourceTabs(state).find(tab => tab.sourceId == sourceId);
+}
+
+export function hasPrettyTab(state: State, sourceUrl: URL): boolean {
+ const prettyUrl = getPrettySourceURL(sourceUrl);
+ return !!getSourceTabs(state).find(tab => tab.url === prettyUrl);
+}
+
+export default update;