From 949c7b00bb5614ab05ecacbac09d0f7a6cc9d442 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Mon, 23 Mar 2026 02:48:46 +0800 Subject: [PATCH] Fix/misc bug1 (#688) * fix: possible error on wiki creation * Add isSubWiki/mainWikiToLink and optimize tidgi.config Introduce new syncable wiki fields (isSubWiki, mainWikiToLink) with schema, types, and defaults; add localization for an autofill note. Expose Database.readWikiConfig over IPC and implement readTidgiConfig usage. When updating workspaces, only write tidgi.config.json if syncable fields actually changed to avoid redundant O(n) disk writes. Update AddWorkspace UI to eagerly read tidgi.config.json (when enabled) to pre-fill form fields and show a helper note. Improve workspace hibernation logic: avoid hibernating page workspaces' servers when switching and prevent concurrent duplicate hibernation calls. Also update template submodule reference. * Update ErrorDuringRelease.md * Fix workspace config sync and sub-workspace settings * adjust menu * Add sub-workspace UI, view navigation & types Expose view navigation helpers and types, add sub-workspace UI and related translations, and introduce provider registry types. - Add canGoBack/canGoForward/goBack/goForward APIs to view service and IPC interface to allow navigating embedded views. - Implement UI for sub-workspace management in EditWorkspace: list bound sub-workspaces, open sub-workspace settings, and select main workspace for sub-wikis. Add tests IDs and small UX tweaks (cancel button test id). - Update SaveAndSyncOptions and SubWorkspaceRouting to reflect new sub-workspace flows and remove deprecated main workspace path field. - Add provider registry interface that re-exports external API types and IPC descriptor (src/services/providerRegistry/interface.ts). - Add ambient type declarations for @modelcontextprotocol SDK client transports. - Improve test/e2e support: detect packaged e2e runs via --test-scenario arg in environment constants, update step definition to open edit workspace via the window service using WindowNames, and adjust feature file assertions for sub-wiki bindings. - Add English and Simplified Chinese translation keys for sub-workspace UI strings. These changes enable managing sub-workspaces from the Edit Workspace window, provide programmatic view navigation, and add types/interfaces required for integrating external provider tooling and tests. * fix lint * Add diagnostics, process monitoring, and menu improvements Add process diagnostics and monitoring: introduce shared processInfo types, native.getProcessInfo and startProcessMonitoring (30s snapshots + logs), label the main Node process and give descriptive initial window titles; include renderer PIDs in view info and log view creation. Unified Developer Tools panel into a Process & View Diagnostics dialog that shows Node/renderer memory and wiki worker info (wiki.getWorkersInfo). Fix FileSystemAdaptor to pass old fileInfo to generateTiddlerFileInfo to avoid numeric suffixes when overwriting tiddlers. Menu and workspace changes: add a Sync menu and move git items there, simplify context menu generation, remove some developer menu items, and adjust createBackupMenuItems signature. Window and workspace improvements: add recreateUnlessWorkspaceID option and safer window close handling, restore hibernated flag when bringing workspace views up, and improve sub-workspace settings UI. Also add several i18n entries for the new diagnostics and UI text. * Add renderer metrics; fix hibernation & menus Collect and display renderer process metrics and harden workspace/window logic. Highlights: - Add new i18n keys for renderer PID/CPU/private memory (en and zh-Hans). - Native service: check and throw on shell.openPath errors; gather per-renderer metrics (private/working set KB and cpu percent) via app.getAppMetrics() and webContents, and improve logging. - Extend IRendererProcessInfo with private_KB, workingSet_KB and cpu_percent. - DeveloperTools UI: show PID tooltip, private memory and CPU columns, sort renderers by memory, color-code values, and fix SquirrelTemp path/openPath call. - Git/menu changes: consolidate backup/sync items to include AI option inline; only show sync for cloud workspaces with a git remote and authenticated user; always show local backup. - Workspaces/view: prevent races when switching to a workspace by tracking hibernation as awaitable promises (Map), awaiting in-flight hibernations, and re-fetching workspace state before actions. - View/window safety: add null/undefined checks for view.webContents in several handlers (setupViewEventHandlers, handleAttachToTidgiMiniWindow) to avoid operations on destroyed/closed webContents. These changes improve diagnostics, prevent race conditions, and make menu behavior more consistent. * fix: lint errors - remove unused WindowNames import, fix import order and indentation * fix: address review comments - remove mainWikiToLink from syncable config (absolute path unsafe to sync), fix missing-field detection in syncableChanged, precompute viewsInfo Map in renderer table, use stable pid as row key * fix: auto-expand SubWorkspaceRouting accordion when bound sub-wikis exist, so e2e test selectors are visible * fix: update e2e test menu paths - git/sync items moved from Wiki menu to Sync menu * fix: stabilize sync settings and e2e sync helper --- docs/ErrorDuringRelease.md | 14 + docs/internal/MenuSystem.md | 52 +++ features/aiCommitMessage.feature | 2 +- features/gitLog.feature | 10 +- features/stepDefinitions/sync.ts | 11 +- features/stepDefinitions/wiki.ts | 37 +- features/subWiki.feature | 20 +- features/sync.feature | 10 +- localization/locales/en/translation.json | 60 ++- localization/locales/zh-Hans/translation.json | 60 ++- package.json | 3 +- pnpm-lock.yaml | 40 +- src/constants/appPaths.ts | 11 +- src/constants/environment.ts | 5 +- src/main.ts | 5 +- src/pages/Main/index.tsx | 2 +- .../tools/modelContextProtocol.ts | 5 +- src/services/database/index.ts | 14 +- src/services/database/interface.ts | 10 +- src/services/database/settingsInit.ts | 17 +- src/services/database/tidgiConfig.ts | 1 - src/services/git/index.ts | 14 +- src/services/git/menuItems.ts | 69 +--- src/services/git/registerMenu.ts | 5 +- src/services/gitServer/index.ts | 9 + src/services/menu/index.ts | 91 +---- src/services/menu/loadDefaultMenuTemplate.ts | 12 +- src/services/native/index.ts | 77 +++- src/services/native/interface.ts | 7 + src/services/native/processInfo.ts | 27 ++ src/services/providerRegistry/interface.ts | 33 ++ src/services/view/index.ts | 39 ++ src/services/view/interface.ts | 25 ++ src/services/view/setupViewEventHandlers.ts | 16 +- src/services/wiki/index.ts | 18 +- src/services/wiki/interface.ts | 14 + .../plugin/ipcSyncAdaptor/ipc-syncadaptor.ts | 7 +- .../FileSystemAdaptor.ts | 8 + src/services/wikiGitWorkspace/index.ts | 3 +- .../windows/handleAttachToTidgiMiniWindow.ts | 2 +- .../windows/handleCreateBasicWindow.ts | 7 +- src/services/windows/index.ts | 28 +- src/services/windows/interface.ts | 11 + .../workspaces/__tests__/tokenAuth.test.ts | 6 +- .../workspaces/getWorkspaceMenuTemplate.ts | 86 ++-- src/services/workspaces/index.ts | 54 +-- src/services/workspaces/interface.ts | 75 +--- src/services/workspaces/registerMenu.ts | 3 +- src/services/workspaces/syncableConfig.ts | 6 + .../workspaces/tidgi.config.schema.json | 10 + src/services/workspacesView/index.ts | 93 +++-- src/services/workspacesView/registerMenu.ts | 12 - src/type.d.ts | 23 ++ src/windows/AddWorkspace/ExistedWikiForm.tsx | 35 +- src/windows/AddWorkspace/index.tsx | 7 +- .../EditWorkspace/SaveAndSyncOptions.tsx | 27 +- .../EditWorkspace/SubWorkspaceRouting.tsx | 312 ++++++++++----- src/windows/EditWorkspace/index.tsx | 35 +- .../Preferences/sections/DeveloperTools.tsx | 378 +++++++++++++++++- template/wiki | 2 +- 60 files changed, 1528 insertions(+), 547 deletions(-) create mode 100644 docs/internal/MenuSystem.md create mode 100644 src/services/native/processInfo.ts create mode 100644 src/services/providerRegistry/interface.ts diff --git a/docs/ErrorDuringRelease.md b/docs/ErrorDuringRelease.md index 2e89d20c..aa1f752d 100644 --- a/docs/ErrorDuringRelease.md +++ b/docs/ErrorDuringRelease.md @@ -6,6 +6,20 @@ Error: EBUSY: resource busy or locked, unlink 'i:\Temp\...\tidgi.0.13.0-prerelease18.nupkg' ``` +### Out? + +Try + +```sh +pnpm clean +``` + +### Temp? + +Try use FileLockSmith unlock `'i:\Temp\...\tidgi.0.13.0-prerelease18.nupkg'` + +### ESBuild? + esbuild process doesn't exit properly after packaging, holding file handles to temp files. Solution: kill background **esbuild** process diff --git a/docs/internal/MenuSystem.md b/docs/internal/MenuSystem.md new file mode 100644 index 00000000..ed7d8739 --- /dev/null +++ b/docs/internal/MenuSystem.md @@ -0,0 +1,52 @@ +# Menu System Overview + +This document describes the menu registration and context menu system in TidGi-Desktop, including how right-click context menus and application menus are registered, how they are shared or separated, and the main extension points for workspace-related actions. + +## Menu Registration Entry Points + +### Application Menubar (Main Process) + +- Registered via `src/services/menu/index.ts` using the `MenuService` class. +- Menus are built from a template and can be extended by calling `insertMenu` from various service modules (e.g., `workspaces/registerMenu.ts`, `windows/registerMenu.ts`). +- The menubar is rebuilt whenever new items are inserted. + +### Context Menus (Webview & Workspace Icon) + +- Webview right-click: Registered by `MenuService.initContextMenuForWindowWebContents`, which listens to the Electron `context-menu` event on each window's webContents. +- Workspace icon right-click: Handled in the renderer by `SortableWorkspaceSelectorButton.tsx`, which calls `getSimplifiedWorkspaceMenuTemplate` and triggers `window.remote.buildContextMenuAndPopup`. +- Both use the same menu template logic for workspace-related actions, ensuring consistency. + +## Menu Template Structure + +### getSimplifiedWorkspaceMenuTemplate + +- Used for both context menu entry points. +- Provides frequently used workspace actions (AI, edit, git history, etc.) and a "Current Workspace" submenu with the full set of actions. +- Calls `getWorkspaceMenuTemplate` for the submenu. + +### getWorkspaceMenuTemplate + +- Returns the full set of actions for a workspace, including: + - Open workspace tiddler + - Open in new window + - Edit workspace + - View git history + - Open workspace folder (in file manager, editor, or Git GUI) + - Open in browser (if HTTP API enabled) + - Remove workspace + - Backup/Sync (if applicable) + - Restart/Reload (for main wikis) + - Back/Forward navigation (for main wikis) + +## Extending the Menu + +- To add new workspace actions, extend `getWorkspaceMenuTemplate`. +- To add global actions, use `MenuService.insertMenu` from any service module. + +--- + +This design ensures that both the application menubar and all context menus remain consistent, extensible, and easy to maintain. For more details, see: + +- `src/services/menu/index.ts` +- `src/services/workspaces/getWorkspaceMenuTemplate.ts` +- `src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorButton.tsx` diff --git a/features/aiCommitMessage.feature b/features/aiCommitMessage.feature index 39bb12ac..1ad44161 100644 --- a/features/aiCommitMessage.feature +++ b/features/aiCommitMessage.feature @@ -29,7 +29,7 @@ Feature: AI-Generated Git Commit Messages When I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to contain "AI-generated commit message test content" Then I wait for tiddler "Index" to be updated by watch-fs # Open Git Log window to commit using the button - When I click menu "知识库 > 查看历史备份" + When I click menu "同步和备份 > 查看历史备份" And I switch to "gitHistory" window And I wait for the page to load completely # Should see uncommitted changes row diff --git a/features/gitLog.feature b/features/gitLog.feature index 9caa68b4..2fe8ddfb 100644 --- a/features/gitLog.feature +++ b/features/gitLog.feature @@ -31,11 +31,11 @@ Feature: Git Log Window """ Then I wait for tiddler "GitLogTestTiddler" to be added by watch-fs # Use menu to commit the file - this will use default message (no AI configured) - When I click menu "知识库 > 立即本地Git备份" + When I click menu "同步和备份 > 立即本地Git备份" # wait for git operation to complete Then I wait for "git commit completed" log marker "[test-id-git-commit-complete]" # Open Git Log through menu - When I click menu "知识库 > 查看历史备份" + When I click menu "同步和备份 > 查看历史备份" And I switch to "gitHistory" window And I wait for the page to load completely # Wait for git log to query history and render UI @@ -56,7 +56,7 @@ Feature: Git Log Window And I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to contain "Modified Index content - testing realtime update!" Then I wait for tiddler "Index" to be updated by watch-fs # Open Git Log window - When I click menu "知识库 > 查看历史备份" + When I click menu "同步和备份 > 查看历史备份" And I should see "Modified Index content" in the browser view content And I switch to "gitHistory" window And I wait for the page to load completely @@ -115,7 +115,7 @@ Feature: Git Log Window And I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to contain "Discard test content - should be reverted!" Then I wait for tiddler "Index" to be updated by watch-fs # Open Git Log window - When I click menu "知识库 > 查看历史备份" + When I click menu "同步和备份 > 查看历史备份" And I should see "Discard test content" in the browser view content And I switch to "gitHistory" window And I wait for the page to load completely @@ -144,7 +144,7 @@ Feature: Git Log Window @git Scenario: Git Log window auto-refreshes when files change (only when window is open) # Open Git Log window FIRST - When I click menu "知识库 > 查看历史备份" + When I click menu "同步和备份 > 查看历史备份" And I switch to "gitHistory" window And I wait for the page to load completely # Should see initial commits diff --git a/features/stepDefinitions/sync.ts b/features/stepDefinitions/sync.ts index 12594838..789ed6bb 100644 --- a/features/stepDefinitions/sync.ts +++ b/features/stepDefinitions/sync.ts @@ -7,6 +7,11 @@ import type { IWorkspace } from '../../src/services/workspaces/interface'; import { getSettingsPath, getWikiTestRootPath } from '../supports/paths'; import type { ApplicationWorld } from './application'; +function cleanupPathBestEffort(targetPath: string): void { + // Git child processes can keep short-lived handles on Windows after a successful push. + void fs.remove(targetPath).catch(() => {}); +} + /** * Read settings.json and find workspace by name, returning its id and port. */ @@ -119,7 +124,7 @@ Then('the remote repository {string} should contain commit with message {string} } finally { // Clean up temporary clone if (await fs.pathExists(temporaryClonePath)) { - await fs.remove(temporaryClonePath); + cleanupPathBestEffort(temporaryClonePath); } } }); @@ -171,7 +176,7 @@ Then('the remote repository {string} should contain file {string}', async functi } finally { // Clean up temporary clone if (await fs.pathExists(temporaryClonePath)) { - await fs.remove(temporaryClonePath); + cleanupPathBestEffort(temporaryClonePath); } } }); @@ -215,7 +220,7 @@ When( } } finally { if (await fs.pathExists(temporaryClonePath)) { - await fs.remove(temporaryClonePath); + cleanupPathBestEffort(temporaryClonePath); } } }, diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index 0aaaa8bb..02c25370 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -3,6 +3,7 @@ import { exec as gitExec } from 'dugite'; import { backOff } from 'exponential-backoff'; import fs from 'fs-extra'; import path from 'path'; +import { WindowNames } from '../../src/services/windows/WindowProperties'; import type { IWikiWorkspace, IWorkspace } from '../../src/services/workspaces/interface'; import { parseDataTableRows } from '../supports/dataTable'; import { getLogPath, getSettingsPath, getWikiTestRootPath, getWikiTestWikiPath } from '../supports/paths'; @@ -907,6 +908,11 @@ When('I open edit workspace window for workspace with name {string}', async func // Try to read name from tidgi.config.json if (isWikiWorkspace(workspace)) { + if (path.basename(workspace.wikiFolderLocation) === workspaceName) { + targetWorkspaceId = id; + return; + } + try { const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); if (await fs.pathExists(tidgiConfigPath)) { @@ -932,23 +938,22 @@ When('I open edit workspace window for workspace with name {string}', async func throw new Error(`No workspace found with name: ${workspaceName}`); } - // Call window service through main window's webContents to open edit workspace window - await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); + const mainWindow = await this.getWindow('main'); + if (!mainWindow) { + throw new Error('Main window not found'); + } - if (!mainWindow) { - throw new Error('Main window not found'); - } - - // Call the window service to open edit workspace window - // Safely pass workspaceId using JSON serialization to avoid string interpolation vulnerability - await mainWindow.webContents.executeJavaScript(` - (async () => { - await window.service.window.open('editWorkspace', { workspaceID: ${JSON.stringify(workspaceId)} }); - })(); - `); - }, targetWorkspaceId); + await mainWindow.evaluate(async ({ workspaceId, windowName }: { workspaceId: string; windowName: string }) => { + const serviceWindow = (window as Window & { + service: { + window: { + open: (windowName: string, meta: { workspaceID: string }, config: { recreate: boolean }) => Promise; + }; + }; + }).service.window; + const openWindow = serviceWindow.open; + await openWindow(windowName, { workspaceID: workspaceId }, { recreate: true }); + }, { workspaceId: targetWorkspaceId, windowName: WindowNames.editWorkspace }); // Wait for the edit workspace window to appear const success = await this.waitForWindowCondition( diff --git a/features/subWiki.feature b/features/subWiki.feature index 99703ee0..8d97ac72 100644 --- a/features/subWiki.feature +++ b/features/subWiki.feature @@ -146,15 +146,23 @@ Feature: Sub-Wiki Functionality | element description | selector | | wiki workspace | div[data-testid^='workspace-']:has-text('wiki') | | SubWikiSettings workspace | div[data-testid^='workspace-']:has-text('SubWikiSettings') | - # Open the edit workspace window using existing step + # Main workspace should list its bound sub-workspaces and provide direct edit entry + When I open edit workspace window for workspace with name "wiki" + And I switch to "editWorkspace" window + And I wait for the page to load completely + Then I should see "main workspace sub-workspace bindings" elements with selectors: + | element description | selector | + | sub-workspace options accordion | [data-testid='preference-section-subWorkspaceOptions'] | + | bound sub-workspace row | [data-testid='bound-sub-workspace-row']:has-text('SubWikiSettings'):has-text('SettingsTag') | + | open sub-workspace settings button| [data-testid='open-sub-workspace-settings-button'] | When I open edit workspace window for workspace with name "SubWikiSettings" And I switch to "editWorkspace" window And I wait for the page to load completely - # For sub-wikis, the accordion is defaultExpanded - Then I should see "sub-workspace options accordion and includeTagTree switch" elements with selectors: - | element description | selector | - | sub-workspace options accordion| [data-testid='preference-section-subWorkspaceOptions'] | - | includeTagTree switch | [data-testid='include-tag-tree-switch'] | + Then I should see "sub-workspace options accordion and bindings" elements with selectors: + | element description | selector | + | sub-workspace options accordion | [data-testid='preference-section-subWorkspaceOptions'] | + | main workspace select | [data-testid='main-wiki-select'] | + | includeTagTree switch | [data-testid='include-tag-tree-switch'] | # Enable includeTagTree option and save When I click on "includeTagTree switch and save button" elements with selectors: | element description | selector | diff --git a/features/sync.feature b/features/sync.feature index 00508713..5389f975 100644 --- a/features/sync.feature +++ b/features/sync.feature @@ -51,7 +51,7 @@ Feature: Git Sync """ Then I wait for tiddler "SyncMenuTestTiddler" to be added by watch-fs When I clear test-id markers from logs - When I click menu "知识库 > 立即同步云端" + When I click menu "同步和备份 > 立即同步云端" Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]" Then the remote repository "{tmpDir}/remote-repo-menu.git" should contain commit with message "使用太记桌面版备份" And the remote repository "{tmpDir}/remote-repo-menu.git" should contain file "tiddlers/SyncMenuTestTiddler.tid" @@ -86,7 +86,7 @@ Feature: Git Sync """ Then I wait for tiddler "AppendDoc" to be added by watch-fs When I clear test-id markers from logs - When I click menu "知识库 > 立即同步云端" + When I click menu "同步和备份 > 立即同步云端" Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]" When I push a commit to bare repository "{tmpDir}/remote-diverge.git" adding file "tiddlers/AppendDoc.tid" with content: @@ -118,7 +118,7 @@ Feature: Git Sync Then I wait for tiddler "AppendDoc" to be updated by watch-fs When I clear test-id markers from logs - When I click menu "知识库 > 立即同步云端" + When I click menu "同步和备份 > 立即同步云端" Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]" Then file "{tmpDir}/wiki/tiddlers/AppendDoc.tid" should contain text "Appended from external." And file "{tmpDir}/wiki/tiddlers/AppendDoc.tid" should contain text "Inserted from desktop." @@ -136,7 +136,7 @@ Feature: Git Sync """ Then I wait for tiddler "ConflictDoc" to be added by watch-fs When I clear test-id markers from logs - When I click menu "知识库 > 立即同步云端" + When I click menu "同步和备份 > 立即同步云端" Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]" When I push a commit to bare repository "{tmpDir}/remote-diverge.git" adding file "tiddlers/ConflictDoc.tid" with content: @@ -161,7 +161,7 @@ Feature: Git Sync Then I wait for tiddler "ConflictDoc" to be updated by watch-fs When I clear test-id markers from logs - When I click menu "知识库 > 立即同步云端" + When I click menu "同步和备份 > 立即同步云端" Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]" Then file "{tmpDir}/wiki/tiddlers/ConflictDoc.tid" should contain text "External edited this line." And file "{tmpDir}/wiki/tiddlers/ConflictDoc.tid" should contain text "Desktop edited this line." diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index b36cec47..2568cea3 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -83,6 +83,7 @@ "WorkspaceFolder": "Location of workspace's folder", "UseTidgiConfigWhenImport": "Apply workspace config from tidgi.config.json", "UseTidgiConfigWhenImportDescription": "When enabled, import uses the id and synced workspace settings from tidgi.config.json. If the id already exists locally, import is rejected.", + "FilledFromTidgiConfig": "Form fields pre-filled from tidgi.config.json (isSubWiki, tags, main wiki link)", "WorkspaceFolderNameToCreate": "The name of the new workspace folder", "WorkspaceParentFolder": "Parent Folder of workspace's folder", "WorkspaceUserName": "Workspace User Name", @@ -181,14 +182,20 @@ "HTTPSUploadCert": "Add Cert file", "HTTPSUploadKey": "Add Key file", "HibernateDescription": "Save CPU usage, memory and battery. This will disable auto sync, you need to manually commit and sync to backup data.", - "HibernateTitle": "Hibernate when not used", + "HibernateTitle": "Enable auto-hibernate when not used", "IgnoreSymlinks": "Ignore Symlinks", "IgnoreSymlinksDescription": "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.", "IsSubWorkspace": "Is SubWorkspace", + "IsSubWorkspaceDescription": "Attaches this workspace to a main wiki as a sub-wiki, allowing tiddlers with specific tags to be routed here.", + "BoundSubWorkspacesTitle": "Linked Sub-Workspaces", + "BoundSubWorkspacesDescription": "The following sub-workspaces are linked to this main workspace. Click \"Open Settings\" to edit a sub-workspace in a new window.", + "OpenSubWorkspaceSettings": "Open Settings", "LastNodeJSArgv": "Command line arguments from the latest startup", "LastVisitState": "Last page visited", "MainWorkspacePath": "Main Workspace Path", "MiscOptions": "Misc", + "SubWorkspaceNoTagBindings": "No tag bindings configured", + "SubWorkspaceTagBindings": "Tag bindings", "MoveWorkspace": "Move Workspace...", "MoveWorkspaceFailed": "Failed to Move Workspace", "MoveWorkspaceFailedMessage": "Failed to move workspace", @@ -418,6 +425,7 @@ "SelectNextWorkspace": "Select Next Workspace", "SelectPreviousWorkspace": "Select Previous Workspace", "TidGi": "TidGi", + "Sync": "Sync & Backup", "TidGiMiniWindow": "TidGi Mini Window", "View": "View", "Wiki": "Wiki", @@ -514,6 +522,56 @@ "OpenV8CacheFolderDetail": "The V8 cache folder stores cached files that accelerate application startup", "OpenInstallerLogFolder": "Open the installer log folder", "OpenInstallerLogFolderDetail": "The Windows installer log folder (SquirrelTemp) contains logs from application installation and updates", + "ViewDebugPanel": "View Debug Panel", + "ViewDebugPanelDetail": "Show all open WebContentsView instances with their bounds, memory usage, URL, and DevTools access", + "ViewDebugRefresh": "Refresh", + "DiagPanel": "Process & View Diagnostics", + "DiagPanelDetail": "Snapshot all Electron processes, TiddlyWiki workers and renderer views in one place", + "WorkerDebugPanel": "Worker Debug Panel", + "WorkerDebugPanelDetail": "Show the running status of all TiddlyWiki worker threads, including port numbers and DevTools access", + "WorkerDebugRefresh": "Refresh", + "ProcessInfoPanel": "Process Info", + "ProcessInfoPanelDetail": "Snapshot current memory usage of all Electron processes — useful for identifying memory growth", + "ProcessInfoMainNode": "Node.js Main Process", + "ProcessInfoRenderers": "Renderer Processes", + "ProcessInfoRenderersEmpty": "No active renderer processes", + "ProcessInfoTitle": "Title / Label", + "ProcessInfoType": "Type", + "ProcessInfoRSS": "RSS (MB)", + "ProcessInfoRSSTooltip": "Resident Set Size — total OS memory allocated to the process (heap + stack + code)", + "ProcessInfoHeapUsed": "Heap Used (MB)", + "ProcessInfoHeapUsedTooltip": "V8 heap memory currently occupied by live JavaScript objects", + "ProcessInfoHeapTotal": "Heap Total (MB)", + "ProcessInfoHeapTotalTooltip": "Total V8 heap capacity committed (used + free reserve)", + "ProcessInfoExternal": "External (MB)", + "ProcessInfoExternalTooltip": "Memory used by C++ objects bound to JS (e.g. Buffer, ArrayBuffer)", + "WorkerDebugThreadId": "Thread ID", + "WorkerDebugThreadIdTooltip": "Node.js worker_threads thread ID — unique within this process (not an OS PID)", + "CopiedToClipboard": "Copied to clipboard", + "RendererPrivateMem": "Private (MB)", + "RendererPrivateMemTooltip": "Private memory: OS pages owned exclusively by this renderer process — this is what Task Manager shows. On non-Windows shows working set (physical RAM) prefixed with ~", + "RendererCPU": "CPU %", + "RendererCPUTooltip": "CPU usage of this renderer process since the last snapshot. Matches the CPU column in Task Manager's Details tab.", + "RendererPIDTooltip": "Process ID — to identify which TidGi entry in Task Manager corresponds to this row, open Task Manager → Details tab and match the PID column", + "WorkerDebugWorkspace": "Workspace", + "WorkerDebugPort": "Port", + "WorkerDebugStatus": "Status", + "WorkerDebugActions": "Actions", + "WorkerDebugRunning": "Running", + "WorkerDebugStopped": "Stopped", + "WorkerDebugOpenBrowser": "Open in Browser", + "WorkerDebugRestart": "Restart", + "WorkerDebugEmpty": "No running workers", + "ViewDebugWorkspace": "Workspace", + "ViewDebugWindow": "Window", + "ViewDebugBounds": "Bounds", + "ViewDebugMemory": "Memory", + "ViewDebugURL": "URL", + "ViewDebugActions": "Actions", + "ViewDebugPrivateMemory": "Private: {{kb}} KB", + "ViewDebugSharedMemory": "Shared: {{kb}} KB", + "ViewDebugDestroyed": "destroyed", + "ViewDebugEmpty": "No views registered", "Performance": "Performance", "PrivacyAndSecurity": "Privacy & Security", "ReceivePreReleaseUpdates": "Receive pre-release updates", diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index 5583a6cd..175fe380 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -83,6 +83,7 @@ "WorkspaceFolder": "工作区文件夹的位置", "UseTidgiConfigWhenImport": "导入时使用 tidgi.config.json 工作区配置", "UseTidgiConfigWhenImportDescription": "启用后会使用 tidgi.config.json 中的 id 与工作区设置导入;若该 id 已在本地存在,则拒绝导入。", + "FilledFromTidgiConfig": "已从 tidgi.config.json 自动填写表单(isSubWiki、标签、主工作区关联)", "WorkspaceFolderNameToCreate": "即将新建的知识库文件夹名", "WorkspaceParentFolder": "文件夹所在的父文件夹", "WorkspaceUserName": "工作区编辑者名", @@ -181,14 +182,20 @@ "HTTPSUploadCert": "添加Cert文件", "HTTPSUploadKey": "添加Key文件", "HibernateDescription": "在工作区未使用时休眠以节省 CPU 和内存消耗并省电,这会关闭所有自动同步功能,需要手动同步备份数据。", - "HibernateTitle": "开启休眠", + "HibernateTitle": "开启自动休眠", "IgnoreSymlinks": "忽略符号链接", "IgnoreSymlinksDescription": "符号链接类似快捷方式,旧版曾用它实现子工作区功能,但新版已经不再需要。你可以手动删除遗留的符号链接。", "IsSubWorkspace": "是子工作区", + "IsSubWorkspaceDescription": "将此工作区附加到主工作区,成为子工作区,带有指定标签的条目将被路由保存到这里。", + "BoundSubWorkspacesTitle": "已绑定的子工作区", + "BoundSubWorkspacesDescription": "以下子工作区已链接到此主工作区,点击「打开设置」可在新窗口中编辑对应子工作区的配置。", + "OpenSubWorkspaceSettings": "打开设置", "LastNodeJSArgv": "最近一次启动的命令行参数", "LastVisitState": "上次访问的页面", "MainWorkspacePath": "主工作区路径", "MiscOptions": "杂项设置", + "SubWorkspaceNoTagBindings": "未配置标签绑定", + "SubWorkspaceTagBindings": "标签绑定", "MoveWorkspace": "移动工作区...", "MoveWorkspaceFailed": "移动工作区失败", "MoveWorkspaceFailedMessage": "移动工作区失败", @@ -418,6 +425,7 @@ "SelectNextWorkspace": "选择下一个工作区", "SelectPreviousWorkspace": "选择前一个工作区", "TidGi": "太记", + "Sync": "同步和备份", "TidGiMiniWindow": "太记小窗", "View": "查看", "Wiki": "知识库", @@ -532,6 +540,56 @@ "OpenV8CacheFolderDetail": "V8缓存文件夹存有加速应用启动的快取文件", "OpenInstallerLogFolder": "打开安装包日志文件夹", "OpenInstallerLogFolderDetail": "Windows 安装程序日志文件夹 (SquirrelTemp) 包含应用安装和更新的日志", + "ViewDebugPanel": "视图调试面板", + "ViewDebugPanelDetail": "显示所有已打开的 WebContentsView 实例,包括它们的位置、内存占用、URL 和 DevTools 入口", + "ViewDebugRefresh": "刷新", + "DiagPanel": "进程与视图诊断", + "DiagPanelDetail": "在一处快照所有 Electron 进程、TiddlyWiki Worker 线程和渲染视图", + "WorkerDebugPanel": "Worker 调试面板", + "WorkerDebugPanelDetail": "显示所有 TiddlyWiki Worker 线程的运行状态,包括端口号和 DevTools 入口", + "WorkerDebugRefresh": "刷新", + "ProcessInfoPanel": "进程信息", + "ProcessInfoPanelDetail": "快照当前所有 Electron 进程的内存占用,用于排查内存增长", + "ProcessInfoMainNode": "Node.js 主进程", + "ProcessInfoRenderers": "渲染进程", + "ProcessInfoRenderersEmpty": "暂无活跃的渲染进程", + "ProcessInfoTitle": "标题 / 标签", + "ProcessInfoType": "类型", + "ProcessInfoRSS": "RSS(MB)", + "ProcessInfoRSSTooltip": "常驻集大小 — 操作系统分配给进程的全部内存(堆 + 栈 + 代码段)", + "ProcessInfoHeapUsed": "已用堆(MB)", + "ProcessInfoHeapUsedTooltip": "V8 当前被存活 JavaScript 对象占用的堆内存", + "ProcessInfoHeapTotal": "堆总量(MB)", + "ProcessInfoHeapTotalTooltip": "V8 已提交的堆总容量(已用 + 空闲预留)", + "ProcessInfoExternal": "外部(MB)", + "ProcessInfoExternalTooltip": "绑定到 JS 的 C++ 对象所用内存(如 Buffer、ArrayBuffer)", + "WorkerDebugThreadId": "线程 ID", + "WorkerDebugThreadIdTooltip": "Node.js worker_threads 线程 ID — 在本进程内唯一(不是操作系统 PID)", + "CopiedToClipboard": "已复制到剪贴板", + "RendererPrivateMem": "私有内存(MB)", + "RendererPrivateMemTooltip": "私有内存:该渲染进程独占的操作系统内存页 — 即 Windows 任务管理器里显示的内存占用数字。非 Windows 平台显示工作集(物理 RAM),前缀 ~ 表示是工作集大小,不是私有内存", + "RendererCPU": "CPU %", + "RendererCPUTooltip": "该渲染进程自上次快照以来的 CPU 占用率,对应任务管理器详细信息页面的 CPU 列。", + "RendererPIDTooltip": "进程号 — 要对应任务管理器里的哪个 TidGi,打开任务管理器 → 详细信息标签页,对比 PID 列即可确认", + "WorkerDebugWorkspace": "工作区", + "WorkerDebugPort": "端口", + "WorkerDebugStatus": "状态", + "WorkerDebugActions": "操作", + "WorkerDebugRunning": "运行中", + "WorkerDebugStopped": "已停止", + "WorkerDebugOpenBrowser": "在浏览器打开", + "WorkerDebugRestart": "重启", + "WorkerDebugEmpty": "暂无运行中的 Worker", + "ViewDebugWorkspace": "工作区", + "ViewDebugWindow": "窗口", + "ViewDebugBounds": "位置/尺寸", + "ViewDebugMemory": "内存", + "ViewDebugURL": "URL", + "ViewDebugActions": "操作", + "ViewDebugPrivateMemory": "私有: {{kb}} KB", + "ViewDebugSharedMemory": "共享: {{kb}} KB", + "ViewDebugDestroyed": "已销毁", + "ViewDebugEmpty": "暂无注册的视图", "Performance": "性能", "PrivacyAndSecurity": "隐私和安全", "ProviderAddedSuccessfully": "提供商已成功添加", diff --git a/package.json b/package.json index 6555bd3c..47ab1b25 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,6 @@ "@rjsf/utils": "6.1.2", "@rjsf/validator-ajv8": "6.1.2", "@tomplum/react-git-log": "^3.5.0", - "@types/react-window": "^2.0.0", - "@types/react-window-infinite-loader": "^2.0.0", "ai": "^5.0.98", "ansi-to-html": "^0.7.2", "app-path": "^4.0.0", @@ -167,6 +165,7 @@ "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", + "baseline-browser-mapping": "^2.10.9", "chai": "6.2.0", "cross-env": "10.1.0", "dprint": "^0.50.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c416f283..03067858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,12 +83,6 @@ importers: '@tomplum/react-git-log': specifier: ^3.5.0 version: 3.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@types/react-window': - specifier: ^2.0.0 - version: 2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@types/react-window-infinite-loader': - specifier: ^2.0.0 - version: 2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) ai: specifier: ^5.0.98 version: 5.0.98(zod@4.1.12) @@ -378,6 +372,9 @@ importers: '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + baseline-browser-mapping: + specifier: ^2.10.9 + version: 2.10.9 chai: specifier: 6.2.0 version: 6.2.0 @@ -2608,14 +2605,6 @@ packages: peerDependencies: '@types/react': '*' - '@types/react-window-infinite-loader@2.0.0': - resolution: {integrity: sha512-2YYQRI77fduB8sQJ9h43FR5qIC7C8qodN6waHcWZQmkc+PFw9vKjAs5WUeJHZZk9EGQDy95umYegK4CgZ1CYOw==} - deprecated: This is a stub types definition. react-window-infinite-loader provides its own type definitions, so you do not need this installed. - - '@types/react-window@2.0.0': - resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==} - deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed. - '@types/react@19.2.6': resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} @@ -3295,8 +3284,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.30: - resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==} + baseline-browser-mapping@2.10.9: + resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + engines: {node: '>=6.0.0'} hasBin: true basic-auth@2.0.1: @@ -10378,20 +10368,6 @@ snapshots: dependencies: '@types/react': 19.2.6 - '@types/react-window-infinite-loader@2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - react-window-infinite-loader: 2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - transitivePeerDependencies: - - react - - react-dom - - '@types/react-window@2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - react-window: 2.2.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - transitivePeerDependencies: - - react - - react-dom - '@types/react@19.2.6': dependencies: csstype: 3.2.3 @@ -11197,7 +11173,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.30: {} + baseline-browser-mapping@2.10.9: {} basic-auth@2.0.1: dependencies: @@ -11271,7 +11247,7 @@ snapshots: browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.30 + baseline-browser-mapping: 2.10.9 caniuse-lite: 1.0.30001756 electron-to-chromium: 1.5.259 node-releases: 2.0.27 diff --git a/src/constants/appPaths.ts b/src/constants/appPaths.ts index 4fa60c24..89fab9b0 100644 --- a/src/constants/appPaths.ts +++ b/src/constants/appPaths.ts @@ -4,7 +4,7 @@ import { __TEST__ as v8CompileCacheLibrary } from 'v8-compile-cache-lib'; import { slugify } from '../helpers/slugify'; import { isElectronDevelopment, isTest } from './environment'; import { cacheDatabaseFolderName, httpsCertKeyFolderName, settingFolderName } from './fileNames'; -import { sourcePath } from './paths'; +import { DEFAULT_FIRST_WIKI_FOLDER_PATH as PATHS_DEFAULT_FIRST_WIKI_FOLDER_PATH, DEFAULT_FIRST_WIKI_NAME, sourcePath } from './paths'; /** * Application Path Configuration @@ -69,3 +69,12 @@ export const LOCAL_GIT_DIRECTORY = isPackaged export const LOG_FOLDER = path.resolve(USER_DATA_FOLDER, 'logs'); export const V8_CACHE_FOLDER = v8CompileCacheLibrary.getCacheDir(); export const DEFAULT_DOWNLOADS_PATH = path.join(app.getPath('home'), 'Downloads'); + +// Use Electron's app.getPath('desktop') which correctly resolves the Desktop folder even when it has +// been redirected (e.g. OneDrive Desktop sync on Windows). path.join(os.homedir(), 'Desktop') can +// point to a non-existent path in such environments and causes E-3 errors when creating a new wiki. +// For dev/test keep the paths.ts value (which has the proper test isolation logic). +export const DEFAULT_FIRST_WIKI_FOLDER_PATH = (isElectronDevelopment || isTest) + ? PATHS_DEFAULT_FIRST_WIKI_FOLDER_PATH + : app.getPath('desktop'); +export const DEFAULT_FIRST_WIKI_PATH = path.join(DEFAULT_FIRST_WIKI_FOLDER_PATH, DEFAULT_FIRST_WIKI_NAME); diff --git a/src/constants/environment.ts b/src/constants/environment.ts index de07f6e8..d875ac4b 100644 --- a/src/constants/environment.ts +++ b/src/constants/environment.ts @@ -1,5 +1,8 @@ import { isElectronDevelopment } from './isElectronDevelopment'; export { isElectronDevelopment }; -export const isTest = process.env.NODE_ENV === 'test'; +const hasTestScenarioArgument = process.argv.some((argument) => argument.startsWith('--test-scenario=')); + +// Packaged e2e runs do not reliably preserve NODE_ENV, so we also key off the explicit scenario arg. +export const isTest = process.env.NODE_ENV === 'test' || hasTestScenarioArgument; export const isDevelopmentOrTest = isElectronDevelopment || isTest; diff --git a/src/main.ts b/src/main.ts index 62e210f5..0d216f0a 100755 --- a/src/main.ts +++ b/src/main.ts @@ -47,7 +47,9 @@ import type { IWindowService } from './services/windows/interface'; import type { IWorkspaceService } from './services/workspaces/interface'; import type { IWorkspaceViewService } from './services/workspacesView/interface'; -logger.info('App booting'); +logger.info('App booting', { pid: process.pid }); +// Label the Node.js main process so it stands out in the OS process list +process.title = 'TidGi [Node-Main]'; if (process.env.DEBUG_MAIN === 'true') { inspector.open(); inspector.waitForDebugger(); @@ -216,6 +218,7 @@ const commonInit = async (): Promise => { mainWindow.on('unmaximize', handleMaximize); } } + nativeService.startProcessMonitoring(); // trigger whenTrulyReady ipcMain.emit(MainChannel.commonInitFinished); }; diff --git a/src/pages/Main/index.tsx b/src/pages/Main/index.tsx index eceeb341..73426240 100644 --- a/src/pages/Main/index.tsx +++ b/src/pages/Main/index.tsx @@ -74,7 +74,7 @@ export default function Main(): React.JSX.Element { return ( - {t('Menu.TidGi')}{isTidgiMiniWindow ? ` - ${t('Menu.TidGiMiniWindow')}` : ''} + {t('Menu.TidGi')}{isTidgiMiniWindow ? ` [${t('Menu.TidGiMiniWindow')}]` : ' [App]'} {showSidebar && } diff --git a/src/services/agentInstance/tools/modelContextProtocol.ts b/src/services/agentInstance/tools/modelContextProtocol.ts index 53dc9650..5aa1d9e9 100644 --- a/src/services/agentInstance/tools/modelContextProtocol.ts +++ b/src/services/agentInstance/tools/modelContextProtocol.ts @@ -78,7 +78,7 @@ async function connectAndListTools(config: ModelContextProtocolParameter, agentI try { // Dynamic import to handle cases where SDK isn't installed. // Use /* @vite-ignore */ so Vite/Vitest don't try to resolve the path at build time. - /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, import/no-unresolved */ + /* eslint-disable-next-line import/no-unresolved */ const { Client } = await import(/* @vite-ignore */ '@modelcontextprotocol/sdk/client/index.js'); const client = new Client({ name: 'TidGi-Agent', version: '1.0.0' }, { capabilities: {} }); @@ -87,10 +87,12 @@ async function connectAndListTools(config: ModelContextProtocolParameter, agentI if (config.command) { // Stdio transport + /* eslint-disable-next-line import/no-unresolved */ const { StdioClientTransport } = await import(/* @vite-ignore */ '@modelcontextprotocol/sdk/client/stdio.js'); transport = new StdioClientTransport({ command: config.command, args: config.args ?? [] }); } else if (config.serverUrl) { // SSE transport + /* eslint-disable-next-line import/no-unresolved */ const { SSEClientTransport } = await import(/* @vite-ignore */ '@modelcontextprotocol/sdk/client/sse.js'); transport = new SSEClientTransport(new URL(config.serverUrl)); } else { @@ -112,7 +114,6 @@ async function connectAndListTools(config: ModelContextProtocolParameter, agentI logger.info('MCP connected', { agentId, toolCount: tools.length, tools: tools.map((t: { name: string }) => t.name) }); return tools; - /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, import/no-unresolved */ } catch (error) { logger.error('MCP connection failed', { error, agentId }); return []; diff --git a/src/services/database/index.ts b/src/services/database/index.ts index 2880bdfc..222e79ff 100644 --- a/src/services/database/index.ts +++ b/src/services/database/index.ts @@ -14,7 +14,7 @@ import { DEBOUNCE_SAVE_SETTING_BACKUP_FILE, DEBOUNCE_SAVE_SETTING_FILE } from '@ import { SQLITE_BINARY_PATH } from '@/constants/paths'; import { logger } from '@services/libs/log'; import { BaseDataSourceOptions } from 'typeorm/data-source/BaseDataSourceOptions.js'; -import { ensureSettingFolderExist, fixSettingFileWhenError } from './configSetting'; +import { ensureSettingFolderExist, fixSettingFileWhenError, readTidgiConfig } from './configSetting'; import type { DatabaseInitOptions, IDatabaseService, ISettingFile } from './interface'; import { AgentDefinitionEntity, AgentInstanceEntity, AgentInstanceMessageEntity, ScheduledTaskEntity } from './schema/agent'; import { AgentBrowserTabEntity } from './schema/agentBrowser'; @@ -48,7 +48,13 @@ export class DatabaseService implements IDatabaseService { }); // Initialize settings folder and load settings ensureSettingFolderExist(); - this.settingFileContent = settings.getSync() as unknown as ISettingFile; + const rawSettings = settings.getSync(); + // Guard against corrupted settings files that contain a non-object root value (e.g. a JSON string). + // Such files pass JSON.parse without error but cause "Cannot create property 'x' on string" when + // setSetting() tries to write into them. + this.settingFileContent = (rawSettings !== null && typeof rawSettings === 'object' && !Array.isArray(rawSettings)) + ? rawSettings as unknown as ISettingFile + : {} as ISettingFile; // Initialize settings backup stream try { this.settingBackupStream = rotateFs.createStream(`settings.json.bak`, { @@ -304,6 +310,10 @@ export class DatabaseService implements IDatabaseService { } } + public async readWikiConfig(wikiFolderLocation: string) { + return readTidgiConfig(wikiFolderLocation); + } + /** * Close database connection for a given key */ diff --git a/src/services/database/interface.ts b/src/services/database/interface.ts index 64e21858..d83eb3b2 100644 --- a/src/services/database/interface.ts +++ b/src/services/database/interface.ts @@ -2,7 +2,7 @@ import { DatabaseChannel } from '@/constants/channels'; import type { IUserInfos } from '@services/auth/interface'; import { AIGlobalSettings } from '@services/externalAPI/interface'; import type { IPreferences } from '@services/preferences/interface'; -import type { IWorkspace } from '@services/workspaces/interface'; +import type { ISyncableWikiConfig, IWorkspace } from '@services/workspaces/interface'; import { ProxyPropertyType } from 'electron-ipc-cat/common'; import { DataSource } from 'typeorm'; @@ -82,6 +82,13 @@ export interface IDatabaseService { * Delete the database file for a given key and close any active connection. */ deleteDatabase(key: string): Promise; + + /** + * Read tidgi.config.json from a wiki folder and return the syncable config. + * Exposed over IPC so the renderer can pre-fill the Add Workspace form when + * importing an existing wiki with "use tidgi.config" enabled. + */ + readWikiConfig(wikiFolderLocation: string): Promise | undefined>; } export const DatabaseServiceIPCDescriptor = { @@ -95,5 +102,6 @@ export const DatabaseServiceIPCDescriptor = { getDatabaseInfo: ProxyPropertyType.Function, getDatabasePath: ProxyPropertyType.Function, deleteDatabase: ProxyPropertyType.Function, + readWikiConfig: ProxyPropertyType.Function, }, }; diff --git a/src/services/database/settingsInit.ts b/src/services/database/settingsInit.ts index 789a6383..ac2e4422 100644 --- a/src/services/database/settingsInit.ts +++ b/src/services/database/settingsInit.ts @@ -34,9 +34,14 @@ export function fixSettingFileWhenError(jsonError: Error, providedJSONContent?: { logPrefix: 'settings.json', writeBack: false }, ); - if (repaired) { + // Only write back if the repaired result is a plain object; a non-object (e.g. a string returned by + // best-effort-json-parser for bare-string content) would corrupt the file again. + if (repaired !== null && typeof repaired === 'object' && !Array.isArray(repaired)) { fs.writeJSONSync(settings.file(), repaired); logger.info('Fix JSON content done, saved', { repaired }); + } else if (repaired !== undefined) { + logger.warn('fixSettingFileWhenError: repaired value is not a plain object, resetting settings to {}', { type: typeof repaired }); + fs.writeJSONSync(settings.file(), {}); } } @@ -49,8 +54,14 @@ function fixEmptyAndErrorSettingFileOnStartUp() { if (fs.existsSync(settings.file())) { try { logger.info('Checking Setting file format.'); - fs.readJsonSync(settings.file()); - logger.info('Setting file format good.'); + const content = fs.readJsonSync(settings.file()) as unknown; + // A valid JSON string at the root (e.g. `"wiki"`) passes JSON.parse but breaks property assignment. + if (content === null || typeof content !== 'object' || Array.isArray(content)) { + logger.warn('Settings file has non-object root value, resetting to {}'); + fs.writeJSONSync(settings.file(), {}); + } else { + logger.info('Setting file format good.'); + } } catch (jsonError) { fixSettingFileWhenError(jsonError as Error); } diff --git a/src/services/database/tidgiConfig.ts b/src/services/database/tidgiConfig.ts index e026a0d4..11ff2fb0 100644 --- a/src/services/database/tidgiConfig.ts +++ b/src/services/database/tidgiConfig.ts @@ -228,7 +228,6 @@ export async function writeTidgiConfig(wikiFolderLocation: string, config: Parti delete (mergedConfig as Record)[field]; } } - delete (mergedConfig as Record).port; if (!('id' in nonDefaultConfig)) { delete (mergedConfig as Record).id; } diff --git a/src/services/git/index.ts b/src/services/git/index.ts index 490aaff0..c2063da4 100644 --- a/src/services/git/index.ts +++ b/src/services/git/index.ts @@ -17,7 +17,6 @@ import { logger } from '@services/libs/log'; import type { INativeService } from '@services/native/interface'; import type { IPreferenceService } from '@services/preferences/interface'; import serviceIdentifier from '@services/serviceIdentifier'; -import type { IViewService } from '@services/view/interface'; import type { IWikiService } from '@services/wiki/interface'; import type { IWindowService } from '@services/windows/interface'; import { WindowNames } from '@services/windows/WindowProperties'; @@ -121,12 +120,6 @@ export class Git implements IGitService { // at least 'http://', but in some case it might be shorter, like 'a.b' if (remoteUrl === undefined || remoteUrl.length < 3) return; if (branch === undefined) return; - const viewService = container.get(serviceIdentifier.View); - const browserView = viewService.getView(workspace.id, WindowNames.main); - if (browserView === undefined) { - logger.error(`no browserView in updateGitInfoTiddler for ID ${workspace.id}`); - return; - } // "/tiddly-gittly/TidGi-Desktop/issues/370" const { pathname } = new URL(remoteUrl); // [ "", "tiddly-gittly", "TidGi-Desktop", "issues", "370" ] @@ -136,11 +129,14 @@ export class Git implements IGitService { */ const githubRepoName = `${userName}/${repoName}`; const wikiService = container.get(serviceIdentifier.Wiki); + // Use wikiOperationInServer so the write goes directly through the wiki worker's filesystem + // adapter, which has boot.files correctly populated and can overwrite the existing .tid file + // without appending numeric suffixes. if (await wikiService.wikiOperationInServer(WikiChannel.getTiddlerText, workspace.id, ['$:/GitHub/Repo']) !== githubRepoName) { - await wikiService.wikiOperationInBrowser(WikiChannel.addTiddler, workspace.id, ['$:/GitHub/Repo', githubRepoName]); + await wikiService.wikiOperationInServer(WikiChannel.addTiddler, workspace.id, ['$:/GitHub/Repo', githubRepoName]); } if (await wikiService.wikiOperationInServer(WikiChannel.getTiddlerText, workspace.id, ['$:/GitHub/Branch']) !== branch) { - await wikiService.wikiOperationInBrowser(WikiChannel.addTiddler, workspace.id, ['$:/GitHub/Branch', branch]); + await wikiService.wikiOperationInServer(WikiChannel.addTiddler, workspace.id, ['$:/GitHub/Branch', branch]); } } diff --git a/src/services/git/menuItems.ts b/src/services/git/menuItems.ts index 8c857efe..ce3c32b1 100644 --- a/src/services/git/menuItems.ts +++ b/src/services/git/menuItems.ts @@ -1,7 +1,5 @@ import { DeferredMenuItemConstructorOptions } from '@services/menu/interface'; import type { ISyncService } from '@services/sync/interface'; -import { IWindowService } from '@services/windows/interface'; -import { WindowNames } from '@services/windows/WindowProperties'; import type { IWorkspace } from '@services/workspaces/interface'; import { isWikiWorkspace } from '@services/workspaces/interface'; import type { TFunction } from 'i18next'; @@ -17,7 +15,6 @@ import type { TFunction } from 'i18next'; export function createBackupMenuItems( workspace: IWorkspace, t: TFunction, - windowService: Pick, syncService: Pick, aiEnabled: boolean, ): DeferredMenuItemConstructorOptions[]; @@ -34,7 +31,6 @@ export function createBackupMenuItems( export function createBackupMenuItems( workspace: IWorkspace, t: TFunction, - windowService: Pick, syncService: Pick, aiEnabled: boolean, useDeferred: false, @@ -43,7 +39,6 @@ export function createBackupMenuItems( export function createBackupMenuItems( workspace: IWorkspace, t: TFunction, - windowService: Pick, syncService: Pick, aiEnabled: boolean, _useDeferred: boolean = true, @@ -52,32 +47,18 @@ export function createBackupMenuItems( return []; } - const baseItems = [ - { - label: t('WorkspaceSelector.ViewGitHistory'), - click: async () => { - await windowService.open(WindowNames.gitHistory, { workspaceID: workspace.id }, { recreate: true }); - }, - }, - { type: 'separator' as const }, - { - label: t('ContextMenu.BackupNow'), - click: async () => { - await syncService.syncWikiIfNeeded(workspace, { commitMessage: t('LOG.CommitBackupMessage') }); - }, - }, - ]; - - if (aiEnabled) { - baseItems.push({ - label: t('ContextMenu.BackupNow') + t('ContextMenu.WithAI'), - click: async () => { + const baseItem = { + label: aiEnabled ? t('ContextMenu.BackupNow') + t('ContextMenu.WithAI') : t('ContextMenu.BackupNow'), + click: async () => { + if (aiEnabled) { await syncService.syncWikiIfNeeded(workspace, { useAICommitMessage: true }); - }, - }); - } + } else { + await syncService.syncWikiIfNeeded(workspace, { commitMessage: t('LOG.CommitBackupMessage') }); + } + }, + }; - return baseItems; + return [baseItem]; } /** @@ -129,32 +110,20 @@ export function createSyncMenuItems( } const offlineText = isOnline ? '' : ` (${t('ContextMenu.NoNetworkConnection')})`; - - if (aiEnabled) { - return [ - { - label: t('ContextMenu.SyncNow') + offlineText, - enabled: isOnline, - click: async () => { - await syncService.syncWikiIfNeeded(workspace, { commitMessage: t('LOG.CommitBackupMessage') }); - }, - }, - { - label: t('ContextMenu.SyncNow') + t('ContextMenu.WithAI') + offlineText, - enabled: isOnline, - click: async () => { - await syncService.syncWikiIfNeeded(workspace, { useAICommitMessage: true }); - }, - }, - ]; - } + const label = aiEnabled + ? t('ContextMenu.SyncNow') + t('ContextMenu.WithAI') + offlineText + : t('ContextMenu.SyncNow') + offlineText; return [ { - label: t('ContextMenu.SyncNow') + offlineText, + label, enabled: isOnline, click: async () => { - await syncService.syncWikiIfNeeded(workspace, { commitMessage: t('LOG.CommitBackupMessage') }); + if (aiEnabled) { + await syncService.syncWikiIfNeeded(workspace, { useAICommitMessage: true }); + } else { + await syncService.syncWikiIfNeeded(workspace, { commitMessage: t('LOG.CommitBackupMessage') }); + } }, }, ]; diff --git a/src/services/git/registerMenu.ts b/src/services/git/registerMenu.ts index 0a20dc03..11a97486 100644 --- a/src/services/git/registerMenu.ts +++ b/src/services/git/registerMenu.ts @@ -141,11 +141,10 @@ export async function registerMenu(): Promise { }); } - // Add to Wiki menu - basic items (each item checks for active wiki workspace) + // Add to Sync menu - git history, backup, and sync items await menuService.insertMenu( - 'Wiki', + 'Sync', [ - { type: 'separator', visible: hasActiveWikiWorkspace }, { label: () => i18n.t('WorkspaceSelector.ViewGitHistory'), id: 'git-history', diff --git a/src/services/gitServer/index.ts b/src/services/gitServer/index.ts index 61807526..c8233182 100644 --- a/src/services/gitServer/index.ts +++ b/src/services/gitServer/index.ts @@ -112,6 +112,9 @@ export class GitServerService implements IGitServerService { git = gitSpawn([service.replace('git-', ''), '--stateless-rpc', '--advertise-refs', repoPath], repoPath, { env: { GIT_PROJECT_ROOT: repoPath, GIT_HTTP_EXPORT_ALL: '1' }, }); + if (!git.stdout || !git.stderr) { + throw new Error('Git stdio streams are unavailable for info/refs'); + } git.stdout.on('data', (data: Buffer) => { subscriber.next({ type: 'data', data: new Uint8Array(data) }); @@ -161,6 +164,9 @@ export class GitServerService implements IGitServerService { git = gitSpawn(['upload-pack', '--stateless-rpc', repoPath], repoPath, { env: { GIT_PROJECT_ROOT: repoPath, GIT_HTTP_EXPORT_ALL: '1' }, }); + if (!git.stdin || !git.stdout || !git.stderr) { + throw new Error('Git stdio streams are unavailable for upload-pack'); + } git.stdin.on('error', (error: Error) => { logger.debug('Git upload-pack stdin error:', { error: error.message, workspaceId }); @@ -228,6 +234,9 @@ export class GitServerService implements IGitServerService { git = gitSpawn(['-c', 'receive.denyCurrentBranch=updateInstead', 'receive-pack', '--stateless-rpc', repoPath], repoPath, { env: { GIT_PROJECT_ROOT: repoPath }, }); + if (!git.stdin || !git.stdout || !git.stderr) { + throw new Error('Git stdio streams are unavailable for receive-pack'); + } git.stdin.on('error', (error: Error) => { logger.debug('Git receive-pack stdin error:', { error: error.message, workspaceId }); diff --git a/src/services/menu/index.ts b/src/services/menu/index.ts index 4d0376be..c9e79f93 100644 --- a/src/services/menu/index.ts +++ b/src/services/menu/index.ts @@ -16,7 +16,7 @@ import type { IWikiService } from '@services/wiki/interface'; import type { IWikiGitWorkspaceService } from '@services/wikiGitWorkspace/interface'; import type { IWindowService } from '@services/windows/interface'; import { WindowNames } from '@services/windows/WindowProperties'; -import { getSimplifiedWorkspaceMenuTemplate, getWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate'; +import { getSimplifiedWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate'; import type { IWorkspaceService } from '@services/workspaces/interface'; import { isWikiWorkspace } from '@services/workspaces/interface'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; @@ -291,19 +291,8 @@ export class MenuService implements IMenuService { webContentsOrWindowName: WindowNames | WebContents = WindowNames.main, ): Promise { let webContents: WebContents; - // Get services via container to avoid lazyInject issues const windowService = container.get(serviceIdentifier.Window); const preferenceService = container.get(serviceIdentifier.Preference); - const workspaceService = container.get(serviceIdentifier.Workspace); - const authService = container.get(serviceIdentifier.Authentication); - const contextService = container.get(serviceIdentifier.Context); - const gitService = container.get(serviceIdentifier.Git); - const nativeService = container.get(serviceIdentifier.NativeService); - const viewService = container.get(serviceIdentifier.View); - const wikiService = container.get(serviceIdentifier.Wiki); - const wikiGitWorkspaceService = container.get(serviceIdentifier.WikiGitWorkspace); - const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); - const syncService = container.get(serviceIdentifier.Sync); if (typeof webContentsOrWindowName === 'string') { const windowToPopMenu = windowService.get(webContentsOrWindowName); @@ -318,86 +307,8 @@ export class MenuService implements IMenuService { const sidebar = await preferenceService.get('sidebar'); const contextMenuBuilder = new ContextMenuBuilder(webContents); const menu = contextMenuBuilder.buildMenuForElement(info); - const workspaces = await workspaceService.getWorkspacesAsList(); - const services = { - agentDefinition: container.get(serviceIdentifier.AgentDefinition), - auth: authService, - context: contextService, - externalAPI: this.externalAPIService, - git: gitService, - native: nativeService, - preference: this.preferenceService, - view: viewService, - wiki: wikiService, - wikiGitWorkspace: wikiGitWorkspaceService, - window: windowService, - workspace: workspaceService, - workspaceView: workspaceViewService, - sync: syncService, - }; - // workspace menus (template items are added at the end via insert(0) in reverse order) menu.append(new MenuItem({ type: 'separator' })); - - // Note: Simplified menu and "Current Workspace" are now provided by the frontend template - // (from SortableWorkspaceSelectorButton or content view), so we don't add them here - menu.append( - new MenuItem({ - label: i18n.t('Menu.Workspaces'), - submenu: [ - ...(await Promise.all( - workspaces.map(async (workspace) => { - const workspaceContextMenuTemplate = await getWorkspaceMenuTemplate(workspace, i18n.t.bind(i18n), services); - return { - label: workspace.name, - submenu: workspaceContextMenuTemplate, - }; - }), - )), - { - label: i18n.t('WorkspaceSelector.Add'), - click: async () => { - await container.get(serviceIdentifier.Window).open(WindowNames.addWorkspace); - }, - }, - ], - }), - ); - menu.append( - new MenuItem({ - label: i18n.t('WorkspaceSelector.OpenWorkspaceMenuName'), - submenu: workspaces.map((workspace) => ({ - label: i18n.t('WorkspaceSelector.OpenWorkspaceTagTiddler', { - tagName: isWikiWorkspace(workspace) - ? (workspace.tagNames[0] ?? (workspace.isSubWiki ? workspace.name : `${workspace.name} ${i18n.t('WorkspaceSelector.DefaultTiddlers')}`)) - : workspace.name, - }), - click: async () => { - await container.get(serviceIdentifier.Workspace).openWorkspaceTiddler(workspace); - }, - })), - }), - ); - // Note: "OpenCommandPalette" is now provided by the frontend template - menu.append(new MenuItem({ type: 'separator' })); - menu.append( - new MenuItem({ - label: i18n.t('ContextMenu.Back'), - enabled: webContents.navigationHistory.canGoBack(), - click: () => { - webContents.navigationHistory.goBack(); - }, - }), - ); - menu.append( - new MenuItem({ - label: i18n.t('ContextMenu.Forward'), - enabled: webContents.navigationHistory.canGoForward(), - click: () => { - webContents.navigationHistory.goForward(); - }, - }), - ); menu.append( new MenuItem({ label: sidebar ? i18n.t('Preference.HideSideBar') : i18n.t('Preference.ShowSideBar'), diff --git a/src/services/menu/loadDefaultMenuTemplate.ts b/src/services/menu/loadDefaultMenuTemplate.ts index 5af5f2d3..6687a013 100644 --- a/src/services/menu/loadDefaultMenuTemplate.ts +++ b/src/services/menu/loadDefaultMenuTemplate.ts @@ -51,8 +51,6 @@ export function loadDefaultMenuTemplate(): DeferredMenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+Shift+N', }, { type: 'separator' }, - { role: 'services', submenu: [] }, - { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, @@ -92,6 +90,16 @@ export function loadDefaultMenuTemplate(): DeferredMenuItemConstructorOptions[] return activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace); }, }, + { + label: () => i18n.t('Menu.Sync'), + id: 'Sync', + submenu: [], + visible: async () => { + const workspaceService = container.get(serviceIdentifier.Workspace); + const activeWorkspace = await workspaceService.getActiveWorkspace(); + return activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace); + }, + }, { label: () => i18n.t('Menu.Window'), role: 'windowMenu', diff --git a/src/services/native/index.ts b/src/services/native/index.ts index 819a1545..0e208eea 100644 --- a/src/services/native/index.ts +++ b/src/services/native/index.ts @@ -1,4 +1,4 @@ -import { app, dialog, globalShortcut, ipcMain, MessageBoxOptions, shell } from 'electron'; +import { app, dialog, globalShortcut, ipcMain, MessageBoxOptions, shell, webContents } from 'electron'; import fs from 'fs-extra'; import { inject, injectable } from 'inversify'; import path from 'path'; @@ -24,6 +24,7 @@ import { ZxNotInitializedError } from './error'; import { findEditorOrDefault, findGitGUIAppOrDefault, launchExternalEditor } from './externalApp'; import type { INativeService, IPickDirectoryOptions } from './interface'; import { getShortcutCallback, registerShortcutByKey } from './keyboardShortcutHelpers'; +import type { IProcessInfo } from './processInfo'; import { reportErrorToGithubWithTemplates } from './reportError'; @injectable() @@ -177,7 +178,10 @@ export class NativeService implements INativeService { if (showItemInFolder) { shell.showItemInFolder(filePath); } else { - await shell.openPath(filePath); + const error = await shell.openPath(filePath); + if (error) { + throw new Error(error); + } } } else { const workspaceService = container.get(serviceIdentifier.Workspace); @@ -187,7 +191,10 @@ export class NativeService implements INativeService { if (showItemInFolder) { shell.showItemInFolder(absolutePath); } else { - await shell.openPath(absolutePath); + const error = await shell.openPath(absolutePath); + if (error) { + throw new Error(error); + } } } } @@ -495,4 +502,68 @@ ${message.message} const labeledLogger = getLoggerForLabel(label); labeledLogger.log(level, message, meta); } + + public async getProcessInfo(): Promise { + const mem = process.memoryUsage(); + const toMB = (bytes: number): number => Math.round(bytes / 1024 / 1024); + // app.getAppMetrics() is synchronous and covers ALL Electron processes keyed by PID + const metricsMap = new Map(); + for (const metric of app.getAppMetrics()) { + metricsMap.set(metric.pid, metric); + } + const renderers = webContents.getAllWebContents() + .filter((c: Electron.WebContents) => !c.isDestroyed()) + .map((c: Electron.WebContents) => { + const pid = c.getOSProcessId(); + const metric = metricsMap.get(pid); + return { + pid, + title: c.getTitle().slice(0, 80), + type: c.getType(), + url: c.getURL().slice(0, 120), + isDestroyed: c.isDestroyed(), + private_KB: metric?.memory.privateBytes ?? -1, + workingSet_KB: metric?.memory.workingSetSize ?? -1, + cpu_percent: metric?.cpu.percentCPUUsage ?? -1, + }; + }); + return { + mainNode: { + pid: process.pid, + title: process.title, + rss_MB: toMB(mem.rss), + heapUsed_MB: toMB(mem.heapUsed), + heapTotal_MB: toMB(mem.heapTotal), + external_MB: toMB(mem.external), + }, + renderers, + }; + } + + public startProcessMonitoring(): void { + logger.info('Process map (match PID in task manager Details tab)', { + mainNodePID: process.pid, + processTitle: process.title, + }); + setInterval(async () => { + const info = await this.getProcessInfo(); + logger.debug('Memory snapshot - main Node process', { + pid: info.mainNode.pid, + rss_MB: info.mainNode.rss_MB, + heapUsed_MB: info.mainNode.heapUsed_MB, + heapTotal_MB: info.mainNode.heapTotal_MB, + external_MB: info.mainNode.external_MB, + }); + for (const renderer of info.renderers) { + logger.debug('Memory snapshot - renderer', { + pid: renderer.pid, + title: renderer.title, + type: renderer.type, + private_MB: renderer.private_KB > 0 ? Math.round(renderer.private_KB / 1024) : -1, + workingSet_MB: renderer.workingSet_KB > 0 ? Math.round(renderer.workingSet_KB / 1024) : -1, + cpu_percent: renderer.cpu_percent, + }); + } + }, 30_000); + } } diff --git a/src/services/native/interface.ts b/src/services/native/interface.ts index db84ad03..c3c1c36b 100644 --- a/src/services/native/interface.ts +++ b/src/services/native/interface.ts @@ -6,6 +6,8 @@ import serviceIdentifier from '@services/serviceIdentifier'; import type { IZxFileInput } from '@services/wiki/wikiWorker'; import { WindowNames } from '@services/windows/WindowProperties'; import { ProxyPropertyType } from 'electron-ipc-cat/common'; +import type { IProcessInfo } from './processInfo'; +export type { IProcessInfo, IRendererProcessInfo } from './processInfo'; export interface IPickDirectoryOptions { /** @@ -137,6 +139,10 @@ export interface INativeService { * @returns the index of the clicked button. */ showElectronMessageBoxSync(options: Electron.MessageBoxSyncOptions, windowName?: WindowNames): number | undefined; + /** Collect a point-in-time snapshot of all OS-level process memory usage. Safe to call from renderer via IPC. */ + getProcessInfo(): Promise; + /** Start a periodic (30 s) background loop that writes memory snapshots to the log file. Only called from main process, not exposed via IPC. */ + startProcessMonitoring(): void; } export const NativeServiceIPCDescriptor = { channel: NativeChannel.name, @@ -168,5 +174,6 @@ export const NativeServiceIPCDescriptor = { pickFile: ProxyPropertyType.Function, quit: ProxyPropertyType.Function, showElectronMessageBox: ProxyPropertyType.Function, + getProcessInfo: ProxyPropertyType.Function, }, }; diff --git a/src/services/native/processInfo.ts b/src/services/native/processInfo.ts new file mode 100644 index 00000000..9f273ab5 --- /dev/null +++ b/src/services/native/processInfo.ts @@ -0,0 +1,27 @@ +/** Shared data-only types for process diagnostics, safe to import in both main and renderer. */ + +export interface IRendererProcessInfo { + pid: number; + title: string; + type: string; + url: string; + isDestroyed: boolean; + /** Private (non-shared) memory in KB. Windows only; -1 on other platforms. */ + private_KB: number; + /** Working set size (physical RAM pages) in KB. */ + workingSet_KB: number; + /** CPU usage percentage at the time of the snapshot (0–100+). */ + cpu_percent: number; +} + +export interface IProcessInfo { + mainNode: { + pid: number; + title: string; + rss_MB: number; + heapUsed_MB: number; + heapTotal_MB: number; + external_MB: number; + }; + renderers: IRendererProcessInfo[]; +} diff --git a/src/services/providerRegistry/interface.ts b/src/services/providerRegistry/interface.ts new file mode 100644 index 00000000..cb35cf42 --- /dev/null +++ b/src/services/providerRegistry/interface.ts @@ -0,0 +1,33 @@ +import { ExternalAPIServiceIPCDescriptor } from '@services/externalAPI/interface'; +import type { + AIEmbeddingResponse, + AIErrorDetail, + AIGlobalSettings, + AIImageGenerationResponse, + AIProvider, + AIProviderConfig, + AISpeechResponse, + AIStreamResponse, + AITranscriptionResponse, + IExternalAPIService, + ModelFeature, + ModelInfo, +} from '@services/externalAPI/interface'; + +export type { + AIEmbeddingResponse, + AIErrorDetail, + AIGlobalSettings, + AIImageGenerationResponse, + AIProvider, + AIProviderConfig, + AISpeechResponse, + AIStreamResponse, + AITranscriptionResponse, + ModelFeature, + ModelInfo, +}; + +export type IProviderRegistryService = IExternalAPIService; + +export const ProviderRegistryServiceIPCDescriptor = ExternalAPIServiceIPCDescriptor; diff --git a/src/services/view/index.ts b/src/services/view/index.ts index 6ac35c2e..41fcbec4 100644 --- a/src/services/view/index.ts +++ b/src/services/view/index.ts @@ -428,6 +428,45 @@ export class View implements IViewService { return this.getView(workspaceID, windowName)?.webContents.getURL(); } + public async canGoBackInView(workspaceID: string, windowName: WindowNames): Promise { + return this.getView(workspaceID, windowName)?.webContents.navigationHistory.canGoBack() ?? false; + } + + public async canGoForwardInView(workspaceID: string, windowName: WindowNames): Promise { + return this.getView(workspaceID, windowName)?.webContents.navigationHistory.canGoForward() ?? false; + } + + public async goBackInView(workspaceID: string, windowName: WindowNames): Promise { + this.getView(workspaceID, windowName)?.webContents.navigationHistory.goBack(); + } + + public async goForwardInView(workspaceID: string, windowName: WindowNames): Promise { + this.getView(workspaceID, windowName)?.webContents.navigationHistory.goForward(); + } + + public async getViewsInfo(): Promise { + const results = []; + for (const [workspaceID, windowViews] of this.views.entries()) { + const workspace = await this.workspaceService.get(workspaceID); + const workspaceName = workspace?.name ?? workspaceID; + for (const [windowName, view] of windowViews.entries()) { + const destroyed = view.webContents.isDestroyed(); + const bounds = view.getBounds(); + const url = destroyed ? '' : view.webContents.getURL(); + const pid = destroyed ? -1 : view.webContents.getOSProcessId(); + results.push({ workspaceID, workspaceName, windowName, bounds, url, isDestroyed: destroyed, pid }); + } + } + return results; + } + + public openDevToolsForView(workspaceID: string, windowName: WindowNames): void { + const view = this.getView(workspaceID, windowName); + if (view !== undefined && !view.webContents.isDestroyed()) { + view.webContents.openDevTools(); + } + } + public setViewsAudioPref = (shouldMuteAudio?: boolean): void => { if (shouldMuteAudio !== undefined) this.shouldMuteAudio = shouldMuteAudio; this.forEachView(async (view, id) => { diff --git a/src/services/view/interface.ts b/src/services/view/interface.ts index fb8dd243..9790a0be 100644 --- a/src/services/view/interface.ts +++ b/src/services/view/interface.ts @@ -14,6 +14,17 @@ export type INewWindowAction = overrideBrowserWindowOptions?: Electron.BrowserWindowConstructorOptions | undefined; }; +export interface IViewInfo { + workspaceID: string; + workspaceName: string; + windowName: WindowNames; + bounds: { x: number; y: number; width: number; height: number }; + url: string; + isDestroyed: boolean; + /** Chromium renderer process ID, used to correlate with OS process list and memory info. */ + pid: number; +} + /** * Minimal mechanism-layer API for managing WebContentsView instances. * All policy decisions (which workspace to show, when to hide/restore, mini-window routing) @@ -83,6 +94,14 @@ export interface IViewService { // ── Convenience / Query ─────────────────────────────────── getViewCurrentUrl(workspaceID: string, windowName: WindowNames): Promise; + canGoBackInView(workspaceID: string, windowName: WindowNames): Promise; + canGoForwardInView(workspaceID: string, windowName: WindowNames): Promise; + goBackInView(workspaceID: string, windowName: WindowNames): Promise; + goForwardInView(workspaceID: string, windowName: WindowNames): Promise; + /** Return debug information about every registered view (bounds, URL, memory, etc). */ + getViewsInfo(): Promise; + /** Open Electron DevTools for a specific view's webContents. */ + openDevToolsForView(workspaceID: string, windowName: WindowNames): void; setViewsAudioPref(shouldMuteAudio?: boolean): void; setViewsNotificationsPref(shouldPauseNotifications?: boolean): void; } @@ -96,6 +115,12 @@ export const ViewServiceIPCDescriptor = { getView: ProxyPropertyType.Function, getViewCount: ProxyPropertyType.Function, getViewCurrentUrl: ProxyPropertyType.Function, + canGoBackInView: ProxyPropertyType.Function, + canGoForwardInView: ProxyPropertyType.Function, + goBackInView: ProxyPropertyType.Function, + goForwardInView: ProxyPropertyType.Function, + getViewsInfo: ProxyPropertyType.Function, + openDevToolsForView: ProxyPropertyType.Function, showView: ProxyPropertyType.Function, hideView: ProxyPropertyType.Function, setViewBounds: ProxyPropertyType.Function, diff --git a/src/services/view/setupViewEventHandlers.ts b/src/services/view/setupViewEventHandlers.ts index a4117f13..f9f9720f 100644 --- a/src/services/view/setupViewEventHandlers.ts +++ b/src/services/view/setupViewEventHandlers.ts @@ -54,6 +54,12 @@ export default function setupViewEventHandlers( const preferenceService = container.get(serviceIdentifier.Preference); handleViewFileContentLoading(view); + logger.info('Wiki view created', { + workspaceID: workspace.id, + workspaceName: workspace.name, + windowName, + rendererPID: view.webContents.getOSProcessId(), + }); view.webContents.on('did-start-loading', async () => { const workspaceObject = await workspaceService.get(workspace.id); // this event might be triggered @@ -115,7 +121,9 @@ export default function setupViewEventHandlers( if (await workspaceService.workspaceDidFailLoad(workspace.id)) { return; } - if (view.webContents === null) { + // After webContents.close() the getter can return undefined (not null) in Electron. + + if (view.webContents == null || view.webContents.isDestroyed()) { return; } logger.debug('set isLoading to false', { @@ -167,6 +175,8 @@ export default function setupViewEventHandlers( if (workspaceDidFailLoad) { return; } + + if (view.webContents == null || view.webContents.isDestroyed()) return; if (isMainFrame && errorCode < 0 && errorCode !== -3) { // Fix nodejs wiki start slow on system startup, which cause `-102 ERR_CONNECTION_REFUSED` even if wiki said it is booted, we have to retry several times if (errorCode === -102 && view.webContents.getURL().length > 0 && isWikiWorkspace(workspaceObject) && workspaceObject.homeUrl.startsWith('http')) { @@ -201,6 +211,8 @@ export default function setupViewEventHandlers( if (workspaceObject === undefined) { return; } + + if (view.webContents == null || view.webContents.isDestroyed()) return; if (workspaceObject.active) { await windowService.sendToAllWindows(WindowChannel.updateCanGoBack, view.webContents.navigationHistory.canGoBack()); await windowService.sendToAllWindows(WindowChannel.updateCanGoForward, view.webContents.navigationHistory.canGoForward()); @@ -216,6 +228,8 @@ export default function setupViewEventHandlers( if (workspaceObject === undefined) { return; } + + if (view.webContents == null || view.webContents.isDestroyed()) return; if (workspaceObject.active) { await windowService.sendToAllWindows(WindowChannel.updateCanGoBack, view.webContents.navigationHistory.canGoBack()); await windowService.sendToAllWindows(WindowChannel.updateCanGoForward, view.webContents.navigationHistory.canGoForward()); diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index 1caeed8e..b319edee 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -23,12 +23,12 @@ import serviceIdentifier from '@services/serviceIdentifier'; import type { IViewService } from '@services/view/interface'; import type { IWindowService } from '@services/windows/interface'; import { WindowNames } from '@services/windows/WindowProperties'; -import type { IWorkspace, IWorkspaceService } from '@services/workspaces/interface'; +import type { IWikiWorkspace, IWorkspace, IWorkspaceService } from '@services/workspaces/interface'; import { isWikiWorkspace } from '@services/workspaces/interface'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; import { Observable } from 'rxjs'; import { AlreadyExistError, CopyWikiTemplateError, DoubleWikiInstanceError, HTMLCanNotLoadError, SubWikiSMainWikiNotExistError, WikiRuntimeError } from './error'; -import type { IWikiService } from './interface'; +import type { IWikiService, IWorkerInfo } from './interface'; import { WikiControlActions } from './interface'; import type { IStartNodeJSWikiConfigs, WikiWorker } from './wikiWorker'; import type { IpcServerRouteMethods, IpcServerRouteNames, ITidGiChangedTiddlers } from './wikiWorker/ipcServerRoutes'; @@ -91,6 +91,20 @@ export class Wiki implements IWikiService { return this.wikiWorkers[id]?.proxy; } + public async getWorkersInfo(): Promise { + const workspaceService = container.get(serviceIdentifier.Workspace); + const workspaces = await workspaceService.getWorkspacesAsList(); + return workspaces + .filter((w): w is IWikiWorkspace => isWikiWorkspace(w) && !w.isSubWiki) + .map((workspace) => ({ + workspaceID: workspace.id, + workspaceName: workspace.name, + port: workspace.port, + isRunning: this.wikiWorkers[workspace.id] !== undefined, + threadId: this.wikiWorkers[workspace.id]?.nativeWorker.threadId ?? -1, + })); + } + private getNativeWorker(id: string): Worker | undefined { return this.wikiWorkers[id]?.nativeWorker; } diff --git a/src/services/wiki/interface.ts b/src/services/wiki/interface.ts index d231c5c0..408710d8 100644 --- a/src/services/wiki/interface.ts +++ b/src/services/wiki/interface.ts @@ -51,6 +51,10 @@ export interface IWikiService { * @param workspaceID You can get this from active workspace */ getWorker(workspaceID: string): WikiWorker | undefined; + /** + * Get info about all wiki workers for the debug panel. + */ + getWorkersInfo(): Promise; packetHTMLFromWikiFolder(wikiFolderLocation: string, pathOfNewHTML: string): Promise; removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink?: boolean): Promise; restartWiki(workspace: IWorkspace): Promise; @@ -88,6 +92,15 @@ export interface IWikiService { /** handle start/restart of wiki/subwiki, will handle wiki sync too */ wikiStartup(workspace: IWorkspace): Promise; } +export interface IWorkerInfo { + isRunning: boolean; + port: number; + /** Node.js worker_threads thread ID. -1 if the worker is not running. */ + threadId: number; + workspaceID: string; + workspaceName: string; +} + export const WikiServiceIPCDescriptor = { channel: WikiChannel.name, properties: { @@ -100,6 +113,7 @@ export const WikiServiceIPCDescriptor = { ensureWikiExist: ProxyPropertyType.Function, extractWikiHTML: ProxyPropertyType.Function, getWikiErrorLogs: ProxyPropertyType.Function, + getWorkersInfo: ProxyPropertyType.Function, getTiddlerFilePath: ProxyPropertyType.Function, packetHTMLFromWikiFolder: ProxyPropertyType.Function, removeWiki: ProxyPropertyType.Function, diff --git a/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts b/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts index 206630a2..b98c7a14 100644 --- a/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts +++ b/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts @@ -98,7 +98,8 @@ class TidGiIPCSyncAdaptor { return; } const debouncedSync = debounce(() => { - if ($tw.syncer === undefined) { + const syncer = $tw.syncer; + if (syncer === undefined) { console.error('Syncer is undefined in TidGiIPCSyncAdaptor. Abort the `syncFromServer` in `setupSSE debouncedSync`.'); return; } @@ -106,11 +107,11 @@ class TidGiIPCSyncAdaptor { if (totalPending > 50 && typeof requestIdleCallback === 'function') { // Large batch (e.g. git checkout): defer to idle callback to avoid blocking UI requestIdleCallback(() => { - $tw.syncer.syncFromServer(); + syncer.syncFromServer(); this.clearUpdatedTiddlers(); }, { timeout: 2000 }); } else { - $tw.syncer.syncFromServer(); + syncer.syncFromServer(); this.clearUpdatedTiddlers(); } }, 500); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts index c0a7f7ed..a9bdbb7f 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts @@ -243,11 +243,14 @@ export class FileSystemAdaptor { const hasCanonicalUri = typeof tiddler.fields._canonical_uri === 'string' && tiddler.fields._canonical_uri.length > 0; const extensionFilters = hasCanonicalUri ? ['.tid'] : this.extensionFilters; + // Pass oldFileInfo so generateTiddlerFilepath's uniquifier loop sees the existing + // filepath as `oldPath` and breaks early instead of appending a numeric suffix. return $tw.utils.generateTiddlerFileInfo(tiddler, { directory: targetDirectory, pathFilters: undefined, extFilters: extensionFilters, wiki: this.wiki, + fileInfo: oldFileInfo, }); } finally { // Restore old fileInfo for potential cleanup in saveTiddler @@ -263,6 +266,10 @@ export class FileSystemAdaptor { * CRITICAL: We must temporarily remove the tiddler from boot.files before calling * generateTiddlerFileInfo, otherwise TiddlyWiki will use the old path as a base * and FileSystemPaths filters will apply repeatedly, causing path accumulation. + * + * We also pass the oldFileInfo as `fileInfo` so generateTiddlerFilepath knows the + * existing filepath. The uniquifier loop then breaks when the generated path matches + * oldPath, preventing numeric suffixes (_1, _2 …) on re-saves of the same tiddler. */ protected generateDefaultFileInfo(tiddler: Tiddler): IFileInfo { let pathFilters: string[] | undefined; @@ -292,6 +299,7 @@ export class FileSystemAdaptor { pathFilters, extFilters: extensionFilters, wiki: this.wiki, + fileInfo: oldFileInfo, }); } finally { // Restore old fileInfo for potential cleanup in saveTiddler diff --git a/src/services/wikiGitWorkspace/index.ts b/src/services/wikiGitWorkspace/index.ts index 7778326e..e977592d 100644 --- a/src/services/wikiGitWorkspace/index.ts +++ b/src/services/wikiGitWorkspace/index.ts @@ -15,7 +15,8 @@ import type { INewWikiWorkspaceConfig, IWorkspace, IWorkspaceService } from '@se import { isWikiWorkspace } from '@services/workspaces/interface'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; -import { DEFAULT_FIRST_WIKI_FOLDER_PATH, DEFAULT_FIRST_WIKI_PATH } from '@/constants/paths'; +// Import from appPaths to get the Electron-accurate Desktop path (handles OneDrive Desktop redirect) +import { DEFAULT_FIRST_WIKI_FOLDER_PATH, DEFAULT_FIRST_WIKI_PATH } from '@/constants/appPaths'; import type { IContextService } from '@services/context/interface'; import { i18n } from '@services/libs/i18n'; import { logger } from '@services/libs/log'; diff --git a/src/services/windows/handleAttachToTidgiMiniWindow.ts b/src/services/windows/handleAttachToTidgiMiniWindow.ts index 47e0bb11..a2156a81 100644 --- a/src/services/windows/handleAttachToTidgiMiniWindow.ts +++ b/src/services/windows/handleAttachToTidgiMiniWindow.ts @@ -111,7 +111,7 @@ export async function handleAttachToTidgiMiniWindow( const workspaceService = container.get(serviceIdentifier.Workspace); const activeWs = await workspaceService.getActiveWorkspace(); const view = activeWs ? viewService.getView(activeWs.id, WindowNames.tidgiMiniWindow) : undefined; - if (view && !view.webContents.isDestroyed()) { + if (view && view.webContents != null && !view.webContents.isDestroyed()) { view.webContents.focus(); } } catch (error) { diff --git a/src/services/windows/handleCreateBasicWindow.ts b/src/services/windows/handleCreateBasicWindow.ts index 8482cfe2..5e9732a4 100644 --- a/src/services/windows/handleCreateBasicWindow.ts +++ b/src/services/windows/handleCreateBasicWindow.ts @@ -30,7 +30,12 @@ export async function handleCreateBasicWindow( const unregisterContextMenu = await menuService.initContextMenuForWindowWebContents(newWindow.webContents); newWindow.on('closed', () => { - windowService.set(windowName, undefined); + // Only clear the service slot if this window is still the registered one. + // If recreate replaced us (new window already registered) or if we were a multiple=true + // window (never registered), skip the clear to avoid orphaning the new/existing window. + if (windowService.get(windowName) === newWindow) { + windowService.set(windowName, undefined); + } unregisterContextMenu(); }); diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index 1b3a160e..2526d73a 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -154,14 +154,18 @@ export class Window implements IWindowService { config?: IWindowOpenConfig, returnWindow?: boolean, ): Promise { - const { recreate = false, multiple = false } = config ?? {}; + const { recreate = false, multiple = false, recreateUnlessWorkspaceID } = config ?? {}; const existedWindow = this.get(windowName); - // update window meta - await this.setWindowMeta(windowName, meta); + // Read the OLD meta before overwriting — recreate() must compare old vs new to decide whether to close. const existedWindowMeta = await this.getWindowMeta(windowName); + await this.setWindowMeta(windowName, meta); if (existedWindow !== undefined && !multiple) { - if (recreate === true || (typeof recreate === 'function' && existedWindowMeta !== undefined && recreate(existedWindowMeta))) { + const existedWorkspaceID = (existedWindowMeta as { workspaceID?: string } | undefined)?.workspaceID; + const isRecreateNeeded = recreate === true || + (typeof recreate === 'function' && existedWindowMeta !== undefined && recreate(existedWindowMeta)) || + (recreateUnlessWorkspaceID !== undefined && existedWorkspaceID !== recreateUnlessWorkspaceID); + if (isRecreateNeeded) { existedWindow.close(); } else { if (existedWindow.isMinimized()) { @@ -208,9 +212,25 @@ export class Window implements IWindowService { } // hide titleBar should not take effect on setting window const hideTitleBar = [WindowNames.main, WindowNames.tidgiMiniWindow].includes(windowName) && !showTitleBar; + // Descriptive initial titles help identify renderer processes in the OS task manager before the page sets its own title + const windowTitleByName: Partial> = { + [WindowNames.main]: 'TidGi [App Shell]', + [WindowNames.tidgiMiniWindow]: 'TidGi [Mini Window]', + [WindowNames.secondary]: 'TidGi [Secondary Window]', + [WindowNames.preferences]: 'TidGi [Preferences]', + [WindowNames.addWorkspace]: 'TidGi [Add Workspace]', + [WindowNames.editWorkspace]: 'TidGi [Edit Workspace]', + [WindowNames.about]: 'TidGi [About]', + [WindowNames.gitHistory]: 'TidGi [Git History]', + [WindowNames.notifications]: 'TidGi [Notifications]', + [WindowNames.spellcheck]: 'TidGi [Spellcheck]', + [WindowNames.auth]: 'TidGi [Auth]', + [WindowNames.any]: 'TidGi [Browser]', + }; const windowConfig: BrowserWindowConstructorOptions = { ...windowDimension[windowName], ...windowWithBrowserViewConfig, + title: windowTitleByName[windowName] ?? `TidGi [${windowName}]`, resizable: true, maximizable: true, minimizable: true, diff --git a/src/services/windows/interface.ts b/src/services/windows/interface.ts index c5ba44fa..ea2871bb 100644 --- a/src/services/windows/interface.ts +++ b/src/services/windows/interface.ts @@ -10,8 +10,19 @@ export interface IWindowOpenConfig { multiple?: boolean; /** * If recreate is true, we will close the window if it is already opened, and create a new one. + * Can be a function that receives the current window meta and returns true to recreate. + * NOTE: function form only works when called from main process. When calling via IPC from renderer, + * use `recreateUnlessWorkspaceID` instead (it is serializable). */ recreate?: boolean | ((windowMeta: WindowMeta[N]) => boolean); + /** + * IPC-serializable alternative to the function form of `recreate`. + * When provided, the window is closed and recreated unless the stored meta's + * `workspaceID` already matches this value. + * Use this from renderer code (window.service.window.open) so the comparison + * survives IPC serialization. + */ + recreateUnlessWorkspaceID?: string; } /** diff --git a/src/services/workspaces/__tests__/tokenAuth.test.ts b/src/services/workspaces/__tests__/tokenAuth.test.ts index 51447ba9..0231c7d6 100644 --- a/src/services/workspaces/__tests__/tokenAuth.test.ts +++ b/src/services/workspaces/__tests__/tokenAuth.test.ts @@ -28,8 +28,10 @@ function createWorkspace(overrides: Partial): IWikiWorkspace { } function createWorkspaceService(workspace: IWikiWorkspace): Workspace { - const service = new Workspace() as Workspace & { workspaces?: Record }; - service.workspaces = { [workspace.id]: workspace }; + const service = new Workspace(); + // workspaces is private; bypass in tests via any cast + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).workspaces = { [workspace.id]: workspace }; return service; } diff --git a/src/services/workspaces/getWorkspaceMenuTemplate.ts b/src/services/workspaces/getWorkspaceMenuTemplate.ts index c243a78e..cae59e57 100644 --- a/src/services/workspaces/getWorkspaceMenuTemplate.ts +++ b/src/services/workspaces/getWorkspaceMenuTemplate.ts @@ -1,4 +1,4 @@ -import { IAskAIWithSelectionData, WikiChannel } from '@/constants/channels'; +import { IAskAIWithSelectionData } from '@/constants/channels'; import { getDefaultHTTPServerIP } from '@/constants/urls'; import type { IAgentDefinitionService } from '@services/agentDefinition/interface'; import type { IAuthenticationService } from '@services/auth/interface'; @@ -32,7 +32,7 @@ interface IWorkspaceMenuRequiredServices { native: Pick; preference: Pick; sync: Pick; - view: Pick; + view: Pick; wiki: Pick; wikiGitWorkspace: Pick; window: Pick; @@ -71,7 +71,7 @@ export async function getSimplifiedWorkspaceMenuTemplate( return []; } - const { id, storageService, isSubWiki } = workspace; + const { id, storageService, gitUrl } = workspace; const template: MenuItemConstructorOptions[] = []; const lastUrl = await service.view.getViewCurrentUrl(id, WindowNames.main); @@ -96,30 +96,6 @@ export async function getSimplifiedWorkspaceMenuTemplate( submenu: fullMenuTemplate, }); } - // Restart and Reload (only for non-sub wikis) - if (!isSubWiki) { - template.push( - { - label: t('ContextMenu.RestartService'), - click: async () => { - await service.workspaceView.restartWorkspaceViewService(id); - await service.workspaceView.realignActiveWorkspace(id); - }, - }, - { - label: t('ContextMenu.Reload'), - click: async () => { - await service.view.reloadViewsWebContents(id); - }, - }, - { - label: t('ContextMenu.OpenCommandPalette'), - click: async () => { - await service.wiki.wikiOperationInBrowser(WikiChannel.dispatchEvent, id, ['open-command-palette']); - }, - }, - ); - } // Edit workspace template.push({ label: t('WorkspaceSelector.EditWorkspace'), @@ -128,15 +104,30 @@ export async function getSimplifiedWorkspaceMenuTemplate( }, }); - // Check if AI-generated backup title is enabled + // View git history (always visible for wiki workspaces) + template.push({ + label: t('WorkspaceSelector.ViewGitHistory'), + click: async () => { + await service.window.open(WindowNames.gitHistory, { workspaceID: id }, { recreate: true }); + }, + }); + const aiGenerateBackupTitleEnabled = await service.git.isAIGenerateBackupTitleEnabled(); - // Backup/Sync options (based on storage service) - if (storageService === SupportedStorageServices.local) { - const backupItems = createBackupMenuItems(workspace, t, service.window, service.sync, aiGenerateBackupTitleEnabled, false); - template.push(...backupItems); + // Sync items for cloud workspaces + if (storageService !== SupportedStorageServices.local && gitUrl) { + const userInfo = await service.auth.getStorageServiceUserInfo(storageService); + if (userInfo !== undefined) { + const isOnline = await service.context.isOnline(); + const syncItems = createSyncMenuItems(workspace, t, service.sync, aiGenerateBackupTitleEnabled, isOnline, false); + template.push(...syncItems); + } } + // Local backup option (always shown for all wiki workspaces) + const backupItems = createBackupMenuItems(workspace, t, service.sync, aiGenerateBackupTitleEnabled, false); + template.push(...backupItems); + return template; } @@ -225,20 +216,19 @@ export async function getWorkspaceMenuTemplate( // Check if AI-generated backup title is enabled const aiGenerateBackupTitleEnabled = await service.git.isAIGenerateBackupTitleEnabled(); + // For cloud workspaces with a configured git remote: add sync items if (gitUrl !== null && gitUrl.length > 0 && storageService !== SupportedStorageServices.local) { const userInfo = await service.auth.getStorageServiceUserInfo(storageService); if (userInfo !== undefined) { const isOnline = await service.context.isOnline(); - const syncItems = createSyncMenuItems(workspace, t, service.sync, aiGenerateBackupTitleEnabled, isOnline, false); template.push(...syncItems); } } - if (storageService === SupportedStorageServices.local) { - const backupItems = createBackupMenuItems(workspace, t, service.window, service.sync, aiGenerateBackupTitleEnabled, false); - template.push(...backupItems); - } + // Local backup is always shown for all wiki workspaces + const backupItems = createBackupMenuItems(workspace, t, service.sync, aiGenerateBackupTitleEnabled, false); + template.push(...backupItems); if (!isSubWiki) { template.push( @@ -272,5 +262,27 @@ export async function getWorkspaceMenuTemplate( }); } + const [canGoBack, canGoForward] = await Promise.all([ + service.view.canGoBackInView(id, WindowNames.main), + service.view.canGoForwardInView(id, WindowNames.main), + ]); + template.push( + { type: 'separator' }, + { + label: t('ContextMenu.Back'), + enabled: canGoBack, + click: () => { + void service.view.goBackInView(id, WindowNames.main); + }, + }, + { + label: t('ContextMenu.Forward'), + enabled: canGoForward, + click: () => { + void service.view.goForwardInView(id, WindowNames.main); + }, + }, + ); + return template; } diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index d18d5457..108bd77c 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -2,7 +2,7 @@ import { app } from 'electron'; import fsExtra from 'fs-extra'; import { injectable } from 'inversify'; import { Jimp } from 'jimp'; -import { mapValues, pickBy } from 'lodash'; +import { isEqual, mapValues, pickBy } from 'lodash'; import { nanoid } from 'nanoid'; import path from 'path'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -15,13 +15,10 @@ 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 { i18n } from '@services/libs/i18n'; import { logger } from '@services/libs/log'; import type { IMenuService } from '@services/menu/interface'; import serviceIdentifier from '@services/serviceIdentifier'; -import type { IViewService } from '@services/view/interface'; import type { IWikiService } from '@services/wiki/interface'; -import { WindowNames } from '@services/windows/WindowProperties'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; import { extractSyncableConfig, mergeWithSyncedConfig, readTidgiConfig, readTidgiConfigSync, removeSyncableFields, writeTidgiConfig } from '../database/configSetting'; import type { @@ -36,6 +33,7 @@ import type { } from './interface'; import { isWikiWorkspace, wikiWorkspaceDefaultValues } from './interface'; import { registerMenu } from './registerMenu'; +import { syncableConfigFields } from './syncableConfig'; import { workspaceSorter } from './utilities'; @injectable() @@ -85,17 +83,6 @@ export class Workspace implements IWorkspaceService { }, accelerator: `CmdOrCtrl+${index + 1}`, }, - { - label: () => `${workspace.name || `Workspace ${index + 1}`} ${i18n.t('Menu.DeveloperToolsActiveWorkspace')}`, - id: `${workspace.id}-devtool`, - click: async () => { - const viewService = container.get(serviceIdentifier.View); - const view = viewService.getView(workspace.id, WindowNames.main); - if (view !== undefined) { - view.webContents.toggleDevTools(); - } - }, - }, ]); const menuService = container.get(serviceIdentifier.MenuService); @@ -225,17 +212,34 @@ export class Workspace implements IWorkspaceService { // Update memory cache with full workspace data (including syncable fields) workspaces[id] = workspaceToSave; - // Write syncable config to tidgi.config.json ONLY for the workspace being modified - // This avoids redundant writes to all workspaces on every single update + // Write tidgi.config.json only when syncable fields actually changed. + // Compare against the ACTUAL FILE content (not just in-memory), so that when a field is newly + // added to syncableConfigFields (e.g. isSubWiki, mainWikiToLink) but the existing file predates + // that addition, the file gets updated on the next save rather than only on an explicit change. if (isWikiWorkspace(workspaceToSave)) { - try { - const syncableConfig = extractSyncableConfig(workspaceToSave); - await writeTidgiConfig(workspaceToSave.wikiFolderLocation, syncableConfig); - } catch (error) { - logger.warn('Failed to write tidgi.config.json', { - workspaceId: id, - error: (error as Error).message, - }); + const newSyncableConfig = extractSyncableConfig(workspaceToSave); + const existingFileConfig = readTidgiConfigSync(workspaceToSave.wikiFolderLocation); + // existingFileConfig is undefined when the file doesn't exist → always write on first save. + // When the file exists, compare its content against what we'd write to detect migration gaps. + const fileConfigForComparison = existingFileConfig ?? {}; + const syncableChanged = existingFileConfig === undefined || + syncableConfigFields.some((field) => + // treat a missing key (e.g. newly added field) as changed so the file gets updated + !Object.prototype.hasOwnProperty.call(fileConfigForComparison, field) || + !isEqual( + (newSyncableConfig as Record)[field], + (fileConfigForComparison as Record)[field], + ) + ); + 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, + }); + } } } diff --git a/src/services/workspaces/interface.ts b/src/services/workspaces/interface.ts index 44ee4330..0da7444b 100644 --- a/src/services/workspaces/interface.ts +++ b/src/services/workspaces/interface.ts @@ -11,44 +11,10 @@ import { SetOptional } from 'type-fest'; */ export const nonConfigFields = ['metadata', 'lastNodeJSArgv', 'lastUrl', 'homeUrl', 'hibernated', 'active']; -/** - * 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', - '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]; +// These are the canonical definitions used by both main-process and worker code. +// interface.ts re-exports them so consumers have a single import path. +export { syncableConfigFields } from './syncableConfig'; +export type { ISyncableWikiConfig, SyncableConfigField } from './syncableConfig'; /** * Fields that are device-specific and should only be stored locally. @@ -64,23 +30,16 @@ export const localOnlyFields = [ 'authToken', 'picturePath', 'wikiFolderLocation', - 'mainWikiToLink', - 'mainWikiID', - 'isSubWiki', 'pageType', 'port', ] 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) + * Default values for syncable config fields (stored in tidgi.config.json). + * See syncableConfig.ts for the canonical definition used by worker code. */ export const syncableConfigDefaultValues = { name: '', - port: 5212, gitUrl: null, storageService: SupportedStorageServices.local, userName: '', @@ -103,22 +62,11 @@ export const syncableConfigDefaultValues = { fileSystemPathFilter: null as string | null, rootTiddler: undefined as string | undefined, https: undefined as { enabled: boolean; tlsCert?: string; tlsKey?: string } | undefined, + isSubWiki: false, + mainWikiID: null as string | null, + mainWikiToLink: null as string | null, } 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, @@ -129,9 +77,8 @@ export const localConfigDefaultValues = { 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, + port: 5212, } as const; /** @@ -143,7 +90,7 @@ export const localConfigDefaultValues = { export const wikiWorkspaceDefaultValues = { ...localConfigDefaultValues, ...syncableConfigDefaultValues, -} satisfies Omit; +} satisfies Omit; export interface IDedicatedWorkspace { /** diff --git a/src/services/workspaces/registerMenu.ts b/src/services/workspaces/registerMenu.ts index 990af1d6..d9e68e69 100644 --- a/src/services/workspaces/registerMenu.ts +++ b/src/services/workspaces/registerMenu.ts @@ -48,7 +48,8 @@ export async function registerMenu(): Promise { click: async () => { const currentActiveWorkspace = await workspaceService.getActiveWorkspace(); if (currentActiveWorkspace === undefined) return; - await windowService.open(WindowNames.editWorkspace, { workspaceID: currentActiveWorkspace.id }); + const id = currentActiveWorkspace.id; + await windowService.open(WindowNames.editWorkspace, { workspaceID: id }, { recreateUnlessWorkspaceID: id }); }, enabled: async () => (await workspaceService.countWorkspaces()) > 0, }, diff --git a/src/services/workspaces/syncableConfig.ts b/src/services/workspaces/syncableConfig.ts index c180a95a..e813049d 100644 --- a/src/services/workspaces/syncableConfig.ts +++ b/src/services/workspaces/syncableConfig.ts @@ -59,6 +59,8 @@ export const syncableConfigFields = [ 'fileSystemPathFilter', 'rootTiddler', 'https', + 'isSubWiki', + 'mainWikiID', ] as const; /** @@ -97,6 +99,8 @@ export const syncableConfigDefaultValues = { fileSystemPathFilter: null as string | null, rootTiddler: undefined as string | undefined, https: undefined as { enabled: boolean; tlsCert?: string; tlsKey?: string } | undefined, + isSubWiki: false, + mainWikiID: null as string | null, } as const; /** @@ -127,6 +131,8 @@ export type ISyncableWikiConfig = { fileSystemPathFilter: string | null; rootTiddler?: string; https?: { enabled: boolean; tlsCert?: string; tlsKey?: string }; + isSubWiki: boolean; + mainWikiID: string | null; }; /** diff --git a/src/services/workspaces/tidgi.config.schema.json b/src/services/workspaces/tidgi.config.schema.json index 4b2ffbea..deeb5057 100644 --- a/src/services/workspaces/tidgi.config.schema.json +++ b/src/services/workspaces/tidgi.config.schema.json @@ -155,6 +155,16 @@ }, "required": ["enabled"], "additionalProperties": false + }, + "isSubWiki": { + "type": "boolean", + "description": "Whether this wiki is a sub-wiki attached to a main wiki", + "default": false + }, + "mainWikiID": { + "type": ["string", "null"], + "description": "Canonical workspace ID of the main wiki this sub-wiki is attached to", + "default": null } }, "required": ["version"], diff --git a/src/services/workspacesView/index.ts b/src/services/workspacesView/index.ts index 34910cd2..660b37fd 100644 --- a/src/services/workspacesView/index.ts +++ b/src/services/workspacesView/index.ts @@ -118,6 +118,11 @@ export class WorkspaceView implements IWorkspaceViewService { } return; } + // Workspace is NOT being hibernated - clear any stale hibernated flag from a previous session + // to avoid a state mismatch where views run but the sidebar still shows the workspace as sleeping. + if (isWikiWorkspace(workspace) && workspace.hibernated) { + await workspaceService.update(workspace.id, { hibernated: false }); + } } const syncGitWhenInitializeWorkspaceView = async () => { if (!isWikiWorkspace(workspace)) return; @@ -348,13 +353,13 @@ export class WorkspaceView implements IWorkspaceViewService { if (newWorkspace.pageType) { logger.debug(`${nextWorkspaceID} is a page workspace, only updating workspace state.`); await container.get(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id); - // Hide old workspace view if switching from a regular workspace - if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID && !oldActiveWorkspace.pageType) { + // Hide the previous real wiki's view (it must stay alive because the agent's webview needs the server). + // Record its ID so we can hibernate it when the user eventually switches to a different real wiki. + if (oldActiveWorkspace !== undefined && !oldActiveWorkspace.pageType && oldActiveWorkspace.id !== nextWorkspaceID) { await this.hideWorkspaceView(oldActiveWorkspace.id); - if (isWikiWorkspace(oldActiveWorkspace) && oldActiveWorkspace.hibernateWhenUnused) { - await this.hibernateWorkspaceView(oldActiveWorkspace.id); - } + this.lastNonPageWorkspaceID = oldActiveWorkspace.id; } + // If we're chaining page→page, leave lastNonPageWorkspaceID unchanged (the deferred wiki is still pending). return; } @@ -370,33 +375,44 @@ export class WorkspaceView implements IWorkspaceViewService { // later process will use the current active workspace await container.get(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id); - // Schedule hibernation of old workspace before waking up new workspace - // This prevents blocking when wakeUp calls loadURL - if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) { - void this.hibernateWorkspace(oldActiveWorkspace.id); + // When coming from a page workspace (agent), the wiki that was active *before* the agent was + // deferred and kept alive. Hibernate it now that we have a real wiki destination. + // When coming from a real wiki directly, hibernate that wiki. + const wikiToHibernate = oldActiveWorkspace?.pageType ? this.lastNonPageWorkspaceID : oldActiveWorkspace?.id; + this.lastNonPageWorkspaceID = undefined; + if (wikiToHibernate !== undefined && wikiToHibernate !== nextWorkspaceID) { + void this.hibernateWorkspace(wikiToHibernate); } - if (isWikiWorkspace(newWorkspace) && newWorkspace.hibernated) { + // If a previous switch fired a background hibernation for the workspace we are NOW switching TO, + // wait for it to finish so we don't race between destroy-views and create-views. + const pendingHibernation = this.hibernatingWorkspaces.get(nextWorkspaceID); + if (pendingHibernation !== undefined) { + logger.debug('setActiveWorkspaceView: waiting for in-flight hibernation to finish', { nextWorkspaceID }); + await pendingHibernation; + } + + // Re-fetch workspace state after any hibernation completed above, since hibernated flag may have changed. + const freshWorkspace = await container.get(serviceIdentifier.Workspace).get(nextWorkspaceID); + if (freshWorkspace === undefined) { + throw new Error(`Workspace id ${nextWorkspaceID} disappeared while switching. In setActiveWorkspaceView().`); + } + + if (isWikiWorkspace(freshWorkspace) && freshWorkspace.hibernated) { await this.wakeUpWorkspaceView(nextWorkspaceID); } // fix #556 and #593: Ensure wiki worker is started before showing the view. This must happen before `showWorkspaceView` to ensure the worker is ready when view is created. - if (isWikiWorkspace(newWorkspace) && !newWorkspace.hibernated) { + if (isWikiWorkspace(freshWorkspace) && !freshWorkspace.hibernated) { const wikiService = container.get(serviceIdentifier.Wiki); const worker = wikiService.getWorker(nextWorkspaceID); if (worker === undefined) { - const userName = await this.authService.getUserName(newWorkspace); + const userName = await this.authService.getUserName(freshWorkspace); await wikiService.startWiki(nextWorkspaceID, userName); } } try { - // Schedule hibernation of old workspace before loading new workspace - // This prevents blocking on loadURL and allows faster UI updates - if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) { - void this.hibernateWorkspace(oldActiveWorkspace.id); - } - await this.showWorkspaceView(nextWorkspaceID); await this.realignActiveWorkspace(nextWorkspaceID); } catch (error) { @@ -408,17 +424,46 @@ export class WorkspaceView implements IWorkspaceViewService { } } + // Tracks workspace IDs currently undergoing background hibernation. + // Stored as a Map so callers can await the in-flight promise when switching back to the same workspace. + private readonly hibernatingWorkspaces = new Map>(); + + /** + * When we switch from a wiki workspace to a page workspace (agent), the wiki's server must stay + * alive (the agent's embedded webview still needs it). We defer hibernation of that wiki until + * the user switches to a different real wiki. This field stores that deferred wiki ID. + */ + private lastNonPageWorkspaceID: string | undefined; + /** * This promise could be `void` to let go, not blocking other logic like switch to new workspace, and hibernate workspace on background. */ private async hibernateWorkspace(workspaceID: string): Promise { - const workspace = await container.get(serviceIdentifier.Workspace).get(workspaceID); - if (workspace === undefined) return; - - await this.hideWorkspaceView(workspaceID); - if (isWikiWorkspace(workspace) && workspace.hibernateWhenUnused) { - await this.hibernateWorkspaceView(workspaceID); + if (this.hibernatingWorkspaces.has(workspaceID)) { + logger.debug('hibernateWorkspace: already in progress, skipping duplicate call', { workspaceID }); + return this.hibernatingWorkspaces.get(workspaceID)!; } + const promise = (async () => { + try { + const workspace = await container.get(serviceIdentifier.Workspace).get(workspaceID); + if (workspace === undefined) return; + + // Hide the view first, but don't let a failure here prevent the wiki server from stopping. + try { + await this.hideWorkspaceView(workspaceID); + } catch (error) { + logger.warn('hibernateWorkspace: hideWorkspaceView failed, continuing to stop wiki', { workspaceID, error }); + } + + if (isWikiWorkspace(workspace) && workspace.hibernateWhenUnused) { + await this.hibernateWorkspaceView(workspaceID); + } + } finally { + this.hibernatingWorkspaces.delete(workspaceID); + } + })(); + this.hibernatingWorkspaces.set(workspaceID, promise); + return promise; } public async clearActiveWorkspaceView(idToDeactivate?: string): Promise { diff --git a/src/services/workspacesView/registerMenu.ts b/src/services/workspacesView/registerMenu.ts index 7dd4a32d..986daddd 100644 --- a/src/services/workspacesView/registerMenu.ts +++ b/src/services/workspacesView/registerMenu.ts @@ -35,18 +35,6 @@ export async function registerMenu(): Promise { return activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace); }; - await menuService.insertMenu('Workspaces', [ - { - label: () => i18n.t('Menu.DeveloperToolsActiveWorkspace'), - accelerator: 'CmdOrCtrl+Option+I', - click: async () => { - const ws = await workspaceService.getActiveWorkspace(); - if (ws) viewService.getView(ws.id, WindowNames.main)?.webContents.openDevTools({ mode: 'detach' }); - }, - enabled: hasActiveWorkspaces, - }, - ]); - // Insert Wiki menu items (each item checks for active wiki workspace) await menuService.insertMenu('Wiki', [ { diff --git a/src/type.d.ts b/src/type.d.ts index bcd1db09..a266689d 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -115,3 +115,26 @@ declare module 'default-gateway/sunos' { declare module 'default-gateway/win32' { export function v4(): Promise; } + +declare module '@modelcontextprotocol/sdk/client/index.js' { + export const Client: { + new(info: { name: string; version: string }, options: { capabilities: Record }): { + connect(transport: unknown): Promise; + listTools(): Promise<{ tools?: Array<{ name: string; description?: string; inputSchema?: Record }> }>; + callTool(request: { name: string; arguments?: Record }): Promise; + close?(): Promise; + }; + }; +} + +declare module '@modelcontextprotocol/sdk/client/stdio.js' { + export const StdioClientTransport: { + new(options: { command: string; args?: string[] }): unknown; + }; +} + +declare module '@modelcontextprotocol/sdk/client/sse.js' { + export const SSEClientTransport: { + new(url: URL): unknown; + }; +} diff --git a/src/windows/AddWorkspace/ExistedWikiForm.tsx b/src/windows/AddWorkspace/ExistedWikiForm.tsx index 84f71ed6..dbddf017 100644 --- a/src/windows/AddWorkspace/ExistedWikiForm.tsx +++ b/src/windows/AddWorkspace/ExistedWikiForm.tsx @@ -13,12 +13,15 @@ import type { IWikiWorkspaceFormProps } from './useForm'; export function ExistedWikiForm({ form, isCreateMainWorkspace, + isCreateMainWorkspaceSetter, + useTidgiConfig, isCreateSyncedWorkspace, errorInWhichComponent, errorInWhichComponentSetter, -}: IWikiWorkspaceFormProps & { isCreateSyncedWorkspace: boolean }): React.JSX.Element { +}: IWikiWorkspaceFormProps & { isCreateMainWorkspaceSetter: (value: boolean) => void; isCreateSyncedWorkspace: boolean; useTidgiConfig: boolean }): React.JSX.Element { const { t } = useTranslation(); const [tagInputValue, setTagInputValue] = useState(''); + const [autoFillNote, setAutoFillNote] = useState(); // Fetch all tags from main wiki for autocomplete suggestions const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); @@ -64,8 +67,34 @@ export function ExistedWikiForm({ // Update local state setFullPath(newLocation); } + // When useTidgiConfig is on, eagerly read tidgi.config.json and pre-fill form fields so the + // user can see what will be applied (isSubWiki, tag associations, etc.) before confirming. + if (useTidgiConfig && newLocation) { + const config = await window.service.database.readWikiConfig(newLocation); + if (config) { + if (typeof config.isSubWiki === 'boolean') { + isCreateMainWorkspaceSetter(!config.isSubWiki); + } + if (Array.isArray(config.tagNames) && config.tagNames.length > 0) { + tagNamesSetter(config.tagNames); + } + if (config.mainWikiID) { + const match = mainWorkspaceList.find( + ws => isWikiWorkspace(ws) && ws.id === config.mainWikiID, + ); + if (match !== undefined && isWikiWorkspace(match)) { + mainWikiToLinkSetter({ wikiFolderLocation: match.wikiFolderLocation, port: match.port, id: match.id }); + } + } + setAutoFillNote(t('AddWorkspace.FilledFromTidgiConfig')); + } else { + setAutoFillNote(undefined); + } + } else { + setAutoFillNote(undefined); + } }, - [wikiFolderNameSetter, parentFolderLocationSetter], + [useTidgiConfig, wikiFolderNameSetter, parentFolderLocationSetter, isCreateMainWorkspaceSetter, tagNamesSetter, mainWikiToLinkSetter, mainWorkspaceList, t], ); return ( @@ -92,7 +121,7 @@ export function ExistedWikiForm({ } }} label={t('AddWorkspace.WorkspaceFolder')} - helperText={`${t('AddWorkspace.ImportWiki')}${wikiFolderLocation ?? ''}`} + helperText={autoFillNote ?? `${t('AddWorkspace.ImportWiki')}${wikiFolderLocation ?? ''}`} value={fullPath} /> - + diff --git a/src/windows/EditWorkspace/SaveAndSyncOptions.tsx b/src/windows/EditWorkspace/SaveAndSyncOptions.tsx index bdbb0d62..c7f877be 100644 --- a/src/windows/EditWorkspace/SaveAndSyncOptions.tsx +++ b/src/windows/EditWorkspace/SaveAndSyncOptions.tsx @@ -13,12 +13,11 @@ import { OptionsAccordion, OptionsAccordionSummary, TextField } from './styles'; interface SaveAndSyncOptionsProps { workspace: IWorkspace; workspaceSetter: (newValue: IWorkspace, requestSaveAndRestart?: boolean) => void; - rememberLastPageVisited: boolean | undefined; } export function SaveAndSyncOptions(props: SaveAndSyncOptionsProps): React.JSX.Element { const { t } = useTranslation(); - const { workspace, workspaceSetter, rememberLastPageVisited: _rememberLastPageVisited } = props; + const { workspace, workspaceSetter } = props; const isWiki = isWikiWorkspace(workspace); const { @@ -47,9 +46,21 @@ export function SaveAndSyncOptions(props: SaveAndSyncOptionsProps): React.JSX.El const fallbackUserName = ''; const isCreateSyncedWorkspace = storageService !== SupportedStorageServices.local; + const [expanded, setExpanded] = React.useState(() => isCreateSyncedWorkspace || Boolean(gitUrl)); + + React.useEffect(() => { + if (isCreateSyncedWorkspace || Boolean(gitUrl)) { + setExpanded(true); + } + }, [gitUrl, isCreateSyncedWorkspace]); return ( - + { + setExpanded(isExpanded); + }} + > } data-testid='preference-section-saveAndSyncOptions'> {t('EditWorkspace.SaveAndSyncOptions')} @@ -92,16 +103,6 @@ export function SaveAndSyncOptions(props: SaveAndSyncOptionsProps): React.JSX.El {t('EditWorkspace.MoveWorkspace')} - {isSubWiki && workspace && isWikiWorkspace(workspace) && workspace.mainWikiToLink && ( - - )} {!isSubWiki && ( void; - isSubWiki: boolean; + showDetails: boolean; } export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX.Element { const { t } = useTranslation(); - const { workspace, workspaceSetter, isSubWiki } = props; + const { workspace, workspaceSetter, showDetails } = props; const [tagInputValue, setTagInputValue] = useState(''); + const [accordionExpanded, setAccordionExpanded] = useState(false); const { + isSubWiki, tagNames, includeTagTree, fileSystemPathFilterEnable, @@ -30,115 +35,242 @@ export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX. ? t('AddWorkspace.TagNameInputWarning') : (isSubWiki ? t('AddWorkspace.TagNameHelp') : t('AddWorkspace.TagNameHelpForMain')); + const mainWorkspaceList = usePromiseValue( + async () => { + const workspaces = await window.service.workspace.getWorkspacesAsList(); + return workspaces.filter( + (candidate): candidate is IWikiWorkspace => isWikiWorkspace(candidate) && !candidate.isSubWiki && candidate.id !== workspace.id, + ); + }, + [], + [workspace.id], + ) ?? []; + const boundSubWorkspaces = usePromiseValue( + async () => { + if (isSubWiki) { + return []; + } + return await window.service.workspace.getSubWorkspacesAsList(workspace.id); + }, + [], + [workspace.id, isSubWiki], + ) ?? []; + const selectedMainWorkspace = mainWorkspaceList.find( + (candidate) => candidate.id === workspace.mainWikiID || candidate.wikiFolderLocation === workspace.mainWikiToLink, + ); + + // Auto-expand when there's relevant content to show (bound sub-wikis or sub-wiki is editing its own settings) + useEffect(() => { + if (showDetails && (isSubWiki || boundSubWorkspaces.length > 0)) { + setAccordionExpanded(true); + } + }, [showDetails, isSubWiki, boundSubWorkspaces.length]); + const availableTags = useAvailableTags(workspace.mainWikiID ?? undefined, true); return ( - + { + setAccordionExpanded(expanded); + }} + > } data-testid='preference-section-subWorkspaceOptions'> {t('AddWorkspace.SubWorkspaceOptions')} - - {isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')} - - { - setTagInputValue(newInputValue); - }} - onChange={(_event: React.SyntheticEvent, newValue: string[]) => { - void _event; - workspaceSetter({ ...workspace, tagNames: newValue }, true); - setTagInputValue(''); - }} - slotProps={{ - chip: { - variant: 'outlined', - }, - }} - renderInput={(parameters: AutocompleteRenderInputParams) => ( - - )} - /> - + ) => { - workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true); + workspaceSetter({ ...workspace, isSubWiki: event.target.checked }, true); }} /> } > - - ) => { - workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true); - }} - /> - } - > - - - ) => { - workspaceSetter({ ...workspace, ignoreSymlinks: event.target.checked }, true); - }} - /> - } - > - - {fileSystemPathFilterEnable && ( - ) => { - workspaceSetter({ ...workspace, fileSystemPathFilter: event.target.value || null }, true); - }} - label={t('AddWorkspace.FilterExpression')} - helperText={t('AddWorkspace.FilterExpressionHelp')} - sx={{ mb: 2 }} - /> + {showDetails && ( + <> + {!isSubWiki && boundSubWorkspaces.length > 0 && ( + <> + + {t('EditWorkspace.BoundSubWorkspacesTitle')} + + + {t('EditWorkspace.BoundSubWorkspacesDescription')} + + + {boundSubWorkspaces.map((subWorkspace) => ( + { + void window.service.window.open(WindowNames.editWorkspace, { workspaceID: subWorkspace.id }, { multiple: true }); + }} + > + {t('EditWorkspace.OpenSubWorkspaceSettings')} + + } + > + 0 + ? `${t('EditWorkspace.SubWorkspaceTagBindings')}: ${subWorkspace.tagNames.join(', ')}` + : t('EditWorkspace.SubWorkspaceNoTagBindings')} + /> + + ))} + + + )} + {isSubWiki && ( + ) => { + const nextMainWorkspace = mainWorkspaceList.find((candidate) => candidate.id === event.target.value) ?? null; + workspaceSetter({ + ...workspace, + mainWikiID: nextMainWorkspace?.id ?? null, + mainWikiToLink: nextMainWorkspace?.wikiFolderLocation ?? null, + }, true); + }} + sx={{ mb: 2 }} + > + {mainWorkspaceList.map((candidate) => ( + + {candidate.name} + + ))} + + )} + + {isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')} + + { + setTagInputValue(newInputValue); + }} + onChange={(_event: React.SyntheticEvent, newValue: string[]) => { + void _event; + workspaceSetter({ ...workspace, tagNames: newValue }, true); + setTagInputValue(''); + }} + slotProps={{ + chip: { + variant: 'outlined', + }, + }} + renderInput={(parameters: AutocompleteRenderInputParams) => ( + + )} + /> + + ) => { + workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true); + }} + /> + } + > + + + ) => { + workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true); + }} + /> + } + > + + + ) => { + workspaceSetter({ ...workspace, ignoreSymlinks: event.target.checked }, true); + }} + /> + } + > + + + + {fileSystemPathFilterEnable && ( + ) => { + workspaceSetter({ ...workspace, fileSystemPathFilter: event.target.value || null }, true); + }} + label={t('AddWorkspace.FilterExpression')} + helperText={t('AddWorkspace.FilterExpressionHelp')} + sx={{ mb: 2 }} + /> + )} + )} diff --git a/src/windows/EditWorkspace/index.tsx b/src/windows/EditWorkspace/index.tsx index 39b5a845..6a66ae58 100644 --- a/src/windows/EditWorkspace/index.tsx +++ b/src/windows/EditWorkspace/index.tsx @@ -18,9 +18,8 @@ import { ServerOptions } from './server'; import { Button, FlexGrow, Root, SaveCancelButtonsContainer } from './styles'; import { SubWorkspaceRouting } from './SubWorkspaceRouting'; -const workspaceID = (window.meta() as WindowMeta[WindowNames.editWorkspace]).workspaceID!; - export default function EditWorkspace(): React.JSX.Element { + const workspaceID = (window.meta() as WindowMeta[WindowNames.editWorkspace]).workspaceID!; const { t } = useTranslation(); const originalWorkspace = useWorkspaceObservable(workspaceID); const [requestRestartCountDown, RestartSnackbar] = useRestartSnackbar({ waitBeforeCountDown: 0, workspace: originalWorkspace, restartType: RestartSnackbarType.Wiki }); @@ -30,16 +29,20 @@ export default function EditWorkspace(): React.JSX.Element { const { name } = workspace ?? {}; const isSubWiki = isWiki ? workspace.isSubWiki : false; - - // Check if there are sub-workspaces for this main workspace - const hasSubWorkspaces = usePromiseValue(async () => { - if (isSubWiki) return false; - const subWorkspaces = await window.service.workspace.getSubWorkspacesAsList(workspaceID); - return subWorkspaces.length > 0; - }, false); - - // Show sub-workspace routing options for sub-wikis, or for main wikis that have sub-workspaces - const showSubWorkspaceRouting = isSubWiki || hasSubWorkspaces; + const shouldShowSubWorkspaceDetails = usePromiseValue( + async () => { + if (!isWiki) { + return false; + } + if (isSubWiki) { + return true; + } + const subWorkspaces = await window.service.workspace.getSubWorkspacesAsList(workspaceID); + return subWorkspaces.length > 0; + }, + false, + [isWiki, isSubWiki, workspaceID], + ); const rememberLastPageVisited = usePromiseValue(async () => await window.service.preference.get('rememberLastPageVisited')); @@ -66,12 +69,12 @@ export default function EditWorkspace(): React.JSX.Element { )} - - {showSubWorkspaceRouting && isWiki && ( + + {isWiki && ( )} @@ -81,7 +84,7 @@ export default function EditWorkspace(): React.JSX.Element { - diff --git a/src/windows/Preferences/sections/DeveloperTools.tsx b/src/windows/Preferences/sections/DeveloperTools.tsx index efbe25d5..5cf4a5d8 100644 --- a/src/windows/Preferences/sections/DeveloperTools.tsx +++ b/src/windows/Preferences/sections/DeveloperTools.tsx @@ -1,11 +1,35 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, List, ListItemButton, Switch } from '@mui/material'; +import { + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + List, + ListItemButton, + Snackbar, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ListItem, ListItemText } from '@/components/ListItem'; import { usePromiseValue } from '@/helpers/useServiceValue'; +import type { IProcessInfo } from '@services/native/processInfo'; import { usePreferenceObservable } from '@services/preferences/hooks'; +import type { IViewInfo } from '@services/view/interface'; +import type { IWorkerInfo } from '@services/wiki/interface'; import { Paper, SectionTitle } from '../PreferenceComponents'; import type { ISectionProps } from '../useSections'; @@ -19,6 +43,9 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { )!; const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [diagOpen, setDiagOpen] = useState(false); + const [diagData, setDiagData] = useState<{ processInfo: IProcessInfo; viewsInfo: IViewInfo[]; workersInfo: IWorkerInfo[] } | undefined>(undefined); + const [copiedSnackbarOpen, setCopiedSnackbarOpen] = useState(false); const [externalApiInfo, setExternalApiInfo] = useState<{ exists: boolean; size?: number; path?: string }>({ exists: false }); const [isWindows, setIsWindows] = useState(false); @@ -103,10 +130,10 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { onClick={async () => { const localAppData = process.env.LOCALAPPDATA; if (localAppData) { - // %APPDATA%\Local\SquirrelTemp\SquirrelSetup.log + // %LOCALAPPDATA%\SquirrelTemp\SquirrelSetup.log const squirrelTemporaryPath = `${localAppData}\\SquirrelTemp`; try { - await window.service.native.openPath(squirrelTemporaryPath, true); + await window.service.native.openPath(squirrelTemporaryPath, false); } catch (error: unknown) { void window.service.native.log( 'error', @@ -126,6 +153,25 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { )} + { + const [processInfoResult, workersInfoResult, viewsInfoResult] = await Promise.all([ + window.service.native.getProcessInfo(), + window.service.wiki.getWorkersInfo(), + window.service.view.getViewsInfo(), + ]); + setDiagData({ + processInfo: processInfoResult as unknown as IProcessInfo, + workersInfo: workersInfoResult as unknown as IWorkerInfo[], + viewsInfo: viewsInfoResult as unknown as IViewInfo[], + }); + setDiagOpen(true); + }} + > + + + + { await window.service.preference.resetWithConfirm(); @@ -249,6 +295,332 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { + + {/* Unified Process & Debug Panel dialog */} + { + setDiagOpen(false); + }} + maxWidth='xl' + fullWidth + > + + {t('Preference.DiagPanel')} + + + + {diagData === undefined + ? {t('Loading')} + : ( + <> + {/* Section 1: Node.js main process memory */} + + {`${t('Preference.ProcessInfoMainNode')} — PID ${diagData.processInfo.mainNode.pid}`} + + + + + + {t('Preference.ProcessInfoTitle')} + + + {t('Preference.ProcessInfoRSS')} + + + + + {t('Preference.ProcessInfoHeapUsed')} + + + + + {t('Preference.ProcessInfoHeapTotal')} + + + + + {t('Preference.ProcessInfoExternal')} + + + + + + + + {diagData.processInfo.mainNode.title} + + {diagData.processInfo.mainNode.rss_MB} + {diagData.processInfo.mainNode.heapUsed_MB} + {diagData.processInfo.mainNode.heapTotal_MB} + {diagData.processInfo.mainNode.external_MB} + + +
+
+ + {/* Section 2: Wiki worker_threads (share main process PID, listed separately) */} + + {t('Preference.WorkerDebugPanel')} + + + + + + {t('Preference.WorkerDebugWorkspace')} + + + {t('Preference.WorkerDebugThreadId')} + + + {t('Preference.WorkerDebugPort')} + {t('Preference.WorkerDebugStatus')} + {t('Preference.WorkerDebugActions')} + + + + {diagData.workersInfo.map((info) => ( + + + {info.workspaceName} + {info.workspaceID.slice(0, 8)} + + + {info.isRunning && info.threadId > 0 ? info.threadId : '-'} + + + {info.isRunning ? info.port : '-'} + + + + + + + + + ))} + {diagData.workersInfo.length === 0 && ( + + + {t('Preference.WorkerDebugEmpty')} + + + )} + +
+
+ + {/* Section 3: Renderer processes — correlate OS renderer PID with WebContentsView registry */} + + {t('Preference.ProcessInfoRenderers')} + + + + + + + + PID + + + {t('Preference.ViewDebugWorkspace')} + {t('Preference.ProcessInfoType')} + + + {t('Preference.RendererPrivateMem')} + + + + + {t('Preference.RendererCPU')} + + + {t('Preference.ViewDebugBounds')} + {t('Preference.ViewDebugURL')} + {t('Preference.ViewDebugActions')} + + + + {(() => { + const viewsByPid = new Map(diagData.viewsInfo.map((v) => [v.pid, v])); + return [...diagData.processInfo.renderers] + .sort((a, b) => { + const memA = a.private_KB > 0 ? a.private_KB : a.workingSet_KB; + const memB = b.private_KB > 0 ? b.private_KB : b.workingSet_KB; + return memB - memA; + }) + .map((renderer) => { + const matchingView = viewsByPid.get(renderer.pid); + return ( + + {renderer.pid} + + {matchingView !== undefined + ? ( + <> + {matchingView.workspaceName} + + + ) + : {renderer.title || '-'}} + + + + + + {renderer.private_KB > 0 + ? ( + 500_000 + ? 'error' + : renderer.private_KB > 200_000 + ? 'warning.main' + : 'success.main'} + > + {`${Math.round(renderer.private_KB / 1024)} MB`} + + ) + : renderer.workingSet_KB > 0 + ? ( + 500_000 + ? 'error' + : renderer.workingSet_KB > 200_000 + ? 'warning.main' + : 'success.main'} + > + {`~${Math.round(renderer.workingSet_KB / 1024)} MB`} + + ) + : -} + + + {renderer.cpu_percent >= 0 + ? ( + 20 + ? 'error' + : renderer.cpu_percent > 5 + ? 'warning.main' + : 'text.primary'} + > + {`${renderer.cpu_percent.toFixed(1)} %`} + + ) + : -} + + + {matchingView !== undefined + ? ( + + {`x:${matchingView.bounds.x} y:${matchingView.bounds.y}`} +
+ {`${matchingView.bounds.width}×${matchingView.bounds.height}`} +
+ ) + : '-'} +
+ + { + const url = (matchingView?.url ?? renderer.url) || ''; + if (url) { + void navigator.clipboard.writeText(url); + setCopiedSnackbarOpen(true); + } + }} + sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }} + > + {(matchingView?.url ?? renderer.url) || '-'} + + + + {matchingView !== undefined && !matchingView.isDestroyed && ( + + )} + +
+ ); + }); + })()} + {diagData.processInfo.renderers.length === 0 && ( + + + {t('Preference.ProcessInfoRenderersEmpty')} + + + )} +
+
+
+ + )} +
+ + + +
+ + { + setCopiedSnackbarOpen(false); + }} + message={t('Preference.CopiedToClipboard')} + /> ); } diff --git a/template/wiki b/template/wiki index 6f8ce17c..6e80e117 160000 --- a/template/wiki +++ b/template/wiki @@ -1 +1 @@ -Subproject commit 6f8ce17c893c46a1f8126f599cffd854daf01507 +Subproject commit 6e80e117e81a3c9f0c7441cfcbf0639cc4f3e325