From 51ca7657d88385eb3c20370d66324d89115dbfb1 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Mon, 5 Aug 2024 05:37:50 +0000 Subject: [PATCH] feat(electron): new tab/split view entries (#7708) fix AF-1146 --- .../components/page-list/operation-cell.tsx | 41 +++--- .../page-list/view/collection-operations.tsx | 15 ++ .../views/nodes/collection/operations.tsx | 26 ++++ .../explorer/views/nodes/doc/operations.tsx | 28 ++++ .../explorer/views/nodes/tag/operations.tsx | 28 ++++ .../core/src/modules/navigation/utils.ts | 48 ++++--- .../services/desktop-state-synchronizer.ts | 13 +- .../frontend/electron/src/main/ui/handlers.ts | 2 +- .../src/main/windows-manager/context-menu.ts | 94 +++++++++++++ .../src/main/windows-manager/tab-views.ts | 132 ++++-------------- .../e2e/local-first-openpage-newtab.spec.ts | 3 +- 11 files changed, 272 insertions(+), 158 deletions(-) create mode 100644 packages/frontend/electron/src/main/windows-manager/context-menu.ts diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 003a16dc30..d07d51c918 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -18,7 +18,6 @@ import { useI18n } from '@affine/i18n'; import { DeleteIcon, DeletePermanentlyIcon, - DualLinkIcon, DuplicateIcon, EditIcon, FavoritedIcon, @@ -27,6 +26,7 @@ import { FilterMinusIcon, InformationIcon, MoreVerticalIcon, + OpenInNewIcon, PlusIcon, ResetIcon, SplitViewIcon, @@ -39,7 +39,6 @@ import { WorkspaceService, } from '@toeverything/infra'; import { useCallback, useState } from 'react'; -import { Link } from 'react-router-dom'; import type { CollectionService } from '../../modules/collection'; import { InfoModal } from '../affine/page-properties'; @@ -49,7 +48,7 @@ import * as styles from './list.css'; import { DisablePublicSharing, MoveToTrash } from './operation-menu-items'; import { CreateOrEditTag } from './tags/create-tag'; import type { TagMeta } from './types'; -import { ColWrapper, stopPropagationWithoutPrevent } from './utils'; +import { ColWrapper } from './utils'; import { useEditCollection, useEditCollectionName } from './view'; const tooltipSideTop = { side: 'top' as const }; @@ -100,6 +99,10 @@ export const PageOperationCell = ({ workbench.openDoc(page.id, { at: 'tail' }); }, [page.id, workbench]); + const onOpenInNewTab = useCallback(() => { + workbench.openDoc(page.id, { at: 'new-tab' }); + }, [page.id, workbench]); + const onToggleFavoritePage = useCallback(() => { const status = favAdapter.isFavorite(page.id, 'doc'); favAdapter.toggle(page.id, 'doc'); @@ -173,6 +176,17 @@ export const PageOperationCell = ({ ) : null} + + + + } + > + {t['com.affine.workbench.tab.page-menu-open']()} + + {environment.isDesktop && appSettings.enableMultiView ? ( ) : null} - {!environment.isDesktop && ( - - - - - } - > - {t['com.affine.openPageOperation.newTab']()} - - - )} - diff --git a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx index 5e844ffca7..8ba6a01a2f 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx @@ -12,6 +12,7 @@ import { FavoritedIcon, FavoriteIcon, FilterIcon, + OpenInNewIcon, PlusIcon, SplitViewIcon, } from '@blocksuite/icons/rc'; @@ -79,6 +80,10 @@ export const CollectionOperations = ({ workbench.openCollection(collection.id, { at: 'tail' }); }, [collection.id, workbench]); + const openCollectionNewTab = useCallback(() => { + workbench.openCollection(collection.id, { at: 'new-tab' }); + }, [collection.id, workbench]); + const favAdapter = useService(CompatibleFavoriteItemsAdapter); const onToggleFavoritePage = useCallback(() => { @@ -153,6 +158,15 @@ export const CollectionOperations = ({ : t['com.affine.favoritePageOperation.add'](), click: onToggleFavoritePage, }, + { + icon: ( + + + + ), + name: t['com.affine.workbench.tab.page-menu-open'](), + click: openCollectionNewTab, + }, ...(appSettings.enableMultiView ? [ { @@ -189,6 +203,7 @@ export const CollectionOperations = ({ onAddDocToCollection, favorite, onToggleFavoritePage, + openCollectionNewTab, appSettings.enableMultiView, openCollectionSplitView, service, diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx index 9fba8ee0e1..4acb2e4f36 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx @@ -17,6 +17,7 @@ import { FavoritedIcon, FavoriteIcon, FilterIcon, + OpenInNewIcon, PlusIcon, SplitViewIcon, } from '@blocksuite/icons/rc'; @@ -110,6 +111,15 @@ export const useExplorerCollectionNodeOperations = ( }); }, [collectionId, workbenchService.workbench]); + const handleOpenInNewTab = useCallback(() => { + workbenchService.workbench.openCollection(collectionId, { at: 'new-tab' }); + mixpanel.track('OpenInNewTab', { + page: 'sidebar', + module: 'collection', + control: 'open in new tab button', + }); + }, [collectionId, workbenchService.workbench]); + const handleDeleteCollection = useCallback(() => { collectionService.deleteCollection(deleteInfo, collectionId); mixpanel.track('CollectionDeleted', { @@ -187,6 +197,21 @@ export const useExplorerCollectionNodeOperations = ( ), }, + { + index: 99, + view: ( + + + + } + onClick={handleOpenInNewTab} + > + {t['com.affine.workbench.tab.page-menu-open']()} + + ), + }, ...(appSettings.enableMultiView ? [ { @@ -232,6 +257,7 @@ export const useExplorerCollectionNodeOperations = ( favorite, handleAddDocToCollection, handleDeleteCollection, + handleOpenInNewTab, handleOpenInSplitView, handleShowEdit, handleToggleFavoriteCollection, diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx index ab51c555f6..b18bd137ce 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx @@ -17,6 +17,7 @@ import { FavoriteIcon, InformationIcon, LinkedPageIcon, + OpenInNewIcon, SplitViewIcon, } from '@blocksuite/icons/rc'; import { DocsService, useLiveData, useServices } from '@toeverything/infra'; @@ -75,6 +76,17 @@ export const useExplorerDocNodeOperations = ( }); }, [docRecord, openConfirmModal, t]); + const handleOpenInNewTab = useCallback(() => { + workbenchService.workbench.openDoc(docId, { + at: 'new-tab', + }); + mixpanel.track('OpenInNewTab', { + page: 'sidebar', + module: 'doc', + control: 'open in new tab button', + }); + }, [docId, workbenchService]); + const handleOpenInSplitView = useCallback(() => { workbenchService.workbench.openDoc(docId, { at: 'beside', @@ -151,6 +163,21 @@ export const useExplorerDocNodeOperations = ( ), }, + { + index: 99, + view: ( + + + + } + onClick={handleOpenInNewTab} + > + {t['com.affine.workbench.tab.page-menu-open']()} + + ), + }, ...(appSettings.enableMultiView ? [ { @@ -219,6 +246,7 @@ export const useExplorerDocNodeOperations = ( favorite, handleAddLinkedPage, handleMoveToTrash, + handleOpenInNewTab, handleOpenInSplitView, handleToggleFavoriteDoc, options.openInfoModal, diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx index 8f36c8b484..98c0794d2f 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx @@ -15,6 +15,7 @@ import { DeleteIcon, FavoritedIcon, FavoriteIcon, + OpenInNewIcon, PlusIcon, SplitViewIcon, } from '@blocksuite/icons/rc'; @@ -97,6 +98,17 @@ export const useExplorerTagNodeOperations = ( }); }, [favoriteService, tagId]); + const handleOpenInNewTab = useCallback(() => { + workbenchService.workbench.openTag(tagId, { + at: 'new-tab', + }); + mixpanel.track('OpenInNewTab', { + page: 'sidebar', + module: 'tag', + control: 'open in new tab button', + }); + }, [tagId, workbenchService]); + return useMemo( () => [ { @@ -108,6 +120,21 @@ export const useExplorerTagNodeOperations = ( ), }, + { + index: 50, + view: ( + + + + } + onClick={handleOpenInNewTab} + > + {t['com.affine.workbench.tab.page-menu-open']()} + + ), + }, ...(appSettings.enableMultiView ? [ { @@ -180,6 +207,7 @@ export const useExplorerTagNodeOperations = ( favorite, handleMoveToTrash, handleNewDoc, + handleOpenInNewTab, handleOpenInSplitView, handleToggleFavoriteTag, t, diff --git a/packages/frontend/core/src/modules/navigation/utils.ts b/packages/frontend/core/src/modules/navigation/utils.ts index 8d64800726..8a434be8cc 100644 --- a/packages/frontend/core/src/modules/navigation/utils.ts +++ b/packages/frontend/core/src/modules/navigation/utils.ts @@ -18,30 +18,42 @@ export const resolveRouteLinkMeta = (href: string) => { return null; } - const hash = url.hash; - const pathname = url.pathname; - // http://---/workspace/{workspaceid}/xxx/yyy // http://---/workspace/{workspaceid}/xxx const [_, workspaceId, moduleName, subModuleName] = - pathname.match(/\/workspace\/([^/]+)\/([^/]+)(?:\/([^/]+))?/) || []; + url.pathname.match(/\/workspace\/([^/]+)\/([^/]+)(?:\/([^/]+))?/) || []; - if (isRouteModulePath(moduleName)) { - return { - workspaceId, - moduleName, - subModuleName, - }; - } else if (moduleName) { - // for now we assume all other cases are doc links - return { - workspaceId, - moduleName: 'doc' as const, - docId: moduleName, - blockId: hash.slice(1), + if (workspaceId) { + const basename = `/workspace/${workspaceId}`; + const pathname = url.pathname.replace(basename, ''); + const search = url.search; + const hash = url.hash; + const location = { + pathname, + search, + hash, }; + if (isRouteModulePath(moduleName)) { + return { + location, + basename, + workspaceId, + moduleName, + subModuleName, + }; + } else if (moduleName) { + // for now we assume all other cases are doc links + return { + location, + basename, + workspaceId, + moduleName: 'doc' as const, + docId: moduleName, + blockId: hash.slice(1), + }; + } } - return; + return null; } catch { return null; } diff --git a/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts b/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts index 032dbb0c69..21eb23ea0a 100644 --- a/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts +++ b/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts @@ -24,12 +24,13 @@ export class DesktopStateSynchronizer extends Service { event.type === 'open-in-split-view' && event.payload.tabId === appInfo?.viewId ) { - const activeView = workbench.activeView$.value; - if (activeView) { - workbench.open(activeView.location$.value, { - at: 'beside', - }); - } + const to = + event.payload.view?.path ?? + workbench.activeView$.value?.location$.value; + + workbench.open(to, { + at: 'beside', + }); } if ( diff --git a/packages/frontend/electron/src/main/ui/handlers.ts b/packages/frontend/electron/src/main/ui/handlers.ts index a38c868680..c6ec76bf63 100644 --- a/packages/frontend/electron/src/main/ui/handlers.ts +++ b/packages/frontend/electron/src/main/ui/handlers.ts @@ -22,10 +22,10 @@ import { pingAppLayoutReady, showDevTools, showTab, - showTabContextMenu, updateWorkbenchMeta, updateWorkbenchViewMeta, } from '../windows-manager'; +import { showTabContextMenu } from '../windows-manager/context-menu'; import { getChallengeResponse } from './challenge'; import { uiSubjects } from './subject'; diff --git a/packages/frontend/electron/src/main/windows-manager/context-menu.ts b/packages/frontend/electron/src/main/windows-manager/context-menu.ts new file mode 100644 index 0000000000..01ba7890c0 --- /dev/null +++ b/packages/frontend/electron/src/main/windows-manager/context-menu.ts @@ -0,0 +1,94 @@ +import { Menu } from 'electron'; + +import { logger } from '../logger'; +import { + addTab, + closeTab, + reloadView, + WebContentViewsManager, +} from './tab-views'; + +export const showTabContextMenu = async (tabId: string, viewIndex: number) => { + const workbenches = WebContentViewsManager.instance.tabViewsMeta.workbenches; + const unpinned = workbenches.filter(w => !w.pinned); + const tabMeta = workbenches.find(w => w.id === tabId); + if (!tabMeta) { + return; + } + + const template: Parameters[0] = [ + tabMeta.pinned + ? { + label: 'Unpin tab', + click: () => { + WebContentViewsManager.instance.pinTab(tabId, false); + }, + } + : { + label: 'Pin tab', + click: () => { + WebContentViewsManager.instance.pinTab(tabId, true); + }, + }, + { + label: 'Refresh tab', + click: () => { + reloadView().catch(logger.error); + }, + }, + { + label: 'Duplicate tab', + click: () => { + addTab({ + basename: tabMeta.basename, + view: tabMeta.views, + show: false, + }).catch(logger.error); + }, + }, + + { type: 'separator' }, + + tabMeta.views.length > 1 + ? { + label: 'Separate tabs', + click: () => { + WebContentViewsManager.instance.separateView(tabId, viewIndex); + }, + } + : { + label: 'Open in split view', + click: () => { + WebContentViewsManager.instance.openInSplitView({ tabId }); + }, + }, + + ...(unpinned.length > 0 + ? ([ + { type: 'separator' }, + { + label: 'Close tab', + click: () => { + closeTab(tabId).catch(logger.error); + }, + }, + { + label: 'Close other tabs', + click: () => { + const tabsToRetain = + WebContentViewsManager.instance.tabViewsMeta.workbenches.filter( + w => w.id === tabId || w.pinned + ); + + WebContentViewsManager.instance.patchTabViewsMeta({ + workbenches: tabsToRetain, + activeWorkbenchId: tabId, + }); + }, + }, + ] as const) + : []), + ]; + const menu = Menu.buildFromTemplate(template); + menu.popup(); +}; diff --git a/packages/frontend/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/electron/src/main/windows-manager/tab-views.ts index f8597f1631..2c3fa86660 100644 --- a/packages/frontend/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/electron/src/main/windows-manager/tab-views.ts @@ -4,7 +4,6 @@ import { app, type CookiesSetDetails, globalShortcut, - Menu, type View, type WebContents, WebContentsView, @@ -103,7 +102,10 @@ type SeparateViewAction = { type OpenInSplitViewAction = { type: 'open-in-split-view'; - payload: { tabId: string }; + payload: { + tabId: string; + view?: Omit; + }; }; type TabAction = @@ -114,7 +116,7 @@ type TabAction = | SeparateViewAction | OpenInSplitViewAction; -type AddTabOption = { +export type AddTabOption = { basename?: string; view?: Omit | Array>; target?: string; @@ -384,23 +386,19 @@ export class WebContentViewsManager { } }; - addTab = async (option?: AddTabOption) => { - if (!option) { - const activeWorkbench = this.activeWorkbenchMeta; - const basename = activeWorkbench?.basename ?? '/'; + addTab = async (option: AddTabOption = {}) => { + const activeWorkbench = this.activeWorkbenchMeta; + + option.basename ??= activeWorkbench?.basename ?? '/'; + option.view ??= { + title: 'New Tab', + path: option.basename?.startsWith('/workspace') + ? { + pathname: '/all', + } + : undefined, + }; - option = { - basename, - view: { - title: 'New Tab', - path: basename.startsWith('/workspace') - ? { - pathname: '/all', - } - : undefined, - }, - }; - } const workbenches = this.tabViewsMeta.workbenches; const newKey = this.generateViewId('app'); const views = ( @@ -420,7 +418,7 @@ export class WebContentViewsManager { (option.edge === 'left' ? 0 : 1); const workbench: WorkbenchMeta = { - basename: option.basename ?? this.activeWorkbenchMeta?.basename ?? '/', + basename: option.basename, activeViewIndex: 0, views: views, id: newKey, @@ -588,14 +586,16 @@ export class WebContentViewsManager { addTab(newTabMeta).catch(logger.error); }; - openInSplitView = (tabId: string) => { - const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId); + openInSplitView = (payload: OpenInSplitViewAction['payload']) => { + const tabMeta = this.tabViewsMeta.workbenches.find( + w => w.id === payload.tabId + ); if (!tabMeta) { return; } this.tabAction$.next({ type: 'open-in-split-view', - payload: { tabId }, + payload: payload, }); }; @@ -954,6 +954,7 @@ export const closeTab = WebContentViewsManager.instance.closeTab; export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab; export const activateView = WebContentViewsManager.instance.activateView; export const moveTab = WebContentViewsManager.instance.moveTab; +export const openInSplitView = WebContentViewsManager.instance.openInSplitView; export const reloadView = async () => { const id = WebContentViewsManager.instance.activeWorkbenchId; @@ -993,88 +994,3 @@ export const pingAppLayoutReady = (wc: WebContents) => { WebContentViewsManager.instance.setTabUIReady(viewId); } }; - -export const showTabContextMenu = async (tabId: string, viewIndex: number) => { - const workbenches = WebContentViewsManager.instance.tabViewsMeta.workbenches; - const unpinned = workbenches.filter(w => !w.pinned); - const tabMeta = workbenches.find(w => w.id === tabId); - if (!tabMeta) { - return; - } - - const template: Parameters[0] = [ - tabMeta.pinned - ? { - label: 'Unpin tab', - click: () => { - WebContentViewsManager.instance.pinTab(tabId, false); - }, - } - : { - label: 'Pin tab', - click: () => { - WebContentViewsManager.instance.pinTab(tabId, true); - }, - }, - { - label: 'Refresh tab', - click: () => { - reloadView().catch(logger.error); - }, - }, - { - label: 'Duplicate tab', - click: () => { - addTab({ - basename: tabMeta.basename, - view: tabMeta.views, - show: false, - }).catch(logger.error); - }, - }, - - { type: 'separator' }, - - tabMeta.views.length > 1 - ? { - label: 'Separate tabs', - click: () => { - WebContentViewsManager.instance.separateView(tabId, viewIndex); - }, - } - : { - label: 'Open in split view', - click: () => { - WebContentViewsManager.instance.openInSplitView(tabId); - }, - }, - - ...(unpinned.length > 0 - ? ([ - { type: 'separator' }, - { - label: 'Close tab', - click: () => { - closeTab(tabId).catch(logger.error); - }, - }, - { - label: 'Close other tabs', - click: () => { - const tabsToRetain = - WebContentViewsManager.instance.tabViewsMeta.workbenches.filter( - w => w.id === tabId || w.pinned - ); - - WebContentViewsManager.instance.patchTabViewsMeta({ - workbenches: tabsToRetain, - activeWorkbenchId: tabId, - }); - }, - }, - ] as const) - : []), - ]; - const menu = Menu.buildFromTemplate(template); - menu.popup(); -}; diff --git a/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts b/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts index 8be69b903f..0f2ae2036e 100644 --- a/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts +++ b/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts @@ -25,7 +25,8 @@ test('click btn bew page and open in tab', async ({ page, workspace }) => { page.getByRole('menuitem', { name: 'Open in new tab' }).click(), ]); - expect(newTabPage.url()).toBe(newPageUrl); + await expect(newTabPage).toHaveURL(newPageUrl, { timeout: 15000 }); + const currentWorkspace = await workspace.current(); expect(currentWorkspace.meta.flavour).toContain('local');