import { app } from 'electron'; import fsExtra from 'fs-extra'; import { injectable } from 'inversify'; import { Jimp } from 'jimp'; import { isEqual, mapValues, pickBy } from 'lodash'; import { nanoid } from 'nanoid'; import path from 'path'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { WikiChannel } from '@/constants/channels'; import { defaultCreatedPageTypes, PageType } from '@/constants/pageTypes'; import { DELAY_MENU_REGISTER } from '@/constants/parameters'; import { getDefaultTidGiUrl } from '@/constants/urls'; import type { IAuthenticationService } from '@services/auth/interface'; import { container } from '@services/container'; import type { IDatabaseService } from '@services/database/interface'; import { logger } from '@services/libs/log'; import type { IMenuService } from '@services/menu/interface'; import serviceIdentifier from '@services/serviceIdentifier'; import type { IWikiService } from '@services/wiki/interface'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; import { extractSyncableConfig, mergeWithSyncedConfig, readTidgiConfig, readTidgiConfigSync, removeSyncableFields, writeTidgiConfig } from '../database/configSetting'; import type { IDedicatedWorkspace, INewWikiWorkspaceConfig, IWikiWorkspace, IWorkspace, IWorkspaceMetaData, IWorkspaceService, IWorkspacesWithMetadata, IWorkspaceWithMetadata, } from './interface'; import { isWikiWorkspace, wikiWorkspaceDefaultValues } from './interface'; import { registerMenu } from './registerMenu'; import { workspaceSorter } from './utilities'; @injectable() export class Workspace implements IWorkspaceService { /** * Record from workspace id to workspace settings */ private workspaces: Record | undefined; public workspaces$ = new BehaviorSubject(undefined); constructor() { setTimeout(() => { void registerMenu(); }, DELAY_MENU_REGISTER); } public getWorkspacesWithMetadata(): IWorkspacesWithMetadata { return mapValues(this.getWorkspacesSync(), (workspace: IWorkspace, id): IWorkspaceWithMetadata => { // Only wiki workspaces can have metadata, dedicated workspaces are filtered out if (!isWikiWorkspace(workspace)) { return { ...workspace, metadata: this.getMetaDataSync(id) } as IWorkspaceWithMetadata; } return { ...workspace, metadata: this.getMetaDataSync(id) }; }); } public updateWorkspaceSubject(): void { this.workspaces$.next(this.getWorkspacesWithMetadata()); } /** * Update items like "activate workspace1" or "open devtool in workspace1" in the menu */ private async updateWorkspaceMenuItems(): Promise { const newMenuItems = (await this.getWorkspacesAsList()).flatMap((workspace, index) => [ { label: (): string => workspace.name || `Workspace ${index + 1}`, id: workspace.id, type: 'checkbox' as const, checked: () => workspace.active, click: async (): Promise => { const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); await workspaceViewService.setActiveWorkspaceView(workspace.id); // manually update menu since we have alter the active workspace const menuService = container.get(serviceIdentifier.MenuService); await menuService.buildMenu(); }, accelerator: `CmdOrCtrl+${index + 1}`, }, ]); const menuService = container.get(serviceIdentifier.MenuService); await menuService.insertMenu('Workspaces', newMenuItems, undefined, undefined, 'updateWorkspaceMenuItems'); } /** * load workspaces in sync, and ensure it is an Object */ private getInitWorkspacesForCache(): Record { const databaseService = container.get(serviceIdentifier.Database); const workspacesFromDisk = databaseService.getSetting(`workspaces`) ?? {}; logger.debug('getInitWorkspacesForCache: Loading workspaces from settings.json', { workspaceIds: typeof workspacesFromDisk === 'object' ? Object.keys(workspacesFromDisk) : 'invalid', }); if (typeof workspacesFromDisk === 'object' && !Array.isArray(workspacesFromDisk)) { const sanitizedWorkspaces: Record = {}; const oldToNewIdMap = new Map(); const workspaceEntries = Object.entries(pickBy(workspacesFromDisk, (value) => !!value)); for (const [storedID, workspace] of workspaceEntries) { const sanitized = this.sanitizeWorkspace(workspace, true); const normalizedID = sanitized.id; oldToNewIdMap.set(storedID, normalizedID); if (normalizedID in sanitizedWorkspaces) { logger.error('getInitWorkspacesForCache: Duplicate workspace id after config migration', { storedID, normalizedID, }); continue; } sanitizedWorkspaces[normalizedID] = sanitized; logger.debug('getInitWorkspacesForCache: Sanitized workspace', { storedID, normalizedID, hasName: 'name' in sanitized, name: sanitized.name, hasPort: 'port' in sanitized, port: (sanitized as { port?: number }).port, }); } Object.values(sanitizedWorkspaces).forEach((workspace) => { if (!isWikiWorkspace(workspace) || !workspace.isSubWiki || !workspace.mainWikiID) { return; } const remappedMainWikiID = oldToNewIdMap.get(workspace.mainWikiID); if (remappedMainWikiID && remappedMainWikiID !== workspace.mainWikiID) { workspace.mainWikiID = remappedMainWikiID; } }); const result = sanitizedWorkspaces; return result; } return {}; } public async getWorkspaces(): Promise> { return this.getWorkspacesSync(); } private getWorkspacesSync(): Record { // store in memory to boost performance if (this.workspaces === undefined) { this.workspaces = this.getInitWorkspacesForCache(); } return this.workspaces; } public async countWorkspaces(): Promise { return Object.keys(this.getWorkspacesSync()).length; } /** * Get sorted workspace list * Async so proxy type is async */ public async getWorkspacesAsList(): Promise { return Object.values(this.getWorkspacesSync()).sort(workspaceSorter); } /** * Get sorted workspace list * Sync for internal use */ private getWorkspacesAsListSync(): IWorkspace[] { return Object.values(this.getWorkspacesSync()).sort(workspaceSorter); } public async getSubWorkspacesAsList(workspaceID: string): Promise { const workspace = this.getSync(workspaceID); if (workspace === undefined || !isWikiWorkspace(workspace)) return []; if (workspace.isSubWiki) return []; return this.getWorkspacesAsListSync().filter((w): w is IWikiWorkspace => isWikiWorkspace(w) && w.mainWikiID === workspaceID).sort(workspaceSorter); } public getSubWorkspacesAsListSync(workspaceID: string): IWikiWorkspace[] { const workspace = this.getSync(workspaceID); if (workspace === undefined || !isWikiWorkspace(workspace)) return []; if (workspace.isSubWiki) return []; return this.getWorkspacesAsListSync().filter((w): w is IWikiWorkspace => isWikiWorkspace(w) && w.mainWikiID === workspaceID).sort(workspaceSorter); } public async get(id: string): Promise { return this.getSync(id); } private getSync(id: string): IWorkspace | undefined { const workspaces = this.getWorkspacesSync(); if (id in workspaces) { return workspaces[id]; } // Try find with lowercased key. sometimes user will use id that is all lowercased. Because tidgi:// url is somehow lowercased. const foundKey = Object.keys(workspaces).find((key) => key.toLowerCase() === id.toLowerCase()); return foundKey ? workspaces[foundKey] : undefined; } public get$(id: string): Observable { return this.workspaces$.pipe(map((workspaces) => workspaces?.[id])); } public async set(id: string, workspace: IWorkspace, immediate?: boolean, skipUiUpdate = false): Promise { const workspaces = this.getWorkspacesSync(); const workspaceToSave = this.sanitizeWorkspace(workspace); await this.reactBeforeWorkspaceChanged(workspaceToSave); // Capture previous in-memory state before overwriting, for precise syncable-field diffing below. const previousWorkspace = workspaces[id]; // Update memory cache with full workspace data (including syncable fields) workspaces[id] = workspaceToSave; // Write tidgi.config.json only when syncable fields actually changed. // Compare previous vs new in-memory syncable config using extractSyncableConfig (which already // knows the full field list and default values), so non-syncable updates like lastNodeJSArgv or // hibernated never trigger a file write. if (isWikiWorkspace(workspaceToSave)) { const newSyncableConfig = extractSyncableConfig(workspaceToSave); const previousSyncableConfig = previousWorkspace !== undefined && isWikiWorkspace(previousWorkspace) ? extractSyncableConfig(previousWorkspace) : undefined; // Write when: first time saving this workspace (no previous state), or any syncable field changed. const syncableChanged = previousSyncableConfig === undefined || !isEqual(newSyncableConfig, previousSyncableConfig); if (syncableChanged) { try { await writeTidgiConfig(workspaceToSave.wikiFolderLocation, newSyncableConfig); } catch (error) { logger.warn('Failed to write tidgi.config.json', { workspaceId: id, error: (error as Error).message, }); } } } // Persist only this workspace to settings.json, stripping syncable fields when tidgi.config.json exists. // Updating a single entry avoids iterating all workspaces on every system-internal update (e.g. hibernated, lastNodeJSArgv). const databaseService = container.get(serviceIdentifier.Database); const currentSettingsWorkspaces = databaseService.getSetting('workspaces') ?? {}; currentSettingsWorkspaces[id] = isWikiWorkspace(workspaceToSave) && readTidgiConfigSync(workspaceToSave.wikiFolderLocation) !== undefined ? removeSyncableFields(workspaceToSave) as IWorkspace : workspaceToSave; databaseService.setSetting('workspaces', currentSettingsWorkspaces); if (immediate === true) { await databaseService.immediatelyStoreSettingsToFile(); } // update subject so ui can react to it (can be skipped for batch operations) if (!skipUiUpdate) { this.updateWorkspaceSubject(); // menu is mostly invisible, so we don't need to update it immediately void this.updateWorkspaceMenuItems(); } } public async update(id: string, workspaceSetting: Partial, immediate?: boolean): Promise { const workspace = this.getSync(id); if (workspace === undefined) { logger.error(`Could not update workspace ${id} because it does not exist`); return; } await this.set(id, { ...workspace, ...workspaceSetting }, immediate); } public async setWorkspaces(newWorkspaces: Record): Promise { // Process all workspaces without triggering UI updates for each one const ids = Object.keys(newWorkspaces); for (let index = 0; index < ids.length; index++) { const id = ids[index]; const isLast = index === ids.length - 1; // Skip UI update for all but the last workspace await this.set(id, newWorkspaces[id], false, !isLast); } } public getMainWorkspace(subWorkspace: IWorkspace): IWorkspace | undefined { if (!isWikiWorkspace(subWorkspace)) return undefined; const { mainWikiID, isSubWiki, mainWikiToLink } = subWorkspace; if (!isSubWiki) return undefined; if (mainWikiID) return this.getSync(mainWikiID); const mainWorkspace = this.getWorkspacesAsListSync().find( (workspaceToSearch) => isWikiWorkspace(workspaceToSearch) && mainWikiToLink === workspaceToSearch.wikiFolderLocation, ); return mainWorkspace; } /** * Pure function that make sure workspace setting is consistent, or doing migration across updates. * Also reads and merges syncable config from tidgi.config.json in wiki folder (only during initial load). * @param workspaceToSanitize User input workspace or loaded workspace, that may contains bad values * @param applySyncedConfig Whether to apply config from tidgi.config.json (should only be true during initial load) */ private sanitizeWorkspace(workspaceToSanitize: IWorkspace, applySyncedConfig = false): IWorkspace { // For dedicated workspaces (help, guide, agent), no sanitization needed if (!isWikiWorkspace(workspaceToSanitize)) { return workspaceToSanitize; } logger.debug('sanitizeWorkspace: Starting', { workspaceId: workspaceToSanitize.id, applySyncedConfig, hasName: 'name' in workspaceToSanitize, inputName: workspaceToSanitize.name, hasPort: 'port' in workspaceToSanitize, inputPort: workspaceToSanitize.port, wikiFolderLocation: workspaceToSanitize.wikiFolderLocation, }); // Read syncable config from tidgi.config.json if it exists // Only apply synced config during initial load, not during updates // (to avoid overwriting user's changes with old file content) let workspaceWithSyncedConfig = workspaceToSanitize; if (applySyncedConfig) { try { const syncedConfig = readTidgiConfigSync(workspaceToSanitize.wikiFolderLocation); if (syncedConfig) { logger.debug('sanitizeWorkspace: Loaded syncable config from tidgi.config.json', { workspaceId: workspaceToSanitize.id, fields: Object.keys(syncedConfig), syncedName: syncedConfig.name, }); workspaceWithSyncedConfig = mergeWithSyncedConfig(workspaceToSanitize, syncedConfig); } else { logger.debug('sanitizeWorkspace: No syncable config found in tidgi.config.json, will use defaults', { workspaceId: workspaceToSanitize.id, wikiFolderLocation: workspaceToSanitize.wikiFolderLocation, }); } } catch (error) { logger.warn('sanitizeWorkspace: Failed to read tidgi.config.json during sanitize', { workspaceId: workspaceToSanitize.id, error: (error as Error).message, }); } } const fixingValues: Partial = {}; // we add mainWikiID in creation, we fix this value for old existed workspaces if (workspaceWithSyncedConfig.isSubWiki && !workspaceWithSyncedConfig.mainWikiID) { const mainWorkspace = this.getMainWorkspace(workspaceWithSyncedConfig); if (mainWorkspace !== undefined) { fixingValues.mainWikiID = mainWorkspace.id; } } // Migrate old tagName (string) to tagNames (string[]) const legacyTagName = (workspaceWithSyncedConfig as { tagName?: string | null }).tagName; if (legacyTagName && (!workspaceWithSyncedConfig.tagNames || workspaceWithSyncedConfig.tagNames.length === 0)) { fixingValues.tagNames = [legacyTagName.replaceAll('\n', '')]; } // Migrate old workspaces without name: use folder name as default // This ensures backward compatibility when loading workspaces created before tidgi.config.json was used if (applySyncedConfig && (!workspaceWithSyncedConfig.name || workspaceWithSyncedConfig.name.trim() === '')) { const folderName = path.basename(workspaceWithSyncedConfig.wikiFolderLocation); fixingValues.name = folderName; logger.info('sanitizeWorkspace: Migrating old workspace name from folder', { workspaceId: workspaceWithSyncedConfig.id, wikiFolderLocation: workspaceWithSyncedConfig.wikiFolderLocation, migratedName: folderName, }); } // before 0.8.0, tidgi was loading http content, so lastUrl will be http protocol, but later we switch to tidgi:// protocol, so old value can't be used. if (workspaceWithSyncedConfig.lastUrl && !workspaceWithSyncedConfig.lastUrl.startsWith('tidgi')) { fixingValues.lastUrl = null; } if (typeof workspaceWithSyncedConfig.id === 'string' && workspaceWithSyncedConfig.id.trim() !== '' && workspaceWithSyncedConfig.id !== workspaceToSanitize.id) { fixingValues.id = workspaceWithSyncedConfig.id; fixingValues.homeUrl = getDefaultTidGiUrl(workspaceWithSyncedConfig.id); fixingValues.lastUrl = null; } if (workspaceWithSyncedConfig.homeUrl && !workspaceWithSyncedConfig.homeUrl.startsWith('tidgi')) { fixingValues.homeUrl = getDefaultTidGiUrl(workspaceWithSyncedConfig.id); } if (workspaceWithSyncedConfig.tokenAuth && !workspaceWithSyncedConfig.authToken) { const authService = container.get(serviceIdentifier.Authentication); fixingValues.authToken = authService.generateOneTimeAdminAuthTokenForWorkspaceSync(workspaceWithSyncedConfig.id); } // Apply defaults, then workspace data, then fixing values // This ensures all required fields exist even if missing from settings.json/tidgi.config.json const result = { ...wikiWorkspaceDefaultValues, ...workspaceWithSyncedConfig, ...fixingValues }; logger.debug('sanitizeWorkspace: Complete', { workspaceId: result.id, finalName: result.name, finalPort: result.port, hasSyncedConfig: workspaceWithSyncedConfig !== workspaceToSanitize, }); return result; } /** * Do some side effect before config change, update other services or filesystem, with new and old values * This happened after values sanitized * @param newWorkspaceConfig new workspace settings */ private async reactBeforeWorkspaceChanged(newWorkspaceConfig: IWorkspace): Promise { if (!isWikiWorkspace(newWorkspaceConfig)) return; const existedWorkspace = this.getSync(newWorkspaceConfig.id); const { id, tagNames } = newWorkspaceConfig; // when update tagNames of subWiki if ( existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && tagNames.length > 0 && JSON.stringify(existedWorkspace.tagNames) !== JSON.stringify(tagNames) ) { const { mainWikiToLink } = existedWorkspace; if (typeof mainWikiToLink !== 'string') { throw new TypeError( `mainWikiToLink is null in reactBeforeWorkspaceChanged when try to updateSubWikiPluginContent, workspacesID: ${id}\n${ JSON.stringify( this.workspaces, ) }`, ); } } } public async getByWikiFolderLocation(wikiFolderLocation: string): Promise { return (await this.getWorkspacesAsList()).find((workspace) => isWikiWorkspace(workspace) && workspace.wikiFolderLocation === wikiFolderLocation); } public async getByWikiName(wikiName: string): Promise { return (await this.getWorkspacesAsList()) .sort(workspaceSorter) .find((workspace) => workspace.name === wikiName); } public getPreviousWorkspace = async (id: string): Promise => { const workspaceList = await this.getWorkspacesAsList(); let currentWorkspaceIndex = 0; for (const [index, workspace] of workspaceList.entries()) { if (workspace.id === id) { currentWorkspaceIndex = index; break; } } if (currentWorkspaceIndex === 0) { return workspaceList.at(-1); } return workspaceList[currentWorkspaceIndex - 1]; }; public getNextWorkspace = async (id: string): Promise => { const workspaceList = await this.getWorkspacesAsList(); let currentWorkspaceIndex = 0; for (const [index, workspace] of workspaceList.entries()) { if (workspace.id === id) { currentWorkspaceIndex = index; break; } } if (currentWorkspaceIndex === workspaceList.length - 1) { return workspaceList[0]; } return workspaceList[currentWorkspaceIndex + 1]; }; public getActiveWorkspace = async (): Promise => { return this.getActiveWorkspaceSync(); }; public getActiveWorkspaceSync = (): IWorkspace | undefined => { return this.getWorkspacesAsListSync().find((workspace) => workspace.active); }; public getFirstWorkspace = async (): Promise => { return this.getFirstWorkspaceSync(); }; public getFirstWorkspaceSync = (): IWorkspace | undefined => { return this.getWorkspacesAsListSync()[0]; }; public async setActiveWorkspace(id: string, oldActiveWorkspaceID: string | undefined): Promise { const newWorkspace = this.getSync(id); if (!newWorkspace) { throw new Error(`Workspace with id ${id} not found`); } // active new one if (isWikiWorkspace(newWorkspace)) { await this.update(id, { active: true, hibernated: false }); } else { await this.update(id, { active: true }); } // de-active the other one if (oldActiveWorkspaceID !== id) { await this.clearActiveWorkspace(oldActiveWorkspaceID); } } public async clearActiveWorkspace(oldActiveWorkspaceID: string | undefined): Promise { // de-active the other one if (typeof oldActiveWorkspaceID === 'string') { await this.update(oldActiveWorkspaceID, { active: false }); } } /** * @param id workspace id * @param sourcePicturePath image path, could be an image in app's resource folder or temp folder, we will copy it into app data folder */ public async setWorkspacePicture(id: string, sourcePicturePath: string): Promise { const workspace = this.getSync(id); if (workspace === undefined) { throw new Error(`Try to setWorkspacePicture() but this workspace is not existed ${id}`); } const pictureID = nanoid(); if (workspace.picturePath === sourcePicturePath) { return; } const destinationPicturePath = path.join(app.getPath('userData'), 'pictures', `${pictureID}.png`) as `${string}.${string}`; const newImage = await Jimp.read(sourcePicturePath); await newImage.clone().resize({ w: 128, h: 128 }).write(destinationPicturePath); const currentPicturePath = this.getSync(id)?.picturePath; await this.update(id, { picturePath: destinationPicturePath, }); if (currentPicturePath) { try { await fsExtra.remove(currentPicturePath); } catch (error) { console.error(error); } } } public async removeWorkspacePicture(id: string): Promise { const workspace = this.getSync(id); if (workspace === undefined) { throw new Error(`Try to removeWorkspacePicture() but this workspace is not existed ${id}`); } if (workspace.picturePath) { await fsExtra.remove(workspace.picturePath); await this.set(id, { ...workspace, picturePath: null, }); } } public async remove(id: string): Promise { const workspaces = this.getWorkspacesSync(); if (id in workspaces) { delete workspaces[id]; const databaseService = container.get(serviceIdentifier.Database); const currentSettingsWorkspaces = databaseService.getSetting('workspaces') ?? {}; delete currentSettingsWorkspaces[id]; databaseService.setSetting('workspaces', currentSettingsWorkspaces); } else { throw new Error(`Try to remove workspace, but id ${id} does not exist`); } this.updateWorkspaceSubject(); void this.updateWorkspaceMenuItems(); } /** * Compute the order for a newly created wiki workspace so it appears at * the TOP of the regular-workspace section (before page workspaces). * Shifts all existing non-page workspaces down by 1 to make room. */ private async getNextInsertOrder(): Promise { const all = await this.getWorkspacesAsList(); const regularWorkspaces = all.filter(w => !w.pageType); if (regularWorkspaces.length === 0) return 0; const minOrder = Math.min(...regularWorkspaces.map(w => w.order)); // Shift every existing workspace's order up by 1 for (const ws of all) { if (ws.order >= minOrder) { await this.set(ws.id, { ...ws, order: ws.order + 1 }); } } return minOrder; } public async create(newWorkspaceConfig: INewWikiWorkspaceConfig): Promise { const { useTidgiConfig = true, ...workspaceConfig } = newWorkspaceConfig; const generatedID = nanoid(); let newID = generatedID; // Read existing config from tidgi.config.json if it exists (for re-adding an existing wiki) // Synced config should take priority over the passed config for syncable fields // This allows users to restore their previous settings when re-adding a wiki let existingConfig: Partial = {}; if (useTidgiConfig && workspaceConfig.wikiFolderLocation) { const syncedConfig = await readTidgiConfig(workspaceConfig.wikiFolderLocation); if (syncedConfig) { existingConfig = syncedConfig as Partial; const syncedWorkspaceID = (syncedConfig as { id?: unknown }).id; if (typeof syncedWorkspaceID === 'string' && syncedWorkspaceID.length > 0) { newID = syncedWorkspaceID; } logger.info('Applied synced config from tidgi.config.json during workspace creation', { wikiFolderLocation: workspaceConfig.wikiFolderLocation, syncedConfigFields: Object.keys(syncedConfig), }); } } if (await this.exists(newID)) { throw new Error(`Workspace id already exists: ${newID}`); } const newWorkspace: IWorkspace = { ...wikiWorkspaceDefaultValues, ...workspaceConfig, // Apply config from UI/form first ...existingConfig, // Then override with synced config (user's saved settings take priority) homeUrl: getDefaultTidGiUrl(newID), id: newID, lastUrl: null, lastNodeJSArgv: [], order: typeof workspaceConfig.order === 'number' ? workspaceConfig.order : await this.getNextInsertOrder(), picturePath: null, }; await this.set(newID, newWorkspace); logger.info(`[test-id-WORKSPACE_CREATED] Workspace created`, { workspaceId: newID, workspaceName: newWorkspace.name, wikiFolderLocation: newWorkspace.wikiFolderLocation }); return newWorkspace; } public async createPageWorkspace(pageType: PageType, order: number, active = false): Promise { const pageWorkspace: IDedicatedWorkspace = { id: pageType, name: pageType, pageType, active, order, picturePath: null, }; await this.set(pageType, pageWorkspace); return pageWorkspace; } /** * Initialize default page workspaces on first startup */ public async initializeDefaultPageWorkspaces(): Promise { try { const existingWorkspaces = await this.getWorkspacesAsList(); // Find the maximum order to place page workspaces after regular workspaces const maxWorkspaceOrder = existingWorkspaces.reduce((max, workspace) => workspace.pageType ? max : Math.max(max, workspace.order), -1); const currentOrder = maxWorkspaceOrder + 1; for (const [index, pageType] of defaultCreatedPageTypes.entries()) { // Check if page workspace already exists const existingPageWorkspace = existingWorkspaces.find(w => w.pageType === pageType); if (!existingPageWorkspace) { // Create page workspace with appropriate order await this.createPageWorkspace(pageType, currentOrder + index, false); logger.info(`Created default page workspace for ${pageType}`); } } logger.info('Successfully initialized default page workspaces'); } catch (error) { logger.error('Failed to initialize default page workspaces:', error); throw error; } } /** to keep workspace variables (meta) that * are not saved to disk * badge count, error, etc */ private metaData: Record> = {}; public getMetaData = async (id: string): Promise> => this.getMetaDataSync(id); private readonly getMetaDataSync = (id: string): Partial => this.metaData[id] ?? {}; public getAllMetaData = async (): Promise>> => this.metaData; public updateMetaData = async (id: string, options: Partial): Promise => { logger.debug('updateMetaData', { id, options, function: 'updateMetaData', }); this.metaData[id] = { ...this.metaData[id], ...options, }; this.updateWorkspaceSubject(); }; public async workspaceDidFailLoad(id: string): Promise { const workspaceMetaData = this.getMetaDataSync(id); return typeof workspaceMetaData.didFailLoadErrorMessage === 'string' && workspaceMetaData.didFailLoadErrorMessage.length > 0; } public async openWorkspaceTiddler(workspace: IWorkspace, title?: string): Promise { const { id: idToActive, pageType } = workspace; // Handle page workspace - no special action needed as routing handles the page display if (pageType) { return; } // Only handle wiki workspaces if (!isWikiWorkspace(workspace)) return; const { isSubWiki, mainWikiID, tagNames } = workspace; logger.log('debug', 'openWorkspaceTiddler', { workspace }); // If is main wiki, open the wiki, and open provided title, or simply switch to it if no title provided if (!isSubWiki && idToActive) { const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); const wikiService = container.get(serviceIdentifier.Wiki); // Always call setActiveWorkspaceView, even when clicking the already-active workspace. // When the window is restored from background the WebContentsView may be blank; // calling setActiveWorkspaceView forces showView() → remove+add+focus which triggers // a proper compositor repaint. When switching to a different workspace the logic is // unchanged. setActiveWorkspaceView is safe to call with the same ID (skips hibernation). await workspaceViewService.setActiveWorkspaceView(idToActive); if (title) { await wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, idToActive, [title]); } return; } // If is sub wiki, open the main wiki first and open the tag or provided title if (isSubWiki && mainWikiID) { const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); const wikiService = container.get(serviceIdentifier.Wiki); // Same reasoning as above — always call even if already active. await workspaceViewService.setActiveWorkspaceView(mainWikiID); // Use provided title, or first tag name, or nothing const subWikiTag = title ?? tagNames[0]; if (subWikiTag) { await wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, mainWikiID, [subWikiTag]); } } } public async exists(id: string): Promise { return Boolean(await this.get(id)); } /** * Get workspace token for Git Smart HTTP authentication */ public async getWorkspaceToken(workspaceId: string): Promise { const workspace = this.getSync(workspaceId); if (!workspace || !isWikiWorkspace(workspace) || !workspace.tokenAuth) { return undefined; } return workspace.authToken; } /** * Validate workspace token for Git Smart HTTP authentication */ public async validateWorkspaceToken(workspaceId: string, token: string): Promise { const workspaceToken = await this.getWorkspaceToken(workspaceId); if (!workspaceToken) { return false; } return workspaceToken === token; } }