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}
+
+
{environment.isDesktop && appSettings.enableMultiView ? (
),
},
+ {
+ 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');