From e0fa9c78186fb83a84aeb4ce7dd3801bf8a214d3 Mon Sep 17 00:00:00 2001 From: Andre Vasconcelos Date: Fri, 13 Mar 2026 22:32:15 +0200 Subject: [PATCH 1/4] Bumping prepackaged GitLab plugin version to v1.12.1 (#35595) --- server/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Makefile b/server/Makefile index 8b8c77af2ee..a64f28b0411 100644 --- a/server/Makefile +++ b/server/Makefile @@ -156,7 +156,7 @@ TEMPLATES_DIR=templates PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4 PLUGIN_PACKAGES += mattermost-plugin-github-v2.6.0 -PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.0 +PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.1 PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.0 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0 From a744a758057e509b5268156adf4d3514928ff256 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:57:17 -0400 Subject: [PATCH 2/4] Fix an E2E test broken in #35499 (#35599) --- .../search_results/search_results.test.tsx | 14 +------------- .../components/search_results/search_results.tsx | 16 +--------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/search_results/search_results.test.tsx b/webapp/channels/src/components/search_results/search_results.test.tsx index af6e9a55993..629c2e4dfc2 100644 --- a/webapp/channels/src/components/search_results/search_results.test.tsx +++ b/webapp/channels/src/components/search_results/search_results.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import SearchResults, {arePropsEqual} from 'components/search_results/search_results'; -import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {renderWithContext} from 'tests/react_testing_utils'; import {getHistory} from 'utils/browser_history'; import {popoutRhsSearch} from 'utils/popouts/popout_windows'; import {TestHelper} from 'utils/test_helper'; @@ -168,18 +168,6 @@ describe('components/SearchResults', () => { }); }); - describe('handleChannelNameClick', () => { - test('should navigate to channel URL when channel display name is clicked', () => { - const pushMock = jest.fn(); - jest.mocked(getHistory).mockReturnValue({push: pushMock} as any); - - renderSearchResults({channelDisplayName: 'Test Channel'}); - screen.getByText('Test Channel').click(); - - expect(pushMock).toHaveBeenCalledWith(`/${team.name}/channels/${channel.name}`); - }); - }); - describe('arePropsEqual', () => { const result1 = {test: 'test'}; const result2 = {test: 'test'}; diff --git a/webapp/channels/src/components/search_results/search_results.tsx b/webapp/channels/src/components/search_results/search_results.tsx index 17f62bc6301..e702a7a0e13 100644 --- a/webapp/channels/src/components/search_results/search_results.tsx +++ b/webapp/channels/src/components/search_results/search_results.tsx @@ -28,7 +28,6 @@ import SearchHint from 'components/search_hint/search_hint'; import SearchResultsHeader from 'components/search_results_header'; import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; -import {getHistory} from 'utils/browser_history'; import {searchHintOptions, DataSearchTypes, RHSStates} from 'utils/constants'; import {isFileAttachmentsEnabled} from 'utils/file_utils'; import {popoutRhsSearch} from 'utils/popouts/popout_windows'; @@ -268,12 +267,6 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { ); }, [isMentionSearch, isFlaggedPosts, isPinnedPosts, isChannelFiles, intl, searchTerms, searchType, currentTeam?.name, currentChannel?.name, searchTeamId]); - const handleChannelNameClick = useCallback(() => { - if (currentTeam?.name && currentChannel?.name) { - getHistory().push(`/${currentTeam.name}/channels/${currentChannel.name}`); - } - }, [currentTeam?.name, currentChannel?.name]); - switch (true) { case isLoading: contentItems = ( @@ -390,14 +383,7 @@ const SearchResults: React.FC = (props: Props): JSX.Element => {

{formattedTitle}

- {props.channelDisplayName && - - } + {props.channelDisplayName &&
{props.channelDisplayName}
} {isMessagesSearch && Date: Fri, 13 Mar 2026 18:42:36 -0400 Subject: [PATCH 3/4] [MM-67883] Add "Open in new tab" button to Product Switcher menu items (#35560) * [MM-67883] Add buttons for Product Switcher to pop into new tabs * PR feedback * Fix snaps --------- Co-authored-by: Mattermost Build --- .../__snapshots__/product_menu.test.tsx.snap | 160 ++++++++++++++++-- .../product_menu_item.test.tsx | 38 ++++- .../product_menu_item/product_menu_item.tsx | 67 ++++++-- webapp/channels/src/i18n/en.json | 1 + 4 files changed, 238 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap b/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap index 60611824377..7c710d9cd85 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap @@ -46,7 +46,7 @@ exports[`components/global/product_switcher should have an active button state w role="menu" > @@ -86,7 +86,7 @@ exports[`components/global/product_switcher should have an active button state w Boards + Playbooks + + Playbooks + + Playbooks + + Playbooks +
; describe('components/ProductMenuItem', () => { const defaultProps: ProductMenuItemProps = { @@ -75,14 +76,21 @@ describe('components/ProductMenuItem', () => { expect(svgElements.length).toBe(2); }); - test('should not show check icon when active is false', () => { + test('should show open in new tab button when active is false', () => { renderWithContext(); - const menuItem = screen.getByRole('menuitem'); + expect(screen.getByLabelText('Open in new tab')).toBeInTheDocument(); + }); - // When not active, there should only be one SVG element: the product icon - const svgElements = menuItem.querySelectorAll('svg'); - expect(svgElements.length).toBe(1); + test('should not show open in new tab button when active is true', () => { + const props: ProductMenuItemProps = { + ...defaultProps, + active: true, + }; + + renderWithContext(); + + expect(screen.queryByLabelText('Open in new tab')).not.toBeInTheDocument(); }); test('should call onClick when clicked', async () => { @@ -99,6 +107,24 @@ describe('components/ProductMenuItem', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + test('should open destination in new tab when open in new tab button is clicked', async () => { + const onClick = jest.fn(); + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(); + const props: ProductMenuItemProps = { + ...defaultProps, + onClick, + }; + + renderWithContext(); + + await userEvent.click(screen.getByLabelText('Open in new tab'), {pointerEventsCheck: 0}); + + expect(windowOpenSpy).toHaveBeenCalledWith('/test-destination', '_blank', 'noopener,noreferrer'); + expect(onClick).toHaveBeenCalledTimes(1); + + windowOpenSpy.mockRestore(); + }); + test('should render tour tip when provided', () => { const tourTipContent = 'Tour tip content'; const TourTip =
{tourTipContent}
; diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx index c343a077d6e..c97cd34284c 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx @@ -1,13 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; import {Link} from 'react-router-dom'; import styled from 'styled-components'; -import glyphMap, {CheckIcon} from '@mattermost/compass-icons/components'; +import glyphMap, {CheckIcon, OpenInNewIcon} from '@mattermost/compass-icons/components'; import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; +import WithTooltip from 'components/with_tooltip'; + export interface ProductMenuItemProps { destination: string; icon: IconGlyphTypes | React.ReactNode; @@ -19,6 +22,34 @@ export interface ProductMenuItemProps { id?: string; } +const MenuItemTextContainer = styled.div` + margin-left: 8px; + flex-grow: 1; + font-weight: 600; + font-size: 14px; + line-height: 20px; +`; + +const OpenInNewTabButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: rgba(var(--center-channel-color-rgb), 0.56); + padding: 6px !important; + margin-right: -6px; + opacity: 0; + pointer-events: none; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } +`; + const MenuItem = styled(Link)` && { text-decoration: none; @@ -43,19 +74,24 @@ const MenuItem = styled(Link)` button { padding: 0 6px; } -`; -const MenuItemTextContainer = styled.div` - margin-left: 8px; - flex-grow: 1; - font-weight: 600; - font-size: 14px; - line-height: 20px; + &:hover ${OpenInNewTabButton} { + opacity: 1; + pointer-events: auto; + } `; const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}: ProductMenuItemProps): JSX.Element => { + const {formatMessage} = useIntl(); const ProductIcon = typeof icon === 'string' ? glyphMap[icon as IconGlyphTypes] : null; + const handleOpenInNewTab = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.open(destination, '_blank', 'noopener,noreferrer'); + onClick(); + }, [destination, onClick]); + return ( {text} - {active && ( + {active ? ( + ) : ( + + + + + )} {tourTip || null} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 3b67749402a..d5b8d2c6889 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5684,6 +5684,7 @@ "pricing_modal.plan_label_trialDays": "{days} DAYS LEFT ON TRIAL", "pricing_modal.wantToTry": "Want to try? ", "pricing_modal.wantToUpgrade": "Want to upgrade? ", + "product_menu_item.open_in_new_tab": "Open in new tab", "profile_popover.aria_label.with_username": "{userName}'s profile popover", "profile_popover.aria_label.without_username": "profile popover", "promote_to_user_modal.desc": "This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?", From 3e38cbc5ca2230b513d12748d7def54075d5f3fc Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Fri, 13 Mar 2026 21:30:32 -0400 Subject: [PATCH 4/4] Add --workers flag to mmctl import process to control concurrency (#35582) * Add --workers flag to mmctl import process to control concurrency The bulk import worker count was hardcoded to runtime.NumCPU(), causing high database load on the master during imports on live systems. This is particularly impactful for incremental Slack imports where all users are re-imported each time, generating 8-15 DB operations per user against the master (due to LockToMaster). The new --workers flag allows administrators to reduce concurrency (e.g., --workers 1) to minimize impact on live users at the cost of longer import duration. Defaults to 0 which preserves the existing runtime.NumCPU() behavior. * Add max workers limit, capped at CPU Count * 4 --- server/channels/jobs/import_process/worker.go | 10 +- server/cmd/mmctl/commands/import.go | 23 +++- server/cmd/mmctl/commands/import_test.go | 103 ++++++++++++++---- 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/server/channels/jobs/import_process/worker.go b/server/channels/jobs/import_process/worker.go index f9e974b8405..df1113b8479 100644 --- a/server/channels/jobs/import_process/worker.go +++ b/server/channels/jobs/import_process/worker.go @@ -124,8 +124,16 @@ func MakeWorker(jobServer *jobs.JobServer, app AppIface) *jobs.SimpleWorker { defer jsonFile.Close() extractContent := job.Data["extract_content"] == "true" + + numWorkers := runtime.NumCPU() + if workersStr, ok := job.Data["workers"]; ok { + if n, err := strconv.Atoi(workersStr); err == nil && n > 0 { + numWorkers = n + } + } + // do the actual import. - lineNumber, appErr := app.BulkImportWithPath(appContext, jsonFile, importZipReader, false, extractContent, runtime.NumCPU(), model.ExportDataDir) + lineNumber, appErr := app.BulkImportWithPath(appContext, jsonFile, importZipReader, false, extractContent, numWorkers, model.ExportDataDir) if appErr != nil { job.Data["line_number"] = strconv.Itoa(lineNumber) return appErr diff --git a/server/cmd/mmctl/commands/import.go b/server/cmd/mmctl/commands/import.go index 8283127dd0a..cb6173d35e8 100644 --- a/server/cmd/mmctl/commands/import.go +++ b/server/cmd/mmctl/commands/import.go @@ -11,6 +11,7 @@ import ( "os" "path" "path/filepath" + "runtime" "strconv" "strings" "text/template" @@ -123,6 +124,7 @@ func init() { ImportProcessCmd.Flags().Bool("bypass-upload", false, "If this is set, the file is not processed from the server, but rather directly read from the filesystem. Works only in --local mode.") ImportProcessCmd.Flags().Bool("extract-content", true, "If this is set, document attachments will be extracted and indexed during the import process. It is advised to disable it to improve performance.") + ImportProcessCmd.Flags().Int("workers", 0, "The number of concurrent import worker goroutines. Controls database load during import. When set to 0 (default), uses the number of CPUs available. Maximum allowed is 4x the CPU count.") ImportListCmd.AddCommand( ImportListAvailableCmd, @@ -310,14 +312,25 @@ func importProcessCmdF(c client.Client, command *cobra.Command, args []string) e } extractContent, _ := command.Flags().GetBool("extract-content") + workers, _ := command.Flags().GetInt("workers") + + maxWorkers := runtime.NumCPU() * 4 + if workers > maxWorkers { + return fmt.Errorf("workers value %d exceeds maximum allowed (%d = 4 * CPU count)", workers, maxWorkers) + } + + jobData := map[string]string{ + "import_file": importFile, + "local_mode": strconv.FormatBool(isLocal && bypassUpload), + "extract_content": strconv.FormatBool(extractContent), + } + if workers > 0 { + jobData["workers"] = strconv.Itoa(workers) + } job, _, err := c.CreateJob(context.TODO(), &model.Job{ Type: model.JobTypeImportProcess, - Data: map[string]string{ - "import_file": importFile, - "local_mode": strconv.FormatBool(isLocal && bypassUpload), - "extract_content": strconv.FormatBool(extractContent), - }, + Data: jobData, }) if err != nil { return fmt.Errorf("failed to create import process job: %w", err) diff --git a/server/cmd/mmctl/commands/import_test.go b/server/cmd/mmctl/commands/import_test.go index 68cbb2242c6..f29433e0d16 100644 --- a/server/cmd/mmctl/commands/import_test.go +++ b/server/cmd/mmctl/commands/import_test.go @@ -10,6 +10,8 @@ import ( "net/http" "os" "path/filepath" + "runtime" + "strconv" "strings" "github.com/pkg/errors" @@ -211,28 +213,85 @@ func (s *MmctlUnitTestSuite) TestImportJobListCmdF() { } func (s *MmctlUnitTestSuite) TestImportProcessCmdF() { - printer.Clean() - importFile := "import.zip" - mockJob := &model.Job{ - Type: model.JobTypeImportProcess, - Data: map[string]string{ - "import_file": importFile, - "local_mode": "false", - "extract_content": "false", - }, - } - - s.client. - EXPECT(). - CreateJob(context.TODO(), mockJob). - Return(mockJob, &model.Response{}, nil). - Times(1) - - err := importProcessCmdF(s.client, &cobra.Command{}, []string{importFile}) - s.Require().Nil(err) - s.Len(printer.GetLines(), 1) - s.Empty(printer.GetErrorLines()) - s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + s.Run("default workers", func() { + printer.Clean() + importFile := "import.zip" + mockJob := &model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{ + "import_file": importFile, + "local_mode": "false", + "extract_content": "false", + }, + } + + s.client. + EXPECT(). + CreateJob(context.TODO(), mockJob). + Return(mockJob, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("bypass-upload", false, "") + cmd.Flags().Bool("extract-content", false, "") + cmd.Flags().Int("workers", 0, "") + + err := importProcessCmdF(s.client, cmd, []string{importFile}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Empty(printer.GetErrorLines()) + s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + }) + + s.Run("workers exceeds max", func() { + printer.Clean() + importFile := "import.zip" + tooMany := runtime.NumCPU()*4 + 1 + + cmd := &cobra.Command{} + cmd.Flags().Bool("bypass-upload", false, "") + cmd.Flags().Bool("extract-content", false, "") + cmd.Flags().Int("workers", 0, "") + _ = cmd.Flags().Set("workers", strconv.Itoa(tooMany)) + + err := importProcessCmdF(s.client, cmd, []string{importFile}) + s.Require().NotNil(err) + s.Contains(err.Error(), "exceeds maximum allowed") + s.Empty(printer.GetLines()) + s.Empty(printer.GetErrorLines()) + }) + + s.Run("custom workers", func() { + printer.Clean() + importFile := "import.zip" + mockJob := &model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{ + "import_file": importFile, + "local_mode": "false", + "extract_content": "false", + "workers": "2", + }, + } + + s.client. + EXPECT(). + CreateJob(context.TODO(), mockJob). + Return(mockJob, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("bypass-upload", false, "") + cmd.Flags().Bool("extract-content", false, "") + cmd.Flags().Int("workers", 0, "") + _ = cmd.Flags().Set("workers", "2") + + err := importProcessCmdF(s.client, cmd, []string{importFile}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Empty(printer.GetErrorLines()) + s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + }) } func (s *MmctlUnitTestSuite) TestImportValidateCmdF() {