TidGi-Desktop/src/services/workspaces/interface.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

490 lines
17 KiB
TypeScript

import { WorkspaceChannel } from '@/constants/channels';
import { PageType } from '@/constants/pageTypes';
import { SupportedStorageServices } from '@services/types';
import { ProxyPropertyType } from 'electron-ipc-cat/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { SetOptional } from 'type-fest';
/**
* Fields that not part of config that user can edit. Change of these field won't show "save" button on edit page.
*/
export const nonConfigFields = ['metadata', 'lastNodeJSArgv'];
/**
* Fields that should be synced to wiki folder's tidgi.config.json.
* These are user preferences that should follow the wiki across devices.
*
* ⚠️ IMPORTANT: When modifying this list, remember to also update:
* - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition)
* - syncableConfigDefaultValues (default values)
*/
export const syncableConfigFields = [
'name',
'port',
'gitUrl',
'storageService',
'userName',
'readOnlyMode',
'tokenAuth',
'enableHTTPAPI',
'enableFileSystemWatch',
'ignoreSymlinks',
'backupOnInterval',
'syncOnInterval',
'syncOnStartup',
'disableAudio',
'disableNotifications',
'hibernateWhenUnused',
'transparentBackground',
'excludedPlugins',
'tagNames',
'includeTagTree',
'fileSystemPathFilterEnable',
'fileSystemPathFilter',
'rootTiddler',
'https',
] as const;
/**
* Type for syncable config fields
*/
export type SyncableConfigField = typeof syncableConfigFields[number];
/**
* Fields that are device-specific and should only be stored locally.
*/
export const localOnlyFields = [
'id',
'order',
'active',
'hibernated',
'lastUrl',
'lastNodeJSArgv',
'homeUrl',
'authToken',
'picturePath',
'wikiFolderLocation',
'mainWikiToLink',
'mainWikiID',
'isSubWiki',
'pageType',
] as const;
/**
* Default values for syncable config fields (stored in tidgi.config.json)
*
* ⚠️ IMPORTANT: When modifying this object, remember to also update:
* - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition)
* - syncableConfigFields (field list)
*/
export const syncableConfigDefaultValues = {
name: '',
port: 5212,
gitUrl: null,
storageService: SupportedStorageServices.local,
userName: '',
readOnlyMode: false,
tokenAuth: false,
enableHTTPAPI: false,
enableFileSystemWatch: false,
ignoreSymlinks: true,
backupOnInterval: true,
syncOnInterval: false,
syncOnStartup: true,
disableAudio: false,
disableNotifications: false,
hibernateWhenUnused: false,
transparentBackground: false,
excludedPlugins: [] as string[],
tagNames: [] as string[],
includeTagTree: false,
fileSystemPathFilterEnable: false,
fileSystemPathFilter: null as string | null,
rootTiddler: undefined as string | undefined,
https: undefined as { enabled: boolean; tlsCert?: string; tlsKey?: string } | undefined,
} as const;
/**
* Type for syncable config
*
* ⚠️ IMPORTANT: This type is derived from syncableConfigDefaultValues.
* When modifying types here, remember to also update:
* - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition)
*/
export type ISyncableWikiConfig = {
-readonly [K in keyof typeof syncableConfigDefaultValues]: (typeof syncableConfigDefaultValues)[K];
};
/**
* Default values for local-only fields (stored in database)
*/
export const localConfigDefaultValues = {
id: '',
order: 0,
active: false,
hibernated: false,
lastUrl: null as string | null,
lastNodeJSArgv: [] as string[],
homeUrl: '',
authToken: undefined as string | undefined,
picturePath: null as string | null,
mainWikiToLink: null as string | null,
mainWikiID: null as string | null,
pageType: null as PageType.wiki | null,
} as const;
/**
* Default values for IWikiWorkspace fields. These are used for:
* 1. Initializing new workspaces
* 2. Providing default values when fields are missing from persisted config
* 3. Determining which fields need to be saved (only non-default values are persisted)
*/
export const wikiWorkspaceDefaultValues = {
...localConfigDefaultValues,
...syncableConfigDefaultValues,
} satisfies Omit<IWikiWorkspace, 'wikiFolderLocation' | 'isSubWiki'>;
export interface IDedicatedWorkspace {
/**
* Is this workspace selected by user, and showing corresponding webview?
*/
active: boolean;
id: string;
/**
* Display name for this wiki workspace
*/
name: string;
/**
* You can drag workspaces to reorder them
*/
order: number;
/**
* If this workspace represents a page (like help, guide, agent), this field indicates the page type.
* If null or undefined, this is a regular wiki workspace.
*/
pageType?: PageType | null;
/**
* workspace icon's path in file system
*/
picturePath: string | null;
}
/**
* A workspace is basically a TiddlyWiki instance, it can be a local/online wiki (depends on git related config). Can be a mainWiki that starts a a TiddlyWiki instance or subwiki that link to a main wiki.
*
* New value added here can be init in `sanitizeWorkspace`
*/
export interface IWikiWorkspace extends IDedicatedWorkspace {
authToken?: string;
/**
* When this workspace is a local workspace, we can still use local git to backup
*/
backupOnInterval: boolean;
disableAudio: boolean;
disableNotifications: boolean;
enableHTTPAPI: boolean;
/**
* List of plugins excluded on startup, for example `['$:/plugins/bimlas/kin-filter', '$:/plugins/dullroar/sitemap']`
*/
excludedPlugins: string[];
/**
* The online repo to back data up to
*/
gitUrl: string | null;
/**
* Hibernate workspace on startup and when switch to another workspace.
*/
hibernateWhenUnused: boolean;
/**
* Is this workspace hibernated. You can hibernate workspace manually, without setting its hibernateWhenUnused. So we record this field in workspace.
*/
hibernated: boolean;
/**
* Localhost server url to load in the electron webview
*/
homeUrl: string;
/**
* Mostly used for deploying blog. Need tls-key and tls-cert.
*/
https?: {
enabled: boolean;
tlsCert?: string;
tlsKey?: string;
};
/**
* Is this workspace a subwiki that link to a main wiki, and doesn't have its own webview?
*/
isSubWiki: boolean;
/**
* Nodejs start argument cli, used to start tiddlywiki server in terminal
*/
lastNodeJSArgv?: string[];
/**
* Last visited url, used for rememberLastPageVisited in preferences
*/
lastUrl: string | null;
/**
* ID of main wiki of the sub-wiki. Only useful when isSubWiki === true
*/
mainWikiID: string | null;
/**
* Absolute path of main wiki of the sub-wiki. Only useful when isSubWiki === true , this is the wiki repo that this subwiki's folder soft links to
*/
mainWikiToLink: string | null;
/**
* For wiki workspaces, pageType is restricted to wiki type or null for regular wiki workspaces
*/
pageType?: PageType.wiki | null;
/**
* Localhost tiddlywiki server port
*/
port: number;
/**
* Make wiki readonly if readonly is true. This is normally used for server mode, so also enable gzip.
*
* The principle is to configure anonymous reads, but writes require a login, and then give an unguessable username and password to.
*
* @url https://wiki.zhiheng.io/static/TiddlyWiki%253A%2520Readonly%2520for%2520Node.js%2520Server.html
*/
readOnlyMode: boolean;
/**
* The root tiddler for wiki. When missing, may use `$:/core/save/lazy-images`
* @url https://tiddlywiki.com/#LazyLoading
*/
rootTiddler?: string;
/**
* Storage service this workspace sync to
*/
storageService: SupportedStorageServices;
/**
* Sync wiki every interval.
* If this is false (false by default to save the CPU usage from chokidar watch), then sync will only happen if user manually trigger by click sync button in the wiki, or sync at the app open.
*/
syncOnInterval: boolean;
/**
* Commit and Sync when App starts.
*/
syncOnStartup: boolean;
/**
* Tag names in tiddlywiki's filesystemPath, tiddlers with any of these tags will be saved into this subwiki
*/
tagNames: string[];
/**
* When enabled, tiddlers that are indirectly tagged (tag of tag of tag...) with any of this sub-wiki's tagNames
* will also be saved to this sub-wiki. Uses the in-tagtree-of filter operator.
* Applies when creating new tiddlers and when modifying existing ones (e.g., when tags change).
*/
includeTagTree: boolean;
/**
* When enabled, also use fileSystemPathFilter expressions to match tiddlers, in addition to tagName/includeTagTree matching.
* This allows more complex matching logic using TiddlyWiki filter expressions.
*/
fileSystemPathFilterEnable: boolean;
/**
* TiddlyWiki filter expressions to match tiddlers for this workspace (one per line).
* Example: `[in-tagtree-of[Calendar]!tag[Public]!tag[Draft]]`
* Any matching filter will route the tiddler to this workspace.
* Only used when fileSystemPathFilterEnable is true.
*/
fileSystemPathFilter: string | null;
/**
* Use authenticated-user-header to provide `TIDGI_AUTH_TOKEN_HEADER` as header key to receive a value as username (we use it as token)
*/
tokenAuth: boolean;
transparentBackground: boolean;
userName: string;
/**
* folder path for this wiki workspace
*/
wikiFolderLocation: string;
/**
* Enable file system watching (experimental feature using chokidar)
* When enabled, external file changes will be synced to the wiki automatically
* This is an experimental feature and may have bugs
*/
enableFileSystemWatch: boolean;
/**
* Symlinks are similar to shortcuts. The old version used them to implement sub-wiki functionality,
* but the new version no longer needs them.You can manually delete legacy symlinks.
* When enabled, the file system watcher will skip symlinks to avoid redundant file sync operations.
*/
ignoreSymlinks: boolean;
}
export type IWorkspace = IWikiWorkspace | IDedicatedWorkspace;
/**
* Type guard to check if a workspace is a wiki workspace
*/
export function isWikiWorkspace(workspace: IWorkspace): workspace is IWikiWorkspace {
return 'wikiFolderLocation' in workspace;
}
/**
* Type guard to check if a workspace is a dedicated workspace (like help, guide, agent pages)
*/
export function isDedicatedWorkspace(workspace: IWorkspace): workspace is IDedicatedWorkspace {
return !isWikiWorkspace(workspace);
}
export interface IWorkspaceMetaData {
badgeCount?: number;
/**
* Error message if this workspace fails loading
*/
didFailLoadErrorMessage?: string | null | undefined;
/**
* indicating server or webpage is still loading
*/
isLoading?: boolean;
/**
* Is restarting service for this workspace.
*/
isRestarting?: boolean;
}
export type IWorkspaceWithMetadata = IWorkspace & {
metadata: IWorkspaceMetaData;
};
export type IWorkspacesWithMetadata = Record<string, IWorkspaceWithMetadata>;
/**
* Ignore some field that will assign default value in workspaceService.create, these field don't require to be filled in AddWorkspace form
*/
export type INewWikiWorkspaceConfig = SetOptional<
Omit<IWikiWorkspace, 'active' | 'hibernated' | 'id' | 'lastUrl' | 'syncOnInterval' | 'syncOnStartup'>,
| 'homeUrl'
| 'transparentBackground'
| 'picturePath'
| 'disableNotifications'
| 'disableAudio'
| 'hibernateWhenUnused'
| 'userName'
| 'order'
| 'ignoreSymlinks'
| 'backupOnInterval'
| 'enableHTTPAPI'
| 'excludedPlugins'
| 'includeTagTree'
| 'fileSystemPathFilterEnable'
| 'fileSystemPathFilter'
>;
/**
* Manage workspace level preferences and workspace metadata.
*/
export interface IWorkspaceService {
/** Enter a state that no workspace is active (show welcome page) */
clearActiveWorkspace(oldActiveWorkspaceID: string | undefined): Promise<void>;
/**
* Check if a workspace exists by id
* @param id workspace id to check
* @returns true if workspace exists, false otherwise
*/
exists(id: string): Promise<boolean>;
countWorkspaces(): Promise<number>;
create(newWorkspaceConfig: INewWikiWorkspaceConfig): Promise<IWorkspace>;
get(id: string): Promise<IWorkspace | undefined>;
get$(id: string): Observable<IWorkspace | undefined>;
/**
* Get active workspace, if no active workspace, return the first workspace. Only when workspace list is empty, return undefined.
*/
getActiveWorkspace: () => Promise<IWorkspace | undefined>;
/**
* Only meant to be used in TidGi's services internally.
*/
getActiveWorkspaceSync: () => IWorkspace | undefined;
getAllMetaData: () => Promise<Record<string, Partial<IWorkspaceMetaData>>>;
getByWikiFolderLocation(wikiFolderLocation: string): Promise<IWorkspace | undefined>;
/**
* Get workspace by human readable wiki name, if no workspace found, return undefined. If multiple workspace with same name, return the first one order by sidebar.
*/
getByWikiName(wikiName: string): Promise<IWorkspace | undefined>;
getFirstWorkspace: () => Promise<IWorkspace | undefined>;
/**
* Get parent workspace of a subWorkspace, if the workspace you provided is a main workspace, return undefined.
* @param subWorkspace your workspace object
*/
getMainWorkspace(subWorkspace: IWorkspace): IWorkspace | undefined;
getMetaData: (id: string) => Promise<Partial<IWorkspaceMetaData>>;
getNextWorkspace: (id: string) => Promise<IWorkspace | undefined>;
getPreviousWorkspace: (id: string) => Promise<IWorkspace | undefined>;
getSubWorkspacesAsList(workspaceID: string): Promise<IWikiWorkspace[]>;
/**
* Only meant to be used in TidGi's services internally.
*/
getSubWorkspacesAsListSync(workspaceID: string): IWikiWorkspace[];
getWorkspaces(): Promise<Record<string, IWorkspace>>;
getWorkspacesAsList(): Promise<IWorkspace[]>;
getWorkspacesWithMetadata(): IWorkspacesWithMetadata;
/**
* Initialize default page workspaces on first startup
*/
initializeDefaultPageWorkspaces(): Promise<void>;
/**
* Open a tiddler in the workspace, open workspace's tag by default.
*/
openWorkspaceTiddler(workspace: IWorkspace, title?: string): Promise<void>;
remove(id: string): Promise<void>;
removeWorkspacePicture(id: string): Promise<void>;
set(id: string, workspace: IWorkspace, immediate?: boolean): Promise<void>;
/**
* Set new workspace to active, and make the old active workspace inactive
* @param id id to active
*/
setActiveWorkspace(id: string, oldActiveWorkspaceID: string | undefined): Promise<void>;
setWorkspacePicture(id: string, sourcePicturePath: string): Promise<void>;
setWorkspaces(newWorkspaces: Record<string, IWorkspace>): Promise<void>;
update(id: string, workspaceSetting: Partial<IWorkspace>, immediate?: boolean): Promise<void>;
updateMetaData: (id: string, options: Partial<IWorkspaceMetaData>) => Promise<void>;
/**
* Manually refresh the observable's content, that will be received by react component.
*/
updateWorkspaceSubject(): void;
workspaceDidFailLoad(id: string): Promise<boolean>;
workspaces$: BehaviorSubject<IWorkspacesWithMetadata | undefined>;
}
export const WorkspaceServiceIPCDescriptor = {
channel: WorkspaceChannel.name,
properties: {
clearActiveWorkspace: ProxyPropertyType.Function,
countWorkspaces: ProxyPropertyType.Function,
create: ProxyPropertyType.Function,
get: ProxyPropertyType.Function,
get$: ProxyPropertyType.Function$,
getActiveWorkspace: ProxyPropertyType.Function,
getAllMetaData: ProxyPropertyType.Function,
getByWikiName: ProxyPropertyType.Function,
getFirstWorkspace: ProxyPropertyType.Function,
getMainWorkspace: ProxyPropertyType.Function,
getMetaData: ProxyPropertyType.Function,
getNextWorkspace: ProxyPropertyType.Function,
getPreviousWorkspace: ProxyPropertyType.Function,
getSubWorkspacesAsList: ProxyPropertyType.Function,
getWorkspaces: ProxyPropertyType.Function,
getWorkspacesAsList: ProxyPropertyType.Function,
getWorkspacesWithMetadata: ProxyPropertyType.Function,
initializeDefaultPageWorkspaces: ProxyPropertyType.Function,
openWorkspaceTiddler: ProxyPropertyType.Function,
remove: ProxyPropertyType.Function,
removeWorkspacePicture: ProxyPropertyType.Function,
set: ProxyPropertyType.Function,
setActiveWorkspace: ProxyPropertyType.Function,
setWorkspacePicture: ProxyPropertyType.Function,
setWorkspaces: ProxyPropertyType.Function,
update: ProxyPropertyType.Function,
updateMetaData: ProxyPropertyType.Function,
updateWorkspaceSubject: ProxyPropertyType.Value$,
workspaceDidFailLoad: ProxyPropertyType.Function,
workspaces$: ProxyPropertyType.Value$,
},
};
/**
* Apply default values to a wiki workspace, using the centralized defaults from wikiWorkspaceDefaultValues.
* This ensures that missing fields get their default values when loading from persisted config.
* @param workspace The workspace object that may have missing fields
* @returns A new workspace object with defaults applied to missing fields
*/
export function applyWorkspaceDefaults(workspace: IWikiWorkspace): IWikiWorkspace {
return { ...wikiWorkspaceDefaultValues, ...workspace };
}