diff --git a/README.md b/README.md index 67e27bd1bd..7127c95dc0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ You can see live demo of CloudBeaver here: https://demo.cloudbeaver.io ## Changelog -### 23.3.2. 2024-01-22 +### 23.3.3. 2024-01-22 - Added password policy for the local authorization. Password parameters can be set in the configuration file; - The 'Keep alive' option has been added to the connection settings to keep the connection active even in case of inactivity; - Added ability to display full text for a string data type in value panel; diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 512b792c28..0a110689fa 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:23.04 +FROM ubuntu:23.10 MAINTAINER DBeaver Corp, devops@dbeaver.com diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java index 1ec4d407ed..bd2bab25e2 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java @@ -27,17 +27,12 @@ * Federated auth provider. * Provides links to external auth resource */ -public interface SMAuthProviderFederated { +public interface SMAuthProviderFederated extends SMSignOutLinkProvider { @NotNull String getSignInLink(String id, @NotNull Map providerConfig) throws DBException; - /** - * @return a common link for logout, not related with the user context - */ - @NotNull - String getCommonSignOutLink(String id, @NotNull Map providerConfig) throws DBException; - + @Override default String getUserSignOutLink( @NotNull SMAuthProviderCustomConfiguration providerConfig, @NotNull Map userCredentials diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMSignOutLinkProvider.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMSignOutLinkProvider.java new file mode 100644 index 0000000000..3cbd7f70b1 --- /dev/null +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMSignOutLinkProvider.java @@ -0,0 +1,37 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.auth; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.security.SMAuthProviderCustomConfiguration; + +import java.util.Map; + +public interface SMSignOutLinkProvider { + + /** + * @return a common link for logout, not related with the user context + */ + @NotNull + String getCommonSignOutLink(String id, @NotNull Map providerConfig) throws DBException; + + String getUserSignOutLink( + @NotNull SMAuthProviderCustomConfiguration providerConfig, + @NotNull Map userCredentials + ) throws DBException; +} diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java index 46712a0a59..864d765b41 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java @@ -18,6 +18,7 @@ import io.cloudbeaver.auth.CBAuthConstants; import io.cloudbeaver.auth.SMAuthProviderFederated; +import io.cloudbeaver.auth.SMSignOutLinkProvider; import io.cloudbeaver.utils.WebAppUtils; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; @@ -85,8 +86,8 @@ private String buildRedirectUrl(String baseUrl) { @Property public String getSignOutLink() throws DBException { SMAuthProvider instance = providerDescriptor.getInstance(); - return instance instanceof SMAuthProviderFederated - ? ((SMAuthProviderFederated) instance).getCommonSignOutLink(getId(), config.getParameters()) + return instance instanceof SMSignOutLinkProvider + ? ((SMSignOutLinkProvider) instance).getCommonSignOutLink(getId(), config.getParameters()) : null; } diff --git a/server/bundles/io.cloudbeaver.server/schema/service.navigator.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.navigator.graphqls index 996d076caf..9230aa515b 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.navigator.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.navigator.graphqls @@ -56,8 +56,8 @@ type DatabaseObjectInfo { type NavigatorNodeInfo { # Node ID - generally a full path to the node from root of tree id: ID! - # # Node URI - a unique path to a node including all parent nodes - # uri: ID! @since(version: "23.3.1") + # Node URI - a unique path to a node including all parent nodes + # uri: ID! @since(version: "23.3.1") # Node human readable name name: String #Node full name diff --git a/server/bundles/io.cloudbeaver.service.auth/plugin.xml b/server/bundles/io.cloudbeaver.service.auth/plugin.xml index 01901218ac..e888ba9255 100644 --- a/server/bundles/io.cloudbeaver.service.auth/plugin.xml +++ b/server/bundles/io.cloudbeaver.service.auth/plugin.xml @@ -7,6 +7,9 @@ + + diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPConstants.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPConstants.java new file mode 100644 index 0000000000..304be1493a --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPConstants.java @@ -0,0 +1,26 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.auth; + +interface RPConstants { + String PARAM_LOGOUT_URL = "logout-url"; + String PARAM_USER = "user-header"; + String PARAM_TEAM = "team-header"; + String PARAM_FIRST_NAME = "first-name-header"; + String PARAM_LAST_NAME = "last-name-header"; + String PARAM_ROLE_NAME = "role-header"; +} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPSessionHandler.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPSessionHandler.java index 10f4669cb6..4088b0beaa 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPSessionHandler.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/RPSessionHandler.java @@ -33,6 +33,7 @@ import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.auth.SMAuthInfo; +import org.jkiss.dbeaver.model.security.SMAuthProviderCustomConfiguration; import org.jkiss.dbeaver.model.security.SMConstants; import org.jkiss.dbeaver.model.security.SMController; import org.jkiss.dbeaver.model.security.SMStandardMeta; @@ -41,10 +42,10 @@ import java.io.IOException; import java.text.MessageFormat; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; public class RPSessionHandler implements DBWSessionHandler { @@ -62,22 +63,38 @@ public boolean handleSessionOpen(WebSession webSession, HttpServletRequest reque return false; } - public void reverseProxyAuthentication(@NotNull HttpServletRequest request, @NotNull WebSession webSession) throws DBWebException { + public void reverseProxyAuthentication(@NotNull HttpServletRequest request, @NotNull WebSession webSession) throws DBException { SMController securityController = webSession.getSecurityController(); WebAuthProviderDescriptor authProvider = WebAuthProviderRegistry.getInstance().getAuthProvider(RPAuthProvider.AUTH_PROVIDER); if (authProvider == null) { throw new DBWebException("Auth provider " + RPAuthProvider.AUTH_PROVIDER + " not found"); } SMAuthProviderExternal authProviderExternal = (SMAuthProviderExternal) authProvider.getInstance(); - String userName = request.getHeader(RPAuthProvider.X_USER); - String teams = request.getHeader(RPAuthProvider.X_TEAM); + SMAuthProviderCustomConfiguration configuration = WebAppUtils.getWebAuthApplication() + .getAuthConfiguration() + .getAuthCustomConfigurations() + .stream() + .filter(p -> p.getProvider().equals(authProvider.toString())) + .findFirst() + .orElse(null); + Map paramConfigMap = new HashMap<>(); + if (configuration != null) { + authProvider.getConfigurationParameters().forEach(p -> + paramConfigMap.put(p.getId(), configuration.getParameters().get(p.getId()) + )); + } + String userName = request.getHeader( + resolveParam(paramConfigMap.get(RPConstants.PARAM_USER), RPAuthProvider.X_USER) + ); + String teams = request.getHeader(resolveParam(paramConfigMap.get(RPConstants.PARAM_TEAM), RPAuthProvider.X_TEAM)); if (CommonUtils.isEmpty(teams)) { // backward compatibility teams = request.getHeader(RPAuthProvider.X_ROLE); } - String role = request.getHeader(RPAuthProvider.X_ROLE_TE); - String firstName = request.getHeader(RPAuthProvider.X_FIRST_NAME); - String lastName = request.getHeader(RPAuthProvider.X_LAST_NAME); + String role = request.getHeader(resolveParam(paramConfigMap.get(RPConstants.PARAM_ROLE_NAME), RPAuthProvider.X_ROLE_TE)); + String firstName = request.getHeader(resolveParam(paramConfigMap.get(RPConstants.PARAM_FIRST_NAME), RPAuthProvider.X_FIRST_NAME)); + String lastName = request.getHeader(resolveParam(paramConfigMap.get(RPConstants.PARAM_LAST_NAME), RPAuthProvider.X_LAST_NAME)); + String logoutUrl = Objects.requireNonNull(configuration).getParameter(RPConstants.PARAM_LOGOUT_URL); List userTeams = teams == null ? Collections.emptyList() : List.of(teams.split("\\|")); if (userName != null) { try { @@ -89,6 +106,9 @@ public void reverseProxyAuthentication(@NotNull HttpServletRequest request, @Not if (!CommonUtils.isEmpty(lastName)) { credentials.put(SMStandardMeta.META_LAST_NAME, lastName); } + if (CommonUtils.isNotEmpty(logoutUrl)) { + credentials.put("logoutUrl", logoutUrl); + } Map sessionParameters = webSession.getSessionParameters(); sessionParameters.put(SMConstants.SESSION_PARAM_TRUSTED_USER_TEAMS, userTeams); sessionParameters.put(SMConstants.SESSION_PARAM_TRUSTED_USER_ROLE, role); @@ -102,7 +122,7 @@ public void reverseProxyAuthentication(@NotNull HttpServletRequest request, @Not webSession.getSessionId(), currentSmSessionId, sessionParameters, - WebSession.CB_SESSION_TYPE, authProvider.getId(), null, userCredentials); + WebSession.CB_SESSION_TYPE, authProvider.getId(), configuration.getId(), userCredentials); new WebSessionAuthProcessor(webSession, smAuthInfo, false).authenticateSession(); log.debug(MessageFormat.format( "Successful reverse proxy authentication: user ''{0}'' with teams {1}", userName, userTeams)); @@ -120,4 +140,11 @@ public void reverseProxyAuthentication(@NotNull HttpServletRequest request, @Not public boolean handleSessionClose(WebSession webSession) throws DBException, IOException { return false; } + + private String resolveParam(Object value, String defaultValue) { + if (value != null && !value.toString().isEmpty()) { + return value.toString(); + } + return defaultValue; + } } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/ReverseProxyConfigurator.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/ReverseProxyConfigurator.java new file mode 100644 index 0000000000..ee415c0ffc --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/ReverseProxyConfigurator.java @@ -0,0 +1,110 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.auth; + +import io.cloudbeaver.auth.provider.rp.RPAuthProvider; +import io.cloudbeaver.model.app.WebAppConfiguration; +import io.cloudbeaver.model.app.WebApplication; +import io.cloudbeaver.model.app.WebAuthApplication; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.DBWServiceServerConfigurator; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.security.SMAuthProviderCustomConfiguration; + +import java.util.HashMap; +import java.util.Map; + +public class ReverseProxyConfigurator implements DBWServiceServerConfigurator { + private static final Log log = Log.getLog(ReverseProxyConfigurator.class); + + @Override + public void configureServer( + @NotNull WebApplication application, + @Nullable WebSession session, + @NotNull WebAppConfiguration appConfig + ) throws DBException { + } + + @Override + public void migrateConfigurationIfNeeded(@NotNull WebApplication application) throws DBException { + if (migrationNotNeeded(application)) { + return; + } + migrateConfiguration(application); + } + + @Override + public void reloadConfiguration(@NotNull WebAppConfiguration appConfig) throws DBException { + + } + + private void migrateConfiguration( + @NotNull WebApplication application + ) { + if (!(application instanceof WebAuthApplication authApplication)) { + return; + } + + SMAuthProviderCustomConfiguration smReverseProxyProviderConfiguration = + authApplication.getAuthConfiguration().getAuthProviderConfiguration(RPAuthProvider.AUTH_PROVIDER); + if (smReverseProxyProviderConfiguration == null) { + smReverseProxyProviderConfiguration = new SMAuthProviderCustomConfiguration(RPAuthProvider.AUTH_PROVIDER); + smReverseProxyProviderConfiguration.setProvider(RPAuthProvider.AUTH_PROVIDER); + smReverseProxyProviderConfiguration.setDisplayName("Reverse Proxy"); + smReverseProxyProviderConfiguration.setDescription( + "Automatically created provider after changing Reverse Proxy configuration way in 23.3.4 version" + ); + smReverseProxyProviderConfiguration .setIconURL(""); + Map parameters = new HashMap<>(); + parameters.put(RPConstants.PARAM_USER, RPAuthProvider.X_USER); + parameters.put(RPConstants.PARAM_TEAM, RPAuthProvider.X_TEAM); + parameters.put(RPConstants.PARAM_FIRST_NAME, RPAuthProvider.X_FIRST_NAME); + parameters.put(RPConstants.PARAM_LAST_NAME, RPAuthProvider.X_LAST_NAME); + smReverseProxyProviderConfiguration.setParameters(parameters); + authApplication.getAuthConfiguration().addAuthProviderConfiguration(smReverseProxyProviderConfiguration ); + try { + authApplication.flushConfiguration(); + } catch (Exception e) { + log.error("Failed to save server configuration", e); + } + } + } + + private boolean migrationNotNeeded(@NotNull WebApplication application) { + if (!(application instanceof WebAuthApplication authApplication)) { + return true; + } + + if (!authApplication.getAuthConfiguration().isAuthProviderEnabled(RPAuthProvider.AUTH_PROVIDER)) { + log.debug("Reverse proxy provider disabled, migration not needed"); + return true; + } + + boolean isReverseProxyConfigured = authApplication.getAuthConfiguration() + .getAuthCustomConfigurations().stream() + .anyMatch(p -> p.getProvider().equals(RPAuthProvider.AUTH_PROVIDER)); + + if (isReverseProxyConfigured) { + log.debug("Reverse proxy provider already exist, migration not needed"); + return true; + } + return false; + } +} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java index 0d1f3db208..f590be3c38 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java @@ -19,6 +19,7 @@ import io.cloudbeaver.DBWebException; import io.cloudbeaver.WebServiceUtils; import io.cloudbeaver.auth.SMAuthProviderFederated; +import io.cloudbeaver.auth.SMSignOutLinkProvider; import io.cloudbeaver.auth.provider.local.LocalAuthProvider; import io.cloudbeaver.model.WebPropertyInfo; import io.cloudbeaver.model.session.WebAuthInfo; @@ -148,8 +149,8 @@ public WebLogoutInfo authLogout( var cbApp = CBApplication.getInstance(); for (WebAuthInfo removedInfo : removedInfos) { if (removedInfo.getAuthProviderDescriptor() - .getInstance() instanceof SMAuthProviderFederated federatedProvider - && removedInfo.getAuthSession() instanceof SMSessionExternal externalSession + .getInstance() instanceof SMSignOutLinkProvider provider + && removedInfo.getAuthSession() != null ) { var providerConfig = cbApp.getAuthConfiguration().getAuthProviderConfiguration(removedInfo.getAuthConfiguration()); @@ -157,9 +158,17 @@ public WebLogoutInfo authLogout( log.warn(removedInfo.getAuthConfiguration() + " provider configuration wasn't found"); continue; } - String logoutUrl = federatedProvider.getUserSignOutLink(providerConfig, - externalSession.getAuthParameters()); - logoutUrls.add(logoutUrl); + String logoutUrl; + if (removedInfo.getAuthSession() instanceof SMSessionExternal externalSession) { + logoutUrl = provider.getUserSignOutLink(providerConfig, + externalSession.getAuthParameters()); + } else { + logoutUrl = provider.getUserSignOutLink(providerConfig, + Map.of()); + } + if (CommonUtils.isNotEmpty(logoutUrl)) { + logoutUrls.add(logoutUrl); + } } } return new WebLogoutInfo(logoutUrls); diff --git a/server/bundles/io.cloudbeaver.service.security/plugin.xml b/server/bundles/io.cloudbeaver.service.security/plugin.xml index 88eff847ae..5878dca3c7 100644 --- a/server/bundles/io.cloudbeaver.service.security/plugin.xml +++ b/server/bundles/io.cloudbeaver.service.security/plugin.xml @@ -18,6 +18,7 @@ @@ -25,6 +26,16 @@ + + + + + + + + + + diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/rp/RPAuthProvider.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/rp/RPAuthProvider.java index 8d8d0a0244..14106ec3cb 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/rp/RPAuthProvider.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/rp/RPAuthProvider.java @@ -18,6 +18,7 @@ import io.cloudbeaver.DBWUserIdentity; import io.cloudbeaver.auth.SMAuthProviderExternal; +import io.cloudbeaver.auth.SMSignOutLinkProvider; import io.cloudbeaver.auth.provider.local.LocalAuthSession; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.model.user.WebUser; @@ -37,7 +38,7 @@ import java.util.HashMap; import java.util.Map; -public class RPAuthProvider implements SMAuthProviderExternal { +public class RPAuthProvider implements SMAuthProviderExternal, SMSignOutLinkProvider { private static final Log log = Log.getLog(RPAuthProvider.class); @@ -49,6 +50,7 @@ public class RPAuthProvider implements SMAuthProviderExternal { public static final String X_FIRST_NAME = "X-First-name"; public static final String X_LAST_NAME = "X-Last-name"; public static final String AUTH_PROVIDER = "reverseProxy"; + public static final String LOGOUT_URL = "logout-url"; @NotNull @Override @@ -122,4 +124,18 @@ public void closeSession(@NotNull SMSession mainSession, SMSession session) thro public void refreshSession(@NotNull DBRProgressMonitor monitor, @NotNull SMSession mainSession, SMSession session) throws DBException { } + + @NotNull + @Override + public String getCommonSignOutLink(String id, @NotNull Map providerConfig) throws DBException { + return providerConfig.get(LOGOUT_URL) != null ? providerConfig.get(LOGOUT_URL).toString() : ""; + } + + @Override + public String getUserSignOutLink(@NotNull SMAuthProviderCustomConfiguration providerConfig, @NotNull Map userCredentials) throws DBException { + return providerConfig.getParameters().get(LOGOUT_URL) != null ? + providerConfig.getParameters().get(LOGOUT_URL).toString() : + null; + } + } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java index b3fffe8bc0..d9c223c900 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java @@ -1346,7 +1346,7 @@ public SMAuthInfo authenticate( String signInLink = buildRedirectLink(authProviderFederated.getSignInLink(authProviderConfigurationId, Map.of()), authAttemptId); String signOutLink = authProviderFederated.getCommonSignOutLink(authProviderConfigurationId, - Map.of()); + providerConfig.getParameters()); Map authData = Map.of(new SMAuthConfigurationReference(authProviderId, authProviderConfigurationId), filteredUserCreds); return SMAuthInfo.inProgress(authAttemptId, signInLink, signOutLink, authData, isMainSession); @@ -2858,12 +2858,13 @@ private boolean isProviderDisabled(@NotNull String providerId, @Nullable String public void clearOldAuthAttemptInfo() throws DBException { try (Connection dbCon = database.openConnection()) { JDBCUtils.executeStatement(dbCon, - database.normalizeTableNames("DELETE FROM {table_prefix}CB_AUTH_ATTEMPT_INFO AAI " + + database.normalizeTableNames("DELETE FROM {table_prefix}CB_AUTH_ATTEMPT_INFO " + "WHERE EXISTS " + "(SELECT 1 FROM {table_prefix}CB_AUTH_ATTEMPT AA " + "LEFT JOIN {table_prefix}CB_AUTH_TOKEN CAT ON AA.SESSION_ID = CAT.SESSION_ID " + "WHERE (CAT.REFRESH_TOKEN_EXPIRATION_TIME < ? OR CAT.EXPIRATION_TIME IS NULL) " + - "AND AA.AUTH_ID=AAI.AUTH_ID AND AUTH_STATUS='" + SMAuthStatus.EXPIRED + "') " + + "AND AA.AUTH_ID={table_prefix}CB_AUTH_ATTEMPT_INFO.AUTH_ID " + + "AND AUTH_STATUS='" + SMAuthStatus.EXPIRED + "') " + "AND CREATE_TIME, 'onChan description?: string; labelTooltip?: string; embedded?: boolean; + cursorInitiallyAtEnd?: boolean; }; type ControlledProps = BaseProps & { @@ -54,9 +55,11 @@ export const Textarea: TextareaType = observer(function Textarea({ description, labelTooltip, embedded, + cursorInitiallyAtEnd, onChange = () => {}, ...rest }: ControlledProps | ObjectProps) { + const textareaRef = useRef(null); const layoutProps = getLayoutProps(rest); rest = filterLayoutFakeProps(rest); const styles = useS(textareaStyle); @@ -79,13 +82,21 @@ export const Textarea: TextareaType = observer(function Textarea({ const value = state ? state[name] : controlledValue; + useLayoutEffect(() => { + if (cursorInitiallyAtEnd && typeof value === 'string') { + const position = value.length; + textareaRef.current?.setSelectionRange(position, position); + } + }, [cursorInitiallyAtEnd]); + return ( - + {children} - - {translate('administration_identity_providers_provider_configuration_icon_url')} - - - {translate('administration_identity_providers_provider_configuration_disabled')} - - - {parameters.isLoaded() && parameters.data && ( - <> - {isUncategorizedExists && ( - - {translate('administration_identity_providers_provider_configuration_parameters')} - - - )} - {categories.map(category => ( - - {category} - - - ))} - - )} - {(state.config.metadataLink || state.config.signInLink || state.config.signOutLink || state.config.acsLink) && ( - - {translate('administration_identity_providers_provider_configuration_links')} - copy(state.config.signInLink!, true)} - > - Sign in - - copy(state.config.signOutLink!, true)} - > - Sign out - - copy(state.config.redirectLink!, true)} - > - Redirect - - copy(state.config.acsLink!, true)} - > - ACS - - {state.config.metadataLink && ( - - {translate('administration_identity_providers_provider_configuration_links_metadata')} - - )} - - )} - - ); -}); diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/IdentityProviders/Options/AuthConfigurationOptionsTabService.ts b/webapp/packages/plugin-authentication-administration/src/Administration/IdentityProviders/Options/AuthConfigurationOptionsTabService.ts deleted file mode 100644 index 8a6b8739e5..0000000000 --- a/webapp/packages/plugin-authentication-administration/src/Administration/IdentityProviders/Options/AuthConfigurationOptionsTabService.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import React from 'react'; - -import { AuthConfigurationsResource, AuthProvidersResource } from '@cloudbeaver/core-authentication'; -import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import { CachedMapAllKey } from '@cloudbeaver/core-resource'; -import { getUniqueName } from '@cloudbeaver/core-utils'; - -import { AuthConfigurationFormService } from '../AuthConfigurationFormService'; -import { authConfigurationContext } from '../Contexts/authConfigurationContext'; -import type { - IAuthConfigurationFormFillConfigData, - IAuthConfigurationFormState, - IAuthConfigurationFormSubmitData, -} from '../IAuthConfigurationFormProps'; - -const AuthConfigurationOptions = React.lazy(async () => { - const { AuthConfigurationOptions } = await import('./AuthConfigurationOptions'); - return { default: AuthConfigurationOptions }; -}); - -@injectable() -export class AuthConfigurationOptionsTabService extends Bootstrap { - constructor( - private readonly authConfigurationFormService: AuthConfigurationFormService, - private readonly authConfigurationsResource: AuthConfigurationsResource, - private readonly authProvidersResource: AuthProvidersResource, - ) { - super(); - } - - register(): void { - this.authConfigurationFormService.tabsContainer.add({ - key: 'options', - name: 'ui_options', - order: 1, - panel: () => AuthConfigurationOptions, - }); - - this.authConfigurationFormService.prepareConfigTask.addHandler(this.prepareConfig.bind(this)); - - this.authConfigurationFormService.formValidationTask.addHandler(this.validate.bind(this)); - - this.authConfigurationFormService.formSubmittingTask.addHandler(this.save.bind(this)); - - this.authConfigurationFormService.fillConfigTask.addHandler(this.fillConfig.bind(this)); - } - - load(): void {} - - private async prepareConfig({ state }: IAuthConfigurationFormSubmitData, contexts: IExecutionContextProvider) { - const config = contexts.getContext(authConfigurationContext); - - config.id = state.config.id.trim(); - config.providerId = state.config.providerId; - config.disabled = state.config.disabled; - config.displayName = state.config.displayName.trim(); - - if (state.mode === 'create') { - const configurationNames = this.authConfigurationsResource.values.map(configuration => configuration.displayName); - config.displayName = getUniqueName(config.displayName, configurationNames); - } - - if (Object.keys(state.config.parameters).length) { - config.parameters = state.config.parameters; - - for (const key of Object.keys(config.parameters)) { - const value = config.parameters[key]; - - if (typeof value === 'string') { - config.parameters[key] = value.trim(); - } - } - } - - if (state.config.description) { - config.description = state.config.description.trim(); - } - - if (state.config.iconURL) { - config.iconURL = state.config.iconURL.trim(); - } - } - - private async validate({ state }: IAuthConfigurationFormSubmitData, contexts: IExecutionContextProvider) { - const validation = contexts.getContext(this.authConfigurationFormService.configurationValidationContext); - - if (!state.config.displayName.trim()) { - validation.error("Field 'Name' can't be empty"); - } - - if (state.mode === 'create') { - if (!state.config.providerId) { - validation.error("Field 'Provider' can't be empty"); - } - - if (!state.config.id.trim()) { - validation.error("Field 'ID' can't be empty"); - } - - if (this.authConfigurationsResource.has(state.config.id)) { - validation.error(`A configuration with ID "${state.config.id}" already exists`); - } - } - } - - private async save({ state }: IAuthConfigurationFormSubmitData, contexts: IExecutionContextProvider) { - const status = contexts.getContext(this.authConfigurationFormService.configurationStatusContext); - const config = contexts.getContext(authConfigurationContext); - - try { - const configuration = await this.authConfigurationsResource.saveConfiguration(config); - - if (state.mode === 'create') { - status.info('Configuration created'); - status.info(configuration.displayName); - } else { - status.info('Configuration updated'); - status.info(configuration.displayName); - } - } catch (exception: any) { - status.error('connections_connection_create_fail', exception); - } - } - - private async setDefaults(state: IAuthConfigurationFormState) { - if (state.mode === 'create') { - await this.authProvidersResource.load(CachedMapAllKey); - if (this.authProvidersResource.configurable.length > 0 && !state.config.providerId) { - state.config.providerId = this.authProvidersResource.configurable[0].id; - } - } - } - - private async fillConfig( - { state, updated }: IAuthConfigurationFormFillConfigData, - contexts: IExecutionContextProvider, - ) { - if (!updated) { - return; - } - - if (!state.info) { - await this.setDefaults(state); - return; - } - - if (state.info.id) { - state.config.id = state.info.id; - } - if (state.info.providerId) { - state.config.providerId = state.info.providerId; - } - if (state.info.displayName) { - state.config.displayName = state.info.displayName; - } - if (state.info.disabled !== undefined) { - state.config.disabled = state.info.disabled; - } - if (state.info.iconURL) { - state.config.iconURL = state.info.iconURL; - } - if (state.info.description) { - state.config.description = state.info.description; - } - if (state.info.metadataLink) { - state.config.metadataLink = state.info.metadataLink; - } - if (state.info.acsLink) { - state.config.acsLink = state.info.acsLink; - } - if (state.info.signInLink) { - state.config.signInLink = state.info.signInLink; - } - if (state.info.signOutLink) { - state.config.signOutLink = state.info.signOutLink; - } - if (state.info.redirectLink) { - state.config.redirectLink = state.info.redirectLink; - } - if (state.info.parameters) { - state.config.parameters = { ...state.info.parameters }; - } - } -} diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/IdentityProviders/useAuthConfigurationFormState.ts b/webapp/packages/plugin-authentication-administration/src/Administration/IdentityProviders/useAuthConfigurationFormState.ts deleted file mode 100644 index ebd048e344..0000000000 --- a/webapp/packages/plugin-authentication-administration/src/Administration/IdentityProviders/useAuthConfigurationFormState.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { useState } from 'react'; - -import { useService } from '@cloudbeaver/core-di'; -import type { CachedMapResource } from '@cloudbeaver/core-resource'; -import type { AdminAuthProviderConfiguration, GetAuthProviderConfigurationsQueryVariables } from '@cloudbeaver/core-sdk'; - -import { AuthConfigurationFormService } from './AuthConfigurationFormService'; -import { AuthConfigurationFormState } from './AuthConfigurationFormState'; -import type { IAuthConfigurationFormState } from './IAuthConfigurationFormProps'; - -export function useAuthConfigurationFormState( - resource: CachedMapResource, - configure?: (state: IAuthConfigurationFormState) => any, -): IAuthConfigurationFormState { - const service = useService(AuthConfigurationFormService); - const [state] = useState(() => { - const state = new AuthConfigurationFormState(service, resource); - configure?.(state); - - state.load(); - return state; - }); - - return state; -} diff --git a/webapp/packages/plugin-authentication-administration/src/PluginBootstrap.ts b/webapp/packages/plugin-authentication-administration/src/PluginBootstrap.ts index 0efb7c6edf..8c29d26d81 100644 --- a/webapp/packages/plugin-authentication-administration/src/PluginBootstrap.ts +++ b/webapp/packages/plugin-authentication-administration/src/PluginBootstrap.ts @@ -11,8 +11,6 @@ import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { ServerConfigurationAdministrationNavService, ServerConfigurationService } from '@cloudbeaver/plugin-administration'; import { AuthenticationService } from '@cloudbeaver/plugin-authentication'; -import { AuthConfigurationsAdministrationNavService } from './Administration/IdentityProviders/AuthConfigurationsAdministrationNavService'; - const AuthenticationProviders = React.lazy(async () => { const { AuthenticationProviders } = await import('./Administration/ServerConfiguration/AuthenticationProviders'); return { default: AuthenticationProviders }; @@ -23,7 +21,6 @@ export class PluginBootstrap extends Bootstrap { constructor( private readonly serverConfigurationService: ServerConfigurationService, private readonly serverConfigurationAdministrationNavService: ServerConfigurationAdministrationNavService, - private readonly authConfigurationsAdministrationNavService: AuthConfigurationsAdministrationNavService, private readonly authenticationService: AuthenticationService, ) { super(); @@ -32,7 +29,6 @@ export class PluginBootstrap extends Bootstrap { register(): void { this.serverConfigurationService.configurationContainer.add(AuthenticationProviders, 0); this.authenticationService.setConfigureAuthProvider(() => this.serverConfigurationAdministrationNavService.navToSettings()); - this.authenticationService.setConfigureIdentityProvider(() => this.authConfigurationsAdministrationNavService.navToCreate()); } load(): void | Promise {} diff --git a/webapp/packages/plugin-authentication-administration/src/index.ts b/webapp/packages/plugin-authentication-administration/src/index.ts index 5d86833a85..4ddde22274 100644 --- a/webapp/packages/plugin-authentication-administration/src/index.ts +++ b/webapp/packages/plugin-authentication-administration/src/index.ts @@ -2,8 +2,6 @@ import { manifest } from './manifest'; export default manifest; -export * from './Administration/IdentityProviders/IdentityProvidersServiceLink'; -export * from './Administration/IdentityProviders/AuthConfigurationsAdministrationNavService'; export * from './Administration/Users/UsersAdministrationNavigationService'; export * from './Administration/Users/Teams/TeamFormService'; export * from './Administration/Users/Teams/ITeamFormProps'; @@ -14,8 +12,6 @@ export * from './Administration/Users/UserForm/AdministrationUserFormService'; export * from './Administration/Users/UserForm/AdministrationUserFormState'; export * from './Administration/Users/UserForm/Info/DATA_CONTEXT_USER_FORM_INFO_PART'; export * from './Administration/Users/UserForm/Info/UserFormInfoPartService'; -export * from './Administration/IdentityProviders/IAuthConfigurationFormProps'; -export * from './Administration/IdentityProviders/AuthConfigurationFormService'; export * from './Menus/MENU_USERS_ADMINISTRATION'; export * from './AdministrationUsersManagementService'; export * from './externalUserProviderStatusContext'; diff --git a/webapp/packages/plugin-authentication-administration/src/manifest.ts b/webapp/packages/plugin-authentication-administration/src/manifest.ts index 4a577c35aa..0f1433cdf0 100644 --- a/webapp/packages/plugin-authentication-administration/src/manifest.ts +++ b/webapp/packages/plugin-authentication-administration/src/manifest.ts @@ -7,11 +7,6 @@ */ import type { PluginManifest } from '@cloudbeaver/core-di'; -import { AuthConfigurationFormService } from './Administration/IdentityProviders/AuthConfigurationFormService'; -import { AuthConfigurationsAdministrationNavService } from './Administration/IdentityProviders/AuthConfigurationsAdministrationNavService'; -import { AuthConfigurationsAdministrationService } from './Administration/IdentityProviders/AuthConfigurationsAdministrationService'; -import { CreateAuthConfigurationService } from './Administration/IdentityProviders/CreateAuthConfigurationService'; -import { AuthConfigurationOptionsTabService } from './Administration/IdentityProviders/Options/AuthConfigurationOptionsTabService'; import { ServerConfigurationAuthenticationBootstrap } from './Administration/ServerConfiguration/ServerConfigurationAuthenticationBootstrap'; import { CreateTeamService } from './Administration/Users/Teams/CreateTeamService'; import { GrantedConnectionsTabService } from './Administration/Users/Teams/GrantedConnections/GrantedConnectionsTabService'; @@ -47,11 +42,6 @@ export const manifest: PluginManifest = { UsersAdministrationNavigationService, ServerConfigurationAuthenticationBootstrap, AdministrationUserFormService, - AuthConfigurationsAdministrationService, - CreateAuthConfigurationService, - AuthConfigurationsAdministrationNavService, - AuthConfigurationFormService, - AuthConfigurationOptionsTabService, TeamsAdministrationService, CreateTeamService, TeamsAdministrationNavService, diff --git a/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts b/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts index ae13d183dc..292e0a088f 100644 --- a/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts +++ b/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts @@ -5,17 +5,25 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import type { Compartment, Extension } from '@codemirror/state'; +import type { Compartment, Extension, SelectionRange } from '@codemirror/state'; import type { ViewUpdate } from '@codemirror/view'; +/** Currently we support only main selection range */ +interface ISelection { + anchor: number; + head?: number; +} + export interface IReactCodeMirrorProps extends React.PropsWithChildren { /** in case of using editor in editing mode its better for performance to use getValue instead */ value?: string; + cursor?: ISelection; incomingValue?: string; getValue?: () => string; extensions?: Map; readonly?: boolean; autoFocus?: boolean; onChange?: (value: string, update: ViewUpdate) => void; + onCursorChange?: (selection: SelectionRange, update: ViewUpdate) => void; onUpdate?: (update: ViewUpdate) => void; } diff --git a/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx b/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx index f75f75e2b9..4475709fbc 100644 --- a/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx +++ b/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ import { MergeView } from '@codemirror/merge'; -import { Annotation, Compartment, Extension, StateEffect } from '@codemirror/state'; +import { Annotation, Compartment, Extension, StateEffect, TransactionSpec } from '@codemirror/state'; import { EditorView, ViewUpdate } from '@codemirror/view'; import { observer } from 'mobx-react-lite'; import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -22,7 +22,19 @@ const External = Annotation.define(); export const ReactCodemirror = observer( forwardRef(function ReactCodemirror( - { children, getValue, value, incomingValue, extensions = new Map(), readonly, autoFocus, onChange, onUpdate }, + { + children, + getValue, + value, + cursor, + incomingValue, + extensions = new Map(), + readonly, + autoFocus, + onChange, + onCursorChange, + onUpdate, + }, ref, ) { value = value ?? getValue?.(); @@ -32,7 +44,7 @@ export const ReactCodemirror = observer( const [container, setContainer] = useState(null); const [view, setView] = useState(null); const [incomingView, setIncomingView] = useState(null); - const callbackRef = useObjectRef({ onChange, onUpdate }); + const callbackRef = useObjectRef({ onChange, onCursorChange, onUpdate }); const [selection, setSelection] = useState(view?.state.selection.main ?? null); useLayoutEffect(() => { @@ -50,6 +62,11 @@ export const ReactCodemirror = observer( callbackRef.onChange?.(value, update); } + if (update.selectionSet && !remote) { + const selection = update.state.selection.main; + callbackRef.onCursorChange?.(selection, update); + } + callbackRef.onUpdate?.(update); }); @@ -136,13 +153,22 @@ export const ReactCodemirror = observer( }); useLayoutEffect(() => { - if (value !== undefined && view && value !== view.state.doc.toString()) { - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: value }, - annotations: [External.of(true)], - }); + if (view) { + const transaction: TransactionSpec = { annotations: [External.of(true)] }; + + if (value !== undefined && value !== view.state.doc.toString()) { + transaction.changes = { from: 0, to: view.state.doc.length, insert: value }; + } + + if (cursor && (view.state.selection.main.anchor !== cursor.anchor || view.state.selection.main.head !== cursor.head)) { + transaction.selection = cursor; + } + + if (transaction.changes || transaction.selection) { + view.dispatch(transaction); + } } - }, [value, view]); + }); useLayoutEffect(() => { if (incomingValue !== undefined && incomingView && incomingValue !== incomingView.state.doc.toString()) { diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css deleted file mode 100644 index e909fb879a..0000000000 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css +++ /dev/null @@ -1,6 +0,0 @@ -.toolsContainer { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index 6e96115529..7d261c020e 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -10,7 +10,7 @@ import { observer } from 'mobx-react-lite'; import { useMemo } from 'react'; import styled, { css } from 'reshadow'; -import { Button, s, useS, useStyles, useTranslate } from '@cloudbeaver/core-blocks'; +import { ActionIconButton, Button, Container, Fill, Group, useStyles, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { QuotasService } from '@cloudbeaver/core-root'; @@ -25,8 +25,8 @@ import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResult import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; import { QuotaPlaceholder } from '../QuotaPlaceholder'; import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES'; +import { getDefaultLineWrapping } from './getDefaultLineWrapping'; import { getTypeExtension } from './getTypeExtension'; -import moduleStyles from './TextValuePresentation.m.css'; import { TextValuePresentationService } from './TextValuePresentationService'; import { useTextValue } from './useTextValue'; @@ -34,25 +34,6 @@ const styles = css` Tab { composes: theme-ripple theme-background-surface theme-text-text-primary-on-light from global; } - container { - display: flex; - gap: 8px; - flex-direction: column; - overflow: auto; - flex: 1; - } - actions { - display: flex; - justify-content: center; - flex: 0; - } - EditorLoader { - border-radius: var(--theme-group-element-radius); - } - EditorLoader { - flex: 1; - overflow: auto; - } TabList { composes: theme-border-color-background theme-background-background from global; overflow: auto; @@ -68,9 +49,7 @@ const styles = css` } `; -const TEXT_PLAIN_TYPE = 'text/plain'; -const TEXT_JSON_TYPE = 'text/json'; -const APPLICATION_JSON_TYPE = 'application/json'; +const DEFAULT_CONTENT_TYPE = 'text/plain'; export const TextValuePresentation: TabContainerPanelComponent> = observer( function TextValuePresentation({ model, resultIndex, dataFormat }) { @@ -79,7 +58,6 @@ export const TextValuePresentation: TabContainerPanelComponent observable({ - currentContentType: TEXT_PLAIN_TYPE, - - setContentType(contentType: string) { - if (contentType === TEXT_JSON_TYPE) { - contentType = APPLICATION_JSON_TYPE; - } + lineWrapping: null as boolean | null, + currentContentType: null as string | null, + setContentType(contentType: string | null) { this.currentContentType = contentType; }, - handleTabOpen(tabId: string) { - // currentContentType may be selected automatically we don't want to change state in this case - if (tabId !== this.currentContentType) { - this.setContentType(tabId); - } + setLineWrapping(lineWrapping: boolean | null) { + this.lineWrapping = lineWrapping; }, }), ); + + let contentType = state.currentContentType; + let autoContentType = DEFAULT_CONTENT_TYPE; + + if (isResultSetContentValue(contentValue)) { + if (contentValue.contentType) { + switch (contentValue.contentType) { + case 'text/json': + autoContentType = 'application/json'; + break; + case 'application/octet-stream': + autoContentType = 'application/octet-stream;type=base64'; + break; + default: + autoContentType = contentValue.contentType; + break; + } + } + } + + if (contentType === null) { + contentType = autoContentType ?? DEFAULT_CONTENT_TYPE; + } + + if (activeTabs.length > 0 && !activeTabs.some(tab => tab.key === contentType)) { + contentType = activeTabs[0].key; + } + + const autoLineWrapping = getDefaultLineWrapping(contentType); + const lineWrapping = state.lineWrapping ?? autoLineWrapping; + const { textValue, isTruncated, isTextColumn, pasteFullText } = useTextValue({ model, resultIndex, - currentContentType: state.currentContentType, + currentContentType: contentType, }); const isSelectedCellReadonly = firstSelectedCell && (formatAction.isReadOnly(firstSelectedCell) || formatAction.isBinary(firstSelectedCell)); const isReadonlyByResultIndex = model.isReadonly(resultIndex) || model.isDisabled(resultIndex) || !firstSelectedCell; @@ -125,16 +128,16 @@ export const TextValuePresentation: TabContainerPanelComponent getTypeExtension(state.currentContentType) ?? [], [state.currentContentType]); + const typeExtension = useMemo(() => getTypeExtension(contentType!) ?? [], [contentType]); const extensions = useCodemirrorExtensions(undefined, typeExtension); - function handleChange(newValue: string) { + function valueChangeHandler(newValue: string) { if (firstSelectedCell && !isReadonly) { editAction.set(firstSelectedCell, newValue); } } - async function save() { + async function saveHandler() { if (!firstSelectedCell) { return; } @@ -146,41 +149,70 @@ export const TextValuePresentation: TabContainerPanelComponent tab.key === state.currentContentType)) { - const contentType = activeTabs.length > 0 && activeTabs[0].key ? activeTabs[0].key : TEXT_PLAIN_TYPE; - state.setContentType(contentType); + async function selectTabHandler(tabId: string) { + // currentContentType may be selected automatically we don't want to change state in this case + if (tabId !== contentType) { + state.setContentType(tabId); + } + } + + function toggleLineWrappingHandler() { + state.setLineWrapping(!lineWrapping); } return styled(style)( - - - state.handleTabOpen(tab.tabId)} - > - - - - - {isTruncated && } -
+ + + + selectTabHandler(tab.tabId)} + > + + + + + + + + {isTruncated && ( + + + + )} + {canSave && ( - + )} + + {shouldShowPasteButton && ( - + + + )} -
-
, + + , ); }, ); diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts index c55823647c..7a337125b6 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts @@ -66,14 +66,14 @@ export class TextValuePresentationBootstrap extends Bootstrap { }); this.textValuePresentationService.add({ - key: 'text/hex', + key: 'application/octet-stream;type=hex', name: 'data_viewer_presentation_value_text_hex_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, isHidden: (_, context) => !isBlobPresentationAvailable(context), }); this.textValuePresentationService.add({ - key: 'text/base64', + key: 'application/octet-stream;type=base64', name: 'data_viewer_presentation_value_text_base64_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/getDefaultLineWrapping.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/getDefaultLineWrapping.ts new file mode 100644 index 0000000000..99b842c4f8 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/getDefaultLineWrapping.ts @@ -0,0 +1,24 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { parseMIME } from '@cloudbeaver/core-utils'; + +export function getDefaultLineWrapping(mime: string): boolean { + const parsed = parseMIME(mime); + + // let's just list supported mime types here + switch (parsed.essence) { + case 'application/json': + case 'text/plain': + case 'text/xml': + case 'text/html': + case 'application/octet-stream': + return true; + default: + return true; + } +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts index a164a7c403..7c23a4584c 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts @@ -34,9 +34,9 @@ export function useAutoFormat() { } switch (type) { - case 'text/base64': + case 'application/octet-stream;type=base64': return value.binary; - case 'text/hex': + case 'application/octet-stream;type=hex': return base64ToHex(value.binary); default: return value.text; diff --git a/webapp/packages/plugin-data-viewer/src/locales/en.ts b/webapp/packages/plugin-data-viewer/src/locales/en.ts index a6aad623d4..31e16530c0 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/en.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/en.ts @@ -23,6 +23,8 @@ export default [ ['data_viewer_auto_refresh_settings', 'Auto refresh Settings'], ['data_viewer_auto_refresh_settings_stop_on_error', 'Stop on error'], ['data_viewer_presentation_value_title', 'Value'], + ['data_viewer_presentation_value_text_line_wrapping_wrap', 'Wrap lines'], + ['data_viewer_presentation_value_text_line_wrapping_no_wrap', "Don't wrap lines"], ['data_viewer_presentation_value_text_title', 'Text'], ['data_viewer_presentation_value_text_plain_title', 'Text'], ['data_viewer_presentation_value_text_html_title', 'HTML'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/it.ts b/webapp/packages/plugin-data-viewer/src/locales/it.ts index 1e4176ad7e..f365b109fb 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/it.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/it.ts @@ -19,6 +19,8 @@ export default [ ['data_viewer_auto_refresh_settings', 'Auto refresh Settings'], ['data_viewer_auto_refresh_settings_stop_on_error', 'Stop on error'], ['data_viewer_presentation_value_title', 'Valore'], + ['data_viewer_presentation_value_text_line_wrapping_wrap', 'Wrap lines'], + ['data_viewer_presentation_value_text_line_wrapping_no_wrap', "Don't wrap lines"], ['data_viewer_presentation_value_text_title', 'Testo'], ['data_viewer_presentation_value_text_plain_title', 'Testo'], ['data_viewer_presentation_value_text_html_title', 'HTML'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/ru.ts b/webapp/packages/plugin-data-viewer/src/locales/ru.ts index 82624eea1a..40b067cda2 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/ru.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/ru.ts @@ -23,6 +23,8 @@ export default [ ['data_viewer_auto_refresh_settings', 'Параметры автообновления'], ['data_viewer_auto_refresh_settings_stop_on_error', 'Остановить при ошибке'], ['data_viewer_presentation_value_title', 'Значение'], + ['data_viewer_presentation_value_text_line_wrapping_wrap', 'Переносить строки'], + ['data_viewer_presentation_value_text_line_wrapping_no_wrap', 'Не переносить строки'], ['data_viewer_presentation_value_text_title', 'Текст'], ['data_viewer_presentation_value_image_title', 'Изображение'], ['data_viewer_presentation_value_image_fit', 'Растянуть'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/zh.ts b/webapp/packages/plugin-data-viewer/src/locales/zh.ts index 9d4da4d6ac..f00b6e8635 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/zh.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/zh.ts @@ -23,6 +23,8 @@ export default [ ['data_viewer_auto_refresh_settings', 'Auto refresh Settings'], ['data_viewer_auto_refresh_settings_stop_on_error', 'Stop on error'], ['data_viewer_presentation_value_title', '值'], + ['data_viewer_presentation_value_text_line_wrapping_wrap', 'Wrap lines'], + ['data_viewer_presentation_value_text_line_wrapping_no_wrap', "Don't wrap lines"], ['data_viewer_presentation_value_text_title', '文本'], ['data_viewer_presentation_value_text_plain_title', '文本'], ['data_viewer_presentation_value_text_html_title', 'HTML'], diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/TreeDataTransformer.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/TreeDataTransformer.ts new file mode 100644 index 0000000000..e5a4c64782 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/TreeDataTransformer.ts @@ -0,0 +1,9 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export type TreeDataTransformer = (nodeId: string, data: T) => T; diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/applyTransforms.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/applyTransforms.ts new file mode 100644 index 0000000000..7ea11fa424 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/applyTransforms.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import type { TreeDataTransformer } from './TreeDataTransformer'; + +export function applyTransforms(id: string, data: T, transformers?: TreeDataTransformer[]) { + if (!transformers) { + return data; + } + + for (const transformer of transformers) { + data = transformer(id, data); + } + + return data; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/rootTransformers.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/rootTransformers.ts new file mode 100644 index 0000000000..ad16c6657d --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/DataTransformers/rootTransformers.ts @@ -0,0 +1,36 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import type { INode } from '../INode'; +import type { INodeState } from '../INodeState'; +import type { TreeDataTransformer } from './TreeDataTransformer'; + +export function rootNodeStateTransformer(root: string): TreeDataTransformer { + return function rootNodeStateTransformer(nodeId, data) { + if (nodeId === root) { + return { + ...data, + expanded: true, + }; + } + + return data; + }; +} + +export function rootNodeTransformer(root: string): TreeDataTransformer { + return function rootNodeTransformer(nodeId, data) { + if (nodeId === root) { + return { + ...data, + leaf: false, + }; + } + + return data; + }; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/INode.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/INode.ts new file mode 100644 index 0000000000..d2759d641b --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/INode.ts @@ -0,0 +1,14 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface INode { + name: string; + tooltip?: string; + icon?: string; + leaf?: boolean; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/INodeRenderer.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/INodeRenderer.ts new file mode 100644 index 0000000000..d87914343c --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/INodeRenderer.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface INodeComponentBaseProps { + nodeId: string; + offsetHeight: number; +} + +export interface INodeComponentProps extends INodeComponentBaseProps { + childrenRenderer: React.FC; +} + +export type NodeComponent = React.FC; + +export type INodeRenderer = (nodeId: string) => NodeComponent | null; diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/INodeState.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/INodeState.ts new file mode 100644 index 0000000000..3cf03b12d2 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/INodeState.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface INodeState { + selected: boolean; + expanded: boolean; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/Node.tsx b/webapp/packages/plugin-navigation-tree/src/TreeNew/Node.tsx new file mode 100644 index 0000000000..79db54e011 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/Node.tsx @@ -0,0 +1,51 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; +import { useContext } from 'react'; + +import { TreeNode } from '@cloudbeaver/core-blocks'; + +import { TreeContext } from './contexts/TreeContext'; +import { TreeDataContext } from './contexts/TreeDataContext'; +import type { NodeComponent } from './INodeRenderer'; +import { NodeControl } from './NodeControl'; +import { useNodeDnD } from './useNodeDnD'; + +export const Node: NodeComponent = observer(function Node({ nodeId, offsetHeight, childrenRenderer }) { + const tree = useContext(TreeContext)!; + const data = useContext(TreeDataContext)!; + + const { expanded, selected } = data.getState(nodeId); + + const dndData = useNodeDnD(nodeId, () => { + if (!selected) { + tree.selectNode(nodeId, true); + } + }); + + function handleOpen() { + return tree.openNode(nodeId); + } + + function handleToggleExpand() { + return tree.expandNode(nodeId, !expanded); + } + + function handleSelect() { + tree.selectNode(nodeId, !selected); + } + + const NavigationTreeChildrenNew = childrenRenderer; + + return ( + + + {expanded && } + + ); +}); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeChildren.tsx b/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeChildren.tsx new file mode 100644 index 0000000000..4e73f223f7 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeChildren.tsx @@ -0,0 +1,79 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; +import { useContext, useId } from 'react'; + +import { getComputed, TreeNodeNested } from '@cloudbeaver/core-blocks'; + +import { NodeSizeCacheContext } from './contexts/NodeSizeCacheContext'; +import { TreeDataContext } from './contexts/TreeDataContext'; +import { TreeVirtualizationContext } from './contexts/TreeVirtualizationContext'; +import { NodeRenderer } from './NodeRenderer'; + +interface Props { + nodeId: string; + offsetHeight: number; + root?: boolean; +} + +const OVERSCAN = 128; + +function getPositionWithOverscan(position: number, forward: boolean) { + if (forward) { + return position - (position % OVERSCAN) + OVERSCAN; + } + + return position - (position % OVERSCAN); +} + +const NodeChildrenObserved = observer(function NodeChildren({ nodeId, offsetHeight, root }) { + const data = useContext(TreeDataContext)!; + const optimization = useContext(TreeVirtualizationContext)!; + const sizeCache = useContext(NodeSizeCacheContext)!; + const firstId = useId(); + const lastId = useId(); + const viewPortFrom = getComputed(() => getPositionWithOverscan(optimization.viewPort.from, false)) - offsetHeight; + const viewPortTo = getComputed(() => getPositionWithOverscan(optimization.viewPort.to, true)) - offsetHeight; + + const children = data.getChildren(nodeId); + + function renderChildren() { + let offset = 0; + let postFillHeight = 0; + + const elements = []; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const size = sizeCache.getSize(child); + + if (offset + size < viewPortFrom) { + offset += size; + } else if (offset < viewPortTo) { + if (offset > 0 && elements.length === 0) { + elements.push(
); + } + + elements.push(); + offset += size; + } else { + postFillHeight += size; + } + } + + if (postFillHeight > 0) { + elements.push(
); + } + + return elements; + } + + return {renderChildren()}; +}); + +export const NodeChildren = NodeChildrenObserved; diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeControl.tsx b/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeControl.tsx new file mode 100644 index 0000000000..eafd487a9a --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeControl.tsx @@ -0,0 +1,36 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; +import { forwardRef, useContext } from 'react'; + +import { TreeNodeControl, TreeNodeExpand, TreeNodeIcon, TreeNodeName } from '@cloudbeaver/core-blocks'; + +import { TreeContext } from './contexts/TreeContext'; +import { TreeDataContext } from './contexts/TreeDataContext'; + +interface Props { + nodeId: string; +} + +export const NodeControl = observer( + forwardRef(function NodeControl({ nodeId }, ref) { + const data = useContext(TreeDataContext)!; + const tree = useContext(TreeContext)!; + + const node = data.getNode(nodeId); + const height = tree.getNodeHeight(nodeId); + + return ( + + + + {node.name} + + ); + }), +); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeRenderer.tsx b/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeRenderer.tsx new file mode 100644 index 0000000000..1719998b05 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/NodeRenderer.tsx @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; +import { useContext } from 'react'; + +import { TreeContext } from './contexts/TreeContext'; +import type { NodeComponent } from './INodeRenderer'; +import { Node } from './Node'; + +export const NodeRenderer: NodeComponent = observer(function NodeRenderer(props) { + const tree = useContext(TreeContext)!; + const NodeComponent = tree.getNodeComponent(props.nodeId) ?? Node; + + return ; +}); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/Tree.tsx b/webapp/packages/plugin-navigation-tree/src/TreeNew/Tree.tsx new file mode 100644 index 0000000000..927a1cc2af --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/Tree.tsx @@ -0,0 +1,61 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; + +import type { IDataContext } from '@cloudbeaver/core-data-context'; + +import { NodeSizeCacheContext } from './contexts/NodeSizeCacheContext'; +import { TreeContext } from './contexts/TreeContext'; +import { TreeDataContext } from './contexts/TreeDataContext'; +import { TreeDnDContext } from './contexts/TreeDnDContext'; +import { TreeVirtualizationContext } from './contexts/TreeVirtualizationContext'; +import type { INodeRenderer } from './INodeRenderer'; +import { NodeChildren } from './NodeChildren'; +import { useNodeSizeCache } from './useNodeSizeCache'; +import { useTree } from './useTree'; +import type { ITreeData } from './useTreeData'; +import { useTreeDnD } from './useTreeDnD'; +import { useTreeVirtualization } from './useTreeVirtualization'; + +export interface NavigationTreeNewProps { + data: ITreeData; + nodeRenderers?: INodeRenderer[]; + onNodeDoubleClick?(id: string): void | Promise; + getNodeDnDContext?(id: string, context: IDataContext): void; + getNodeHeight(id: string): number; +} + +export const Tree = observer(function Tree({ data, nodeRenderers, onNodeDoubleClick, getNodeDnDContext, getNodeHeight }) { + const tree = useTree({ + data, + nodeRenderers, + onNodeDoubleClick, + getNodeHeight, + }); + const mountOptimization = useTreeVirtualization(); + const elementsSizeCache = useNodeSizeCache(tree, data); + const treeDnD = useTreeDnD({ + getContext: getNodeDnDContext, + }); + + return ( +
+ + + + + + + + + + + +
+ ); +}); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/TreeLazy.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/TreeLazy.ts new file mode 100644 index 0000000000..f53d85411e --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/TreeLazy.ts @@ -0,0 +1,10 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { importLazyComponent } from '@cloudbeaver/core-utils'; + +export const Tree = importLazyComponent(() => import('./Tree').then(m => m.Tree)); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/TreeState.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/TreeState.ts new file mode 100644 index 0000000000..b343427829 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/TreeState.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import type { MetadataMap } from '@cloudbeaver/core-utils'; + +import type { INodeState } from './INodeState'; + +export type TreeState = MetadataMap; diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/NodeSizeCacheContext.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/NodeSizeCacheContext.ts new file mode 100644 index 0000000000..03eaadef45 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/NodeSizeCacheContext.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createContext } from 'react'; + +import type { INodeSizeCache } from '../useNodeSizeCache'; + +export const NodeSizeCacheContext = createContext(undefined); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeContext.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeContext.ts new file mode 100644 index 0000000000..46bb2ad6d3 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeContext.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createContext } from 'react'; + +import type { ITree } from '../useTree'; + +export const TreeContext = createContext(undefined); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeDataContext.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeDataContext.ts new file mode 100644 index 0000000000..39f7564a77 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeDataContext.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createContext } from 'react'; + +import type { ITreeData } from '../useTreeData'; + +export const TreeDataContext = createContext(undefined); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeDnDContext.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeDnDContext.ts new file mode 100644 index 0000000000..7328faae1a --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeDnDContext.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createContext } from 'react'; + +import type { ITreeDnD } from '../useTreeDnD'; + +export const TreeDnDContext = createContext(undefined); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeVirtualizationContext.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeVirtualizationContext.ts new file mode 100644 index 0000000000..99b8290777 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/contexts/TreeVirtualizationContext.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createContext } from 'react'; + +import type { ITreeVirtualization } from '../useTreeVirtualization'; + +export const TreeVirtualizationContext = createContext(undefined); diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useNodeDnD.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useNodeDnD.ts new file mode 100644 index 0000000000..5c25f4d305 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useNodeDnD.ts @@ -0,0 +1,31 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { useContext } from 'react'; + +import { useDataContext } from '@cloudbeaver/core-data-context'; +import { useDNDData } from '@cloudbeaver/core-ui'; + +import { TreeDnDContext } from './contexts/TreeDnDContext'; + +export function useNodeDnD(nodeId: string, onDragStart: () => void) { + const treeDnD = useContext(TreeDnDContext)!; + const context = useDataContext(); + + const dndData = useDNDData(context, { + canDrag: () => true, + onDragStart: () => { + treeDnD.getContext(nodeId, context); + onDragStart(); + }, + onDragEnd: () => { + treeDnD.getContext(nodeId, context); + }, + }); + + return dndData; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useNodeSizeCache.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useNodeSizeCache.ts new file mode 100644 index 0000000000..787d56cdc7 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useNodeSizeCache.ts @@ -0,0 +1,50 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { computed, type IComputedValue } from 'mobx'; +import { useState } from 'react'; + +import { useObjectRef } from '@cloudbeaver/core-blocks'; +import { MetadataMap } from '@cloudbeaver/core-utils'; + +import type { ITree } from './useTree'; +import type { ITreeData } from './useTreeData'; + +export interface INodeSizeCache { + getSize(id: string): number; +} + +export function useNodeSizeCache(tree: ITree, treeData: ITreeData): INodeSizeCache { + const [sizeRangeCache] = useState( + () => + new MetadataMap>((id, metadata) => + computed(() => { + let size = tree.getNodeHeight(id); + const expanded = treeData.getState(id).expanded; + + if (expanded) { + const children = treeData.getChildren(id); + + for (const child of children) { + size += metadata.get(child).get(); + } + } + + return size; + }), + ), + ); + + return useObjectRef( + () => ({ + getSize(id: string): number { + return sizeRangeCache.get(id).get(); + }, + }), + {}, + ); +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useTree.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTree.ts new file mode 100644 index 0000000000..91978ad042 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTree.ts @@ -0,0 +1,84 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observable } from 'mobx'; + +import { useObservableRef } from '@cloudbeaver/core-blocks'; + +import type { INodeRenderer, NodeComponent } from './INodeRenderer'; +import type { ITreeData } from './useTreeData'; + +interface IOptions { + data: ITreeData; + nodeRenderers?: INodeRenderer[]; + onNodeDoubleClick?(id: string): void | Promise; + getNodeHeight(id: string): number; +} + +export interface ITree { + getNodeComponent(id: string): NodeComponent | null; + getNodeHeight(id: string): number; + + openNode(id: string): Promise; + expandNode(id: string, state: boolean): Promise; + selectNode(id: string, state: boolean): void; +} + +export function useTree(options: IOptions): ITree { + options = useObservableRef(options, { + data: observable.ref, + nodeRenderers: observable.ref, + onNodeDoubleClick: observable.ref, + getNodeHeight: observable.ref, + }); + + const data = useObservableRef( + () => ({ + getNodeComponent(id: string): NodeComponent | null { + if (!options.nodeRenderers) { + return null; + } + + for (const renderer of options.nodeRenderers) { + const component = renderer(id); + + if (component) { + return component; + } + } + + return null; + }, + getNodeHeight(id: string): number { + return options.getNodeHeight(id); + }, + async openNode(id: string) { + await options.onNodeDoubleClick?.(id); + }, + async expandNode(id: string, state: boolean) { + try { + options.data.updateState(id, { expanded: state }); + if (state) { + await options.data.load(id, true); + const children = options.data.getChildren(id); + + if (children.length === 0) { + options.data.updateState(id, { expanded: false }); + } + } + } catch (exception) {} + }, + selectNode(id: string, state: boolean) { + options.data.updateState(id, { selected: state }); + }, + }), + {}, + {}, + ); + + return data; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeData.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeData.ts new file mode 100644 index 0000000000..b2a90633a6 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeData.ts @@ -0,0 +1,135 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { computed, IComputedValue, observable } from 'mobx'; +import { useEffect, useState } from 'react'; + +import { useObservableRef } from '@cloudbeaver/core-blocks'; +import { MetadataMap } from '@cloudbeaver/core-utils'; + +import { applyTransforms } from './DataTransformers/applyTransforms'; +import { rootNodeStateTransformer, rootNodeTransformer } from './DataTransformers/rootTransformers'; +import type { TreeDataTransformer } from './DataTransformers/TreeDataTransformer'; +import type { INode } from './INode'; +import type { INodeState } from './INodeState'; +import type { TreeState } from './TreeState'; +import { useTreeState } from './useTreeState'; + +interface IOptions { + rootId: string; + externalState?: TreeState; + getNode(id: string): INode; + getChildren: (node: string) => string[]; + load(nodeId: string, manual: boolean): Promise; + + childrenTransformers?: TreeDataTransformer[]; + nodeTransformers?: TreeDataTransformer[]; + stateTransformers?: TreeDataTransformer[]; +} + +export interface ITreeData { + rootId: string; + + getNode(id: string): INode; + getChildren: (node: string) => string[]; + getState(id: string): Readonly; + + updateState(id: string, state: Partial): void; + load(nodeId: string, manual: boolean): Promise; + update(): Promise; +} + +export function useTreeData(options: IOptions): ITreeData { + options = useObservableRef( + { + ...options, + childrenTransformers: [...(options.childrenTransformers || [])], + nodeTransformers: [...(options.nodeTransformers || [])], + stateTransformers: [...(options.stateTransformers || [])], + }, + { + rootId: observable.ref, + getNode: observable.ref, + getChildren: observable.ref, + load: observable.ref, + + childrenTransformers: observable.ref, + nodeTransformers: observable.ref, + stateTransformers: observable.ref, + }, + ); + const state = useTreeState(options.externalState); + const [nodeCache] = useState( + () => + new MetadataMap>(id => + computed(() => applyTransforms(id, options.getNode(id), [rootNodeTransformer(options.rootId), ...(options.nodeTransformers || [])])), + ), + ); + const [childrenCache] = useState( + () => + new MetadataMap>(id => + computed(() => applyTransforms(id, options.getChildren(id), options.childrenTransformers)), + ), + ); + const [stateCache] = useState( + () => + new MetadataMap>(id => + computed(() => applyTransforms(id, state.getState(id), [rootNodeStateTransformer(options.rootId), ...(options.stateTransformers || [])])), + ), + ); + + const treeData = useObservableRef( + () => ({ + getNode(id: string): INode { + return nodeCache.get(id).get(); + }, + getChildren(nodeId: string): string[] { + return childrenCache.get(nodeId).get(); + }, + getState(id: string): Readonly { + return stateCache.get(id).get(); + }, + updateState(id: string, state: Partial) { + this.state.updateState(id, state); + }, + async load(nodeId: string, manual: boolean) { + await options.load(nodeId, manual); + }, + async update() { + const nodes = [this.rootId]; + + while (nodes.length > 0) { + const nodeId = nodes.shift()!; + const state = this.state.getState(nodeId); + + if (!state.expanded) { + continue; + } + + await options.load(nodeId, false); + + const children = this.getChildren(nodeId); + + if (children.length === 0) { + this.state.updateState(nodeId, { expanded: false }); + continue; + } + + nodes.push(...children); + } + }, + }), + { state: observable.ref, rootId: observable.ref }, + { state, rootId: options.rootId }, + ); + + useEffect(() => { + treeData.update(); + }, [options.rootId]); + + return treeData; +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeDnD.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeDnD.ts new file mode 100644 index 0000000000..c444445491 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeDnD.ts @@ -0,0 +1,34 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { useObjectRef } from '@cloudbeaver/core-blocks'; +import type { IDataContext } from '@cloudbeaver/core-data-context'; + +interface IOptions { + getContext?(id: string, context: IDataContext): void; +} + +export interface ITreeDnD { + getContext(id: string, context: IDataContext): void; + canDrop(moveContext: IDataContext): boolean; +} + +export function useTreeDnD(options: IOptions): ITreeDnD { + options = useObjectRef(options); + + return useObjectRef( + () => ({ + getContext(id: string, context: IDataContext): void { + options.getContext?.(id, context); + }, + canDrop(moveContext: IDataContext): boolean { + return true; + }, + }), + {}, + ); +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeState.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeState.ts new file mode 100644 index 0000000000..aada8f2b52 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeState.ts @@ -0,0 +1,50 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observable } from 'mobx'; +import { useState } from 'react'; + +import { useObservableRef } from '@cloudbeaver/core-blocks'; +import { MetadataMap } from '@cloudbeaver/core-utils'; + +import type { INodeState } from './INodeState'; +import type { TreeState } from './TreeState'; + +export interface ITreeState { + getState(id: string): Readonly; + updateState(id: string, state: Partial): void; +} + +export function useTreeState(externalState?: TreeState): ITreeState { + const [innerState] = useState( + () => + new MetadataMap(() => + observable({ + expanded: false, + selected: false, + showInFilter: false, + }), + ), + ); + + const state = externalState ?? innerState; + + return useObservableRef( + () => ({ + getState(id: string): Readonly { + return state.get(id); + }, + updateState(id: string, state: Partial): void { + Object.assign(this.getState(id), state); + }, + }), + { + state: observable.ref, + }, + { state }, + ); +} diff --git a/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeVirtualization.ts b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeVirtualization.ts new file mode 100644 index 0000000000..812855b803 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/TreeNew/useTreeVirtualization.ts @@ -0,0 +1,87 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observable, runInAction } from 'mobx'; +import { useEffect } from 'react'; + +import { useObjectRef } from '@cloudbeaver/core-blocks'; + +interface IPrivate extends ITreeVirtualization { + observer: ResizeObserver | null; + element: HTMLElement | null; + handleScroll(event: Event): void; + handleResize(): void; + dispose(): void; +} + +export interface ITreeVirtualization { + viewPort: { from: number; to: number }; + setRootRef(element: HTMLElement | null): void; +} + +export function useTreeVirtualization(): ITreeVirtualization { + const mountOptimization = useObjectRef( + () => ({ + observer: null, + element: null, + viewPort: observable({ from: 0, to: 0 }), + setRootRef(element: HTMLElement | null) { + if (this.element === element) { + return; + } + + if (this.element) { + this.dispose(); + } + + this.element = element; + if (element) { + this.observer = new ResizeObserver(this.handleResize); + element.addEventListener('scroll', this.handleScroll); + this.observer.observe(element); + + runInAction(() => { + this.viewPort.from = element.scrollTop; + this.viewPort.to = element.scrollTop + element.clientHeight; + }); + } + }, + dispose() { + if (this.element) { + this.element.removeEventListener('scroll', this.handleScroll); + } + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + }, + handleScroll(event) { + runInAction(() => { + const target = event.target as HTMLElement; + + this.viewPort.from = target.scrollTop; + this.viewPort.to = target.scrollTop + target.clientHeight; + }); + }, + handleResize() { + runInAction(() => { + if (!this.element) { + return; + } + this.viewPort.from = this.element.scrollTop; + this.viewPort.to = this.element.scrollTop + this.element.clientHeight; + }); + }, + }), + false, + ['setRootRef', 'dispose', 'handleScroll', 'handleResize'], + ); + + useEffect(() => () => mountOptimization.dispose(), []); + + return mountOptimization; +} diff --git a/webapp/packages/plugin-navigation-tree/src/index.ts b/webapp/packages/plugin-navigation-tree/src/index.ts index 1a763288d1..6280a219e5 100644 --- a/webapp/packages/plugin-navigation-tree/src/index.ts +++ b/webapp/packages/plugin-navigation-tree/src/index.ts @@ -36,6 +36,9 @@ export { default as NavigationNodeControlRendererStyles } from './NavigationTree export { default as NavigationNodeControlStyles } from './NavigationTree/ElementsTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.m.css'; export * from './NavigationTree/NavigationTreeLoader'; +export * from './TreeNew/TreeLazy'; +export * from './TreeNew/useTreeData'; +export * from './TreeNew/INode'; export * from './NavigationTree/getNavigationTreeUserSettingsId'; export * from './NodesManager/NavNodeView/IFolderTransform'; diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx index 5f406fa001..dda25ca030 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx @@ -125,13 +125,17 @@ export const SQLCodeEditorPanel: TabContainerPanelComponent data.value} + cursor={{ + anchor: data.cursor.begin, + head: data.cursor.end, + }} incomingValue={data.incomingValue} extensions={extensions} readonly={data.readonly} autoFocus lineNumbers onChange={panel.onQueryChange} - onUpdate={panel.onUpdate} + onCursorChange={selection => panel.onCursorChange(selection.from, selection.to)} > {data.isIncomingChanges && ( <> diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts index c6acf23ea4..43ff3ac336 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts @@ -5,6 +5,7 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ +import { action } from 'mobx'; import { useCallback } from 'react'; import { useExecutor, useObservableRef } from '@cloudbeaver/core-blocks'; @@ -17,7 +18,7 @@ import type { IEditor } from '../SQLCodeEditor/useSQLCodeEditor'; interface State { highlightActiveQuery: () => void; onQueryChange: (query: string) => void; - onUpdate: (update: ViewUpdate) => void; + onCursorChange: (begin: number, end?: number) => void; } export function useSQLCodeEditorPanel(data: ISQLEditorData, editor: IEditor) { @@ -35,21 +36,12 @@ export function useSQLCodeEditorPanel(data: ISQLEditorData, editor: IEditor) { onQueryChange(query: string) { this.data.setScript(query); }, - onUpdate(update: ViewUpdate) { - const transactions = update.transactions.filter(t => t.selection !== undefined); - const lastTransaction = transactions[transactions.length - 1] as Transaction | undefined; - - if (lastTransaction) { - const from = lastTransaction.selection?.main.from ?? update.state.selection.main.from; - const to = lastTransaction.selection?.main.to ?? update.state.selection.main.to; - - this.data.setCursor(from, to); - } + onCursorChange(begin: number, end?: number) { + this.data.setCursor(begin, end); }, }), - {}, + { onQueryChange: action.bound, onCursorChange: action.bound }, { editor, data }, - ['onQueryChange', 'onUpdate'], ); const updateHighlight = useCallback( diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx b/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx index ebc6089838..566fa1f50b 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx @@ -75,15 +75,16 @@ export const SqlEditor = observer(function SqlEditor({ state, c } const displayedEditors = getComputed(() => sqlEditorModeService.tabsContainer.getDisplayed({ state, data }).length); + const isEditorEmpty = displayedEditors === 0; useEffect(() => { - if (displayedEditors === 0) { + if (isEditorEmpty) { split.fixate('maximize', true); } else if (split.state.disable) { split.fixate('resize', false); split.state.setSize(-1); } - }); + }, [isEditorEmpty]); return styled( styles, diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts b/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts index 1783f2f7c0..fde2dc5d56 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts @@ -138,7 +138,6 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { onExecute: new SyncExecutor(), onSegmentExecute: new SyncExecutor(), onUpdate: new SyncExecutor(), - onFormat: new SyncExecutor(), parser: new SQLParser(), cursor: { begin: 0, end: 0 }, @@ -225,8 +224,8 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { this.readonlyState = true; const formatted = await this.sqlDialectInfoService.formatScript(this.dataSource.executionContext, script.query); - this.onFormat.execute([script, formatted]); this.setScript(query.substring(0, script.begin) + formatted + query.substring(script.end)); + this.setCursor(script.begin + formatted.length); } finally { this.readonlyState = false; } diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 164cee423e..e6e1acc243 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4942,6 +4942,11 @@ resolved "https://registry.yarnpkg.com/@types/wellknown/-/wellknown-0.5.8.tgz#5da12a97fd90b64d3688e7e602f1216d0f3103e1" integrity sha512-/tXv+IfentaS3F0VplScdl+pwP7biW4RBnOxCTtoWlpNiulFBQWqA8rA7r/knavu9SELsFcNVtdGo6YNIElMdw== +"@types/whatwg-mimetype@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz#e5e06dcd3e92d4e622ef0129637707d66c28d6a4" + integrity sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA== + "@types/ws@^8.0.0", "@types/ws@^8.5.5": version "8.5.10" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" @@ -18198,6 +18203,11 @@ whatwg-mimetype@^3.0.0: resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + whatwg-url@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"