TidGi-Desktop/src/services/workspacesView/index.ts
lin onetwo 7edb132d32
Fix/watch fs and ai commit (#674)
* fix: missing return

* feat: showApiKey

* feat: undo commit

* feat: amend commit

* fix: file name quoted in git log

* fix: wikiWorkspaceDefaultValues

* fix: no ai commit message sometimes

* Persist only non-default preferences to storage

Added a utility to store only preferences that differ from defaults, reducing storage size and improving config readability. Updated the setPreferences method to use this utility before saving preferences.

* fix: External Attachment Handling in fs plugin instead of ext-attachment-plugin to handle direct tag update case which won't trigger  th-saving-tiddler hook

* feat: api for plugin to create base64 file

* Show all untracked files and recreate Git history window

Updated git status commands to use '-uall' for displaying all untracked files, not just directories. Modified windowService.open calls for Git history to include the { recreate: true } option, ensuring the window is refreshed when opened from various menus.

* fix: handling of external attachments with _canonical_uri

Ensure tiddlers with _canonical_uri are always saved as .tid files, not as binary files, by forcing the .tid extension in FileSystemAdaptor. Update tests to verify this behavior. Also, skip loading files from the external attachments folder in loadWikiTiddlersWithSubWikis to prevent them from being loaded as separate tiddlers.

* Refactor external attachment utilities to module exports

Refactored externalAttachmentUtilities to use ES module exports instead of attaching functions to $tw.utils. Updated imports and mocks accordingly, removed related type definitions from ExtendedUtilities, and cleaned up obsolete meta file.

* disable enableFileSystemWatch to prevent bug for innocent users

* fix: test that requires enableFileSystemWatch use new step set to true

* Fix extension filter usage and sync workspace state after save

Refactored variable naming for extension filters in FileSystemAdaptor to improve clarity and fixed their usage in generateTiddlerFileInfo calls. Removed an unused import in routingUtilities.type.ts. Added a useEffect in useForm to sync workspace state with originalWorkspace after save, ensuring the save button disappears as expected.

* fix: review

* lint

* feat: unify AI commit entry points and add availability check  - Unified all AI commit message generation to use syncService.syncWikiIfNeeded() for consistent business logic handling - Added externalAPI.isAIAvailable() method to check if AI provider and model are properly configured - Updated gitService.isAIGenerateBackupTitleEnabled() to use the new availability check - Removed redundant logging code since generateFromAI() automatically logs to database when externalAPIDebug is enabled - Simplified menu item creation logic in menuItems.ts - Ensured AI menu options only appear when both API credentials and free model are configured - Updated documentation to reflect the unified architecture

* Improve AI commit message diff filtering and API checks

Renamed the AI commit message entry points doc for clarity. Enhanced the AI availability check to better handle provider API key requirements, including support for providers that do not require keys. Improved plugin diff filtering to retain small config file diffs while omitting large plugin file contents, optimizing AI token usage.

* Update wiki

* Refactor and enhance Tidgi mini window initialization and sync

Refactors Tidgi mini window startup to use a new initializeTidgiMiniWindow method, improving workspace selection logic and view management. Adds concurrency locks to prevent race conditions during open/close operations. Enhances workspace sync/fixed mode handling, view cleanup, and error logging. Updates interfaces and utilities to support new behaviors and improves robustness of tray icon creation and view realignment.

* Refactor file system sync to use $tw.syncer.syncFromServer()

Introduces FileSystemWatcher to monitor file changes and collect updates for the syncer, replacing direct wiki updates in WatchFileSystemAdaptor. Updates documentation to describe the new syncer-driven architecture, echo prevention, and event handling. WatchFileSystemAdaptor now delegates file change detection and lazy loading to FileSystemWatcher, improving batch change handling and eliminating echo loops.

* Improve logging and cleanup in file system watcher and git ops

Added detailed logging to WatchFileSystemAdaptor and FileSystemWatcher for better traceability during initialization and test stabilization. Introduced a constant for the temporary git index prefix in gitOperations. Removed the unused comparison.ts utility for tiddler comparison. Enhanced comments and logging for AI commit message generation context.

* Improve GitLog i18n test and config refresh logic

Updated gitLog.feature to use only Chinese selectors for actions, revert, and discard buttons, improving i18n test reliability. In FileSystemWatcher, re-fetch workspace config before checking enableFileSystemWatch to ensure latest settings are respected. In useGitLogData, prevent file-change events from overriding commit/undo events to maintain correct auto-selection behavior.

* Improve Git log selection and test stability

Refines auto-selection logic in the Git log window to better handle uncommitted changes, commits, reverts, and undos. Updates the feature test to explicitly verify selection and UI state after each operation, improving reliability. Removes unnecessary config re-fetch in FileSystemWatcher and enhances logging for more accurate DOM update detection.

* Implement workspace config sync via tidgi.config.json

Adds support for syncing workspace configuration to tidgi.config.json in the wiki folder, enabling settings persistence and migration across devices. Introduces new documentation, feature tests, and supporting utilities for config file reading, writing, migration, and validation. Updates step definitions and test helpers to support config sync scenarios, and refactors database config utilities for modularity.

* Improve workspace config handling and sync logic

Enhances workspace lookup in step definitions to check both settings.json and tidgi.config.json, ensuring properties are found even if moved. Updates tidgiConfig write logic to remove the config file if all values are default. Refactors workspace save logic to always write syncable config to tidgi.config.json for all wiki workspaces before removing those fields from settings.json, preventing config loss.

* Update .gitignore

* Update wiki.ts

* Add delay before waiting for git log render after revert

- Add 1 second wait after clearing git-log-data-rendered markers following revert
- This gives UI time to start refreshing before we check for the new marker
- Fixes CI timing issue where revert operation needs more time to trigger UI refresh

* Update test log markers for git log refresh events

Replaces '[test-id-git-log-data-rendered]' with '[test-id-git-log-refreshed]' in gitLog.feature to better reflect UI refresh events after commit and revert actions. Adds a debug log marker '[test-id-git-revert-complete]' in revertCommit for improved test synchronization.

* Fix git revert refresh timing - remove intermediate step and rely on git-log-refreshed

* Add detailed logging to handleRevert for CI debugging

* Fix git log refresh by adding manual triggerRefresh fallback

- Add triggerRefresh function to useGitLogData hook for manual refresh
- Call triggerRefresh in handleCommitSuccess, handleRevertSuccess, and handleUndoSuccess
- This fixes cross-process IPC observable subscription issues where gitStateChange$
  notifications from main process may not reach renderer process reliably
- Add detailed logging to handleRevert for CI debugging

* Update index.tsx
2026-01-10 23:57:59 +08:00

676 lines
34 KiB
TypeScript

import { mapSeries } from 'bluebird';
import { app, dialog, session } from 'electron';
import { inject, injectable } from 'inversify';
import { WikiChannel } from '@/constants/channels';
import { WikiCreationMethod } from '@/constants/wikiCreation';
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { IContextService } from '@services/context/interface';
import { i18n } from '@services/libs/i18n';
import { logger } from '@services/libs/log';
import type { IMenuService } from '@services/menu/interface';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import { SupportedStorageServices } from '@services/types';
import type { IViewService } from '@services/view/interface';
import type { IWikiService } from '@services/wiki/interface';
import type { IWindowService } from '@services/windows/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import type { IWorkspace, IWorkspaceService } from '@services/workspaces/interface';
import { isWikiWorkspace } from '@services/workspaces/interface';
import { DELAY_MENU_REGISTER } from '@/constants/parameters';
import type { ISyncService } from '@services/sync/interface';
import { workspaceSorter } from '@services/workspaces/utilities';
import type { IInitializeWorkspaceOptions, IWorkspaceViewService } from './interface';
import { registerMenu } from './registerMenu';
import { getTidgiMiniWindowTargetWorkspace } from './utilities';
@injectable()
export class WorkspaceView implements IWorkspaceViewService {
constructor(
@inject(serviceIdentifier.Authentication) private readonly authService: IAuthenticationService,
@inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService,
) {
setTimeout(() => {
void registerMenu();
}, DELAY_MENU_REGISTER);
}
public async initializeAllWorkspaceView(): Promise<void> {
logger.info('starting', { function: 'initializeAllWorkspaceView' });
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspacesList = await workspaceService.getWorkspacesAsList();
logger.info(`Found ${workspacesList.length} workspaces to initialize`, {
workspaces: workspacesList.map(w => ({ id: w.id, name: w.name, isSubWiki: isWikiWorkspace(w) ? w.isSubWiki : false, pageType: w.pageType })),
}, { function: 'initializeAllWorkspaceView' });
// Only load workspace that is not a subwiki and not a page type
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
workspacesList.filter((workspace) => isWikiWorkspace(workspace) && !workspace.isSubWiki && !workspace.pageType).forEach((workspace) => {
wikiService.setWikiStartLockOn(workspace.id);
});
const sortedList = workspacesList
.sort(workspaceSorter)
.sort((a, b) => (a.active && !b.active ? -1 : 0)) // put active wiki first
.sort((a, b) => (isWikiWorkspace(a) && a.isSubWiki && (!isWikiWorkspace(b) || !b.isSubWiki) ? -1 : 0)); // put subwiki on top, they can't restart wiki, so need to sync them first, then let main wiki restart the wiki // revert this after tw can reload tid from fs
await mapSeries(sortedList, async (workspace) => {
await this.initializeWorkspaceView(workspace);
});
wikiService.setAllWikiStartLockOff();
}
public async initializeWorkspaceView(workspace: IWorkspace, options: IInitializeWorkspaceOptions = {}): Promise<void> {
logger.info(i18n.t('Log.InitializeWorkspaceView'));
// Skip initialization for page workspaces - they don't need TiddlyWiki setup
if (workspace.pageType) {
logger.info('skipping initialization for page workspace', { function: 'initializeWorkspaceView', workspaceId: workspace.id, pageType: workspace.pageType });
return;
}
const { followHibernateSettingWhenInit = true, syncImmediately = true, isNew = false } = options;
// skip if workspace don't contains a valid tiddlywiki setup, this allows user to delete workspace later
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const shouldBeMainWiki = isWikiWorkspace(workspace) && !workspace.isSubWiki;
logger.info('checking wiki existence', {
workspaceId: workspace.id,
shouldBeMainWiki,
wikiFolderLocation: isWikiWorkspace(workspace) ? workspace.wikiFolderLocation : undefined,
function: 'initializeWorkspaceView',
});
const checkResult = await wikiService.checkWikiExist(workspace, { shouldBeMainWiki, showDialog: true });
if (checkResult !== true) {
logger.warn('checkWikiExist found invalid wiki', {
workspaceId: workspace.id,
checkResult,
shouldBeMainWiki,
wikiFolderLocation: isWikiWorkspace(workspace) ? workspace.wikiFolderLocation : undefined,
function: 'initializeWorkspaceView',
});
return;
}
logger.info('wiki validation passed', {
workspaceId: workspace.id,
function: 'initializeWorkspaceView',
});
logger.debug('Initializing workspace', {
workspaceId: workspace.id,
options: JSON.stringify(options),
function: 'initializeWorkspaceView',
});
if (followHibernateSettingWhenInit) {
const hibernateUnusedWorkspacesAtLaunch = await this.preferenceService.get('hibernateUnusedWorkspacesAtLaunch');
if ((hibernateUnusedWorkspacesAtLaunch || (isWikiWorkspace(workspace) && workspace.hibernateWhenUnused)) && !workspace.active) {
logger.debug(
`initializeWorkspaceView() quit because ${
JSON.stringify({
followHibernateSettingWhenInit,
'workspace.hibernateWhenUnused': isWikiWorkspace(workspace) ? workspace.hibernateWhenUnused : false,
'workspace.active': workspace.active,
hibernateUnusedWorkspacesAtLaunch,
})
}`,
);
if (isWikiWorkspace(workspace) && !workspace.hibernated) {
await workspaceService.update(workspace.id, { hibernated: true });
}
return;
}
}
const syncGitWhenInitializeWorkspaceView = async () => {
if (!isWikiWorkspace(workspace)) return;
const { wikiFolderLocation, gitUrl: githubRepoUrl, storageService, isSubWiki } = workspace;
// we are using syncWikiIfNeeded that handles recursive sync for all subwiki, so we only need to pass main wiki to it in this method.
if (isSubWiki) {
return;
}
// get sync process ready
try {
if (workspace.syncOnStartup && storageService !== SupportedStorageServices.local && syncImmediately) {
// check synced wiki should have githubRepoUrl
if (typeof githubRepoUrl !== 'string') {
throw new TypeError(`githubRepoUrl is undefined in initializeAllWorkspaceView when init ${wikiFolderLocation}`);
}
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
if (mainWindow === undefined) {
throw new Error(i18n.t(`Error.MainWindowMissing`));
}
const userInfo = await this.authService.getStorageServiceUserInfo(storageService);
if (userInfo?.accessToken) {
// sync in non-blocking way
void container.get<ISyncService>(serviceIdentifier.Sync).syncWikiIfNeeded(workspace);
} else {
// user not login into Github or something else
void dialog.showMessageBox(mainWindow, {
title: i18n.t('Dialog.StorageServiceUserInfoNoFound'),
message: i18n.t('Dialog.StorageServiceUserInfoNoFoundDetail'),
buttons: ['OK'],
cancelId: 0,
defaultId: 0,
});
}
}
} catch (error) {
logger.error('wikiStartup sync failed', {
function: 'initializeAllWorkspaceView',
error,
});
}
};
const addViewWhenInitializeWorkspaceView = async (): Promise<void> => {
// adding WebContentsView for each workspace
// skip view initialize if this is a sub wiki
if (isWikiWorkspace(workspace) && workspace.isSubWiki) {
return;
}
// if we run this due to RestartService, then skip the view adding and the while loop, because the workspaceMetadata.isLoading will be false, because addViewForAllBrowserViews will return before it run loadInitialUrlWithCatch
if (await container.get<IViewService>(serviceIdentifier.View).alreadyHaveView(workspace)) {
logger.debug('Skip because alreadyHaveView');
return;
}
// Create browserView, and if user want a tidgi mini window, we also create a new window for that
await this.addViewForAllBrowserViews(workspace);
if (isNew && options.from === WikiCreationMethod.Create) {
const view = container.get<IViewService>(serviceIdentifier.View).getView(workspace.id, WindowNames.main);
if (view !== undefined) {
// if is newly created wiki, we set the language as user preference
const currentLanguage = await this.preferenceService.get('language');
const contextService = container.get<IContextService>(serviceIdentifier.Context);
const tiddlywikiLanguagesMap = await contextService.get('tiddlywikiLanguagesMap');
const tiddlywikiLanguageName = tiddlywikiLanguagesMap[currentLanguage];
if (tiddlywikiLanguageName === undefined) {
const errorMessage = `When creating new wiki, and switch to language "${currentLanguage}", there is no corresponding tiddlywiki language registered`;
logger.error(errorMessage, {
tiddlywikiLanguagesMap,
});
} else {
logger.debug('setting wiki language on init', { function: 'initializeWorkspaceView', currentLanguage, tiddlywikiLanguageName });
await container.get<IWikiService>(serviceIdentifier.Wiki).setWikiLanguage(workspace.id, tiddlywikiLanguageName);
}
}
}
};
logger.debug('calling wikiStartup', {
function: 'initializeWorkspaceView',
});
await Promise.all([
container.get<IWikiService>(serviceIdentifier.Wiki).wikiStartup(workspace),
addViewWhenInitializeWorkspaceView(),
]);
void syncGitWhenInitializeWorkspaceView();
}
public async addViewForAllBrowserViews(workspace: IWorkspace): Promise<void> {
const mainTask = container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.main);
// For tidgi mini window, decide which workspace to show based on preferences
const tidgiMiniWindowTask = (async () => {
const tidgiMiniWindow = await this.preferenceService.get('tidgiMiniWindow');
if (!tidgiMiniWindow) {
return;
}
const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspace.id);
// If syncing with main window, use the current workspace
if (shouldSync) {
await container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.tidgiMiniWindow);
return;
}
// If not syncing and a fixed workspace is set, only add view if this IS the fixed workspace
if (targetWorkspaceId && workspace.id === targetWorkspaceId) {
await container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.tidgiMiniWindow);
}
// If not syncing and no fixed workspace is set, don't add any view (user needs to select one)
})();
await Promise.all([mainTask, tidgiMiniWindowTask]);
}
public async openWorkspaceWindowWithView(workspace: IWorkspace, configs?: { uri?: string }): Promise<void> {
const uriToOpen = configs?.uri ?? (isWikiWorkspace(workspace) ? workspace.lastUrl : undefined) ?? (isWikiWorkspace(workspace) ? workspace.homeUrl : undefined);
logger.debug('Open workspace in new window. uriToOpen here will overwrite the decision in initializeWorkspaceViewHandlersAndLoad.', {
id: workspace.id,
uriToOpen,
function: 'openWorkspaceWindowWithView',
});
const browserWindow = await container.get<IWindowService>(serviceIdentifier.Window).open(WindowNames.secondary, undefined, { multiple: true }, true);
const sharedWebPreferences = await container.get<IViewService>(serviceIdentifier.View).getSharedWebPreferences(workspace);
const view = await container.get<IViewService>(serviceIdentifier.View).createViewAddToWindow(workspace, browserWindow, sharedWebPreferences, WindowNames.secondary);
logger.debug('View created in new window.', { id: workspace.id, uriToOpen, function: 'openWorkspaceWindowWithView' });
await container.get<IViewService>(serviceIdentifier.View).initializeWorkspaceViewHandlersAndLoad(browserWindow, view, {
workspace,
sharedWebPreferences,
windowName: WindowNames.secondary,
uri: uriToOpen,
});
}
public async updateLastUrl(
workspaceID: string,
view: Electron.CrossProcessExports.WebContentsView | undefined = container.get<IViewService>(serviceIdentifier.View).getView(workspaceID, WindowNames.main),
): Promise<void> {
if (view?.webContents) {
const currentUrl = view.webContents.getURL();
logger.debug('Updating lastUrl for workspace', {
workspaceID,
currentUrl,
function: 'updateLastUrl',
});
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).update(workspaceID, {
lastUrl: currentUrl,
});
} else {
logger.warn(`Can't update lastUrl for workspace ${workspaceID}, view is not found`);
}
}
public async openUrlInWorkspace(url: string, id: string): Promise<void> {
if (typeof id === 'string' && id.length > 0) {
// if id is defined, switch to that workspace
await this.setActiveWorkspaceView(id);
await container.get<IMenuService>(serviceIdentifier.MenuService).buildMenu();
// load url in the current workspace
const activeWorkspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace();
if (activeWorkspace !== undefined) {
await this.loadURL(url, activeWorkspace.id);
}
}
}
public async setWorkspaceView(workspaceID: string, workspaceOptions: IWorkspace): Promise<void> {
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).set(workspaceID, workspaceOptions);
container.get<IViewService>(serviceIdentifier.View).setViewsAudioPref();
container.get<IViewService>(serviceIdentifier.View).setViewsNotificationsPref();
}
public async setWorkspaceViews(workspaces: Record<string, IWorkspace>): Promise<void> {
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).setWorkspaces(workspaces);
container.get<IViewService>(serviceIdentifier.View).setViewsAudioPref();
container.get<IViewService>(serviceIdentifier.View).setViewsNotificationsPref();
}
public async wakeUpWorkspaceView(workspaceID: string): Promise<void> {
const workspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(workspaceID);
if (workspace !== undefined) {
// First, update workspace state and start wiki server
await Promise.all([
container.get<IWorkspaceService>(serviceIdentifier.Workspace).update(workspaceID, {
hibernated: false,
}),
this.authService.getUserName(workspace).then(userName => container.get<IWikiService>(serviceIdentifier.Wiki).startWiki(workspaceID, userName)),
]);
// Then add view after wiki server is ready and workspace is marked as not hibernated
await this.addViewForAllBrowserViews(workspace);
}
}
public async hibernateWorkspaceView(workspaceID: string): Promise<void> {
const workspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(workspaceID);
logger.debug('hibernating workspace', {
function: 'hibernateWorkspaceView',
workspaceID,
active: String(workspace?.active),
});
if (workspace !== undefined && !workspace.active) {
await Promise.all([
container.get<IWikiService>(serviceIdentifier.Wiki).stopWiki(workspaceID),
container.get<IWorkspaceService>(serviceIdentifier.Workspace).update(workspaceID, {
hibernated: true,
}),
]);
container.get<IViewService>(serviceIdentifier.View).removeAllViewOfWorkspace(workspaceID, true);
}
}
public async setActiveWorkspaceView(nextWorkspaceID: string): Promise<void> {
logger.debug('setActiveWorkspaceView', { nextWorkspaceID });
const [oldActiveWorkspace, newWorkspace] = await Promise.all([
container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace(),
container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(nextWorkspaceID),
]);
if (newWorkspace === undefined) {
throw new Error(`Workspace id ${nextWorkspaceID} does not exist. When setActiveWorkspaceView().`);
}
logger.debug(
`Set active workspace oldActiveWorkspace.id: ${oldActiveWorkspace?.id ?? 'undefined'} nextWorkspaceID: ${nextWorkspaceID} newWorkspace.isSubWiki ${
String(
isWikiWorkspace(newWorkspace) ? newWorkspace.isSubWiki : false,
)
}`,
);
// Handle page workspace - only update workspace state, no view management needed
if (newWorkspace.pageType) {
logger.debug(`${nextWorkspaceID} is a page workspace, only updating workspace state.`);
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id);
// Hide old workspace view if switching from a regular workspace
if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID && !oldActiveWorkspace.pageType) {
await this.hideWorkspaceView(oldActiveWorkspace.id);
if (isWikiWorkspace(oldActiveWorkspace) && oldActiveWorkspace.hibernateWhenUnused) {
await this.hibernateWorkspaceView(oldActiveWorkspace.id);
}
}
return;
}
if (isWikiWorkspace(newWorkspace) && newWorkspace.isSubWiki && typeof newWorkspace.mainWikiID === 'string') {
logger.debug(`${nextWorkspaceID} is a subwiki, set its main wiki ${newWorkspace.mainWikiID} to active instead.`);
await this.setActiveWorkspaceView(newWorkspace.mainWikiID);
// Open the first tag if available
if (newWorkspace.tagNames.length > 0) {
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagNames[0]]);
}
return;
}
// later process will use the current active workspace
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id);
// Schedule hibernation of old workspace before waking up new workspace
// This prevents blocking when wakeUp calls loadURL
if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) {
void this.hibernateWorkspace(oldActiveWorkspace.id);
}
if (isWikiWorkspace(newWorkspace) && newWorkspace.hibernated) {
await this.wakeUpWorkspaceView(nextWorkspaceID);
}
// fix #556 and #593: Ensure wiki worker is started before setting active view. When switching to a wiki workspace that doesn't have a view yet, the view service will create one and immediately try to loadURL. If the wiki worker hasn't started, loadURL will hang forever waiting for the IPC server that never comes online. This must happen before `setActiveViewForAllBrowserViews` to ensure the worker is ready when view is created.
if (isWikiWorkspace(newWorkspace) && !newWorkspace.hibernated) {
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
const worker = wikiService.getWorker(nextWorkspaceID);
if (worker === undefined) {
const userName = await this.authService.getUserName(newWorkspace);
await wikiService.startWiki(nextWorkspaceID, userName);
}
}
try {
// Schedule hibernation of old workspace before loading new workspace
// This prevents blocking on loadURL and allows faster UI updates
if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) {
void this.hibernateWorkspace(oldActiveWorkspace.id);
}
await container.get<IViewService>(serviceIdentifier.View).setActiveViewForAllBrowserViews(nextWorkspaceID);
await this.realignActiveWorkspace(nextWorkspaceID);
} catch (error) {
logger.error('setActiveWorkspaceView error', {
function: 'setActiveWorkspaceView',
error,
});
throw error;
}
}
/**
* This promise could be `void` to let go, not blocking other logic like switch to new workspace, and hibernate workspace on background.
*/
private async hibernateWorkspace(workspaceID: string): Promise<void> {
const workspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(workspaceID);
if (workspace === undefined) return;
await this.hideWorkspaceView(workspaceID);
if (isWikiWorkspace(workspace) && workspace.hibernateWhenUnused) {
await this.hibernateWorkspaceView(workspaceID);
}
}
public async clearActiveWorkspaceView(idToDeactivate?: string): Promise<void> {
const activeWorkspace = idToDeactivate === undefined
? await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace()
: await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(idToDeactivate);
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).clearActiveWorkspace(activeWorkspace?.id);
if (activeWorkspace === undefined) {
return;
}
if (isWikiWorkspace(activeWorkspace) && activeWorkspace.isSubWiki && typeof activeWorkspace.mainWikiID === 'string') {
logger.debug(`${activeWorkspace.id} is a subwiki, set its main wiki ${activeWorkspace.mainWikiID} to deactivated instead.`, { function: 'clearActiveWorkspaceView' });
await this.clearActiveWorkspaceView(activeWorkspace.mainWikiID);
return;
}
try {
await this.hideWorkspaceView(activeWorkspace.id);
} catch (error) {
logger.error('setActiveWorkspaceView error', {
function: 'clearActiveWorkspaceView',
error,
});
throw error;
}
if (isWikiWorkspace(activeWorkspace) && activeWorkspace.hibernateWhenUnused) {
await this.hibernateWorkspaceView(activeWorkspace.id);
}
}
public async removeWorkspaceView(workspaceID: string): Promise<void> {
container.get<IViewService>(serviceIdentifier.View).removeAllViewOfWorkspace(workspaceID, true);
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
// if there's only one workspace left, clear all
if ((await container.get<IWorkspaceService>(serviceIdentifier.Workspace).countWorkspaces()) === 1) {
if (mainWindow !== undefined) {
mainWindow.setTitle(app.name);
}
} else if (
(await container.get<IWorkspaceService>(serviceIdentifier.Workspace).countWorkspaces()) > 1 &&
(await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(workspaceID))?.active === true
) {
const previousWorkspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getPreviousWorkspace(workspaceID);
if (previousWorkspace !== undefined) {
await this.setActiveWorkspaceView(previousWorkspace.id);
}
}
}
public async restartWorkspaceViewService(id?: string): Promise<void> {
const workspaceToRestart = id === undefined
? await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace()
: await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(id);
if (workspaceToRestart === undefined) {
logger.warn(`restartWorkspaceViewService: no workspace ${id ?? 'id undefined'} to restart`);
return;
}
if (isWikiWorkspace(workspaceToRestart) && workspaceToRestart.isSubWiki) {
const mainWikiIDToRestart = workspaceToRestart.mainWikiID;
if (mainWikiIDToRestart) {
await this.restartWorkspaceViewService(mainWikiIDToRestart);
}
return;
}
logger.info(`Restarting workspace ${workspaceToRestart.id}`);
await this.updateLastUrl(workspaceToRestart.id);
// start restarting. Set isLoading to false, and it will be set by some callback elsewhere to true.
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).updateMetaData(workspaceToRestart.id, {
didFailLoadErrorMessage: null,
isLoading: false,
isRestarting: true,
});
await container.get<IWikiService>(serviceIdentifier.Wiki).stopWiki(workspaceToRestart.id);
await this.initializeWorkspaceView(workspaceToRestart, { syncImmediately: false });
if (await container.get<IWorkspaceService>(serviceIdentifier.Workspace).workspaceDidFailLoad(workspaceToRestart.id)) {
logger.warn('skip because workspaceDidFailLoad', { function: 'restartWorkspaceViewService' });
return;
}
await container.get<IViewService>(serviceIdentifier.View).reloadViewsWebContents(workspaceToRestart.id);
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.generalNotification, workspaceToRestart.id, [
i18n.t('ContextMenu.RestartServiceComplete'),
]);
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).updateMetaData(workspaceToRestart.id, { isRestarting: false });
}
public async restartAllWorkspaceView(): Promise<void> {
const workspaces = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getWorkspacesAsList();
await Promise.all(
workspaces.map(async (workspace) => {
await Promise.all(
[WindowNames.main, WindowNames.tidgiMiniWindow].map(async (windowName) => {
const view = container.get<IViewService>(serviceIdentifier.View).getView(workspace.id, windowName);
if (view !== undefined) {
await container.get<IViewService>(serviceIdentifier.View).loadUrlForView(workspace, view);
}
}),
);
}),
);
}
public async clearBrowsingDataWithConfirm(): Promise<void> {
const availableWindowToShowDialog = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.preferences) ??
container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
if (availableWindowToShowDialog !== undefined) {
await dialog
.showMessageBox(availableWindowToShowDialog, {
type: 'question',
buttons: [i18n.t('Preference.ResetNow'), i18n.t('Cancel')],
message: i18n.t('Preference.ClearBrowsingDataMessage'),
cancelId: 1,
})
.then(async ({ response }) => {
if (response === 0) {
await this.clearBrowsingData();
}
})
.catch(console.error);
}
}
public async clearBrowsingData(): Promise<void> {
await session.defaultSession.clearStorageData();
const workspaces = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getWorkspaces();
await Promise.all(
Object.keys(workspaces).map(async (id) => {
await session.fromPartition(`persist:${id}`).clearStorageData();
}),
);
// shared session
await session.fromPartition('persist:shared').clearStorageData();
}
public async loadURL(url: string, id: string | undefined): Promise<void> {
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
const activeWorkspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace();
const activeWorkspaceID = id ?? activeWorkspace?.id;
if (mainWindow !== undefined && activeWorkspaceID !== undefined) {
const view = container.get<IViewService>(serviceIdentifier.View).getView(activeWorkspaceID, WindowNames.main);
if (view?.webContents) {
view.webContents.focus();
await view.webContents.loadURL(url);
}
}
}
/**
* Seems this is for relocating WebContentsView in the electron window
*/
public async realignActiveWorkspace(id?: string): Promise<void> {
// this function only call browserView.setBounds
// do not attempt to recall browserView.webContents.focus()
// as it breaks page focus (cursor, scroll bar not visible)
await this.realignActiveWorkspaceView(id);
try {
await container.get<IMenuService>(serviceIdentifier.MenuService).buildMenu();
} catch (error) {
logger.error('realignActiveWorkspace buildMenu error', {
function: 'realignActiveWorkspace',
error,
});
throw error;
}
}
private async realignActiveWorkspaceView(id?: string): Promise<void> {
const workspaceToRealign = id === undefined
? await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace()
: await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(id);
logger.debug('activeWorkspace.id', {
workspaceId: workspaceToRealign?.id ?? 'undefined',
stack: new Error('stack').stack?.replace('Error:', ''),
function: 'realignActiveWorkspaceView',
});
if (workspaceToRealign && isWikiWorkspace(workspaceToRealign) && workspaceToRealign.isSubWiki) {
logger.debug('skip because subwiki; realign main wiki instead', { workspaceId: workspaceToRealign.id, function: 'realignActiveWorkspaceView' });
if (workspaceToRealign.mainWikiID) {
await this.realignActiveWorkspaceView(workspaceToRealign.mainWikiID);
}
return;
}
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
const tidgiMiniWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.tidgiMiniWindow);
logger.info(
`realignActiveWorkspaceView: id ${workspaceToRealign?.id ?? 'undefined'}`,
);
if (workspaceToRealign === undefined) {
logger.warn('realignActiveWorkspaceView: no active workspace');
return;
}
if (mainWindow === undefined && tidgiMiniWindow === undefined) {
logger.warn('realignActiveWorkspaceView: no active window');
return;
}
const tasks = [];
if (mainWindow === undefined) {
logger.warn(`realignActiveWorkspaceView: no mainBrowserViewWebContent, skip main window for ${workspaceToRealign.id}.`);
} else {
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(mainWindow, workspaceToRealign.id, WindowNames.main));
logger.debug(`realignActiveWorkspaceView: realign main window for ${workspaceToRealign.id}.`);
}
if (tidgiMiniWindow === undefined) {
logger.info(`realignActiveWorkspaceView: no tidgiMiniWindowBrowserViewWebContent, skip tidgi mini window for ${workspaceToRealign.id}.`);
} else {
// For tidgi mini window, decide which workspace to show based on preferences
const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceToRealign.id);
if (shouldSync) {
// Sync mode - realign with the same workspace as main window
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, workspaceToRealign.id, WindowNames.tidgiMiniWindow));
} else if (targetWorkspaceId) {
// Fixed workspace mode - only realign if the workspace being realigned is the fixed workspace
if (workspaceToRealign.id === targetWorkspaceId) {
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, targetWorkspaceId, WindowNames.tidgiMiniWindow));
} else {
// Main window is realigning a different workspace, but tidgi mini window stays on fixed workspace
// Still need to realign the fixed workspace to ensure it's displayed correctly
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, targetWorkspaceId, WindowNames.tidgiMiniWindow));
}
}
// If not syncing and no fixed workspace, don't realign tidgi mini window
}
await Promise.all(tasks);
}
private async hideWorkspaceView(idToDeactivate: string): Promise<void> {
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
const tidgiMiniWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.tidgiMiniWindow);
const tasks = [];
if (mainWindow === undefined) {
logger.warn(`hideWorkspaceView: no mainBrowserWindow, skip main window browserView.`);
} else {
logger.info(`hideWorkspaceView: hide main window browserView.`);
tasks.push(container.get<IViewService>(serviceIdentifier.View).hideView(mainWindow, WindowNames.main, idToDeactivate));
}
if (tidgiMiniWindow === undefined) {
logger.debug(`hideWorkspaceView: no tidgiMiniWindowBrowserWindow, skip tidgi mini window browserView.`);
} else {
// For tidgi mini window, only hide if syncing with main window OR if this is the fixed workspace being deactivated
const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(idToDeactivate);
// Only hide tidgi mini window view if:
// 1. Syncing with main window (should hide when main window hides)
// 2. OR the workspace being hidden is the fixed workspace (rare case, but should be handled)
if (shouldSync || idToDeactivate === targetWorkspaceId) {
logger.info(`hideWorkspaceView: hide tidgi mini window browserView.`);
tasks.push(container.get<IViewService>(serviceIdentifier.View).hideView(tidgiMiniWindow, WindowNames.tidgiMiniWindow, idToDeactivate));
} else {
logger.debug(`hideWorkspaceView: skip hiding tidgi mini window browserView (fixed workspace: ${targetWorkspaceId || 'none'}).`);
}
}
await Promise.all(tasks);
logger.info(`hideWorkspaceView: done.`);
}
}