TidGi-Desktop/src/services/workspaces/index.ts
lin onetwo 4c5e1d16c7
Fix/misc bug (#691)
* Use git service for backups and dynamic AI menus

Switch backup actions to call gitService.commitAndSync(commitOnly) so local backups work without remote auth and AI commit generation is triggered by omitting commitMessage. Make AI-related menu items always registered but use dynamic visibility/enabled checks (isAIEnabled) so they appear/disappear at runtime. Update menu item types/imports accordingly. Optimize workspace persistence to write only the single updated workspace to settings.json (stripping syncable fields when tidgi.config exists) instead of saving all workspaces; remove the old saveWorkspacesToSettings method. Add warnings/logging: warn if git worker observable is undefined, log/notify when cloud sync is skipped due to missing auth/gitUrl. Misc: remove a redundant debug log in tidgiConfig, remove native process monitoring startup call, include commitMessage for CommitDetailsPanel sync, and drop entriesFingerprint/debug noise from git log data.

* fix: avoid rewriting unchanged workspace config

* fix: separate plain and ai backup flows

* feat: add searchable settings views

* fix: narrow sync adaptor revision type

* chore: release tidgi-shared 0.1.3

* Preferences: unify Scheduled/Background tasks and fix skeleton nav

- Remove legacy background task UI/dialogs (use ScheduledTask unified system)
- Attach invisible anchors to skeleton placeholders so sidebar scrollIntoView works while loading
- Add English/zh translation keys for AddAlarm/AddHeartbeat
- Add requestIdleCallback polyfill (tests) and speed up to 0ms for tests
- Search: match English translations (txEn) in SearchResultsView

* Fix view zero-size on minimize; realign on restore/show

Guard view bounds from 0x0 content size to prevent BrowserView disappearing when window is minimized; fall back to safe offscreen size. Add window 'restore' and 'show' handlers to realign views. Add getMemoryUsage() in wiki worker and expose RSS/heap via getWorkersInfo; show worker memory in Developer Tools diagnostics.

* Fix background/quit behavior: synchronous close handler; platform-specific runOnBackground default; add getWindowMetaSync; add forced-exit timeout for before-quit cleanup

* Refactor preferences: add definitions and registry

Add a new structured preferences system: introduce definition schemas, typed item/section types, explicit section files, a registry (allSections/sectionById), side effects, action handlers, and helper builders (zodPreferencesSchema). Add custom preference UI items and registration (customItems, registerCustomSections) and tests validating schemas. Replace previous zod/settings schema files with the new definitions and make IPreferences an explicit TypeScript interface. Small UI updates: use LanguageSelectorItem and WikiUserNameItem in Guide and Help pages. Also remove net.isOnline() pre-checks from Git.commitAndSync and Git.forcePull to avoid false-negative network detections.

* Fix blank wiki after hide+reopen: re-attach orphaned views on realign

Root cause: three compounding issues when the main window is hidden and then re-shown via a second-instance shortcut click.

1. getView() now auto-removes stale entries whose webContents.isDestroyed() == true.
   This allows addView() / showWorkspaceView() to recreate destroyed views instead of
   silently skipping them (which left the new window blank).

2. realignView() now calls browserWindow.contentView.addChildView(view) before setBounds().
   If a view survived window destruction but became orphaned (detached from its parent
   BrowserWindow), re-attaching it makes it visible again.  addChildView is idempotent
   so normal re-entrant calls are safe.

3. window.open() existing-window branch now calls addViewForAllBrowserViews(activeWorkspace)
   before realignActiveWorkspace(). This ensures that any destroyed/missing view is
   recreated BEFORE the realign attempts to reposition it.  The call is a no-op when
   views already exist and are healthy.

* Fix blank WebContentsView after restoring hidden window (Windows bug)

Electron on Windows sometimes fails to repaint a WebContentsView that remains attached to a window that is hidden and then shown again. By unconditionally calling \
emoveChildView\ followed by \ddChildView\ during \
ealignView\ and \showView\, we force the Chromium compositor to reparent and paint the view correctly, ensuring the Wiki becomes visible as soon as the user restores the app from the background.

* Fix blank WebView on window restore: show view before realign, fix same-workspace click

Two root causes identified and fixed:

1. openWorkspaceTiddler silently skipped setActiveWorkspaceView when the user
   clicked the already-active workspace icon (guard: oldId !== newId).  The view
   was blank and clicking its icon did nothing.  Guard removed  setActiveWorkspaceView
   is now always called; it is safe with the same ID (hibernation guard is already
   correct for that case).

2. The 'show' event handler and window.open() existing-window path were calling
   realignActiveWorkspace() which only calls setBounds.  On Windows, when a window
   transitions from hidden/background to visible the Chromium compositor may not
   repaint a WebContentsView whose bounds have not changed.  Both paths now call
   refreshActiveWorkspaceView()  a new lightweight helper that calls showView()
   (removeChildView + addChildView + setBounds + webContents.focus) before realigning.
   This forces a compositor repaint and makes the wiki page visible immediately.

* Refactor view restore chain: clean up redundant repaint/realign calls

Full chain analysis identified 4 structural problems:

1. refreshActiveWorkspaceView() called TWICE concurrently on window restore:
   window.open() called existedWindow.show() which fired the 'show' event
    refreshActiveWorkspaceView() AND THEN immediately called refreshActiveWorkspaceView()
   again explicitly, creating a race condition on removeChildView+addChildView.

2. realignView() contained removeChildView+addChildView, which ran immediately after
   showView() already did removeChildView+addChildView+setBounds+focus.  The second
   remove+add clobbered the focus state set by showView, breaking keyboard focus.

3. setActiveWorkspaceView() called showWorkspaceView + realignActiveWorkspace, meaning
   the view was remove+add+setBounds+focused by showView, then immediately remove+add+
   setBounds-without-focus again by realignView.  Double bounds, lost focus.

4. Same pattern in refreshActiveWorkspaceView: showWorkspaceView + realignActiveWorkspace.

Clean design after refactor:
- showView()        = force-repaint path: remove+add+setBounds+focus (unchanged)
- realignView()     = bounds-only:        setBounds ONLY, no remove+add
- showWorkspaceView = calls showView for main+mini windows
- realignActiveWorkspace = calls realignView (now just setBounds) + buildMenu;
                     used for fullscreen/sidebar/resize events
- setActiveWorkspaceView = showWorkspaceView + buildMenu (not +realignActiveWorkspace)
- refreshActiveWorkspaceView = showWorkspaceView + buildMenu (not +realignActiveWorkspace);
                     called from 'show' window event (fire-and-forget: no rethrow)
- window.open() existing-window = show() only; 'show' event handler calls
                     refreshActiveWorkspaceView automatically, no duplicate call

* chore: bump electron-ipc-cat to 2.4.0

Rolling Observable timeout (120s initial, 60s idle) fixes git-upload-pack
timeout for large repos (100+ MB) during mobile sync.

* style: unify layout between Preferences and EditWorkspace

Use PageRoot and PageInner from PreferenceComponents to eliminate subtle padding/background differences. Resize EditWorkspace window to match Preferences. Clean up lint errors.

* Add E2E test for window-restore blank-view bug + log markers

Two changes:

1. Log markers added to aid diagnosis and enable E2E verification:
   - [test-id-VIEW_SHOWN]             in ViewService.showView()
   - [test-id-REFRESH_ACTIVE_VIEW_START/DONE] in WorkspaceView.refreshActiveWorkspaceView()

2. New E2E feature: features/windowRestore.feature
   Scenario 1: 'Wiki WebContentsView is visible immediately after restoring hidden window'
     - hides main window (same path as close+runOnBackground)
     - triggers second-instance via app.emit('second-instance')
     - asserts [test-id-REFRESH_ACTIVE_VIEW_DONE] and [test-id-VIEW_SHOWN] log markers
     - asserts browser view is within visible window bounds
     - asserts wiki content is readable
   Scenario 2: 'Clicking already-active workspace icon re-shows the WebContentsView'
     - verifies the removed oldId !== newId guard: clicking current workspace must
       now call setActiveWorkspaceView which fires showView

   Two step definitions added to features/stepDefinitions/application.ts:
   - 'I hide the main window as if closing with runOnBackground'
     calls BrowserWindow.hide() directly in main process
   - 'I reopen the main window as second instance would'
     emits app 'second-instance' event in main process

* Fix E2E test: correct second-instance emit args, add wiki-ready wait in Background

Three issues found and fixed by running the tests:

1. app.emit('second-instance') argument order wrong
   DeepLinkService listener: (_event, commandLine) => commandLine.pop()
   Our emit: app.emit('second-instance', [], process.cwd(), {})
   This made 'process.cwd()' land in commandLine, .pop() failed on a string.
   Fix: app.emit('second-instance', {}, [], '', {})  fake Event first,
   then empty argv array, then workingDirectory.

2. In test mode, window.open() skips existedWindow.show() to avoid UI popups.
   The 'show' event never fired so refreshActiveWorkspaceView was never called
   and the window stayed hidden from Playwright's perspective.
   Fix: explicitly call mainWindow.show() via app.evaluate() after emitting
   second-instance, replicating what production window.open() does.

3. Background used 'the browser view should be loaded and visible' which has
   a 21-second timeout and fails before TiddlyWiki finishes initializing in
   the test environment (pre-existing issue in defaultWiki.feature too).
   Fix: replaced with deterministic log marker waits:
     [test-id-WIKI_WORKER_STARTED] + [test-id-VIEW_LOADED]
   plus 'I confirm the main window browser view is positioned within visible
   window bounds' for a structural check without content dependency.

Result: both @window-restore scenarios pass (31/31 steps green, ~48s).

* Fix reopened main window restore after recreation and rebind view resize

Root cause on Windows was not the hide/show path, but the close+recreate path when tidgi mini window keeps the app alive while runOnBackground is false.

What was actually happening:
1. The user closed the main window.
2. The app stayed alive because tidgi mini window still existed.
3. A second-instance launch recreated a new main BrowserWindow.
4. The old workspace WebContentsView still existed in ViewService.
5. But the new main window missed the automatic restore because the BrowserWindow 'show' event fired inside handleCreateBasicWindow() before registerBrowserViewWindowListeners() attached the 'show' listener.
6. If the user then clicked the workspace icon, showView() reattached the old view manually, but its resize listener was still bound to the old destroyed BrowserWindow, so resizing the new window no longer resized the view.

Fix:
- ViewService now rebinds the debounced resize handler every time showView() attaches an existing view to a BrowserWindow.
- Window.open() now detects the recreate-main-window case for BrowserView windows and immediately calls refreshActiveWorkspaceView() if the active workspace already has an existing view instance.
  This restores the view without waiting for a workspace icon click.

Why old E2E missed it:
- It simulated hide/show (runOnBackground=true) instead of the real user path (main window close + app kept alive by tidgi mini window).
- It only checked that the view was within visible bounds; it did not resize the window and assert the view filled the content area after the reopen.

New E2E coverage:
- Configures tidgiMiniWindow=true and runOnBackground=false before launch.
- Closes the main window, reopens it via second-instance, verifies refresh/view-shown markers, verifies bounds, resizes the recreated main window, and asserts the BrowserView fills the content area after the debounced resize handler runs.
- Scenario passes locally: 1 scenario, 20 steps, all green.

* Update pnpm-lock.yaml

* fix: address Copilot PR review issues

- Restore workspaceID from window.meta() in EditWorkspace (was hard-coded debug value)
- Add missing React/type imports to customComponentRegistry.ts, workspaceCustomComponentRegistry.ts, registerCustomSections.tsx, registerWorkspaceCustomSections.tsx, useSections.ts
- Fix HighlightText regex: use index parity (odd index = match) instead of stateful regex.test() with global flag
- Fix actionHandlers native.pickDirectory to read current preference value instead of passing the key string as a path
- Move PreferenceComponents import before registerCustomSections() call to fix import ordering

* fix: fix import ordering to satisfy dprint/eslint format rules

* fix: stabilize e2e selectors and EditWorkspace loading fallback

- align workspace section testids in e2e features
- migrate background-task e2e to scheduled-task selectors
- add edit workspace fallback loading when metadata/observable is late
- add deterministic switch testid for schema boolean items
- make sync snackbar assertion resilient to progress text changes
- clear draft-check timeout handle in sync service

* fix: add apiKey to test provider config so isAIAvailable() returns true

The AI commit message e2e test expects both commit-now-button and
commit-now-ai-button to appear. The AI button only renders when
isAIGenerateBackupTitleEnabled() returns true, which internally calls
externalAPIService.isAIAvailable(). That method requires a non-empty
apiKey for openAICompatible providers, but the test's
createProviderConfig() never set one, causing isAIAvailable() to
return false and the AI button to never render.

* feat(gitServer): add generateFullArchive for fast mobile clone

- Add generateFullArchive() to IGitServerService interface
- Implement tar archive generation: git archive + system tar append
- Archives working tree + minimal .git metadata (HEAD, refs, objects)
- Cache by HEAD commit hash, auto-cleanup old archives
- Bump tidgi-shared to 0.1.5

* fix(e2e): resolve workspace by runtime name/folder in step defs

* fix(ci): satisfy lint rules in gitServer archive generation
2026-04-01 15:45:26 +08:00

768 lines
32 KiB
TypeScript

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<string, IWorkspace> | undefined;
public workspaces$ = new BehaviorSubject<IWorkspacesWithMetadata | undefined>(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<void> {
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<void> => {
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
await workspaceViewService.setActiveWorkspaceView(workspace.id);
// manually update menu since we have alter the active workspace
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
await menuService.buildMenu();
},
accelerator: `CmdOrCtrl+${index + 1}`,
},
]);
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
await menuService.insertMenu('Workspaces', newMenuItems, undefined, undefined, 'updateWorkspaceMenuItems');
}
/**
* load workspaces in sync, and ensure it is an Object
*/
private getInitWorkspacesForCache(): Record<string, IWorkspace> {
const databaseService = container.get<IDatabaseService>(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<string, IWorkspace> = {};
const oldToNewIdMap = new Map<string, string>();
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<Record<string, IWorkspace>> {
return this.getWorkspacesSync();
}
private getWorkspacesSync(): Record<string, IWorkspace> {
// store in memory to boost performance
if (this.workspaces === undefined) {
this.workspaces = this.getInitWorkspacesForCache();
}
return this.workspaces;
}
public async countWorkspaces(): Promise<number> {
return Object.keys(this.getWorkspacesSync()).length;
}
/**
* Get sorted workspace list
* Async so proxy type is async
*/
public async getWorkspacesAsList(): Promise<IWorkspace[]> {
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<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 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<IWorkspace | undefined> {
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<IWorkspace | undefined> {
return this.workspaces$.pipe(map((workspaces) => workspaces?.[id]));
}
public async set(id: string, workspace: IWorkspace, immediate?: boolean, skipUiUpdate = false): Promise<void> {
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<IDatabaseService>(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<IWorkspace>, immediate?: boolean): Promise<void> {
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<string, IWorkspace>): Promise<void> {
// 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<typeof workspaceWithSyncedConfig> = {};
// 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<IAuthenticationService>(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<void> {
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<IWorkspace | undefined> {
return (await this.getWorkspacesAsList()).find((workspace) => isWikiWorkspace(workspace) && workspace.wikiFolderLocation === wikiFolderLocation);
}
public async getByWikiName(wikiName: string): Promise<IWorkspace | undefined> {
return (await this.getWorkspacesAsList())
.sort(workspaceSorter)
.find((workspace) => workspace.name === wikiName);
}
public getPreviousWorkspace = async (id: string): Promise<IWorkspace | undefined> => {
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<IWorkspace | undefined> => {
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<IWorkspace | undefined> => {
return this.getActiveWorkspaceSync();
};
public getActiveWorkspaceSync = (): IWorkspace | undefined => {
return this.getWorkspacesAsListSync().find((workspace) => workspace.active);
};
public getFirstWorkspace = async (): Promise<IWorkspace | undefined> => {
return this.getFirstWorkspaceSync();
};
public getFirstWorkspaceSync = (): IWorkspace | undefined => {
return this.getWorkspacesAsListSync()[0];
};
public async setActiveWorkspace(id: string, oldActiveWorkspaceID: string | undefined): Promise<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
const workspaces = this.getWorkspacesSync();
if (id in workspaces) {
delete workspaces[id];
const databaseService = container.get<IDatabaseService>(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<number> {
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<IWorkspace> {
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<INewWikiWorkspaceConfig> = {};
if (useTidgiConfig && workspaceConfig.wikiFolderLocation) {
const syncedConfig = await readTidgiConfig(workspaceConfig.wikiFolderLocation);
if (syncedConfig) {
existingConfig = syncedConfig as Partial<INewWikiWorkspaceConfig>;
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<IWorkspace> {
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<void> {
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<string, Partial<IWorkspaceMetaData>> = {};
public getMetaData = async (id: string): Promise<Partial<IWorkspaceMetaData>> => this.getMetaDataSync(id);
private readonly getMetaDataSync = (id: string): Partial<IWorkspaceMetaData> => this.metaData[id] ?? {};
public getAllMetaData = async (): Promise<Record<string, Partial<IWorkspaceMetaData>>> => this.metaData;
public updateMetaData = async (id: string, options: Partial<IWorkspaceMetaData>): Promise<void> => {
logger.debug('updateMetaData', {
id,
options,
function: 'updateMetaData',
});
this.metaData[id] = {
...this.metaData[id],
...options,
};
this.updateWorkspaceSubject();
};
public async workspaceDidFailLoad(id: string): Promise<boolean> {
const workspaceMetaData = this.getMetaDataSync(id);
return typeof workspaceMetaData.didFailLoadErrorMessage === 'string' && workspaceMetaData.didFailLoadErrorMessage.length > 0;
}
public async openWorkspaceTiddler(workspace: IWorkspace, title?: string): Promise<void> {
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<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
const wikiService = container.get<IWikiService>(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<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
const wikiService = container.get<IWikiService>(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<boolean> {
return Boolean(await this.get(id));
}
/**
* Get workspace token for Git Smart HTTP authentication
*/
public async getWorkspaceToken(workspaceId: string): Promise<string | undefined> {
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<boolean> {
const workspaceToken = await this.getWorkspaceToken(workspaceId);
if (!workspaceToken) {
return false;
}
return workspaceToken === token;
}
}