Save multiple query tab histories

This commit is contained in:
Laurent Nguyen 2024-11-04 09:14:42 +01:00
parent e448e8df6b
commit f7dffa4183
9 changed files with 145 additions and 199 deletions

View File

@ -51,6 +51,8 @@ export interface OpenCollectionTab extends OpenTab {
*/
export interface OpenQueryTab extends OpenCollectionTab {
query: QueryInfo;
splitterDirection?: "vertical" | "horizontal";
queryViewSizePercent?: number;
}
/**

View File

@ -115,7 +115,13 @@ export interface CollectionBase extends TreeNode {
isSampleCollection?: boolean;
onDocumentDBDocumentsClick(): void;
onNewQueryClick(source: any, event?: MouseEvent, queryText?: string): void;
onNewQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
stringsplitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
expandCollection(): void;
collapseCollection(): void;
getDatabase(): Database;
@ -309,6 +315,8 @@ export interface QueryTabOptions extends TabOptions {
partitionKey?: DataModels.PartitionKey;
queryText?: string;
resourceTokenPartitionKey?: string;
stringsplitterDirection?: "horizontal" | "vertical";
queryViewSizePercent?: number;
}
export interface ScriptTabOption extends TabOptions {

View File

@ -4,11 +4,15 @@ import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { DataExplorerAction } from "Contracts/ActionContracts";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { handleOpenAction } from "Explorer/OpenActions/OpenActions";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { OPEN_TABS_SUBCOMPONENT_NAME } from "Explorer/Tabs/QueryTab/QueryTabStateUtil";
import { IGalleryItem } from "Juno/JunoClient";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { AppStateComponentNames, readSubComponentState } from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
@ -1134,7 +1138,7 @@ export default class Explorer {
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases();
: await this.refreshAllDatabases();
}
await useNotebook.getState().refreshNotebooksEnabledStateForAccount();
@ -1159,6 +1163,7 @@ export default class Explorer {
}
await this.refreshSampleData();
this.restoreOpenTabs();
}
public async configureCopilot(): Promise<void> {
@ -1205,4 +1210,19 @@ export default class Explorer {
return;
}
}
private restoreOpenTabs() {
const openTabsState = readSubComponentState<DataExplorerAction[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
[],
);
openTabsState.forEach((openTabState) => {
if (openTabState) {
handleOpenAction(openTabState, useDatabases.getState().databases, this);
}
});
}
}

View File

@ -121,10 +121,13 @@ function openCollectionTab(
action.tabKind === ActionContracts.TabKind.SQLQuery ||
action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.SQLQuery]
) {
const openQueryTabAction = action as ActionContracts.OpenQueryTab;
collection.onNewQueryClick(
collection,
undefined,
generateQueryText(action as ActionContracts.OpenQueryTab, collection.partitionKeyProperties),
generateQueryText(openQueryTabAction, collection.partitionKeyProperties),
openQueryTabAction.splitterDirection,
openQueryTabAction.queryViewSizePercent,
);
break;
}

View File

@ -39,6 +39,8 @@ export class NewQueryTab extends TabsBase {
tabsBaseInstance: this,
queryText: options.queryText,
partitionKey: this.partitionKey,
stringsplitterDirection: options.stringsplitterDirection,
queryViewSizePercent: options.queryViewSizePercent,
container: this.props.container,
onTabAccessor: (instance: ITabAccessor): void => {
this.iTabAccessor = instance;

View File

@ -13,26 +13,13 @@ import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/Query
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import {
OpenTabIndexRetriever,
QueryTexts,
SubComponentName,
deleteQueryTabSubComponentState,
readQueryTabSubComponentState,
saveQueryTabSubComponentState,
} from "Explorer/Tabs/QueryTab/QueryTabStateUtil";
import { deleteQueryTabState, saveQueryTabState } from "Explorer/Tabs/QueryTab/QueryTabStateUtil";
import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction } from "KeyboardShortcuts";
import { QueryConstants } from "Shared/Constants";
import {
LocalStorageUtility,
StorageKey,
getDefaultQueryResultsView,
getRUThreshold,
ruThresholdEnabled,
} from "Shared/StorageUtility";
import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { Allotment } from "allotment";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
@ -107,6 +94,8 @@ export interface IQueryTabComponentProps {
copilotEnabled?: boolean;
isSampleCopilotActive?: boolean;
copilotStore?: Partial<QueryCopilotState>;
stringsplitterDirection?: "horizontal" | "vertical";
queryViewSizePercent?: number;
}
interface IQueryTabStates {
@ -132,6 +121,8 @@ interface IQueryTabStates {
export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles();
const copilotStore = useCopilotStore();
const tabIndex = useTabs.getState().openedTabs.findIndex((tab) => tab.tabId === props.tabId);
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const queryTabProps = {
...props,
@ -140,24 +131,25 @@ export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any =>
(useQueryCopilot().copilotUserDBEnabled || (isSampleCopilotActive && !!userContext.sampleDataConnectionInfo)),
isSampleCopilotActive: isSampleCopilotActive,
copilotStore: copilotStore,
tabIndex,
};
return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>;
};
export const QueryTabComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles();
return <QueryTabComponentImpl styles={styles} {...props}></QueryTabComponentImpl>;
const tabIndex = useTabs.getState().openedTabs.findIndex((tab) => tab.tabId === props.tabId);
return <QueryTabComponentImpl styles={styles} {...{ ...props, tabIndex }}></QueryTabComponentImpl>;
};
type QueryTabComponentImplProps = IQueryTabComponentProps & {
styles: QueryTabStyles;
tabIndex: number;
};
// Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook).
class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> {
// This is a static data structure to keep track which index in persistence is for which tabId
private static openTabIndexRetriever = new OpenTabIndexRetriever();
private static readonly DEBOUNCE_DELAY_MS = 1000;
public queryEditorId: string;
@ -175,17 +167,11 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
constructor(props: QueryTabComponentImplProps) {
super(props);
QueryTabComponentImpl.openTabIndexRetriever.setOpenTabIndex(
props.collection.databaseId,
props.collection.id(),
props.tabId,
);
this.queryEditor = createRef<EditorReact>();
this.state = {
toggleState: ToggleState.Result,
sqlQueryEditorContent: this._getDefaultQueryEditorContent(props),
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
selectedContent: "",
queryResults: undefined,
errors: [],
@ -196,8 +182,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
cancelQueryTimeoutID: undefined,
copilotActive: this._queryCopilotActive(),
currentTabActive: true,
queryResultsView: this._getDefaultQUeryResultsViewDirection(props),
queryViewSizePercent: this._getQueryViewSizePercent(props),
queryResultsView:
props.stringsplitterDirection === "horizontal" ? SplitterDirection.Horizontal : SplitterDirection.Vertical,
queryViewSizePercent: props.queryViewSizePercent,
};
this.isCloseClicked = false;
this.splitterId = this.props.tabId + "_splitter";
@ -226,63 +213,33 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
onSaveClickEvent: this.getCurrentEditorQuery.bind(this),
onCloseClickEvent: this.onCloseClick.bind(this),
});
// Update persistence
this.saveQueryTabStateDebounced();
// DO THIS IN useTabs.activateNewTab() INSTEAD
}
/**
* Helper function to save the query text in the query tab state
* Since it reads and writes to the same state, it is debounced
* @param collection
* @param queryText
* @param queryTabIndex
*/
private saveQueryTextDebounced = (queryText: string) => {
private saveQueryTabStateDebounced = () => {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(async () => {
const queryTexts = readQueryTabSubComponentState<QueryTexts>(
SubComponentName.QueryText,
saveQueryTabState(
this.props.collection,
[],
{
queryText: this.state.sqlQueryEditorContent,
splitterDirection: this.state.queryResultsView,
queryViewSizePercent: this.state.queryViewSizePercent,
},
this.props.tabIndex,
);
const queryTextsIndex = QueryTabComponentImpl.openTabIndexRetriever.getOpenTabIndex(
this.props.collection.databaseId,
this.props.collection.id(),
this.props.tabId,
);
queryTexts[queryTextsIndex] = queryText;
saveQueryTabSubComponentState<QueryTexts>(SubComponentName.QueryText, this.props.collection, queryTexts);
}, QueryTabComponentImpl.DEBOUNCE_DELAY_MS);
};
private _getQueryViewSizePercent(props: QueryTabComponentImplProps): number {
return readQueryTabSubComponentState<number>(SubComponentName.QueryViewSizePercent, props.collection, 50);
}
private _getDefaultQUeryResultsViewDirection(props: QueryTabComponentImplProps): SplitterDirection {
const defaultQueryResultsView = getDefaultQueryResultsView();
return readQueryTabSubComponentState<SplitterDirection>(
SubComponentName.SplitterDirection,
props.collection,
defaultQueryResultsView,
);
}
private _getDefaultQueryEditorContent(props: QueryTabComponentImplProps): string {
const defaultText = props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c";
// Retrieve from app state if available
const queryTexts = readQueryTabSubComponentState<QueryTexts>(SubComponentName.QueryText, props.collection, []);
const queryTextsIndex = QueryTabComponentImpl.openTabIndexRetriever.getOpenTabIndex(
this.props.collection.databaseId,
this.props.collection.id(),
this.props.tabId,
);
return queryTexts[queryTextsIndex] || defaultText;
}
private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) {
return readCopilotToggleStatus(userContext.databaseAccount);
@ -406,9 +363,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
this._iterator = this.props.isPreferredApiMongoDB
? queryIterator(this.props.collection.databaseId, this.props.viewModelcollection, query)
: queryDocuments(this.props.collection.databaseId, this.props.collection.id(), query, {
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
abortSignal: this.queryAbortController.signal,
} as unknown as FeedOptions);
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
abortSignal: this.queryAbortController.signal,
} as unknown as FeedOptions);
}
await this._queryDocumentsPage(firstItemIndex);
@ -643,14 +600,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
};
}
private _setViewLayout(direction: SplitterDirection): void {
this.setState({ queryResultsView: direction });
// Store to local storage
saveQueryTabSubComponentState<SplitterDirection>(
SubComponentName.SplitterDirection,
this.props.collection,
direction,
);
this.setState({ queryResultsView: direction }, () => this.saveQueryTabStateDebounced());
// We'll need to refresh the context buttons to update the selected state of the view buttons
setTimeout(() => {
@ -682,13 +632,16 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
if (this.state.copilotActive) {
this.props.copilotStore?.setQuery(newContent);
}
this.setState({
sqlQueryEditorContent: newContent,
queryCopilotGeneratedQuery: "",
this.setState(
{
sqlQueryEditorContent: newContent,
queryCopilotGeneratedQuery: "",
// Clear the markers when the user edits the document.
modelMarkers: [],
});
// Clear the markers when the user edits the document.
modelMarkers: [],
},
() => this.saveQueryTabStateDebounced(),
);
if (this.isPreferredApiMongoDB) {
if (newContent.length > 0) {
this.executeQueryButton = {
@ -706,8 +659,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
this.saveQueryButton.enabled = newContent.length > 0;
useCommandBar.getState().setContextButtons(this.getTabsButtons());
this.saveQueryTextDebounced(newContent);
}
public onSelectedContent(selectedContent: string, selection: monaco.Selection): void {
@ -775,28 +726,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
document.removeEventListener("keydown", this.handleCopilotKeyDown);
// Remove persistence
const queryTextsIndex = QueryTabComponentImpl.openTabIndexRetriever.getOpenTabIndex(
this.props.collection.databaseId,
this.props.collection.id(),
this.props.tabId,
);
const queryTexts = readQueryTabSubComponentState<QueryTexts>(SubComponentName.QueryText, this.props.collection, []);
if (queryTexts.length === 0 || queryTextsIndex >= queryTexts.length) {
return;
}
queryTexts.splice(queryTextsIndex, 1);
QueryTabComponentImpl.openTabIndexRetriever.removeOpenTabIndex(
this.props.collection.databaseId,
this.props.collection.id(),
this.props.tabId,
);
if (queryTexts.length === 0) {
deleteQueryTabSubComponentState(SubComponentName.QueryText, this.props.collection);
} else {
saveQueryTabSubComponentState<QueryTexts>(SubComponentName.QueryText, this.props.collection, queryTexts);
}
deleteQueryTabState(this.props.tabIndex);
}
private getEditorAndQueryResult(): JSX.Element {
@ -818,13 +748,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
vertical={vertical}
onDragEnd={(sizes: number[]) => {
const queryViewSizePercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveQueryTabSubComponentState<number>(
SubComponentName.QueryViewSizePercent,
this.props.collection,
queryViewSizePercent,
true,
);
this.setState({ queryViewSizePercent });
this.setState({ queryViewSizePercent }, () => this.saveQueryTabStateDebounced());
}}
>
<Allotment.Pane data-test="QueryTab/EditorPane" preferredSize={`${this.state.queryViewSizePercent}%`}>

View File

@ -1,86 +1,65 @@
// Definitions of State data
import { ActionType, OpenQueryTab, TabKind } from "Contracts/ActionContracts";
import {
AppStateComponentNames,
deleteSubComponentState,
readSubComponentState,
saveSubComponentState,
} from "Shared/AppStatePersistenceUtility";
import * as ViewModels from "../../../Contracts/ViewModels";
export enum SubComponentName {
SplitterDirection = "SplitterDirection",
QueryViewSizePercent = "QueryViewSizePercent",
QueryText = "QueryText",
}
export const OPEN_TABS_SUBCOMPONENT_NAME = "OpenTabs";
export type QueryViewSizePercent = number;
export type QueryTexts = string[];
// Wrap the ...SubComponentState functions for type safety
export const readQueryTabSubComponentState = <T>(
subComponentName: SubComponentName,
export const saveQueryTabState = (
collection: ViewModels.CollectionBase,
defaultValue: T,
): T => readSubComponentState<T>(AppStateComponentNames.QueryTab, subComponentName, collection, defaultValue);
state: {
queryText: string;
splitterDirection: "vertical" | "horizontal";
queryViewSizePercent: number;
},
tabIndex: number,
): void => {
const openTabsState = readSubComponentState<OpenQueryTab[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
[],
);
export const saveQueryTabSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
state: T,
debounce?: boolean,
): void => saveSubComponentState<T>(AppStateComponentNames.QueryTab, subComponentName, collection, state, debounce);
openTabsState[tabIndex] = {
actionType: ActionType.OpenCollectionTab,
tabKind: TabKind.SQLQuery,
databaseResourceId: collection.databaseId,
collectionResourceId: collection.id(),
query: {
text: state.queryText,
},
splitterDirection: state.splitterDirection,
queryViewSizePercent: state.queryViewSizePercent,
};
export const deleteQueryTabSubComponentState = (
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
) => deleteSubComponentState(AppStateComponentNames.QueryTab, subComponentName, collection);
saveSubComponentState<OpenQueryTab[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
openTabsState,
);
};
/**
* For a given databaseId-collectionId tuple:
* Query tab texts are persisted in a form of an array of strings.
* Each tab's index in the array is determined by the order they are open.
* If a tab is closed, the array is updated to reflect the new order.
*
* We use a map to separate the arrays per databaseId-collectionId tuple.
* We use a Set for the array to ensure uniqueness of tabId (the set also maintains order of insertion).
*/
export class OpenTabIndexRetriever {
private openTabsMap: Map<string, Set<string>>;
export const deleteQueryTabState = (tabIndex: number): void => {
const openTabsState = readSubComponentState<OpenQueryTab[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
[],
);
constructor() {
this.openTabsMap = new Map<string, Set<string>>();
}
openTabsState.splice(tabIndex, 1);
public getOpenTabIndex(databaseId: string, collectionId: string, tabId: string): number {
const key = `${databaseId}-${collectionId}`;
const openTabs = this.openTabsMap.get(key);
if (!openTabs) {
return -1;
}
const openTabArray = Array.from(openTabs);
return openTabArray.indexOf(tabId);
}
public setOpenTabIndex(databaseId: string, collectionId: string, tabId: string): void {
const key = `${databaseId}-${collectionId}`;
let openTabs = this.openTabsMap.get(key);
if (!openTabs) {
openTabs = new Set<string>();
this.openTabsMap.set(key, openTabs);
}
openTabs.add(tabId);
}
public removeOpenTabIndex(databaseId: string, collectionId: string, tabId: string): void {
const key = `${databaseId}-${collectionId}`;
const openTabs = this.openTabsMap.get(key);
if (!openTabs) {
return;
}
openTabs.delete(tabId);
}
}
saveSubComponentState<OpenQueryTab[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
openTabsState,
);
};

View File

@ -626,7 +626,13 @@ export default class Collection implements ViewModels.Collection {
}
};
public onNewQueryClick(source: any, event: MouseEvent, queryText?: string) {
public onNewQueryClick(
source: any,
event: MouseEvent,
queryText?: string,
stringsplitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
) {
const collection: ViewModels.Collection = source.collection || source;
const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1;
const title = "Query " + id;
@ -649,6 +655,8 @@ export default class Collection implements ViewModels.Collection {
queryText: queryText,
partitionKey: collection.partitionKey,
onLoadStartKey: startKey,
stringsplitterDirection,
queryViewSizePercent,
},
{ container: this.container },
),

View File

@ -9,7 +9,7 @@ export enum AppStateComponentNames {
DocumentsTab = "DocumentsTab",
MostRecentActivity = "MostRecentActivity",
QueryCopilot = "QueryCopilot",
QueryTab = "QueryTab",
DataExplorerAction = "DataExplorerAction",
}
export const PATH_SEPARATOR = "/"; // export for testing purposes
@ -136,7 +136,7 @@ export const deleteAllStates = (): void => {
export const readSubComponentState = <T>(
componentName: AppStateComponentNames,
subComponentName: string,
collection: ViewModels.CollectionBase,
collection: ViewModels.CollectionBase | undefined,
defaultValue: T,
): T => {
const globalAccountName = userContext.databaseAccount?.name;
@ -151,8 +151,8 @@ export const readSubComponentState = <T>(
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
databaseName: collection ? collection.databaseId : "",
containerName: collection ? collection.id() : "",
}) as T;
return state || defaultValue;
@ -168,7 +168,7 @@ export const readSubComponentState = <T>(
export const saveSubComponentState = <T>(
componentName: AppStateComponentNames,
subComponentName: string,
collection: ViewModels.CollectionBase,
collection: ViewModels.CollectionBase | undefined,
state: T,
debounce?: boolean,
): void => {
@ -185,8 +185,8 @@ export const saveSubComponentState = <T>(
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
databaseName: collection ? collection.databaseId : "",
containerName: collection ? collection.id() : "",
},
state,
);