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