TidGi-Desktop/src/services/menu/index.ts
lin onetwo c2be8e4186
Fix/sub wiki tag tree (#667)
* fix: different out path on macos

* fix: let go hibernation promise, so it's faster on macos

* log marker [test-id-TIDGI_MINI_WINDOW_CREATED]

* Skip registerShortcutByKey  in test

* fix: mini window on mac

* Remove useless log marker

* Update handleAttachToTidgiMiniWindow.ts

* fix: log marker check all log files

* lint

* fix: open in new window now showing wiki title

* Update package.json

* feat: basic load and save to sub wiki using in-tag-tree-of

* fix: load sub-wiki content and prevent echo

* fix: test and ui logic

* test: refactor subwiki test logic to a file

* refactor: shorten steps by using dedicated step, and test underlying micro steps

* fix: review

* refactor: remove outdated method signature

* test: unit cover adaptor subwiki routing

* Update FileSystemAdaptor.routing.test.ts

* fix: merge issue
2025-12-07 03:31:34 +08:00

462 lines
21 KiB
TypeScript

import { getWorkspaceIdFromUrl } from '@/constants/urls';
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { IContextService } from '@services/context/interface';
import type { IExternalAPIService } from '@services/externalAPI/interface';
import type { IGitService } from '@services/git/interface';
import { i18n } from '@services/libs/i18n';
import { logger } from '@services/libs/log';
import type { INativeService } from '@services/native/interface';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import type { ISyncService } from '@services/sync/interface';
import type { IViewService } from '@services/view/interface';
import type { IWikiService } from '@services/wiki/interface';
import type { IWikiGitWorkspaceService } from '@services/wikiGitWorkspace/interface';
import type { IWindowService } from '@services/windows/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import { getSimplifiedWorkspaceMenuTemplate, getWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate';
import type { IWorkspaceService } from '@services/workspaces/interface';
import { isWikiWorkspace } from '@services/workspaces/interface';
import type { IWorkspaceViewService } from '@services/workspacesView/interface';
import { app, ContextMenuParams, Menu, MenuItem, MenuItemConstructorOptions, shell, WebContents } from 'electron';
import { inject, injectable } from 'inversify';
import { compact, debounce, drop, remove, reverse, take } from 'lodash';
import ContextMenuBuilder from './contextMenu/contextMenuBuilder';
import { IpcSafeMenuItem, mainMenuItemProxy } from './contextMenu/rendererMenuItemProxy';
import { InsertMenuAfterSubMenuIndexError } from './error';
import type { IMenuService, IOnContextMenuInfo } from './interface';
import { DeferredMenuItemConstructorOptions } from './interface';
import { loadDefaultMenuTemplate } from './loadDefaultMenuTemplate';
@injectable()
export class MenuService implements IMenuService {
constructor(
@inject(serviceIdentifier.Authentication) private readonly authService: IAuthenticationService,
@inject(serviceIdentifier.Context) private readonly contextService: IContextService,
@inject(serviceIdentifier.ExternalAPI) private readonly externalAPIService: IExternalAPIService,
@inject(serviceIdentifier.NativeService) private readonly nativeService: INativeService,
@inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService,
) {
// debounce so build menu won't be call very frequently on app launch, where every services are registering menu items
this.buildMenu = debounce(this.buildMenu.bind(this), 50) as () => Promise<void>;
}
#menuTemplate?: DeferredMenuItemConstructorOptions[];
private get menuTemplate(): DeferredMenuItemConstructorOptions[] {
if (this.#menuTemplate === undefined) {
this.#menuTemplate = loadDefaultMenuTemplate();
}
return this.#menuTemplate;
}
/**
* Record each menu part contains what menuItem, so we can delete these menuItem before insert new ones
* `{ [menuPartKey]: [menuID, menuItemID][] }`
* Menu part means "refresh part", that will be refresh upon insert new items.
*/
private menuPartRecord: Record<string, Array<[string, string]>> = {};
/** check if menuItem with menuID and itemID belongs to a menuPartKey */
private belongsToPart(menuPartKey: string, menuID: string, itemID?: string): boolean {
// if menuItem only have role, it won't be refresh, so it won't belongs to a refresh part
if (itemID === undefined) {
return false;
}
const record = this.menuPartRecord[menuPartKey];
if (record !== undefined) {
return record.some(([currentMenuID, currentItemID]) => menuID === currentMenuID && itemID === currentItemID);
}
return false;
}
private updateMenuPartRecord(
menuPartKey: string,
menuID: string,
newSubMenuItems: Array<DeferredMenuItemConstructorOptions | MenuItemConstructorOptions>,
): void {
this.menuPartRecord[menuPartKey] = newSubMenuItems.filter((item) => item.id !== undefined).map((item) => [menuID, item.id!] as [string, string]);
}
/**
* Rebuild or create menubar from the latest menu template, will be call after some method change the menuTemplate
* You don't need to call this after calling method like insertMenu, it will be call automatically.
*/
public async buildMenu(): Promise<void> {
const latestTemplate = (await this.getCurrentMenuItemConstructorOptions(this.menuTemplate)) ?? [];
try {
const menu = Menu.buildFromTemplate(latestTemplate);
Menu.setApplicationMenu(menu);
} catch (error) {
logger.error('buildMenu failed', {
error,
function: 'buildMenu',
});
try {
const index = Number(/Error processing argument at index (\d+)/.exec((error as Error).message)?.[1]);
logger.error('buildMenu failed example', {
index,
example: Number.isFinite(index) ? JSON.stringify(latestTemplate[index]) : JSON.stringify(latestTemplate),
function: 'buildMenu',
});
} catch (error) {
logger.error('buildMenu failed fallback', {
error,
function: 'buildMenu',
});
}
}
}
/**
* We have some value in template that need to get latest value, they are functions, we execute every functions in the template
* @param submenu menu options to get latest value
* @returns MenuTemplate that `Menu.buildFromTemplate` wants
*/
private async getCurrentMenuItemConstructorOptions(
submenu?: Array<DeferredMenuItemConstructorOptions | MenuItemConstructorOptions>,
): Promise<MenuItemConstructorOptions[] | undefined> {
if (submenu === undefined) return;
return await Promise.all(
submenu
.filter((item) => Object.keys(item).length > 0)
.map(async (item) => ({
...item,
/** label sometimes is null, causing */
label: typeof item.label === 'function' ? item.label() ?? undefined : item.label,
checked: typeof item.checked === 'function' ? await item.checked() : item.checked,
enabled: typeof item.enabled === 'function' ? await item.enabled() : item.enabled,
visible: typeof item.visible === 'function' ? await item.visible() : item.visible,
submenu: Array.isArray(item.submenu) ? await this.getCurrentMenuItemConstructorOptions(compact(item.submenu)) : item.submenu,
})),
);
}
/** Register `on('context-menu', openContextMenuForWindow)` for a window, return an unregister function */
public async initContextMenuForWindowWebContents(webContents: WebContents): Promise<() => void> {
const openContextMenuForWindow = async (_event: Electron.Event, parameters: ContextMenuParams): Promise<void> => {
const template: MenuItemConstructorOptions[] = [];
// Try to get workspace ID from URL
const url = webContents.getURL();
const workspaceId = getWorkspaceIdFromUrl(url);
if (workspaceId) {
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspace = await workspaceService.get(workspaceId);
// Add workspace-specific menu items if workspace exists and is a wiki
if (workspace !== undefined && isWikiWorkspace(workspace)) {
const services = {
auth: container.get<IAuthenticationService>(serviceIdentifier.Authentication),
context: container.get<IContextService>(serviceIdentifier.Context),
externalAPI: this.externalAPIService,
git: container.get<IGitService>(serviceIdentifier.Git),
native: container.get<INativeService>(serviceIdentifier.NativeService),
preference: this.preferenceService,
sync: container.get<ISyncService>(serviceIdentifier.Sync),
view: container.get<IViewService>(serviceIdentifier.View),
wiki: container.get<IWikiService>(serviceIdentifier.Wiki),
wikiGitWorkspace: container.get<IWikiGitWorkspaceService>(serviceIdentifier.WikiGitWorkspace),
window: container.get<IWindowService>(serviceIdentifier.Window),
workspace: workspaceService,
workspaceView: container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView),
};
// Get simplified menu items (includes command palette, simplified actions, and "Current Workspace")
const simplifiedMenuItems = await getSimplifiedWorkspaceMenuTemplate(workspace, i18n.t.bind(i18n), services);
template.push(...simplifiedMenuItems);
}
}
await this.buildContextMenuAndPopup(template, parameters, webContents);
};
webContents.on('context-menu', openContextMenuForWindow);
return () => {
if (webContents.isDestroyed()) {
return;
}
webContents.removeListener('context-menu', openContextMenuForWindow);
};
}
private static isMenuItemEqual<T extends DeferredMenuItemConstructorOptions | MenuItemConstructorOptions>(a: T, b: T): boolean {
if (a.id === b.id && a.id !== undefined) {
return true;
}
if (a.role === b.role && a.role !== undefined) {
return true;
}
if (typeof a.label === 'string' && typeof b.label === 'string' && a.label === b.label && a.label !== undefined) {
return true;
}
if (typeof a.label === 'function' && typeof b.label === 'function' && a.label() === b.label() && a.label() !== undefined) {
return true;
}
if (typeof a.label === 'function' && typeof b.label === 'string' && a.label() === b.label && b.label !== undefined) {
return true;
}
if (typeof b.label === 'function' && typeof a.label === 'string' && b.label() === a.label && a.label !== undefined) {
return true;
}
return false;
}
/**
* Insert provided sub menu items into menubar, so user and services can register custom menu items
* @param menuID Top level menu name to insert menu items
* @param newSubMenuItems An array of menu item to insert or update, if some of item is already existed, it will be updated instead of inserted
* @param afterSubMenu The `id` or `role` of a submenu you want your submenu insert after. `null` means inserted as first submenu item; `undefined` means inserted as last submenu item;
* @param withSeparator Need to insert a separator first, before insert menu items
* @param menuPartKey When you update a part of menu, you can overwrite old menu part with same key
*/
public async insertMenu(
menuID: string,
newSubMenuItems: Array<DeferredMenuItemConstructorOptions | MenuItemConstructorOptions>,
afterSubMenu?: string | null,
withSeparator = false,
menuPartKey?: string,
): Promise<void> {
let foundMenuName = false;
const copyOfNewSubMenuItems = [...newSubMenuItems];
// try insert menu into an existed menu's submenu
for (const menu of this.menuTemplate) {
// match top level menu
if (menu.id === menuID) {
foundMenuName = true;
// heck some menu item existed, we update them and pop them out
const currentSubMenu = compact(menu.submenu);
// we push old and new content into this array, and assign back to menu.submenu later
let filteredSubMenu: Array<DeferredMenuItemConstructorOptions | MenuItemConstructorOptions> = currentSubMenu;
// refresh menu part by delete previous menuItems that belongs to the same partKey
if (menuPartKey !== undefined) {
filteredSubMenu = filteredSubMenu.filter((currentSubMenuItem) => !this.belongsToPart(menuPartKey, menuID, currentSubMenuItem.id));
}
for (const newSubMenuItem of newSubMenuItems) {
const existedItemIndex = currentSubMenu.findIndex((existedItem) => MenuService.isMenuItemEqual(existedItem, newSubMenuItem));
// replace existed item, and remove it from needed-to-add-items
if (existedItemIndex !== -1) {
filteredSubMenu[existedItemIndex] = newSubMenuItem;
remove(newSubMenuItems, (item) => item.id === newSubMenuItem.id);
}
}
if (afterSubMenu === undefined) {
// inserted as last submenu item
if (withSeparator) {
filteredSubMenu.push({ type: 'separator' });
}
filteredSubMenu = [...filteredSubMenu, ...newSubMenuItems];
} else if (afterSubMenu === null) {
// inserted as first submenu item
if (withSeparator) {
newSubMenuItems.push({ type: 'separator' });
}
filteredSubMenu = [...newSubMenuItems, ...filteredSubMenu];
} else if (typeof afterSubMenu === 'string') {
// insert after afterSubMenu
const afterSubMenuIndex = filteredSubMenu.findIndex((item) => item.id === afterSubMenu || item.role === afterSubMenu);
if (afterSubMenuIndex === -1) {
throw new InsertMenuAfterSubMenuIndexError(afterSubMenu, menuID, menu);
}
filteredSubMenu = [...take(filteredSubMenu, afterSubMenuIndex + 1), ...newSubMenuItems, ...drop(filteredSubMenu, afterSubMenuIndex - 1)];
}
menu.submenu = filteredSubMenu;
// leave this finding menu loop
break;
}
}
// if user wants to create a new menu in menubar
if (!foundMenuName) {
this.menuTemplate.push({
label: menuID,
submenu: newSubMenuItems,
});
}
// update menuPartRecord
if (menuPartKey !== undefined) {
this.updateMenuPartRecord(menuPartKey, menuID, copyOfNewSubMenuItems);
}
await this.buildMenu();
}
public async buildContextMenuAndPopup(
template: MenuItemConstructorOptions[] | IpcSafeMenuItem[],
info: IOnContextMenuInfo,
webContentsOrWindowName: WindowNames | WebContents = WindowNames.main,
): Promise<void> {
let webContents: WebContents;
// Get services via container to avoid lazyInject issues
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const authService = container.get<IAuthenticationService>(serviceIdentifier.Authentication);
const contextService = container.get<IContextService>(serviceIdentifier.Context);
const gitService = container.get<IGitService>(serviceIdentifier.Git);
const nativeService = container.get<INativeService>(serviceIdentifier.NativeService);
const viewService = container.get<IViewService>(serviceIdentifier.View);
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
const wikiGitWorkspaceService = container.get<IWikiGitWorkspaceService>(serviceIdentifier.WikiGitWorkspace);
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
const syncService = container.get<ISyncService>(serviceIdentifier.Sync);
if (typeof webContentsOrWindowName === 'string') {
const windowToPopMenu = windowService.get(webContentsOrWindowName);
const webContentsOfWindowToPopMenu = windowToPopMenu?.webContents;
if (windowToPopMenu === undefined || webContentsOfWindowToPopMenu === undefined) {
return;
}
webContents = webContentsOfWindowToPopMenu;
} else {
webContents = webContentsOrWindowName;
}
const sidebar = await preferenceService.get('sidebar');
const contextMenuBuilder = new ContextMenuBuilder(webContents);
const menu = contextMenuBuilder.buildMenuForElement(info);
const workspaces = await workspaceService.getWorkspacesAsList();
const services = {
auth: authService,
context: contextService,
externalAPI: this.externalAPIService,
git: gitService,
native: nativeService,
preference: this.preferenceService,
view: viewService,
wiki: wikiService,
wikiGitWorkspace: wikiGitWorkspaceService,
window: windowService,
workspace: workspaceService,
workspaceView: workspaceViewService,
sync: syncService,
};
// workspace menus (template items are added at the end via insert(0) in reverse order)
menu.append(new MenuItem({ type: 'separator' }));
// Note: Simplified menu and "Current Workspace" are now provided by the frontend template
// (from SortableWorkspaceSelectorButton or content view), so we don't add them here
menu.append(
new MenuItem({
label: i18n.t('Menu.Workspaces'),
submenu: [
...(await Promise.all(
workspaces.map(async (workspace) => {
const workspaceContextMenuTemplate = await getWorkspaceMenuTemplate(workspace, i18n.t.bind(i18n), services);
return {
label: workspace.name,
submenu: workspaceContextMenuTemplate,
};
}),
)),
{
label: i18n.t('WorkspaceSelector.Add'),
click: async () => {
await container.get<IWindowService>(serviceIdentifier.Window).open(WindowNames.addWorkspace);
},
},
],
}),
);
menu.append(
new MenuItem({
label: i18n.t('WorkspaceSelector.OpenWorkspaceMenuName'),
submenu: workspaces.map((workspace) => ({
label: i18n.t('WorkspaceSelector.OpenWorkspaceTagTiddler', {
tagName: isWikiWorkspace(workspace)
? (workspace.tagNames[0] ?? (workspace.isSubWiki ? workspace.name : `${workspace.name} ${i18n.t('WorkspaceSelector.DefaultTiddlers')}`))
: workspace.name,
}),
click: async () => {
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).openWorkspaceTiddler(workspace);
},
})),
}),
);
// Note: "OpenCommandPalette" is now provided by the frontend template
menu.append(new MenuItem({ type: 'separator' }));
menu.append(
new MenuItem({
label: i18n.t('ContextMenu.Back'),
enabled: webContents.navigationHistory.canGoBack(),
click: () => {
webContents.navigationHistory.goBack();
},
}),
);
menu.append(
new MenuItem({
label: i18n.t('ContextMenu.Forward'),
enabled: webContents.navigationHistory.canGoForward(),
click: () => {
webContents.navigationHistory.goForward();
},
}),
);
menu.append(
new MenuItem({
label: sidebar ? i18n.t('Preference.HideSideBar') : i18n.t('Preference.ShowSideBar'),
click: async () => {
await this.preferenceService.set('sidebar', !sidebar);
await container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView).realignActiveWorkspace();
},
}),
);
menu.append(new MenuItem({ type: 'separator' }));
menu.append(
new MenuItem({
label: i18n.t('ContextMenu.More'),
submenu: [
{
label: i18n.t('ContextMenu.Preferences'),
click: async () => {
await container.get<IWindowService>(serviceIdentifier.Window).open(WindowNames.preferences);
},
},
{ type: 'separator' },
{
label: i18n.t('ContextMenu.About'),
click: async () => {
await container.get<IWindowService>(serviceIdentifier.Window).open(WindowNames.about);
},
},
{
label: i18n.t('ContextMenu.TidGiSupport'),
click: async () => {
await shell.openExternal('https://github.com/tiddly-gittly/TidGi-Desktop/issues/new/choose');
},
},
{
label: i18n.t('ContextMenu.TidGiWebsite'),
click: async () => {
await shell.openExternal('https://github.com/tiddly-gittly/TidGi-Desktop');
},
},
{ type: 'separator' },
{
label: i18n.t('ContextMenu.Quit'),
click: () => {
app.quit();
},
},
],
}),
);
// add custom menu items
if (template !== undefined && Array.isArray(template) && template.length > 0) {
// if our menu item config is pass from the renderer process, we reconstruct callback from the ipc.on channel id.
const menuItems = (typeof template[0]?.click === 'string'
? mainMenuItemProxy(template as IpcSafeMenuItem[], webContents)
: template) as unknown as MenuItemConstructorOptions[];
menu.insert(0, new MenuItem({ type: 'separator' }));
// we are going to prepend items, so inverse first, so order will remain
reverse(menuItems)
.map((menuItem) => new MenuItem(menuItem))
.forEach((menuItem) => {
menu.insert(0, menuItem);
});
}
menu.popup();
}
}